diff --git a/.gitguardian.yml b/.gitguardian.yml new file mode 100644 index 0000000..66c42b2 --- /dev/null +++ b/.gitguardian.yml @@ -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 diff --git a/docs/site-join.md b/docs/site-join.md new file mode 100644 index 0000000..b5dffe3 --- /dev/null +++ b/docs/site-join.md @@ -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. diff --git a/nodejs/app.js b/nodejs/app.js index af912be..69d5c59 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -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')); diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 20f0272..d9d245f 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -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 ] }); diff --git a/nodejs/models/site_join_key.js b/nodejs/models/site_join_key.js new file mode 100644 index 0000000..f6a6e3d --- /dev/null +++ b/nodejs/models/site_join_key.js @@ -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 }; diff --git a/nodejs/package.json b/nodejs/package.json index f93aa05..533c2ac 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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", diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index ea680c5..a9bc759 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -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' } }); diff --git a/nodejs/routes/api_site.js b/nodejs/routes/api_site.js new file mode 100644 index 0000000..1417fc8 --- /dev/null +++ b/nodejs/routes/api_site.js @@ -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; diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 54357ce..1c88156 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -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')}, diff --git a/nodejs/tests/site_config.test.js b/nodejs/tests/site_config.test.js new file mode 100644 index 0000000..c56c556 --- /dev/null +++ b/nodejs/tests/site_config.test.js @@ -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'); +}); diff --git a/nodejs/tests/site_join.test.js b/nodejs/tests/site_join.test.js new file mode 100644 index 0000000..ea73731 --- /dev/null +++ b/nodejs/tests/site_join.test.js @@ -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(''); +}); diff --git a/nodejs/utils/site_config.js b/nodejs/utils/site_config.js new file mode 100644 index 0000000..dbae108 --- /dev/null +++ b/nodejs/utils/site_config.js @@ -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 }; diff --git a/nodejs/utils/site_join.js b/nodejs/utils/site_join.js new file mode 100644 index 0000000..746677a --- /dev/null +++ b/nodejs/utils/site_join.js @@ -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 }; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 20b1fc2..516bbe0 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -63,7 +63,7 @@
Loading…
' }); + app.modal.open({ title: 'Access for ' + uid, bodyHtml: 'Loading…
' }); 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 @@Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:
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
- agent.yml — nothing to copy back and forth. One key works for as
+ agent.yml — nothing to copy back and forth. One key works for as
many hosts as you like; each still gets its own revocable identity.