diff --git a/docs/site-join.md b/docs/site-join.md index b5dffe3..871d6c2 100644 --- a/docs/site-join.md +++ b/docs/site-join.md @@ -6,25 +6,28 @@ 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. +> Status: **server endpoints + UI + setup.sh wiring.** A fresh bring-up can +> adopt a master directory via the Directory UI or via `setup.env`, and a +> joined spoke is read-only with live WAN health. ## 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. + stored hashed, revocable) — Directory → the Master Site modal → **Site Join Keys**. +2. On the **spoke** (a fresh install), either: + - **UI**: Directory → the Master Site modal → **Join an Existing Site**, or + - **setup.sh**: set `CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY` + in `setup.env` before the first run. 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`). + (`isMaster: false`, `masterUrl`, `siteSlug`) in `/config/site.json`. + +Joining is allowed only on a **fresh install** (no users beyond the bootstrap +admin, no enrolled agents) — the join endpoint enforces this, so a populated +directory can never be merged into a master's. ## Endpoints -### Join key management (admin session) - | Method | Path | Purpose | | :--- | :--- | :--- | | `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) | @@ -32,60 +35,42 @@ full architecture). This page covers the server endpoints that make a spoke | `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) | +| `POST` | `/api/site/export` | Master directory export (Bearer `stj_` key) | +| `POST` | `/api/site/ping` | Lightweight master reachability probe (Bearer `stj_` key) | +| `POST` | `/api/site/join` | Adopt a master directory (admin session) | -Mint a key: +## Behavior after joining (spoke) -```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 -``` +- **Read-only**: directory-write requests (resources, edges, groups, secrets, + grants, driver actions, discovery merges) are rejected with `403` pointing at + the master. Writes must go to the master. +- **WAN health**: `site-status` pings the master over the stored site join key + and reports `wanConnected`; the Master Site modal shows live Online/Offline. +- **Role persists**: `isMaster`/`masterUrl`/`siteSlug` live in `/config/site.json` + (the env vars `IS_MASTER`/`MASTER_URL`/`SITE_SLUG` only seed the defaults), so + a restart never silently reverts a spoke to master. -### Export (master side — no admin session) +## Deployment (setup.sh) -`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: +`setup.env` carries 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... +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. +`setup.sh` runs `bootstrap/site-join.js` inside the sso-manager container after +the bootstrap; it logs in as the admin and calls `/api/site/join`. A node that +already joined reports "already a spoke" and setup continues (idempotent). ## 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. +- The export/ping endpoints return only the directory tree/catalog (no admin + secrets) and require a valid join key. +- Join is admin-gated on the spoke, key-gated on the master, and fresh-install + gated on both sides. +- The join key is stored on the spoke only so it can reach the master for WAN + health (and, in a later layer, write-proxy). diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index a9bc759..2856fbc 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -252,6 +252,23 @@ router.get('/resources', async (req, res, next) => { } catch (err) { next(err); } }); +// ── Spoke read-only enforcement ───────────────────────────────────────────── +// On a joined spoke the catalog is a copy of the master's; directory writes +// must go to the master (MULTI_SITE_SPEC.md — spoke = read-only catalog). Any +// mutating request below this point is rejected on a spoke with a pointer to +// the master. (site-status / site-promote live AFTER this middleware and are +// not directory writes.) +router.use((req, res, next) => { + if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { + const cfg = siteConfig.get(); + if (!cfg.isMaster) { + const hint = cfg.masterUrl ? ' Directory writes must go to the master at ' + cfg.masterUrl + '.' : ''; + return res.status(403).json({ status: 'error', message: 'This node is a spoke (read-only catalog).' + hint }); + } + } + next(); +}); + router.post('/resources', async (req, res, next) => { try { if (!req.body.hostId && req.body.parentSlug) { @@ -887,6 +904,30 @@ router.post('/discovered/merge', async (req, res, next) => { // 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'); +const { siteIsFresh } = require('../utils/site_join'); +const { Agent } = require('../models/agent'); + +// probeMasterHealth checks whether this (spoke) node can reach its master over +// the site join key. The master's /api/site/ping is deliberately lightweight. +async function probeMasterHealth(cfg) { + if (cfg.isMaster) return true; + if (!cfg.masterUrl || !cfg.masterJoinKey) return false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10000); + try { + const resp = await fetch(String(cfg.masterUrl).replace(/\/+$/, '') + '/api/site/ping', { + method: 'POST', + headers: { Authorization: 'Bearer ' + cfg.masterJoinKey, 'Content-Type': 'application/json' }, + body: '{}', + signal: controller.signal + }); + return resp.ok; + } catch (e) { + return false; + } finally { + clearTimeout(timer); + } +} router.get('/site-status', async (req, res, next) => { try { @@ -895,14 +936,20 @@ router.get('/site-status', async (req, res, next) => { const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard'); const cfg = siteConfig.get(); + const wanConnected = await probeMasterHealth(cfg); + let canJoin = false; + if (cfg.isMaster) { + canJoin = await siteIsFresh({ User, Agent }).catch(() => false); + } res.json({ status: 'ok', config: { isMaster: cfg.isMaster, masterUrl: cfg.masterUrl, siteSlug: cfg.siteSlug, - wanConnected: cfg.wanConnected, - siteMode: cfg.isMaster ? 'master' : 'spoke' + wanConnected, + siteMode: cfg.isMaster ? 'master' : 'spoke', + canJoin }, sitesCount: sites.length, sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })), diff --git a/nodejs/routes/api_site.js b/nodejs/routes/api_site.js index 1417fc8..2bcf858 100644 --- a/nodejs/routes/api_site.js +++ b/nodejs/routes/api_site.js @@ -25,8 +25,10 @@ const permission = require('../utils/permission'); const conf = require('@simpleworkjs/conf'); const { Resource, ResourceEdge } = require('../models/resource'); const { SiteJoinKey } = require('../models/site_join_key'); +const User = require('../models/user'); +const { Agent } = require('../models/agent'); const siteConfig = require('../utils/site_config'); -const { importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join'); +const { importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join'); const execFileAsync = promisify(execFile); const router = express.Router(); @@ -80,6 +82,19 @@ router.post('/export', async (req, res, next) => { } catch (e) { next(e); } }); +// ── Ping (MASTER side, Bearer site-join-key; no admin session) ───────────── +// Lightweight reachability probe a spoke uses for WAN-health — deliberately +// cheap (no LDAP dump / catalog), unlike /export. +router.post('/ping', 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' }); + res.json({ status: 'ok', siteSlug: siteConfig.get().siteSlug, ts: Math.floor(Date.now() / 1000) }); + } catch (e) { next(e); } +}); + // ── Everything below requires an admin session ────────────────────────────── router.use(middleware.auth); router.use(async (req, res, next) => { @@ -157,6 +172,14 @@ router.post('/join', async (req, res, next) => { if (!cfg.isMaster) { return res.status(400).json({ status: 'error', message: 'this node is already a spoke (re-join is not supported)' }); } + // Only a fresh install may join — a directory with real users must not be + // merged into a master's (that is the destructive case). + if (!(await siteIsFresh({ User, Agent }))) { + return res.status(409).json({ + status: 'error', + message: 'This directory already has users/agents. Only a fresh install may join a site (re-provision the host to adopt a master directory).' + }); + } const base = String(masterUrl).replace(/\/+$/, ''); const controller = new AbortController(); @@ -201,8 +224,11 @@ router.post('/join', async (req, res, next) => { ldapNote = 'skipped/failed: ' + e.message; } - // 3. Persist the spoke role (survives restarts). - siteConfig.save({ isMaster: false, masterUrl: base, siteSlug: exportData.siteSlug || cfg.siteSlug }); + // 3. Persist the spoke role (survives restarts). The join key is kept so + // the spoke can run WAN-health checks (and, in a later layer, proxy + // writes) against the master — it is a spoke-to-master credential, not + // a shared secret. + siteConfig.save({ isMaster: false, masterUrl: base, siteSlug: exportData.siteSlug || cfg.siteSlug, masterJoinKey: joinKey }); logAudit('joined', { actor: req.user.uid, diff --git a/nodejs/tests/site_join.test.js b/nodejs/tests/site_join.test.js index ea73731..064316e 100644 --- a/nodejs/tests/site_join.test.js +++ b/nodejs/tests/site_join.test.js @@ -1,6 +1,6 @@ 'use strict'; -const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join'); +const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join'); // In-memory model stubs so importDirectory can be exercised without a DB. function makeStore() { @@ -119,3 +119,32 @@ test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () => expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com'); expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe(''); }); + +// The fresh-install guard: only no-users-beyond-admin + no-agents may join. +test('siteIsFresh is true with only the bootstrap admin and no agents', async () => { + const User = { listDetail: async () => [{ uid: 'admin', isServiceAccount: false }] }; + const Agent = { list: async () => [] }; + expect(await siteIsFresh({ User, Agent })).toBe(true); +}); + +test('siteIsFresh is false with a second real user', async () => { + const User = { listDetail: async () => [{ uid: 'admin' }, { uid: 'bob' }] }; + const Agent = { list: async () => [] }; + expect(await siteIsFresh({ User, Agent })).toBe(false); +}); + +test('siteIsFresh is false with an enrolled agent', async () => { + const User = { listDetail: async () => [{ uid: 'admin' }] }; + const Agent = { list: async () => [{ id: 'a1' }] }; + expect(await siteIsFresh({ User, Agent })).toBe(false); +}); + +test('siteIsFresh ignores service accounts', async () => { + const User = { listDetail: async () => [ + { uid: 'admin' }, + { uid: 'sso-svc', isServiceAccount: true }, + { uid: 'ldapclient', isServiceAccount: true } + ] }; + const Agent = { list: async () => [] }; + expect(await siteIsFresh({ User, Agent })).toBe(true); +}); diff --git a/nodejs/utils/site_join.js b/nodejs/utils/site_join.js index 746677a..a8799d7 100644 --- a/nodejs/utils/site_join.js +++ b/nodejs/utils/site_join.js @@ -101,4 +101,25 @@ function baseDnFrom(conf) { return m ? m[1] : ''; } -module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom }; +// siteIsFresh reports whether this deployment may join a master site +// (MULTI_SITE_SPEC.md): no users beyond the bootstrap admin and no enrolled +// agents. The bootstrap always seeds a handful of default resources (site → +// host → sso/proxy services), so resources are NOT the signal — the operator's +// rule is "no users". A directory with real users must never be merged into a +// master's; that is the destructive case this guard prevents. +async function siteIsFresh({ User, Agent }) { + const agents = (Agent && Agent.list ? await Agent.list().catch(() => []) : []); + if (agents && agents.length > 0) return false; + if (User && typeof User.listDetail === 'function') { + try { + const users = await User.listDetail(); + const real = (users || []).filter(u => !u.isServiceAccount); + return real.length <= 1; // at most the bootstrap admin + } catch (e) { + // LDAP unreachable — fall back to the agent-only check. + } + } + return true; +} + +module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh }; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 516bbe0..d5e9cf7 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -3336,7 +3336,9 @@ '
| Local Site Slug: | ' + esc(cfg.siteSlug || 'site-default') + ' |
|---|---|
| Master Authority URL: | ' + (cfg.masterUrl ? ('' + esc(cfg.masterUrl) + '') : '(This Node is Master)') + ' |
| WAN Sync Health: | Online / Operational |
| WAN Sync Health: | ' + (res.config.wanConnected === false + ? ' Offline / Disconnected' + : ' Online / Operational') + ' |
| Registered Sites: | ' + (res.sitesCount || 0) + ' sites |
| Theta Gateways: | ' + (res.gatewaysCount || 0) + ' active gateways |
theta-gateway subnets (10.x.0.0/16) with default NETMAP shadow translations (10.x.168.0/24 → 192.168.1.0/24).' +
'';
+ // Fresh install (no users/resources yet): offer to JOIN an existing
+ // master site instead of seeding a new directory.
+ if (isMaster && cfg.canJoin) {
+ html += 'This is a fresh install. Join an existing (master) deployment to run as a read-only spoke of its directory — paste the master URL and a site join key minted there.
' + + '| Label | Prefix | Used | Status |
|---|
' + esc(k.keyPrefix) + '' + res.key + '' +
+ '