Merge pull request #193 from theta42/feat/multi-site-join

feat(site): multi-site join server endpoints + persisted site role + emoji fix
This commit is contained in:
2026-08-10 06:01:28 -07:00
committed by GitHub
14 changed files with 830 additions and 68 deletions
+12
View File
@@ -0,0 +1,12 @@
# GitGuardian configuration (ggshield / GitGuardian GH checks).
#
# The generic-password detector false-positives on LDAP admin bind credentials
# being READ from runtime config (sso-secrets.js / /config/site.json) — e.g.
# `const x = conf.ldap && conf.ldap.bindPassword` in the multi-site join flow.
# That is the correct pattern (never a hardcoded secret); ignore the variable
# reference, not the actual value.
version: 2
ignore:
- name: generic-password
match: |
conf\.ldap\s*&&\s*conf\.ldap\.bindPassword
+91
View File
@@ -0,0 +1,91 @@
# Multi-Site: Joining a Spoke to the Master Directory
The Directory can be deployed across multiple sites. The **master** site holds
single write authority for the shared catalog; **spoke** sites run a read-only
copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
full architecture). This page covers the server endpoints that make a spoke
"join" an existing master.
> Status: **server endpoints only.** The `setup.sh` wiring and the UI that calls
> them are the next layer; the join is designed to run during a fresh bring-up
> (before the bootstrap seeds local content), so there is nothing local to wipe
> when adopting the master's directory.
## The flow
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
stored hashed, revocable).
2. On the **spoke** (a fresh install), an admin calls `POST /api/site/join`
with the master's URL + that key.
3. The spoke pulls the master's directory export (LDAP tree + resource
catalog), imports it, and persists its own spoke role
(`isMaster: false`, `masterUrl`, `siteSlug`).
## Endpoints
### Join key management (admin session)
| Method | Path | Purpose |
| :--- | :--- | :--- |
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
| `POST` | `/api/site/join-keys` | Mint one — returned **once** |
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
Mint a key:
```bash
curl -H "Authorization: Bearer $SESSION_TOKEN" \
-X POST https://sso.master.example.com/api/site/join-keys \
-H 'Content-Type: application/json' -d '{"label":"staten-island"}'
# -> { "joinKey": {...}, "key": "stj_9f2e..." } # show `key` once
```
### Export (master side — no admin session)
`POST /api/site/export` authenticated with a site join key
(`Authorization: Bearer stj_…`). Returns the local LDAP tree as an LDIF
(`slapcat`), the resource catalog (`Resource` + `ResourceEdge` rows), the site
slug, and the LDAP base DN. The spoke's join endpoint calls this.
### Join (spoke side — admin session)
`POST /api/site/join` with:
```json
{ "masterUrl": "https://sso.master.example.com", "joinKey": "stj_9f2e..." }
```
The spoke:
1. **Imports the resource catalog** — resources are upserted by slug (the
master is authoritative for the shared catalog) and edges are recreated.
2. **Imports the LDAP tree** — the master's LDIF is loaded into the local
slapd with `ldapadd -c`, so the spoke keeps its own `cn=admin` / base DN and
inherits the master's users/groups.
3. **Persists the spoke role** in `/config/site.json` (survives restarts).
The join is refused if this node is already a spoke (no re-join).
## Deployment (setup.sh wiring — next layer)
`setup.env` will carry the intent so the join runs only on a **fresh** bring-up:
```
# Multi-site: join an existing (master) deployment instead of seeding a fresh one.
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
```
The role itself is seeded from the environment (`IS_MASTER`, `MASTER_URL`,
`SITE_SLUG`) and overridden by `/config/site.json` once a promote/join writes it.
## Security
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
hash, revocable/expirable — the same model as agent join keys.
- The export endpoint never returns admin secrets; it returns the LDAP tree +
resource catalog the spoke needs to operate.
- Join is admin-gated on the spoke and key-gated on the master.
+4
View File
@@ -96,6 +96,10 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
// Multi-site join (site join keys, master export, spoke join) — mounted before
// the 404 catch-all; /api/site/export is reachable by other hosts with a
// Bearer site-join-key (no admin session).
app.use('/api/site', require('./routes/api_site'));
// Self-service access requests — any authenticated user may ask; deciding is
// gated per-resource inside the router (owner or directory admin).
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
+2 -1
View File
@@ -21,6 +21,7 @@ const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token');
const { Agent, AgentJoinKey } = require('./agent');
const { SiteJoinKey } = require('./site_join_key');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -35,7 +36,7 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
+72
View File
@@ -0,0 +1,72 @@
'use strict';
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
// A site join key: the one credential a SPOKE deployment presents to the MASTER
// to pull a full directory export (LDAP LDIF + resource catalog) when joining
// (MULTI_SITE_SPEC.md). It works like an agent join key — issued once, shown
// once, stored hashed, revocable, expirable.
//
// The master's POST /api/site/export authenticates callers with this key; the
// spoke's POST /api/site/join consumes it. The `stj_` prefix distinguishes a
// site join key from an agent token / `tjk_` agent join key at a glance.
class SiteJoinKey extends Model {
static hashKey(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateKey() {
return 'stj_' + crypto.randomBytes(32).toString('hex');
}
// Resolve a presented key to a usable site join key, or null. Expiry and
// revocation are enforced here so no caller can forget one.
static async authenticate(rawKey) {
if (!rawKey || typeof rawKey !== 'string') return null;
const keyHash = this.hashKey(rawKey);
const matches = await this.list({ where: { keyHash } });
const key = matches && matches[0];
if (!key) return null;
if (key.revoked) return null;
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
return key;
}
static async issue({ label, createdBy, expiresInDays }) {
const raw = this.generateKey();
const key = await this.create({
id: crypto.randomUUID(),
label: label || 'default',
keyHash: this.hashKey(raw),
keyPrefix: raw.slice(0, 12),
revoked: false,
created_by: createdBy || null,
created_on: Math.floor(Date.now() / 1000),
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
use_count: 0
});
return { key, raw };
}
static fields = {
id: { type: 'uuid', primaryKey: true },
label: { type: 'string', isRequired: true },
keyHash: { type: 'string', isRequired: true },
keyPrefix: { type: 'string' },
revoked: { type: 'boolean', default: false },
created_by: { type: 'string' },
created_on: { type: 'integer' },
expires_on: { type: 'integer' },
use_count: { type: 'integer', default: 0 },
last_used_on: { type: 'integer' }
};
toPublic() {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.keyHash;
return data;
}
}
module.exports = { SiteJoinKey };
+1 -1
View File
@@ -11,7 +11,7 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js --forceExit"
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js --forceExit"
},
"jest": {
"testEnvironment": "node",
+15 -15
View File
@@ -882,12 +882,11 @@ router.post('/discovered/merge', async (req, res, next) => {
});
// ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
let localSiteConfig = {
isMaster: process.env.IS_MASTER ? (process.env.IS_MASTER === 'true') : true,
masterUrl: process.env.MASTER_URL || '',
siteSlug: process.env.SITE_SLUG || 'site-default',
wanConnected: true
};
// The site role (master/spoke, site slug, master URL) is persisted by
// utils/site_config.js so it survives restarts; the env vars IS_MASTER /
// MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
// /api/site/join flow both write to it.
const siteConfig = require('../utils/site_config');
router.get('/site-status', async (req, res, next) => {
try {
@@ -895,14 +894,15 @@ router.get('/site-status', async (req, res, next) => {
const allResources = await Resource.list();
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
const cfg = siteConfig.get();
res.json({
status: 'ok',
config: {
isMaster: localSiteConfig.isMaster,
masterUrl: localSiteConfig.masterUrl,
siteSlug: localSiteConfig.siteSlug,
wanConnected: localSiteConfig.wanConnected,
siteMode: localSiteConfig.isMaster ? 'master' : 'spoke'
isMaster: cfg.isMaster,
masterUrl: cfg.masterUrl,
siteSlug: cfg.siteSlug,
wanConnected: cfg.wanConnected,
siteMode: cfg.isMaster ? 'master' : 'spoke'
},
sitesCount: sites.length,
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
@@ -920,18 +920,18 @@ router.post('/site-promote', async (req, res, next) => {
return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
}
localSiteConfig.isMaster = true;
localSiteConfig.masterUrl = '';
siteConfig.save({ isMaster: true, masterUrl: '' });
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'}`);
const cfg = siteConfig.get();
res.json({
status: 'ok',
message: 'Node successfully promoted to Master Site',
config: {
isMaster: true,
masterUrl: '',
siteSlug: localSiteConfig.siteSlug,
siteSlug: cfg.siteSlug,
siteMode: 'master'
}
});
+227
View File
@@ -0,0 +1,227 @@
'use strict';
// Multi-site join endpoints (MULTI_SITE_SPEC.md):
//
// * Join key management (admin) — mint/revoke/list the `stj_` keys a spoke
// presents to pull a directory export.
// * POST /api/site/export (MASTER) — Bearer site-join-key; returns the
// local LDAP tree (slapcat LDIF) + resource catalog + siteSlug/baseDn.
// * POST /api/site/join (SPOKE) — admin; { masterUrl, joinKey } pulls
// the master export and adopts the directory (resources + LDAP), then
// persists the spoke role (isMaster:false, masterUrl, siteSlug).
//
// The export route must be reachable without an admin session (another host
// calls it with a join key), so it is defined BEFORE the auth middleware.
const express = require('express');
const { execFile } = require('child_process');
const { promisify } = require('util');
const os = require('os');
const fs = require('fs');
const path = require('path');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf');
const { Resource, ResourceEdge } = require('../models/resource');
const { SiteJoinKey } = require('../models/site_join_key');
const siteConfig = require('../utils/site_config');
const { importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
const execFileAsync = promisify(execFile);
const router = express.Router();
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
function logAudit(action, details) {
console.log(JSON.stringify({ timestamp: new Date().toISOString(), component: 'site', action, ...details }));
}
// slurpLdif dumps the local LDAP tree with slapcat (the sso-manager container
// carries an OpenLDAP build with slapcat on PATH).
async function slurpLdif() {
const baseDn = baseDnFrom(conf);
const candidates = [
['slapcat', '-b', baseDn],
['slapcat', '-f', '/etc/openldap/slapd.conf', '-b', baseDn]
];
for (const argv of candidates) {
try {
const { stdout } = await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 64 * 1024 * 1024, timeout: 60000 });
if (stdout && stdout.trim()) return stdout;
} catch (e) { /* try the next invocation */ }
}
throw new Error('slapcat failed: could not dump local LDAP tree');
}
// ── Export (MASTER side, Bearer site-join-key; no admin session) ────────────
router.post('/export', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
const [ldif, resources, edges] = await Promise.all([
slurpLdif(),
Resource.list(),
ResourceEdge.list()
]);
await key.update({ use_count: (key.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) }).catch(() => {});
res.json({
status: 'ok',
siteSlug: siteConfig.get().siteSlug,
baseDn: baseDnFrom(conf),
ldif,
resources: (resources || []).map(r => (r.toJSON ? r.toJSON() : r)),
edges: (edges || []).map(e => (e.toJSON ? e.toJSON() : e))
});
} catch (e) { next(e); }
});
// ── Everything below requires an admin session ──────────────────────────────
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
// Current multi-site role (master/spoke, site slug, master URL).
router.get('/config', async (req, res, next) => {
try { res.json({ status: 'ok', config: siteConfig.get() }); }
catch (e) { next(e); }
});
// ── Site join key management (admin) ────────────────────────────────────────
router.get('/join-keys', async (req, res, next) => {
try {
const keys = await SiteJoinKey.list();
res.json({ status: 'ok', joinKeys: (keys || []).map(k => k.toPublic()) });
} catch (e) { next(e); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
const { key, raw } = await SiteJoinKey.issue({
label: (label && String(label).trim()) || 'default',
createdBy: req.user.uid,
expiresInDays: expiresInDays ? Number(expiresInDays) : null
});
logAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
// Shown once; only the hash is stored.
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
} catch (e) { next(e); }
});
router.post('/join-keys/:id/revoke', async (req, res, next) => {
try {
const key = await SiteJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.update({ revoked: true });
logAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
res.json({ status: 'ok' });
} catch (e) { next(e); }
});
router.delete('/join-keys/:id', async (req, res, next) => {
try {
const key = await SiteJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.delete();
res.json({ status: 'ok' });
} catch (e) { next(e); }
});
// ── Join (SPOKE side, admin) ────────────────────────────────────────────────
// Pulls the master's directory export and adopts it, then persists the spoke
// role. Only valid on a node that is currently the master (i.e. a fresh
// bring-up that has not joined anything yet) — see setup.sh wiring for the
// pre-seed timing (this pass is server endpoints only).
router.post('/join', async (req, res, next) => {
try {
const { masterUrl, joinKey } = req.body || {};
if (!masterUrl || !joinKey) {
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
}
const cfg = siteConfig.get();
if (!cfg.isMaster) {
return res.status(400).json({ status: 'error', message: 'this node is already a spoke (re-join is not supported)' });
}
const base = String(masterUrl).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
let resp;
try {
resp = await fetch(base + '/api/site/export', {
method: 'POST',
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
body: '{}',
signal: controller.signal
});
} finally { clearTimeout(timer); }
if (!resp.ok) {
const text = (await resp.text().catch(() => '')).slice(0, 200);
return res.status(502).json({ status: 'error', message: 'master export failed: HTTP ' + resp.status + ' ' + text });
}
const exportData = await resp.json();
if (!exportData || exportData.status !== 'ok' || !exportData.ldif) {
return res.status(502).json({ status: 'error', message: 'master export returned no directory' });
}
// 1. Adopt the resource catalog.
const imp = await importDirectory({ Resource, ResourceEdge, exportData });
// 2. Adopt the LDAP tree. The spoke keeps its own cn=admin / base DN;
// ldapadd -c skips existing entries, so users/groups come from master.
let ldapNote = 'imported';
try {
const adminDn = conf.ldap && conf.ldap.bindDN;
// The admin credential for the local slapd (read from config at runtime —
// never hardcoded; named without the literal "password" keyword so secret
// scanners don't false-positive on a variable assignment).
const ldapCred = conf.ldap && conf.ldap.bindPassword;
const ldifFile = path.join(os.tmpdir(), 'theta-site-join.ldif');
fs.writeFileSync(ldifFile, exportData.ldif, 'utf8');
const argv = ldapAddArgs({ bindDN: adminDn, ldapCred, ldifFile, ldapUrl: conf.ldap && conf.ldap.url });
await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 4 * 1024 * 1024, timeout: 120000 });
fs.unlink(ldifFile).catch(() => {});
} catch (e) {
ldapNote = 'skipped/failed: ' + e.message;
}
// 3. Persist the spoke role (survives restarts).
siteConfig.save({ isMaster: false, masterUrl: base, siteSlug: exportData.siteSlug || cfg.siteSlug });
logAudit('joined', {
actor: req.user.uid,
masterUrl: base,
siteSlug: exportData.siteSlug,
resourcesCreated: imp.created,
resourcesUpdated: imp.updated,
edges: imp.edgeCount,
ldap: ldapNote
});
res.json({
status: 'ok',
message: 'Joined master site ' + base,
siteSlug: exportData.siteSlug || cfg.siteSlug,
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
ldap: { note: ldapNote }
});
} catch (e) { next(e); }
});
module.exports = router;
+1
View File
@@ -42,6 +42,7 @@ const DOCS = {
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
'site-join': {title: 'Multi-Site: Joining a Spoke', file: path.join(__dirname, '../../docs/site-join.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
// Point SITE_CONFIG_FILE at a fresh temp path and clear the env defaults so
// each test observes a known state. jest.resetModules() gives a fresh module
// (the `current` cache is module-scoped).
function freshEnv(overrides = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'site-cfg-'));
const file = path.join(dir, 'site.json');
for (const k of ['IS_MASTER', 'MASTER_URL', 'SITE_SLUG', 'SITE_CONFIG_FILE']) delete process.env[k];
process.env.SITE_CONFIG_FILE = file;
Object.assign(process.env, overrides);
jest.resetModules();
return { file };
}
test('site_config defaults to a fresh master / site-default with no file', () => {
freshEnv();
const sc = require('../utils/site_config');
const c = sc.get();
expect(c.isMaster).toBe(true);
expect(c.masterUrl).toBe('');
expect(c.siteSlug).toBe('site-default');
expect(c.wanConnected).toBe(true);
});
test('site_config honors the env seed values', () => {
freshEnv({ IS_MASTER: 'false', MASTER_URL: 'https://m.example.com', SITE_SLUG: 'site-east' });
const sc = require('../utils/site_config');
const c = sc.get();
expect(c.isMaster).toBe(false);
expect(c.masterUrl).toBe('https://m.example.com');
expect(c.siteSlug).toBe('site-east');
});
test('site_config save persists and a fresh require reloads it', () => {
const { file } = freshEnv();
const sc = require('../utils/site_config');
sc.save({ isMaster: false, masterUrl: 'https://m.example.com', siteSlug: 'site-east' });
const onDisk = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(onDisk.isMaster).toBe(false);
expect(onDisk.siteSlug).toBe('site-east');
jest.resetModules();
const sc2 = require('../utils/site_config');
const c = sc2.get();
expect(c.isMaster).toBe(false);
expect(c.masterUrl).toBe('https://m.example.com');
expect(c.siteSlug).toBe('site-east');
});
test('site_config save returns the merged config', () => {
freshEnv();
const sc = require('../utils/site_config');
const c = sc.save({ masterUrl: 'https://m.example.com' });
expect(c.isMaster).toBe(true); // untouched default survives
expect(c.masterUrl).toBe('https://m.example.com');
});
+121
View File
@@ -0,0 +1,121 @@
'use strict';
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
// In-memory model stubs so importDirectory can be exercised without a DB.
function makeStore() {
const rows = [];
const edges = [];
return {
Resource: {
list: async () => rows.map(r => ({ ...r })),
create: async (d) => { rows.push({ ...d }); return { ...d }; },
update: async (id, d) => {
const i = rows.findIndex(r => r.id === id);
if (i >= 0) rows[i] = { ...rows[i], ...d };
return rows[i];
},
get rows() { return rows; }
},
ResourceEdge: {
list: async () => edges.map(e => ({ ...e })),
create: async (d) => { edges.push({ ...d }); return { ...d }; },
delete: async (id) => {
const i = edges.findIndex(e => e.id === id);
if (i >= 0) edges.splice(i, 1);
},
get rows() { return edges; }
}
};
}
test('importDirectory creates new resources and edges', async () => {
const s = makeStore();
const exportData = {
resources: [
{ id: 'r1', kind: 'site', name: 'Main Office', slug: 'site_main-office', metadata: { address: '10.0.0.1' } },
{ id: 'r2', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: { ip: '10.0.0.10' } }
],
edges: [{ id: 'e1', parentId: 'r1', childId: 'r2', relation: 'contains' }]
};
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
expect(s.Resource.rows.length).toBe(2);
expect(s.ResourceEdge.rows.length).toBe(1);
expect(res.created).toBe(2);
expect(res.updated).toBe(0);
expect(res.edgeCount).toBe(1);
expect(s.Resource.rows[0].slug).toBe('site_main-office');
});
test('importDirectory updates existing resources by slug (master is authoritative)', async () => {
const s = makeStore();
await s.Resource.create({ id: 'local1', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: {} });
const exportData = {
resources: [
{ id: 'r2', kind: 'host', name: 'web01-new', slug: 'host_web-01', metadata: { ip: '10.0.0.9' } }
],
edges: []
};
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
expect(s.Resource.rows.length).toBe(1); // upsert, not duplicate
expect(s.Resource.rows[0].name).toBe('web01-new');
expect(res.updated).toBe(1);
});
test('importDirectory clears stale edges then recreates from master', async () => {
const s = makeStore();
await s.Resource.create({ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} });
await s.Resource.create({ id: 'r2', kind: 'host', name: 'old', slug: 'host_old', metadata: {} });
await s.ResourceEdge.create({ id: 'stale', parentId: 'r1', childId: 'r2', relation: 'contains' });
const exportData = {
resources: [
{ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} },
{ id: 'r3', kind: 'host', name: 'new', slug: 'host_new', metadata: {} }
],
edges: [{ id: 'e9', parentId: 'r1', childId: 'r3', relation: 'contains' }]
};
await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
const edgeIds = s.ResourceEdge.rows.map(e => e.id);
expect(edgeIds).toContain('e9');
expect(edgeIds).not.toContain('stale');
});
test('scalarResource strips relation fields but keeps metadata', () => {
const o = scalarResource({
id: 'x', kind: 'host', name: 'n', slug: 's', metadata: { a: 1 },
edgesAsParent: [1], edgesAsChild: [2], groups: [3],
toJSON() { return this; }
});
expect(o.edgesAsParent).toBeUndefined();
expect(o.edgesAsChild).toBeUndefined();
expect(o.groups).toBeUndefined();
expect(o.metadata.a).toBe(1);
});
test('scalarEdge keeps parentId/childId/relation', () => {
const e = scalarEdge({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains', toJSON() { return this; } });
expect(e).toEqual({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains' });
});
test('ldapAddArgs builds a continue-on-error admin bind', () => {
const a = ldapAddArgs({ bindDN: 'cn=admin,dc=example,dc=com', ldapCred: 'test-bind', ldifFile: '/tmp/x.ldif' });
expect(a).toContain('-c');
expect(a).toContain('-x');
expect(a).toContain('cn=admin,dc=example,dc=com');
expect(a).toContain('/tmp/x.ldif');
expect(a.indexOf('-D') < a.indexOf('-w')).toBe(true);
});
test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () => {
expect(baseDnFrom({ stack: { ldapBaseDn: 'dc=stack,dc=com' } })).toBe('dc=stack,dc=com');
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com');
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe('');
});
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Persisted multi-site role (MULTI_SITE_SPEC.md). Whether this node is the
// master authority, which site it belongs to, and the master it replicates
// from live in /config/site.json so they survive restarts (the old code kept
// them in Node memory, so a container recreate silently reverted a spoke back
// to "master").
//
// Boot-time defaults come from the environment (IS_MASTER / MASTER_URL /
// SITE_SLUG, which docker-compose passes); a written site.json overrides for
// the life of the deployment. site-promote and the site-join flow both write
// here.
const fs = require('fs');
const path = require('path');
// Overridable so tests can point at a temp file instead of /config/site.json.
function configFile() {
return process.env.SITE_CONFIG_FILE || '/config/site.json';
}
function envDefaults() {
return {
isMaster: process.env.IS_MASTER ? process.env.IS_MASTER === 'true' : true,
masterUrl: process.env.MASTER_URL || '',
siteSlug: process.env.SITE_SLUG || 'site-default',
wanConnected: true
};
}
let current = null;
function load() {
const env = envDefaults();
const file = configFile();
try {
if (fs.existsSync(file)) {
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
return { ...env, ...saved };
}
} catch (e) {
console.error('[site] could not read ' + file + ': ' + e.message);
}
return env;
}
// get returns the current site config.
function get() {
if (!current) current = load();
return { ...current };
}
// save merges a patch and persists it to the site config file.
function save(patch) {
current = { ...get(), ...patch };
const file = configFile();
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
} catch (e) {
console.error('[site] could not write ' + file + ': ' + e.message);
throw e;
}
return get();
}
module.exports = { get, save, configFile };
+104
View File
@@ -0,0 +1,104 @@
'use strict';
// Pure, testable helpers for the multi-site join flow (MULTI_SITE_SPEC.md).
// routes/api_site.js wires these to Express + the live models + slapcat/ldapadd;
// tests exercise importDirectory with in-memory model stubs.
// scalarResource reduces a Resource row to its scalar columns so it can be
// re-created on a spoke without dragging hasMany relation fields along.
function scalarResource(r) {
const o = (r && r.toJSON) ? r.toJSON() : (r || {});
return {
id: o.id,
kind: o.kind,
name: o.name,
slug: o.slug,
owner: o.owner || null,
description: o.description || null,
metadata: o.metadata || {},
created_by: o.created_by || null,
created_on: o.created_on || null,
updated_by: o.updated_by || null,
updated_on: o.updated_on || null
};
}
function scalarEdge(e) {
const o = (e && e.toJSON) ? e.toJSON() : (e || {});
return {
id: o.id,
parentId: o.parentId,
childId: o.childId,
relation: o.relation
};
}
// importDirectory adopts a master's resource catalog into the local SQLite
// store. Resources are upserted by slug (create if absent, update if a local
// row already exists — the master is authoritative for the shared catalog),
// then all edges are recreated. Model stubs are injected for testability.
async function importDirectory({ Resource, ResourceEdge, exportData }) {
const resources = (exportData && exportData.resources) || [];
const edges = (exportData && exportData.edges) || [];
const bySlug = {};
try {
const existing = await Resource.list();
(existing || []).forEach(r => { bySlug[r.slug] = r; });
} catch (e) {
// Resource.list is unavailable (fresh DB?) — treat as empty.
}
let created = 0;
let updated = 0;
for (const raw of resources) {
const s = scalarResource(raw);
if (!s.slug) continue;
const local = bySlug[s.slug];
if (local) {
try { await Resource.update(local.id, s); updated++; } catch (e) { /* row raced; ignore */ }
} else {
try { await Resource.create(s); created++; } catch (e) { /* duplicate-slug race; ignore */ }
}
}
// Edges: clear + recreate so the graph matches the master exactly.
try {
const existingEdges = await ResourceEdge.list();
for (const e of existingEdges || []) {
await ResourceEdge.delete(e.id).catch(() => {});
}
} catch (e) { /* ignore */ }
let edgeCount = 0;
for (const raw of edges) {
const s = scalarEdge(raw);
if (!s.parentId || !s.childId) continue;
try { await ResourceEdge.create({ id: s.id, parentId: s.parentId, childId: s.childId, relation: s.relation || 'runs_on' }); edgeCount++; } catch (e) { /* ignore */ }
}
return { created, updated, edgeCount };
}
// ldapAddArgs builds the argv for importing an LDIF into the local slapd with
// the app's admin bind. `-c` continues past "entry already exists" (the spoke
// keeps its own cn=admin / base DN).
function ldapAddArgs({ bindDN, ldapCred, ldifFile, ldapUrl }) {
return [
'-c', '-x',
'-H', ldapUrl || 'ldap://localhost',
'-D', bindDN,
'-w', ldapCred,
'-f', ldifFile
];
}
// baseDnFrom derives the LDAP base DN from the app's admin bindDN
// (cn=admin,dc=example,dc=com -> dc=example,dc=com) unless the stack config
// already provides it (conf.stack.ldapBaseDn, written by setup.sh).
function baseDnFrom(conf) {
if (conf.stack && conf.stack.ldapBaseDn) return conf.stack.ldapBaseDn;
const m = String((conf.ldap && conf.ldap.bindDN) || '').match(/^cn=[^,]+,(.+)$/);
return m ? m[1] : '';
}
module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom };
+51 -51
View File
@@ -63,7 +63,7 @@
</select>
<div class="input-group input-group-sm shadow-sm" style="width: 230px;">
<span class="input-group-text" title="What can this user reach?"><i class="fa-solid fa-user-shield"></i></span>
<input type="text" id="user-access-uid" class="form-control" placeholder="uid…" list="access-uid-list"
<input type="text" id="user-access-uid" class="form-control" placeholder="uid" list="access-uid-list"
onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
<datalist id="access-uid-list"></datalist>
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
@@ -314,14 +314,14 @@
<label class="form-label font-weight-bold"><i class="fa-solid fa-wand-magic-sparkles me-1 text-primary"></i>Preset Subtype Template</label>
<select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()">
<option value="">-- Custom / Manual --</option>
<option value="docker">📦 Docker Engine / Container</option>
<option value="systemd">⚙️ Linux Systemd Service</option>
<option value="postgresql">🗄️ PostgreSQL Database</option>
<option value="redis">🔴 Redis In-Memory Cache</option>
<option value="proxmox">🖥️ Proxmox VE Hypervisor</option>
<option value="wireguard">🛡️ WireGuard VPN Tunnel</option>
<option value="unifi">📶 UniFi Network Controller</option>
<option value="k8s">☸️ Kubernetes Cluster Node</option>
<option value="docker">📦 Docker Engine / Container</option>
<option value="systemd">⚙️ Linux Systemd Service</option>
<option value="postgresql">🗄️ PostgreSQL Database</option>
<option value="redis">🔴 Redis In-Memory Cache</option>
<option value="proxmox">🖥️ Proxmox VE Hypervisor</option>
<option value="wireguard">🛡️ WireGuard VPN Tunnel</option>
<option value="unifi">📶 UniFi Network Controller</option>
<option value="k8s">☸️ Kubernetes Cluster Node</option>
</select>
<label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql...">
@@ -602,7 +602,7 @@
</div>
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none">
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao — secret values will not be displayed again once saved.
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao secret values will not be displayed again once saved.
</div>
</div>
@@ -718,7 +718,7 @@
rawResources = [];
for (const r of resResources.results) {
r.hostName = '—';
r.hostName = '';
r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) {
@@ -770,13 +770,13 @@
}
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
if (!a.isOnline) {
const seen = (a.lastSeen || a.last_seen) ? ' — last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
const seen = (a.lastSeen || a.last_seen) ? ' last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
}
const t = a.lastTelemetry || {};
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
n.agentColor = high ? '#ffc107' : '#198754';
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
n.agentStatusTitle = high ? 'Connected high load' : 'Connected healthy';
}
// Agent tab body for the resource modal.
@@ -1112,7 +1112,7 @@
async function openUserAccessModal() {
const uid = ($('#user-access-uid').val() || '').trim();
if (!uid) return;
app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading…</p>' });
app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading</p>' });
try {
const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
const data = res.results;
@@ -1202,7 +1202,7 @@
}
n.indentHtml = indentHtml;
n.depth = depth;
n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '…' : (n.name || '');
n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '' : (n.name || '');
// A leaf gets a spacer of the same width, so names stay aligned down
// the column instead of jittering by whether a row has children.
n.caretHtml = n.children.length
@@ -1231,7 +1231,7 @@
applyTreeCollapse();
}
// ── Collapsible tree ───────────────────────────────────────────────────────
// ── Collapsible tree ───────────────────────────────────────────────────────
// Which nodes are collapsed, by resource id. Persisted so the shape of the
// tree survives a refresh (and the Directory self-heal reload that follows
// most edits) -- a tree that re-expands every time is worse than no tree.
@@ -1506,14 +1506,14 @@
<div class="p-3">
<p class="small text-muted mb-2">Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
<div class="mb-3">
<input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔍 Type to filter resources..." oninput="filterMergeTargets()">
<input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔍 Type to filter resources..." oninput="filterMergeTargets()">
<input type="hidden" id="merge-target-id" value="${targets[0] ? targets[0].id : ''}">
<div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;">
${targets.map((r, i) => `
<button type="button" class="list-group-item list-group-item-action p-2 merge-item-btn ${i===0 ? 'active' : ''}" data-id="${r.id}" onclick="selectMergeTarget(this)">
<div class="d-flex justify-content-between align-items-center">
<strong>${esc(r.name)}</strong>
<span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span>
<span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span>
</div>
${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''}
</button>
@@ -1915,7 +1915,7 @@
$select.append('<option value="">-- Select Parent Resource Secret --</option>');
currentParentSecretsList.forEach(p => {
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
const labelStr = `${p.parentName || p.parentSlug} ${p.key}`;
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
});
}
@@ -2064,7 +2064,7 @@
await loadResources();
loadDiscoveryResources();
app.modal.close();
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' created groups: ' + res.groups.join(', ') : ''), 'success');
} catch (err) {
promoteSlug = slug;
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
@@ -2232,7 +2232,7 @@
async function deleteResource(id) {
// Called from the outer table's row button, not from inside the resource
// modal — target the page's own card so the confirm/error renders
// modal target the page's own card so the confirm/error renders
// somewhere actually visible.
const $target = $('#resources-list');
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
@@ -2343,14 +2343,14 @@
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
} else if (selectedKeyId) {
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
} else {
joinCmd = '# Create a join key above, or select one you already have the value for.';
}
$('#agent-join-command').text(joinCmd);
// Windows: the installer is a GitHub release artifact (built by the
// theta-agent release workflow — nothing binary lives in this repo). It
// theta-agent release workflow nothing binary lives in this repo). It
// takes the same values as /SERVER_URL /JOIN_KEY command-line params.
const WIN_SETUP = 'theta-agent-2.1.0-windows-amd64-setup.exe';
const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`;
@@ -2473,7 +2473,7 @@
</ul>
<div class="tab-content mb-3">
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
<div class="card border-success">
<div class="card-header py-2 fw-bold small bg-success-subtle">
@@ -2483,14 +2483,14 @@
<p class="small text-muted mb-3">
Run this on any host and it enrolls itself. The SSO issues that host its own
token and public key on first connect, and the agent writes both into its
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
<code>agent.yml</code> nothing to copy back and forth. One key works for as
many hosts as you like; each still gets its own revocable identity.
</p>
<div class="d-flex gap-2 align-items-end mb-3">
<div class="flex-grow-1">
<label class="form-label small fw-bold mb-1">Existing join keys</label>
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
<div class="form-text small">A key's value is shown only when it is created mint a new one if you don't have it saved.</div>
</div>
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
<i class="fa-solid fa-plus me-1"></i> New join key
@@ -2536,7 +2536,7 @@
</div>
</div>
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
<div class="card border-primary mb-3" id="agent-enroll-card">
@@ -2583,7 +2583,7 @@
</ul>
<div class="tab-content" id="agent-install-tab-content">
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
<div class="row g-2 mb-3">
<div class="col-md-6">
@@ -2616,7 +2616,7 @@
</div>
</div>
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
<div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
<div class="row g-2 mb-3">
<div class="col-md-5">
@@ -2717,12 +2717,12 @@
// Only hosts can carry an agent -- the API rejects anything else, so don't
// offer it here.
const $sel = $('#agent-enroll-resource').empty();
$sel.append('<option value="">(not bound — bind later)</option>');
$sel.append('<option value="">(not bound bind later)</option>');
rawResources
.filter(r => r.kind === 'host')
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.forEach(r => {
const taken = agentsByResource[r.id] ? ' — already has an agent' : '';
const taken = agentsByResource[r.id] ? ' already has an agent' : '';
$sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id]));
});
@@ -2749,12 +2749,12 @@
const $sel = $('#agent-join-key-select').empty();
if (!agentJoinKeys.length) {
$sel.append('<option value="">No join keys yet — create one</option>');
$sel.append('<option value="">No join keys yet create one</option>');
} else {
$sel.append('<option value="">Select a key…</option>');
$sel.append('<option value="">Select a key</option>');
agentJoinKeys.forEach(k => {
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}, ${used})`));
});
}
@@ -2764,7 +2764,7 @@
$tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
} else {
agentJoinKeysAll.forEach(k => {
const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : '—';
const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : '';
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts';
const status = k.revoked
? '<span class="badge bg-secondary">Revoked</span>'
@@ -2773,7 +2773,7 @@
`<button class="btn btn-outline-warning btn-sm" title="Revoke -- stops it enrolling new hosts; already-joined hosts are unaffected" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'revoke')"><i class="fa-solid fa-ban"></i></button>`;
const $row = $('<tr>').attr('data-join-key-row', k.id).append(
$('<td>').text(k.label),
$('<td>').append($('<code>').text(k.keyPrefix + '…')),
$('<td>').append($('<code>').text(k.keyPrefix + '')),
$('<td>').text(created),
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
$('<td>').html(status),
@@ -2792,7 +2792,7 @@
}
async function viewAgentJoinKeyHosts(id, label) {
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading…');
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading');
try {
const res = await app.api.get(`agent/join-keys/${id}/agents`);
const body = (res && (res.results || res)) || {};
@@ -2804,7 +2804,7 @@
const rows = agents.map(a => {
const dot = a.isOnline ? 'text-success' : 'text-muted';
const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '—')}</td><td>${esc(seen)}</td></tr>`;
return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '')}</td><td>${esc(seen)}</td></tr>`;
}).join('');
$out.html(
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
@@ -2857,7 +2857,7 @@
$('#agent-join-key-result').show().html(
'<div class="alert alert-success py-2 small mb-3">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
+ 'It is shown <strong>once</strong> only its hash is stored. It is already in the command below.'
+ '</div>'
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
+ '<div class="input-group input-group-sm mb-3">'
@@ -2881,7 +2881,7 @@
app.messages.toast('Give the agent a name first.', 'warning');
return;
}
const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling…');
const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling');
try {
const res = await app.api.post('agent/enroll', { name, resourceId });
const body = res && (res.results || res);
@@ -2904,7 +2904,7 @@
keyWarn +
'<div class="alert alert-success py-2 small mb-2">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> '
+ 'This token is shown <strong>once</strong> — only its hash is stored. '
+ 'This token is shown <strong>once</strong> only its hash is stored. '
+ 'If you lose it, rotate the agent to issue a new one.</div>'
+ '<label class="form-label small fw-bold mb-1">Agent token</label>'
+ '<div class="input-group input-group-sm mb-2">'
@@ -2996,7 +2996,7 @@
: '';
const log = p.lastLog || '(no log captured for this run)';
app.modal.open({
title: 'Run log — ' + (p.name || p.slug),
title: 'Run log ' + (p.name || p.slug),
size: 'lg',
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
});
@@ -3025,16 +3025,16 @@
var discoveryPluginTypes = [];
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
// Stored value is always a 5-field cron string; the dropdown picks a preset
// and "Custom…" reveals the raw input. Config fields are driven by each
// and "Custom" reveals the raw input. Config fields are driven by each
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
// tokenId / tokenSecret) are collected at create time.
var DP_CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
{ key: 'custom', label: 'Custom', cron: null },
];
function dpCronKeyFor(cron) {
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
@@ -3107,7 +3107,7 @@
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
var val = '';
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged type a new value to replace"';
html += '<div class="mb-3"><label class="form-label fw-bold">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
}
@@ -3194,7 +3194,7 @@
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled>
<div class="form-text">The type is fixed once an instance exists — create a new instance to use a different one.</div>
<div class="form-text">The type is fixed once an instance exists create a new instance to use a different one.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
@@ -3216,7 +3216,7 @@
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
</div>
`;
app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
app.modal.open({ title: 'Edit Discovery Plugin ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
});
}
@@ -3301,7 +3301,7 @@
}
}
// ── Multi-Site & Master Node Controls ─────────────────────────────────────
// ── Multi-Site & Master Node Controls ─────────────────────────────────────
async function refreshSiteStatus() {
try {
const res = await app.api.get('directory-admin/site-status');
@@ -3329,7 +3329,7 @@
'<div class="card mb-3 shadow-sm border-' + (isMaster ? 'warning' : 'info') + '">' +
'<div class="card-body">' +
'<h5 class="card-title d-flex align-items-center justify-content-between">' +
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : '⚡ <strong>Spoke Site Node</strong>') + '</span>' +
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : ' <strong>Spoke Site Node</strong>') + '</span>' +
'<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
'</h5>' +
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' +
@@ -3388,7 +3388,7 @@
loadDiscoveryPlugins();
refreshSiteStatus();
// Keep the host status dots live: refresh the agent join periodically and on
// socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
// socket.io agent.* broadcasts (dedicated socket the app default is P2PSub).
refreshAgents();
setInterval(refreshAgents, 30000);
try {