feat(site): join UI, spoke read-only enforcement, live WAN health, fresh-install guard

Completes the multi-site join layer on top of the v2.2.0 endpoints:

- UI (Master Site modal): a fresh install (canJoin) gets a 'Join an Existing
  Site' form (master URL + stj_ key); a master gets a 'Site Join Keys' manager
  (mint/revoke/list, key shown once); WAN Sync Health now reflects a live probe.
- POST /api/site/ping (Bearer stj_ key, no admin session): lightweight master
  reachability probe for WAN health (cheap vs /export).
- Spoke read-only: directory-write routes (resources/edges/groups/secrets/
  grants/driver-action/discovered) reject with 403 pointing at the master.
- Fresh-install guard: /api/site/join refuses unless no users beyond the
  bootstrap admin and no enrolled agents (siteIsFresh), and site-status exposes
  canJoin so the UI only offers join on a genuinely fresh install. The
  bootstrap's seeded default resources are NOT the signal (they always exist).
- The spoke stores the join key (masterJoinKey) in /config/site.json so WAN
  health (and a future write-proxy) can reach the master.
- Tests: siteIsFresh cases in tests/site_join.test.js.
This commit is contained in:
2026-08-10 09:12:28 -07:00
parent 3d18c3f0cb
commit 9d266d2e4c
6 changed files with 268 additions and 61 deletions
+22 -1
View File
@@ -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 };