230 lines
8.1 KiB
JavaScript
230 lines
8.1 KiB
JavaScript
/**
|
|
* Private Network wizard + status routes — DC-152
|
|
* (specs/PRIVATE-NETWORK-SPEC.md §9).
|
|
*
|
|
* Mounted on the authed apiRouter under /api/v1/network/wizard (auth
|
|
* middleware is the caller's job, same as every route module here).
|
|
*
|
|
* GET /state → current step + which checks pass
|
|
* POST /mode → pick tailscale|wireguard + credentials
|
|
* POST /domain → private TLD pick (default .internal)
|
|
* POST /connect → health check + first sync
|
|
* POST /dns → technitium checkSetup + split-DNS (gated)
|
|
* POST /ca → named root CA (locked-in warning)
|
|
* POST /admin → first person (bootstrap admin)
|
|
* POST /complete → gate: all checks green → completedAt
|
|
*
|
|
* Status surface (same file, mounted separately under /network/status):
|
|
* GET /status → who's online, recent connections,
|
|
* ACL/firewall sync state, drift banner
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const { createNetworkWizard, WizardError } = require('../src/network/wizard');
|
|
const { ok, created, errorResponse } = require('../src/utils/responses');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const noopLog = { info() {}, warn() {}, error() {} };
|
|
|
|
function _asyncHandlerFallback(fn) {
|
|
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
}
|
|
|
|
/**
|
|
* Wire a WizardError (or anything statusCode-shaped) to an HTTP response.
|
|
*/
|
|
function _wizardErrorHandler(res, err) {
|
|
const status = err instanceof WizardError ? err.statusCode : (err.statusCode || 500);
|
|
const payload = {
|
|
success: false,
|
|
error: err.message || 'wizard error',
|
|
code: err.code || null,
|
|
};
|
|
if (err.extra && typeof err.extra === 'object' && Object.keys(err.extra).length) {
|
|
payload.details = err.extra;
|
|
}
|
|
return errorResponse(res, status, payload.error, { code: payload.code, details: payload.details });
|
|
}
|
|
|
|
/**
|
|
* Build a wizard instance from loose route deps (prod path) or accept a
|
|
* pre-built one (tests / composition root reuse).
|
|
*/
|
|
function _buildWizard(wizardDeps, stateFile) {
|
|
return createNetworkWizard({
|
|
registry: wizardDeps.registry,
|
|
providers: wizardDeps.providers || {},
|
|
peopleStore: wizardDeps.peopleStore,
|
|
accessStore: wizardDeps.accessStore || null,
|
|
technitium: wizardDeps.technitium || null,
|
|
caSetup: wizardDeps.caSetup || null,
|
|
credentialStore: wizardDeps.credentialStore || null,
|
|
logger: wizardDeps.log || noopLog,
|
|
...(stateFile ? { stateFile } : {}),
|
|
});
|
|
}
|
|
|
|
module.exports = function networkWizardRoutesFactory({
|
|
wizard = null, // pre-built orchestrator (optional)
|
|
registry = null,
|
|
providers = {},
|
|
peopleStore = null,
|
|
accessStore = null,
|
|
technitium = null,
|
|
caSetup = null,
|
|
credentialStore = null,
|
|
stateFile = null, // default: data/network-wizard-state.json
|
|
connectionLogReader = null, // GET /status injectable
|
|
aclStateReader = null, // GET /status injectable (acl-push state)
|
|
firewallStateReader = null, // GET /status injectable (wg-firewall state)
|
|
asyncHandler = null,
|
|
log = noopLog,
|
|
} = {}) {
|
|
const router = express.Router();
|
|
const wrap = asyncHandler || _asyncHandlerFallback;
|
|
if (!asyncHandler && process.env.NODE_ENV !== 'test') {
|
|
// Must always be wired in prod (see share.js); fallback is test-only.
|
|
}
|
|
|
|
const w = wizard || _buildWizard(
|
|
{ registry, providers, peopleStore, accessStore, technitium, caSetup, credentialStore, log },
|
|
stateFile
|
|
);
|
|
|
|
// ─── Wizard state ──────────────────────────────────────────────────────
|
|
|
|
router.get('/state', wrap(async (req, res) => {
|
|
ok(res, w.getState());
|
|
}, 'network-wizard-state'));
|
|
|
|
// ─── Step 1: mode + credentials ────────────────────────────────────────
|
|
|
|
router.post('/mode', wrap(async (req, res) => {
|
|
try {
|
|
const result = await w.setMode(req.body || {});
|
|
ok(res, result);
|
|
} catch (err) {
|
|
_wizardErrorHandler(res, err);
|
|
}
|
|
}, 'network-wizard-mode'));
|
|
|
|
// ─── Step 2: private domain ────────────────────────────────────────────
|
|
|
|
router.post('/domain', wrap(async (req, res) => {
|
|
try {
|
|
const result = w.setDomain(req.body || {});
|
|
ok(res, result);
|
|
} catch (err) {
|
|
_wizardErrorHandler(res, err);
|
|
}
|
|
}, 'network-wizard-domain'));
|
|
|
|
// ─── Step 3: connect + first sync ──────────────────────────────────────
|
|
|
|
router.post('/connect', wrap(async (req, res) => {
|
|
try {
|
|
const result = await w.connect();
|
|
ok(res, result);
|
|
} catch (err) {
|
|
_wizardErrorHandler(res, err);
|
|
}
|
|
}, 'network-wizard-connect'));
|
|
|
|
// ─── Step 4: DNS (technitium, capability-gated) ───────────────────────
|
|
|
|
router.post('/dns', wrap(async (req, res) => {
|
|
try {
|
|
const result = await w.configureDns();
|
|
ok(res, result);
|
|
} catch (err) {
|
|
_wizardErrorHandler(res, err);
|
|
}
|
|
}, 'network-wizard-dns'));
|
|
|
|
// ─── Step 5: CA ───────────────────────────────────────────────────────
|
|
|
|
router.post('/ca', wrap(async (req, res) => {
|
|
try {
|
|
const result = await w.setupCa(req.body || {});
|
|
ok(res, result);
|
|
} catch (err) {
|
|
_wizardErrorHandler(res, err);
|
|
}
|
|
}, 'network-wizard-ca'));
|
|
|
|
// ─── Step 6: first admin ──────────────────────────────────────────────
|
|
|
|
router.post('/admin', wrap(async (req, res) => {
|
|
try {
|
|
const result = await w.createAdmin(req.body || {});
|
|
created(res, result);
|
|
} catch (err) {
|
|
_wizardErrorHandler(res, err);
|
|
}
|
|
}, 'network-wizard-admin'));
|
|
|
|
// ─── Completion gate ──────────────────────────────────────────────────
|
|
|
|
router.post('/complete', wrap(async (req, res) => {
|
|
try {
|
|
const result = w.complete();
|
|
ok(res, result);
|
|
} catch (err) {
|
|
_wizardErrorHandler(res, err);
|
|
}
|
|
}, 'network-wizard-complete'));
|
|
|
|
// ─── Status (GET /status mounted here too; also exported standalone) ──
|
|
|
|
router.get('/status', wrap(async (req, res) => {
|
|
ok(res, await w.getStatus({
|
|
connectionLogReader,
|
|
aclStateReader,
|
|
firewallStateReader,
|
|
}));
|
|
}, 'network-wizard-status'));
|
|
|
|
return router;
|
|
};
|
|
|
|
/**
|
|
* Standalone status router for mounting at /api/v1/network/status without
|
|
* the wizard routes. Same aggregation, same injectables.
|
|
*/
|
|
module.exports.networkStatusRoutesFactory = function networkStatusRoutesFactory({
|
|
wizard = null,
|
|
connectionLogReader = null,
|
|
aclStateReader = null,
|
|
firewallStateReader = null,
|
|
asyncHandler = null,
|
|
} = {}) {
|
|
const router = express.Router();
|
|
const wrap = asyncHandler || _asyncHandlerFallback;
|
|
const w = wizard; // required for the standalone form
|
|
router.get('/', wrap(async (req, res) => {
|
|
if (!w) {
|
|
return ok(res, {
|
|
wizard: { completedAt: null, mode: null, privateTld: null },
|
|
online: { devices: [], peopleOnline: 0 },
|
|
recentConnections: { events: [], note: 'no connection log source configured' },
|
|
sync: { acl: null, firewall: null },
|
|
driftBanner: false,
|
|
});
|
|
}
|
|
ok(res, await w.getStatus({
|
|
connectionLogReader,
|
|
aclStateReader,
|
|
firewallStateReader,
|
|
}));
|
|
}, 'network-status'));
|
|
return router;
|
|
};
|
|
|
|
// Default state-file path resolution exported for the composition root.
|
|
module.exports.defaultWizardStateFile = function defaultWizardStateFile(dataDir) {
|
|
return path.join(dataDir || os.tmpdir(), 'network-wizard-state.json');
|
|
};
|