feat(site): multi-site join server endpoints + persisted site role + emoji fix

Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.

- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
  hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
  local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
  export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
  the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
  /config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
  LDAP bind credential from runtime config (never a hardcoded secret).
This commit is contained in:
2026-08-10 05:58:28 -07:00
parent 0915043d6d
commit c96a4b6652
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/notification', middleware.auth, require('./routes/notification'));
app.use('/api/discovery', middleware.auth, require('./routes/discovery')); app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin')); 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 // Self-service access requests — any authenticated user may ask; deciding is
// gated per-resource inside the router (owner or directory admin). // gated per-resource inside the router (owner or directory admin).
app.use('/api/access-requests', middleware.auth, require('./routes/access_request')); 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 { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token'); const { VaultAppToken } = require('./vault_app_token');
const { Agent, AgentJoinKey } = require('./agent'); const { Agent, AgentJoinKey } = require('./agent');
const { SiteJoinKey } = require('./site_join_key');
async function initORM() { async function initORM() {
const ormConf = conf.orm || { const ormConf = conf.orm || {
dialect: 'sqlite', dialect: 'sqlite',
@@ -35,7 +36,7 @@ async function initORM() {
conf: { orm: ormConf }, conf: { orm: ormConf },
models: [ models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance, Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken 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": { "scripts": {
"start": "node ./bin/www", "start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./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": { "jest": {
"testEnvironment": "node", "testEnvironment": "node",
+14 -14
View File
@@ -882,12 +882,11 @@ router.post('/discovered/merge', async (req, res, next) => {
}); });
// ── Multi-Site & Master Node Status Endpoints ──────────────────────────────── // ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
let localSiteConfig = { // The site role (master/spoke, site slug, master URL) is persisted by
isMaster: process.env.IS_MASTER ? (process.env.IS_MASTER === 'true') : true, // utils/site_config.js so it survives restarts; the env vars IS_MASTER /
masterUrl: process.env.MASTER_URL || '', // MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
siteSlug: process.env.SITE_SLUG || 'site-default', // /api/site/join flow both write to it.
wanConnected: true const siteConfig = require('../utils/site_config');
};
router.get('/site-status', async (req, res, next) => { router.get('/site-status', async (req, res, next) => {
try { try {
@@ -895,14 +894,15 @@ router.get('/site-status', async (req, res, next) => {
const allResources = await Resource.list(); const allResources = await Resource.list();
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard'); const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
const cfg = siteConfig.get();
res.json({ res.json({
status: 'ok', status: 'ok',
config: { config: {
isMaster: localSiteConfig.isMaster, isMaster: cfg.isMaster,
masterUrl: localSiteConfig.masterUrl, masterUrl: cfg.masterUrl,
siteSlug: localSiteConfig.siteSlug, siteSlug: cfg.siteSlug,
wanConnected: localSiteConfig.wanConnected, wanConnected: cfg.wanConnected,
siteMode: localSiteConfig.isMaster ? 'master' : 'spoke' siteMode: cfg.isMaster ? 'master' : 'spoke'
}, },
sitesCount: sites.length, sitesCount: sites.length,
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })), 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' }); return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
} }
localSiteConfig.isMaster = true; siteConfig.save({ isMaster: true, masterUrl: '' });
localSiteConfig.masterUrl = '';
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'}`); console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'}`);
const cfg = siteConfig.get();
res.json({ res.json({
status: 'ok', status: 'ok',
message: 'Node successfully promoted to Master Site', message: 'Node successfully promoted to Master Site',
config: { config: {
isMaster: true, isMaster: true,
masterUrl: '', masterUrl: '',
siteSlug: localSiteConfig.siteSlug, siteSlug: cfg.siteSlug,
siteMode: 'master' 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')}, discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')}, vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.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')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.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> </select>
<div class="input-group input-group-sm shadow-sm" style="width: 230px;"> <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> <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();}"> onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
<datalist id="access-uid-list"></datalist> <datalist id="access-uid-list"></datalist>
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button> <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> <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()"> <select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()">
<option value="">-- Custom / Manual --</option> <option value="">-- Custom / Manual --</option>
<option value="docker">📦 Docker Engine / Container</option> <option value="docker">📦 Docker Engine / Container</option>
<option value="systemd">⚙️ Linux Systemd Service</option> <option value="systemd">⚙️ Linux Systemd Service</option>
<option value="postgresql">🗄️ PostgreSQL Database</option> <option value="postgresql">🗄️ PostgreSQL Database</option>
<option value="redis">🔴 Redis In-Memory Cache</option> <option value="redis">🔴 Redis In-Memory Cache</option>
<option value="proxmox">🖥️ Proxmox VE Hypervisor</option> <option value="proxmox">🖥️ Proxmox VE Hypervisor</option>
<option value="wireguard">🛡️ WireGuard VPN Tunnel</option> <option value="wireguard">🛡️ WireGuard VPN Tunnel</option>
<option value="unifi">📶 UniFi Network Controller</option> <option value="unifi">📶 UniFi Network Controller</option>
<option value="k8s">☸️ Kubernetes Cluster Node</option> <option value="k8s">☸️ Kubernetes Cluster Node</option>
</select> </select>
<label class="form-label">Sub Type</label> <label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql..."> <input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql...">
@@ -602,7 +602,7 @@
</div> </div>
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none"> <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>
</div> </div>
@@ -718,7 +718,7 @@
rawResources = []; rawResources = [];
for (const r of resResources.results) { for (const r of resResources.results) {
r.hostName = '—'; r.hostName = '';
r.parentId = null; r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id); const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) { if (parentEdge) {
@@ -770,13 +770,13 @@
} }
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; } if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
if (!a.isOnline) { 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; n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
} }
const t = a.lastTelemetry || {}; const t = a.lastTelemetry || {};
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90); const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
n.agentColor = high ? '#ffc107' : '#198754'; 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. // Agent tab body for the resource modal.
@@ -1112,7 +1112,7 @@
async function openUserAccessModal() { async function openUserAccessModal() {
const uid = ($('#user-access-uid').val() || '').trim(); const uid = ($('#user-access-uid').val() || '').trim();
if (!uid) return; 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 { try {
const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid)); const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
const data = res.results; const data = res.results;
@@ -1202,7 +1202,7 @@
} }
n.indentHtml = indentHtml; n.indentHtml = indentHtml;
n.depth = depth; 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 // 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. // the column instead of jittering by whether a row has children.
n.caretHtml = n.children.length n.caretHtml = n.children.length
@@ -1231,7 +1231,7 @@
applyTreeCollapse(); applyTreeCollapse();
} }
// ── Collapsible tree ─────────────────────────────────────────────────────── // ── Collapsible tree ───────────────────────────────────────────────────────
// Which nodes are collapsed, by resource id. Persisted so the shape of the // 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 // 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. // most edits) -- a tree that re-expands every time is worse than no tree.
@@ -1506,14 +1506,14 @@
<div class="p-3"> <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> <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"> <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 : ''}"> <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;"> <div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;">
${targets.map((r, i) => ` ${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)"> <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"> <div class="d-flex justify-content-between align-items-center">
<strong>${esc(r.name)}</strong> <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> </div>
${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''} ${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''}
</button> </button>
@@ -1915,7 +1915,7 @@
$select.append('<option value="">-- Select Parent Resource Secret --</option>'); $select.append('<option value="">-- Select Parent Resource Secret --</option>');
currentParentSecretsList.forEach(p => { currentParentSecretsList.forEach(p => {
const valStr = `INHERIT:${p.parentSlug}:${p.key}`; 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>`); $select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
}); });
} }
@@ -2064,7 +2064,7 @@
await loadResources(); await loadResources();
loadDiscoveryResources(); loadDiscoveryResources();
app.modal.close(); 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) { } catch (err) {
promoteSlug = slug; promoteSlug = slug;
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger'); app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
@@ -2232,7 +2232,7 @@
async function deleteResource(id) { async function deleteResource(id) {
// Called from the outer table's row button, not from inside the resource // 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. // somewhere actually visible.
const $target = $('#resources-list'); 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'); 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}"`; joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
} else if (selectedKeyId) { } else if (selectedKeyId) {
const k = agentJoinKeys.find(x => x.id === 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 { } else {
joinCmd = '# Create a join key above, or select one you already have the value for.'; joinCmd = '# Create a join key above, or select one you already have the value for.';
} }
$('#agent-join-command').text(joinCmd); $('#agent-join-command').text(joinCmd);
// Windows: the installer is a GitHub release artifact (built by the // 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. // 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 = 'theta-agent-2.1.0-windows-amd64-setup.exe';
const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`; const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`;
@@ -2473,7 +2473,7 @@
</ul> </ul>
<div class="tab-content mb-3"> <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="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
<div class="card border-success"> <div class="card border-success">
<div class="card-header py-2 fw-bold small bg-success-subtle"> <div class="card-header py-2 fw-bold small bg-success-subtle">
@@ -2483,14 +2483,14 @@
<p class="small text-muted mb-3"> <p class="small text-muted mb-3">
Run this on any host and it enrolls itself. The SSO issues that host its own 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 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. many hosts as you like; each still gets its own revocable identity.
</p> </p>
<div class="d-flex gap-2 align-items-end mb-3"> <div class="d-flex gap-2 align-items-end mb-3">
<div class="flex-grow-1"> <div class="flex-grow-1">
<label class="form-label small fw-bold mb-1">Existing join keys</label> <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> <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> </div>
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()"> <button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
<i class="fa-solid fa-plus me-1"></i> New join key <i class="fa-solid fa-plus me-1"></i> New join key
@@ -2536,7 +2536,7 @@
</div> </div>
</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="tab-pane fade" id="agent-mode-pre" role="tabpanel">
<div class="card border-primary mb-3" id="agent-enroll-card"> <div class="card border-primary mb-3" id="agent-enroll-card">
@@ -2583,7 +2583,7 @@
</ul> </ul>
<div class="tab-content" id="agent-install-tab-content"> <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="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-md-6"> <div class="col-md-6">
@@ -2616,7 +2616,7 @@
</div> </div>
</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="tab-pane fade" id="tab-custom-pane" role="tabpanel">
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-md-5"> <div class="col-md-5">
@@ -2717,12 +2717,12 @@
// Only hosts can carry an agent -- the API rejects anything else, so don't // Only hosts can carry an agent -- the API rejects anything else, so don't
// offer it here. // offer it here.
const $sel = $('#agent-enroll-resource').empty(); 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 rawResources
.filter(r => r.kind === 'host') .filter(r => r.kind === 'host')
.sort((a, b) => (a.name || '').localeCompare(b.name || '')) .sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.forEach(r => { .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])); $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(); const $sel = $('#agent-join-key-select').empty();
if (!agentJoinKeys.length) { 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 { } else {
$sel.append('<option value="">Select a key…</option>'); $sel.append('<option value="">Select a key</option>');
agentJoinKeys.forEach(k => { agentJoinKeys.forEach(k => {
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused'; 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>'); $tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
} else { } else {
agentJoinKeysAll.forEach(k => { 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 used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts';
const status = k.revoked const status = k.revoked
? '<span class="badge bg-secondary">Revoked</span>' ? '<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>`; `<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( const $row = $('<tr>').attr('data-join-key-row', k.id).append(
$('<td>').text(k.label), $('<td>').text(k.label),
$('<td>').append($('<code>').text(k.keyPrefix + '…')), $('<td>').append($('<code>').text(k.keyPrefix + '')),
$('<td>').text(created), $('<td>').text(created),
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })), $('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
$('<td>').html(status), $('<td>').html(status),
@@ -2792,7 +2792,7 @@
} }
async function viewAgentJoinKeyHosts(id, label) { 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 { try {
const res = await app.api.get(`agent/join-keys/${id}/agents`); const res = await app.api.get(`agent/join-keys/${id}/agents`);
const body = (res && (res.results || res)) || {}; const body = (res && (res.results || res)) || {};
@@ -2804,7 +2804,7 @@
const rows = agents.map(a => { const rows = agents.map(a => {
const dot = a.isOnline ? 'text-success' : 'text-muted'; const dot = a.isOnline ? 'text-success' : 'text-muted';
const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never'; 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(''); }).join('');
$out.html( $out.html(
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` + `<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
@@ -2857,7 +2857,7 @@
$('#agent-join-key-result').show().html( $('#agent-join-key-result').show().html(
'<div class="alert alert-success py-2 small mb-3">' '<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> ' + '<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>' + '</div>'
+ '<label class="form-label small fw-bold mb-1">Join key</label>' + '<label class="form-label small fw-bold mb-1">Join key</label>'
+ '<div class="input-group input-group-sm mb-3">' + '<div class="input-group input-group-sm mb-3">'
@@ -2881,7 +2881,7 @@
app.messages.toast('Give the agent a name first.', 'warning'); app.messages.toast('Give the agent a name first.', 'warning');
return; 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 { try {
const res = await app.api.post('agent/enroll', { name, resourceId }); const res = await app.api.post('agent/enroll', { name, resourceId });
const body = res && (res.results || res); const body = res && (res.results || res);
@@ -2904,7 +2904,7 @@
keyWarn + keyWarn +
'<div class="alert alert-success py-2 small mb-2">' '<div class="alert alert-success py-2 small mb-2">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> ' + '<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>' + '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>' + '<label class="form-label small fw-bold mb-1">Agent token</label>'
+ '<div class="input-group input-group-sm mb-2">' + '<div class="input-group input-group-sm mb-2">'
@@ -2996,7 +2996,7 @@
: ''; : '';
const log = p.lastLog || '(no log captured for this run)'; const log = p.lastLog || '(no log captured for this run)';
app.modal.open({ app.modal.open({
title: 'Run log — ' + (p.name || p.slug), title: 'Run log ' + (p.name || p.slug),
size: 'lg', 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>', 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 = []; 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 // 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 / // plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
// tokenId / tokenSecret) are collected at create time. // tokenId / tokenSecret) are collected at create time.
var DP_CRON_PRESETS = [ var DP_CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' }, { key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' }, { key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 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) { function dpCronKeyFor(cron) {
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0]; 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 ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
var val = ''; var val = '';
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"'; 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>' + 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>'; '<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
} }
@@ -3194,7 +3194,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label> <label class="form-label fw-bold">Plugin Type</label>
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled> <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>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Instance Name</label> <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> <button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
</div> </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() { async function refreshSiteStatus() {
try { try {
const res = await app.api.get('directory-admin/site-status'); 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 mb-3 shadow-sm border-' + (isMaster ? 'warning' : 'info') + '">' +
'<div class="card-body">' + '<div class="card-body">' +
'<h5 class="card-title d-flex align-items-center justify-content-between">' + '<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>' + '<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
'</h5>' + '</h5>' +
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' + '<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' +
@@ -3388,7 +3388,7 @@
loadDiscoveryPlugins(); loadDiscoveryPlugins();
refreshSiteStatus(); refreshSiteStatus();
// Keep the host status dots live: refresh the agent join periodically and on // 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(); refreshAgents();
setInterval(refreshAgents, 30000); setInterval(refreshAgents, 30000);
try { try {