DC-152: Private Network setup wizard (mode/domain/connect/dns/ca/admin steps) + status API + tests

This commit is contained in:
Hermes committed 2026-10-03 21:47:34 -07:00
1 parent 3291f80d0c
commit 38af7ba224
3 files changed
+1598

No files matched your search

+722
View File
@@ -0,0 +1,722 @@
/**
* Private Network setup wizard orchestrator — DC-152
* (specs/PRIVATE-NETWORK-SPEC.md §9, NORMATIVE step order:
* mode → domain → connect → dns → ca → admin → complete).
*
* Every dependency is injectable so tests stay hermetic:
* registry — src/network/registry.js (setActiveProvider/getActiveProvider)
* providers — { tailscale, wireguard } provider instances used for
* credential/dependency validation and syncs
* peopleStore — src/security/network-people-store.js instance
* accessStore — network-access-store instance (reserved; not yet read)
* technitium — OPTIONAL: { checkSetup(), configureSplitDns(...) }
* caSetup — OPTIONAL: { setupNamedRootCA({ commonName, privateTld }) }
* credentialStore — OPTIONAL: credential-manager-shaped { store, retrieve, delete }
* logger, stateStore / stateFile
*
* Wizard state persists at data/network-wizard-state.json:
* { step, mode, privateTld, providerConnected, dnsConfigured, caConfigured,
* adminCreated, startedAt, completedAt }
*
* Credential reuse (NOT duplicated): Tailscale credentials go through the SAME
* credential-manager keys the existing settings UI uses —
* 'tailscale.coord.apiToken' (src/context/index.js tailscaleCoord.setApiToken)
* 'tailscale.oauth.client_id' / 'tailscale.oauth.client_secret'
* (routes/tailscale.js oauth-config)
* The prod tailscale provider reads the token from credentialManager on every
* call, so store-then-validate ordering works without restarting anything.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { ProviderError } = require('./errors');
const STEP_ORDER = ['mode', 'domain', 'connect', 'dns', 'ca', 'admin', 'complete'];
/** Spec §9 step 2: silently-allowed TLDs; anything else warns (never blocks). */
const SILENT_TLDS = new Set(['.internal', 'home.arpa']);
const TS_API_KEY_PREFIX = 'tskey-api-';
const TS_OAUTH_CLIENT_PREFIX = 'tskey-client-';
const TS_API_KEY_MAX_AGE_DAYS = 90;
const CRED_KEY_TS_API_TOKEN = 'tailscale.coord.apiToken';
const CRED_KEY_TS_OAUTH_ID = 'tailscale.oauth.client_id';
const CRED_KEY_TS_OAUTH_SECRET = 'tailscale.oauth.client_secret';
const WG_BINARIES = ['wg', 'wg-quick', 'nft'];
function _defaultState(nowIso) {
return {
step: 'mode',
mode: null,
privateTld: null,
providerConnected: false,
dnsConfigured: false,
caConfigured: false,
adminCreated: false,
startedAt: nowIso(),
completedAt: null,
// Skip flags so POST /complete can distinguish "not done" from
// "optional dep absent, consciously skipped".
dnsSkipped: false,
caSkipped: false,
};
}
/** File-backed state store (atomic-enough for a single-writer wizard). */
function _createFileStateStore(file) {
return {
load() {
try {
const raw = fs.readFileSync(file, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (_e) {
return null;
}
},
save(state) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf8');
},
};
}
/**
* No-op credential store used when none is injected: credentials are kept
* only for the lifetime of the call (validation), never persisted. Prod
* always injects the real credential-manager.
*/
function _defaultCredentialStore() {
const ephemeral = new Map();
return {
store: async (k, v) => { ephemeral.set(k, v); },
retrieve: async (k) => ephemeral.get(k) || null,
delete: async (k) => { ephemeral.delete(k); },
};
}
class WizardError extends Error {
constructor(message, { statusCode = 400, code = 'wizard_error', extra = {} } = {}) {
super(message);
this.name = 'WizardError';
this.statusCode = statusCode;
this.code = code;
this.extra = extra;
}
}
function createNetworkWizard({
registry,
providers = {},
peopleStore,
accessStore: _accessStore = null,
technitium = null,
caSetup = null,
credentialStore = null,
stateStore = null,
stateFile = null,
logger = { info() {}, warn() {}, error() {} },
now = () => new Date().toISOString(),
} = {}) {
const store = stateStore || _createFileStateStore(
stateFile || path.join(_defaultDataDir(), 'network-wizard-state.json')
);
const log = logger;
function _defaultDataDir() {
try {
// eslint-disable-next-line global-require -- lazy: keeps require-time IO at zero
return require('../../platform-paths').dataDir;
} catch (_e) {
return require('os').tmpdir(); // eslint-disable-line global-require
}
}
let _state = null;
function _load() {
if (!_state) {
const persisted = store.load();
_state = Object.assign(_defaultState(now), persisted || {});
}
return _state;
}
function _persist() {
store.save(_state);
}
function _provider(mode) {
const p = providers[mode || _load().mode];
if (!p) {
throw new WizardError('provider instance for mode "' + mode + '" is not available', {
statusCode: 503,
code: 'provider_unavailable',
});
}
return p;
}
/** Public, JSON-safe view + which checks currently pass. */
function getState() {
const s = _load();
return {
...s,
stepIndex: STEP_ORDER.indexOf(s.step),
steps: STEP_ORDER,
checks: {
modeSelected: !!s.mode,
domainConfigured: !!s.privateTld,
providerConnected: !!s.providerConnected,
dnsConfigured: !!s.dnsConfigured || !!s.dnsSkipped,
caConfigured: !!s.caConfigured || !!s.caSkipped,
adminCreated: !!s.adminCreated,
},
};
}
/* ── Step 1: mode + credentials ─────────────────────────────────── */
async function _validateTailscaleCredentials() {
const provider = _provider('tailscale');
// Spec §3-adjacent: validate by actually exercising the API before
// accepting. listDevices() first; fall back to listPeople().
try {
const devices = await provider.listDevices();
return { ok: true, deviceCount: Array.isArray(devices) ? devices.length : null };
} catch (e) {
if (e instanceof ProviderError && e.code !== 'not_found') throw _asCredentialError(e);
if (!(e instanceof ProviderError)) throw _asCredentialError(e);
const people = await provider.listPeople();
return { ok: true, deviceCount: null, peopleCount: Array.isArray(people) ? people.length : null };
}
}
/** Credential/dependency validation failures are caller errors (4xx). */
function _asCredentialError(e) {
if (e instanceof WizardError) return e;
const status = e instanceof ProviderError && e.code === 'dependency_missing' ? 400 : 400;
return new WizardError('provider validation failed: ' + e.message, {
statusCode: status,
code: e.code || 'credential_validation_failed',
extra: e instanceof ProviderError ? { providerCode: e.code } : {},
});
}
async function setMode({ mode, credentials = {} } = {}) {
const s = _load();
if (mode !== 'tailscale' && mode !== 'wireguard') {
throw new WizardError("mode must be 'tailscale' or 'wireguard'", { code: 'bad_mode' });
}
if (mode === 'tailscale') {
return await _setTailscaleMode(s, credentials);
}
return await _setWireguardMode(s, credentials);
}
async function _setTailscaleMode(s, credentials) {
const { apiKey, oauthClientSecret, oauthClientId } = credentials;
if (!apiKey && !oauthClientSecret) {
throw new WizardError(
"tailscale mode requires credentials: { apiKey } or { oauthClientSecret }",
{ code: 'credentials_required' }
);
}
if (apiKey && typeof apiKey !== 'string') {
throw new WizardError('apiKey must be a string', { code: 'bad_credentials' });
}
const usedApiKey = !!apiKey;
let apiKeyExpiryWarning = false;
let warningMessage = null;
if (usedApiKey && !apiKey.startsWith(TS_API_KEY_PREFIX)) {
throw new WizardError(
'apiKey must start with "' + TS_API_KEY_PREFIX + '"',
{ code: 'bad_credentials' }
);
}
if (usedApiKey) {
// Fact: Tailscale API keys expire after at most ~90 days. The wizard
// surfaces this and recommends a non-expiring OAuth client instead.
apiKeyExpiryWarning = true;
warningMessage =
'Tailscale API keys expire after at most ' + TS_API_KEY_MAX_AGE_DAYS +
' days and will need to be rotated. Recommended: create an OAuth client ' +
'(secret starts with "' + TS_OAUTH_CLIENT_PREFIX + '") which does not expire.';
}
if (oauthClientSecret && typeof oauthClientSecret !== 'string') {
throw new WizardError('oauthClientSecret must be a string', { code: 'bad_credentials' });
}
// Store first (the prod provider reads the token from credentialManager on
// every call), validate, and roll the credential back out on failure.
const creds = credentialStore || _defaultCredentialStore();
const stored = [];
try {
if (usedApiKey) {
await creds.store(CRED_KEY_TS_API_TOKEN, apiKey, {
description: 'Tailscale coordination API token (set by Private Network wizard)',
source: 'network-wizard',
});
stored.push(CRED_KEY_TS_API_TOKEN);
} else {
if (oauthClientId) {
await creds.store(CRED_KEY_TS_OAUTH_ID, oauthClientId, {
description: 'Tailscale OAuth client id (set by Private Network wizard)',
source: 'network-wizard',
});
stored.push(CRED_KEY_TS_OAUTH_ID);
}
await creds.store(CRED_KEY_TS_OAUTH_SECRET, oauthClientSecret, {
description: 'Tailscale OAuth client secret (set by Private Network wizard)',
source: 'network-wizard',
});
stored.push(CRED_KEY_TS_OAUTH_SECRET);
}
const validation = await _validateTailscaleCredentials();
_activate(s, 'tailscale');
return {
mode: 'tailscale',
step: s.step,
validation,
...(apiKeyExpiryWarning
? { apiKeyExpiryWarning: true, warning: warningMessage }
: {}),
};
} catch (e) {
for (const key of stored) {
try { await creds.delete(key); } catch (_del) { /* best-effort rollback */ }
}
throw e;
}
}
async function _setWireguardMode(s, credentials) {
const { endpoint, listenPort, externalInterface } = credentials || {};
if (!endpoint || typeof endpoint !== 'string' || !/^[a-zA-Z0-9.:-]+$/.test(endpoint)) {
throw new WizardError(
'wireguard mode requires credentials.endpoint (public hostname or IP)',
{ code: 'credentials_required' }
);
}
if (listenPort !== undefined) {
const port = Number(listenPort);
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
throw new WizardError('listenPort must be an integer 1024-65535', { code: 'bad_credentials' });
}
}
if (externalInterface !== undefined && typeof externalInterface !== 'string') {
throw new WizardError('externalInterface must be a string', { code: 'bad_credentials' });
}
// Dependency validation via the provider itself (spec §3 wireguard impl):
// a ProviderError with code 'dependency_missing' means host binaries are
// absent — surfaced to the caller as 400 + the missing binary list.
const provider = _provider('wireguard');
try {
await provider.getStatus();
} catch (e) {
if (e instanceof ProviderError && e.code === 'dependency_missing') {
throw new WizardError(e.message, {
statusCode: 400,
code: 'dependency_missing',
extra: { missingBinaries: _extractMissingBinaries(e) },
});
}
throw e;
}
s.wireguard = {
endpoint,
...(listenPort !== undefined ? { listenPort: Number(listenPort) } : {}),
...(externalInterface ? { externalInterface } : {}),
};
_activate(s, 'wireguard');
return { mode: 'wireguard', step: s.step, validation: { ok: true } };
}
function _extractMissingBinaries(err) {
if (Array.isArray(err.missingBinaries)) return err.missingBinaries;
const msg = err.message || '';
return WG_BINARIES.filter((b) => msg.includes('`' + b + '`') || msg.includes(b + ' '));
}
function _activate(s, mode) {
if (registry && typeof registry.setActiveProvider === 'function') {
registry.setActiveProvider(mode);
}
s.mode = mode;
// Re-picking a mode restarts downstream steps honestly.
s.providerConnected = false;
s.dnsConfigured = false;
s.caConfigured = false;
s.adminCreated = false;
s.dnsSkipped = false;
s.caSkipped = false;
s.completedAt = null;
_advance(s, 'domain');
_persist();
log.info('network-wizard', 'mode selected', { mode });
}
function _advance(s, next) {
const cur = STEP_ORDER.indexOf(s.step);
const nxt = STEP_ORDER.indexOf(next);
if (nxt > cur) s.step = next;
}
function _requireStep(s, atLeast, what) {
if (STEP_ORDER.indexOf(s.step) < STEP_ORDER.indexOf(atLeast)) {
throw new WizardError(
what + ' requires completing wizard step "' + atLeast + '" first (current: "' + s.step + '")',
{ statusCode: 409, code: 'step_out_of_order' }
);
}
}
/* ── Step 2: private domain ─────────────────────────────────────── */
function setDomain({ tld } = {}) {
const s = _load();
_requireStep(s, 'domain', 'domain selection');
if (tld === undefined || tld === null || tld === '') {
tld = '.internal'; // spec default
}
if (typeof tld !== 'string' || tld.length > 63) {
throw new WizardError('tld must be a string of ≤63 chars', { code: 'bad_tld' });
}
let normalized = tld.trim().toLowerCase();
if (!normalized.startsWith('.')) normalized = '.' + normalized;
// 'home.arpa' is offered as a unit (spec §9): '.home.arpa' is the zone.
if (!/^\.[a-z0-9-]+(\.[a-z0-9-]+)*$/.test(normalized)) {
throw new WizardError('tld must be alphanumeric/hyphen segments (e.g. ".internal", "home.arpa")', { code: 'bad_tld' });
}
// Silent set compares the dotted form ('.home.arpa').
const silent = normalized === '.home.arpa' || SILENT_TLDS.has(normalized);
s.privateTld = normalized;
_advance(s, 'connect');
_persist();
const res = { tld: normalized, step: s.step };
if (!silent) {
// Spec §9 step 2: custom TLDs are ACCEPTED with a warning, never blocked.
res.customTldWarning = true;
res.warning =
'The custom TLD "' + normalized + '" could become real later (ICANN can delegate ' +
'new public suffixes). ".internal" or "home.arpa" are reserved and always safe.';
}
return res;
}
/* ── Step 3: connect + health check + first sync ────────────────── */
async function connect() {
const s = _load();
_requireStep(s, 'connect', 'provider connect');
const provider = _provider();
// Health check — providers degrade gracefully ({connected:false} rather
// than throwing), so an explicit connected:false is a failed check.
const status = await provider.getStatus();
if (status && status.connected === false) {
throw new WizardError('provider health check failed (not connected)', {
statusCode: 502,
code: 'health_check_failed',
extra: { status },
});
}
// First sync: provider people → store (bootstrap-admin rules live in the
// store, DC-141), plus the device list for the UI.
let peopleCount = 0;
if (peopleStore) {
const providerPeople = await provider.listPeople();
for (const pp of (Array.isArray(providerPeople) ? providerPeople : [])) {
const providerUserId = pp.providerUserId || pp.id || pp.user?.id;
if (!providerUserId) continue;
const existing = await peopleStore.findByProviderUserId(providerUserId);
if (existing) continue; // idempotent; guarded fields live in the store
await peopleStore.upsertPerson({
providerUserId,
displayName: pp.displayName || pp.name || pp.loginName || providerUserId,
devices: Array.isArray(pp.devices) ? pp.devices : [],
});
peopleCount += 1;
}
}
const devices = await provider.listDevices();
s.providerConnected = true;
_advance(s, 'dns');
_persist();
log.info('network-wizard', 'provider connected + first sync', {
mode: s.mode, peopleCount, deviceCount: Array.isArray(devices) ? devices.length : null,
});
return {
connected: true,
health: status,
peopleSynced: peopleCount,
devices,
step: s.step,
};
}
/* ── Step 4: Technitium zone + split DNS ───────────────────────── */
async function configureDns() {
const s = _load();
_requireStep(s, 'dns', 'DNS configuration');
const checks = [];
let configured = false;
let skipped = false;
let reason = null;
if (!technitium) {
// Optional dep absent (e.g. Technitium lane not merged yet) — skip
// honestly rather than fail the wizard.
skipped = true;
reason = 'technitium module not available (skipped; configure DNS manually)';
checks.push({ name: 'technitiumSetup', status: 'skipped', reason });
} else {
const setup = await technitium.checkSetup();
checks.push({
name: 'technitiumSetup',
status: setup && (setup.ok || setup.installed || setup.configured) ? 'pass' : 'fail',
detail: setup,
});
const setupOk = checks[0].status === 'pass';
// Capability gating (spec §3): Tailscale has the split-DNS API; the
// WireGuard hub has no split-DNS API — Technitium is written directly.
const provider = _provider();
const caps = provider.capabilities ? provider.capabilities() : {};
let splitDnsStatus = 'skipped';
if (setupOk && technitium.configureSplitDns) {
const result = await technitium.configureSplitDns({
zone: (s.privateTld || '.internal').replace(/^\./, ''),
server: _dnsServerFromStatus(setup),
viaProviderApi: !!caps.splitDnsApi,
});
splitDnsStatus = result && result.ok === false ? 'fail' : 'pass';
checks.push({ name: 'splitDnsConfigured', status: splitDnsStatus, detail: result });
} else {
checks.push({
name: 'splitDnsConfigured',
status: 'skipped',
reason: setupOk
? 'technitium.configureSplitDns not available'
: 'technitium setup did not pass',
});
}
configured = setupOk && splitDnsStatus === 'pass';
if (!configured && setupOk && splitDnsStatus === 'skipped') {
// Zone exists but split-DNS write is unavailable: treat as configured
// at the zone level so the wizard is not permanently wedged.
configured = true;
}
}
s.dnsSkipped = skipped;
s.dnsConfigured = configured;
_advance(s, 'ca');
_persist();
return {
configured,
...(skipped ? { skipped: true, reason } : {}),
checks,
step: s.step,
};
}
function _dnsServerFromStatus(setup) {
return (setup && (setup.server || setup.dnsServer)) || null;
}
/* ── Step 5: CA naming + root generation ───────────────────────── */
async function setupCa({ commonName } = {}) {
const s = _load();
_requireStep(s, 'ca', 'CA setup');
const LOCKED_IN_WARNING =
'The CA common name is locked in once issued: renaming it later means ' +
'generating a NEW root certificate and re-trusting it on every device.';
if (!caSetup) {
s.caSkipped = true;
_advance(s, 'admin');
_persist();
return {
skipped: true,
reason: 'caSetup module not available (skipped; generate a root CA manually)',
lockedInWarning: LOCKED_IN_WARNING,
step: s.step,
};
}
if (!commonName || typeof commonName !== 'string' || commonName.trim().length < 3
|| commonName.length > 200) {
throw new WizardError('commonName is required (3-200 chars)', { code: 'bad_common_name' });
}
const result = await caSetup.setupNamedRootCA({
commonName: commonName.trim(),
privateTld: s.privateTld,
});
if (result && result.ok === false) {
throw new WizardError('CA setup failed: ' + (result.error || 'unknown'), {
statusCode: 502,
code: 'ca_setup_failed',
extra: { result },
});
}
s.caConfigured = true;
s.caSkipped = false;
_advance(s, 'admin');
_persist();
return { configured: true, lockedInWarning: LOCKED_IN_WARNING, result, step: s.step };
}
/* ── Step 6: first person (bootstrap admin) ────────────────────── */
async function createAdmin({ displayName, providerUserId } = {}) {
const s = _load();
_requireStep(s, 'admin', 'admin creation');
if (!displayName || typeof displayName !== 'string' || !displayName.trim()
|| displayName.length > 200) {
throw new WizardError('displayName is required (1-200 chars)', { code: 'bad_display_name' });
}
if (providerUserId !== undefined && (typeof providerUserId !== 'string' || !providerUserId)) {
throw new WizardError('providerUserId must be a non-empty string when provided', {
code: 'bad_provider_user_id',
});
}
if (!peopleStore) {
throw new WizardError('peopleStore is not available', { statusCode: 503, code: 'people_store_unavailable' });
}
// The store enforces the bootstrap-admin rule (first person in an empty
// store → groups ['Admin'] + protectedAt, DC-141) — the wizard just calls it.
const person = await peopleStore.upsertPerson({
displayName: displayName.trim(),
providerUserId: providerUserId
|| ('wizard-admin-' + (s.mode || 'network') + '-' + s.startedAt),
});
s.adminCreated = true;
_advance(s, 'complete');
_persist();
log.info('network-wizard', 'first person (admin) created', { id: person.id });
return { person, adminCreated: true, step: s.step };
}
/* ── Completion gate ───────────────────────────────────────────── */
function complete() {
const s = _load();
const missing = [];
if (!s.providerConnected) missing.push('providerConnected');
if (!s.dnsConfigured && !s.dnsSkipped) missing.push('dnsConfigured');
if (!s.caConfigured && !s.caSkipped) missing.push('caConfigured');
if (!s.adminCreated) missing.push('adminCreated');
if (missing.length > 0) {
throw new WizardError(
'wizard is not complete — missing checks: ' + missing.join(', '),
{ statusCode: 409, code: 'incomplete', extra: { missing } }
);
}
s.completedAt = now();
_persist();
return { completed: true, completedAt: s.completedAt, step: s.step };
}
/* ── Status aggregation (GET /status) ──────────────────────────── */
async function getStatus({
connectionLogReader = null,
aclStateReader = null,
firewallStateReader = null,
} = {}) {
const s = _load();
const out = {
wizard: {
completedAt: s.completedAt,
mode: s.mode,
privateTld: s.privateTld,
},
online: { devices: [], peopleOnline: 0 },
recentConnections: { events: [], note: 'no connection log source configured' },
sync: { acl: null, firewall: null },
driftBanner: false,
};
if (!s.mode) return out; // nothing connected yet — honest empty status
try {
const provider = _provider();
const [status, devices] = [await provider.getStatus(), await provider.listDevices()];
const list = Array.isArray(devices) ? devices : [];
out.online = {
provider: s.mode,
status,
devices: list,
devicesOnline: list.filter((d) => d && d.online).length,
devicesTotal: list.length,
};
} catch (e) {
out.online = { provider: s.mode, error: e.message };
}
if (typeof connectionLogReader === 'function') {
try {
out.recentConnections = await connectionLogReader();
} catch (e) {
out.recentConnections = { events: [], note: 'connection log read failed: ' + e.message };
}
}
// ACL/firewall sync state is mode-specific (spec §4 layer 1):
// - Tailscale mode → acl-push last-known-good hash present?
// - WireGuard mode → wg-firewall last apply?
// Both arrive via injectable readers (the route wires aclPush.getState() /
// a firewall-state reader); absent readers surface as null, not errors.
if (typeof aclStateReader === 'function') {
try {
out.sync.acl = await aclStateReader();
} catch (e) {
out.sync.acl = { error: e.message };
}
}
if (typeof firewallStateReader === 'function') {
try {
out.sync.firewall = await firewallStateReader();
} catch (e) {
out.sync.firewall = { error: e.message };
}
}
// Drift banner flag (spec §4): outside edits ⇒ banner, not silent
// overwrite. Readers report { drifted: bool } when they can tell;
// a stored-hash mismatch also flips the banner.
const aclDrift = !!(out.sync.acl && out.sync.acl.drifted);
const fwDrift = !!(out.sync.firewall && out.sync.firewall.drifted);
out.driftBanner = aclDrift || fwDrift;
return out;
}
return {
getState,
setMode,
setDomain,
connect,
configureDns,
setupCa,
createAdmin,
complete,
getStatus,
STEP_ORDER,
WizardError,
};
}
module.exports = { createNetworkWizard, WizardError, STEP_ORDER };