Files
dashcaddy/dashcaddy-api/routes/network-wizard.js
T

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');
};