/** * 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 };