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
+49 -2
View File
@@ -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 })),