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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user