diff --git a/dashcaddy-api/__tests__/network-wizard.routes.test.js b/dashcaddy-api/__tests__/network-wizard.routes.test.js new file mode 100644 index 0000000..094879d --- /dev/null +++ b/dashcaddy-api/__tests__/network-wizard.routes.test.js @@ -0,0 +1,647 @@ +/** + * Route tests for the Private Network setup wizard + status — DC-152 + * (specs/PRIVATE-NETWORK-SPEC.md §9). + * + * Hermetic: supertest against an in-process express app with fully mocked + * deps (registry, providers, peopleStore, credentialStore, technitium, + * caSetup). No network, no real filesystem state (tmp state file per app). + * + * Coverage required by the ticket: + * - mode validation for BOTH providers, incl. credential failure → + * step does NOT advance + * - apiKey expiry warning present for apiKey, absent for oauth + * - domain default + custom-TLD warning (accepted, never blocked) + * - connect runs the first sync (people + devices) + * - dns capability gating in both modes + * - ca locked-in warning + skip-when-dep-absent + * - admin bootstrap (first person = admin) + * - complete gate refuses when checks are missing + * - status aggregation shape + */ + +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const wizardRoutes = require('../routes/network-wizard'); +const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store'); +const { ProviderError } = require('../src/network/errors'); + +/* ── fixtures ─────────────────────────────────────────────────────── */ + +function makeCredentialStore() { + const map = new Map(); + return { + map, + async store(key, value) { map.set(key, value); return true; }, + async retrieve(key) { return map.get(key) || null; }, + async delete(key) { map.delete(key); return true; }, + }; +} + +function makeProvider(overrides = {}) { + const base = { + id: () => 'mock', + capabilities: () => ({ invites: true, splitDnsApi: true, perPeerFirewall: false, selfHosted: false }), + getStatus: async () => ({ connected: true, installed: true }), + listDevices: async () => [ + { keyId: 'dev-1', name: 'laptop', ip: '100.64.0.2', online: true }, + { keyId: 'dev-2', name: 'phone', ip: '100.64.0.3', online: false }, + ], + listPeople: async () => [ + { providerUserId: 'user-1', displayName: 'Sami' }, + ], + }; + return Object.assign(base, overrides); +} + +function makeRegistry({ activeId = null } = {}) { + const reg = { + _activeId: activeId, + setActiveProvider: jest.fn(function (id) { this._activeId = id; }), + getActiveProvider: jest.fn(function () { return this._activeId; }), + }; + return reg; +} + +let _seq = 0; +function createApp(overrides = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc152-wizard-')); + const stateFile = path.join(dir, 'network-wizard-state.json'); + const credentialStore = overrides.credentialStore || makeCredentialStore(); + const peopleStore = overrides.peopleStore + || createPeopleStore({ dataDir: dir, log: { info() {}, warn() {}, error() {} } }); + const tailscale = overrides.tailscale || makeProvider({ id: () => 'tailscale' }); + const wireguard = overrides.wireguard || makeProvider({ + id: () => 'wireguard', + capabilities: () => ({ invites: false, splitDnsApi: false, perPeerFirewall: true, selfHosted: true }), + }); + const registry = overrides.registry || makeRegistry(); + const router = wizardRoutes({ + registry, + providers: { tailscale, wireguard }, + peopleStore, + technitium: overrides.technitium !== undefined ? overrides.technitium : null, + caSetup: overrides.caSetup !== undefined ? overrides.caSetup : null, + credentialStore, + stateFile, + connectionLogReader: overrides.connectionLogReader || null, + aclStateReader: overrides.aclStateReader || null, + firewallStateReader: overrides.firewallStateReader || null, + asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), + log: { info() {}, warn() {}, error() {} }, + }); + const app = express(); + app.use(express.json()); + app.use('/network/wizard', router); + app.use((err, _req, res, _next) => { + const status = err.statusCode || 500; + res.status(status).json({ success: false, error: err.message, code: err.code || null }); + }); + _seq += 1; + return { app, dir, stateFile, credentialStore, peopleStore, registry, tailscale, wireguard, seq: _seq }; +} + +function cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ } +} + +/* ── state ────────────────────────────────────────────────────────── */ + +describe('GET /network/wizard/state', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + test('initial state: step mode, no checks passing', async () => { + ctx = createApp(); + const res = await request(ctx.app).get('/network/wizard/state'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.step).toBe('mode'); + expect(res.body.mode).toBeNull(); + expect(res.body.startedAt).toBeTruthy(); + expect(res.body.completedAt).toBeNull(); + expect(res.body.checks).toMatchObject({ + modeSelected: false, + domainConfigured: false, + providerConnected: false, + dnsConfigured: false, + caConfigured: false, + adminCreated: false, + }); + }); + + test('state persists to network-wizard-state.json', async () => { + ctx = createApp(); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-xyz' }, + }); + const raw = JSON.parse(fs.readFileSync(ctx.stateFile, 'utf8')); + expect(raw.mode).toBe('tailscale'); + expect(raw.step).toBe('domain'); + }); +}); + +/* ── step 1: mode ─────────────────────────────────────────────────── */ + +describe('POST /network/wizard/mode', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + test('tailscale with oauthClientSecret: accepted, no expiry warning', async () => { + ctx = createApp(); + const res = await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-abc123' }, + }); + expect(res.status).toBe(200); + expect(res.body.mode).toBe('tailscale'); + expect(res.body.apiKeyExpiryWarning).toBeUndefined(); + expect(res.body.step).toBe('domain'); + expect(ctx.registry.setActiveProvider).toHaveBeenCalledWith('tailscale'); + }); + + test('tailscale with apiKey: accepted WITH expiry warning + oauth recommendation', async () => { + ctx = createApp(); + const res = await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' }, + }); + expect(res.status).toBe(200); + expect(res.body.apiKeyExpiryWarning).toBe(true); + expect(res.body.warning).toMatch(/90 days/); + expect(res.body.warning).toMatch(/tskey-client-/); + expect(res.body.step).toBe('domain'); + }); + + test('tailscale: credential stored under REUSED keys (tailscale.coord.apiToken / oauth)', async () => { + ctx = createApp(); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' }, + }); + expect(ctx.credentialStore.map.get('tailscale.coord.apiToken')).toBe('tskey-api-k1234567890abcdef'); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-aaa' }, + }); + expect(ctx.credentialStore.map.get('tailscale.oauth.client_secret')).toBe('tskey-client-aaa'); + }); + + test('tailscale credential failure (unauthorized): 4xx, step does NOT advance, credential rolled back', async () => { + const tailscale = makeProvider({ + id: () => 'tailscale', + listDevices: async () => { throw new ProviderError('token rejected', { code: 'unauthorized' }); }, + }); + ctx = createApp({ tailscale }); + const res = await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' }, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + expect(res.body.success).toBe(false); + const state = await request(ctx.app).get('/network/wizard/state'); + expect(state.body.step).toBe('mode'); // NOT advanced + expect(state.body.checks.modeSelected).toBe(false); + expect(ctx.credentialStore.map.get('tailscale.coord.apiToken')).toBeUndefined(); + }); + + test('tailscale without any credentials: 400', async () => { + ctx = createApp(); + const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale' }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + test('wireguard happy path: endpoint validated via getStatus, step advances', async () => { + ctx = createApp(); + const res = await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'wireguard', + credentials: { endpoint: 'vpn.example.com', listenPort: 51820, externalInterface: 'eth0' }, + }); + expect(res.status).toBe(200); + expect(res.body.mode).toBe('wireguard'); + expect(res.body.step).toBe('domain'); + expect(ctx.registry.setActiveProvider).toHaveBeenCalledWith('wireguard'); + }); + + test('wireguard dependency_missing: 400 with the missing binary list, step does NOT advance', async () => { + const wireguard = makeProvider({ + id: () => 'wireguard', + getStatus: async () => { + throw new ProviderError('wireguard: `wg` binary not installed; wireguard: `wg-quick` binary not installed', { + code: 'dependency_missing', + }); + }, + }); + ctx = createApp({ wireguard }); + const res = await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'wireguard', credentials: { endpoint: 'vpn.example.com' }, + }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.details.missingBinaries).toEqual(expect.arrayContaining(['wg', 'wg-quick'])); + const state = await request(ctx.app).get('/network/wizard/state'); + expect(state.body.step).toBe('mode'); + }); + + test('wireguard without endpoint: 400', async () => { + ctx = createApp(); + const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'wireguard' }); + expect(res.status).toBe(400); + }); + + test('unknown mode: 400', async () => { + ctx = createApp(); + const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'openvpn' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('bad_mode'); + }); +}); + +/* ── step 2: domain ───────────────────────────────────────────────── */ + +describe('POST /network/wizard/domain', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + beforeEach(async () => { + ctx = createApp(); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + }); + + test('default .internal: accepted silently, no warning', async () => { + const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); + expect(res.status).toBe(200); + expect(res.body.tld).toBe('.internal'); + expect(res.body.customTldWarning).toBeUndefined(); + expect(res.body.step).toBe('connect'); + }); + + test('home.arpa: accepted silently', async () => { + const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: 'home.arpa' }); + expect(res.status).toBe(200); + expect(res.body.tld).toBe('.home.arpa'); + expect(res.body.customTldWarning).toBeUndefined(); + }); + + test('empty body → default .internal', async () => { + const res = await request(ctx.app).post('/network/wizard/domain').send({}); + expect(res.status).toBe(200); + expect(res.body.tld).toBe('.internal'); + expect(res.body.customTldWarning).toBeUndefined(); + }); + + test('custom TLD: accepted WITH "could become real later" warning (never blocked)', async () => { + const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.mynet' }); + expect(res.status).toBe(200); // accepted, not blocked + expect(res.body.tld).toBe('.mynet'); + expect(res.body.customTldWarning).toBe(true); + expect(res.body.warning).toMatch(/could become real later/); + }); + + test('garbage tld: 400', async () => { + const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.not valid!' }); + expect(res.status).toBe(400); + }); + + test('domain before mode: 409, step does not advance', async () => { + const fresh = createApp(); + const res = await request(fresh.app).post('/network/wizard/domain').send({ tld: '.internal' }); + expect(res.status).toBe(409); + cleanup(fresh.dir); + }); +}); + +/* ── step 3: connect ─────────────────────────────────────────────── */ + +describe('POST /network/wizard/connect', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + async function ready(mode = 'tailscale') { + ctx = createApp(); + const creds = mode === 'tailscale' + ? { mode, credentials: { oauthClientSecret: 'tskey-client-x' } } + : { mode, credentials: { endpoint: 'vpn.example.com' } }; + await request(ctx.app).post('/network/wizard/mode').send(creds); + await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); + } + + test('runs health check + first sync (people into store, devices listed)', async () => { + await ready('tailscale'); + const res = await request(ctx.app).post('/network/wizard/connect'); + expect(res.status).toBe(200); + expect(res.body.connected).toBe(true); + expect(res.body.peopleSynced).toBe(1); + expect(Array.isArray(res.body.devices)).toBe(true); + expect(res.body.devices).toHaveLength(2); + expect(res.body.step).toBe('dns'); + const people = await ctx.peopleStore.listPeople(); + expect(people).toHaveLength(1); + expect(people[0].displayName).toBe('Sami'); + }); + + test('connect is idempotent: re-running does not duplicate people', async () => { + await ready('tailscale'); + await request(ctx.app).post('/network/wizard/connect'); + const res = await request(ctx.app).post('/network/wizard/connect'); + expect(res.status).toBe(200); + const people = await ctx.peopleStore.listPeople(); + expect(people).toHaveLength(1); + }); + + test('health check failure → 502, providerConnected stays false', async () => { + await ready('wireguard'); + ctx.wireguard.getStatus = async () => ({ connected: false, installed: true }); + const res = await request(ctx.app).post('/network/wizard/connect'); + expect(res.status).toBe(502); + const state = await request(ctx.app).get('/network/wizard/state'); + expect(state.body.checks.providerConnected).toBe(false); + }); + + test('connect before mode: 409', async () => { + ctx = createApp(); + const res = await request(ctx.app).post('/network/wizard/connect'); + expect(res.status).toBe(409); + }); +}); + +/* ── step 4: dns ─────────────────────────────────────────────────── */ + +describe('POST /network/wizard/dns (capability gating)', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + function makeTechnitium() { + return { + checkSetup: jest.fn(async () => ({ ok: true, installed: true, configured: true, server: '127.0.0.1:5353' })), + configureSplitDns: jest.fn(async ({ zone, server }) => ({ ok: true, zone, server })), + }; + } + + async function readyToDns(mode, technitium) { + ctx = createApp({ technitium }); + const creds = mode === 'tailscale' + ? { mode, credentials: { oauthClientSecret: 'tskey-client-x' } } + : { mode, credentials: { endpoint: 'vpn.example.com' } }; + await request(ctx.app).post('/network/wizard/mode').send(creds); + await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); + await request(ctx.app).post('/network/wizard/connect'); + } + + test('tailscale mode: split-DNS delegated via provider-capable path, per-check results', async () => { + const technitium = makeTechnitium(); + await readyToDns('tailscale', technitium); + const res = await request(ctx.app).post('/network/wizard/dns'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.checks).toHaveLength(2); + expect(technitium.checkSetup).toHaveBeenCalledTimes(1); + expect(technitium.configureSplitDns).toHaveBeenCalledTimes(1); + expect(technitium.configureSplitDns.mock.calls[0][0].zone).toBe('internal'); + expect(technitium.configureSplitDns.mock.calls[0][0].viaProviderApi).toBe(true); // splitDnsApi capability + expect(res.body.step).toBe('ca'); + }); + + test('wireguard mode: splitDnsApi capability false → direct Technitium write path', async () => { + const technitium = makeTechnitium(); + await readyToDns('wireguard', technitium); + const res = await request(ctx.app).post('/network/wizard/dns'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(technitium.configureSplitDns.mock.calls[0][0].viaProviderApi).toBe(false); + }); + + test('technitium dep absent: skipped honestly, dns check counts as done', async () => { + await readyToDns('tailscale', null); + const res = await request(ctx.app).post('/network/wizard/dns'); + expect(res.status).toBe(200); + expect(res.body.skipped).toBe(true); + expect(res.body.reason).toBeTruthy(); + const state = await request(ctx.app).get('/network/wizard/state'); + expect(state.body.checks.dnsConfigured).toBe(true); // skipped counts + expect(state.body.step).toBe('ca'); + }); + + test('technitium setup not ok: configured=false, per-check fail surfaced', async () => { + const technitium = { + checkSetup: async () => ({ ok: false, installed: false }), + configureSplitDns: jest.fn(async () => ({ ok: true })), + }; + await readyToDns('tailscale', technitium); + const res = await request(ctx.app).post('/network/wizard/dns'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(false); + expect(res.body.checks[0].status).toBe('fail'); + expect(technitium.configureSplitDns).not.toHaveBeenCalled(); + const state = await request(ctx.app).get('/network/wizard/state'); + expect(state.body.checks.dnsConfigured).toBe(false); + }); +}); + +/* ── step 5: ca ──────────────────────────────────────────────────── */ + +describe('POST /network/wizard/ca', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + async function readyToCa(caSetup) { + ctx = createApp({ caSetup }); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); + await request(ctx.app).post('/network/wizard/connect'); + await request(ctx.app).post('/network/wizard/dns'); + } + + test('dep present: setupNamedRootCA called with commonName + privateTld, locked-in warning returned', async () => { + const caSetup = { setupNamedRootCA: jest.fn(async ({ commonName, privateTld }) => ({ + ok: true, commonName, privateTld, fingerprint: 'AA:BB', + })) }; + await readyToCa(caSetup); + const res = await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'DashCaddy Home CA' }); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.lockedInWarning).toMatch(/new root/i); + expect(res.body.lockedInWarning).toMatch(/re-trust/i); + expect(caSetup.setupNamedRootCA).toHaveBeenCalledWith({ + commonName: 'DashCaddy Home CA', + privateTld: '.internal', + }); + expect(res.body.step).toBe('admin'); + }); + + test('dep absent: skipped with reason (wizard not wedged), still carries warning', async () => { + await readyToCa(null); + const res = await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'X' }); + expect(res.status).toBe(200); + expect(res.body.skipped).toBe(true); + expect(res.body.reason).toBeTruthy(); + expect(res.body.lockedInWarning).toBeTruthy(); + expect(res.body.step).toBe('admin'); + }); + + test('missing commonName with dep present: 400', async () => { + await readyToCa({ setupNamedRootCA: async () => ({ ok: true }) }); + const res = await request(ctx.app).post('/network/wizard/ca').send({}); + expect(res.status).toBe(400); + expect(res.body.code).toBe('bad_common_name'); + }); +}); + +/* ── step 6: admin ───────────────────────────────────────────────── */ + +describe('POST /network/wizard/admin (bootstrap admin)', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + async function readyToAdmin() { + // No provider people: the store must still be EMPTY at /admin time so + // the bootstrap-admin rule (first person ⇒ Admin group) applies to the + // wizard-created person, not to a connect-synced one. + const tailscale = makeProvider({ id: () => 'tailscale', listPeople: async () => [] }); + ctx = createApp({ tailscale }); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); + await request(ctx.app).post('/network/wizard/connect'); + await request(ctx.app).post('/network/wizard/dns'); + await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'CA' }); + } + + test('creates first person via peopleStore with bootstrap admin rule', async () => { + await readyToAdmin(); + const res = await request(ctx.app).post('/network/wizard/admin').send({ + displayName: 'Sami', providerUserId: 'ts-user-1', + }); + expect(res.status).toBe(201); + expect(res.body.adminCreated).toBe(true); + expect(res.body.person.displayName).toBe('Sami'); + expect(res.body.person.groups).toContain(ADMIN_GROUP); // bootstrap rule + expect(res.body.person.protectedAt).toBeTruthy(); + expect(res.body.step).toBe('complete'); + }); + + test('invalid displayName: 400', async () => { + await readyToAdmin(); + const res = await request(ctx.app).post('/network/wizard/admin').send({ displayName: '' }); + expect(res.status).toBe(400); + }); +}); + +/* ── completion gate ─────────────────────────────────────────────── */ + +describe('POST /network/wizard/complete', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + test('refuses (409) when checks missing — names them', async () => { + ctx = createApp(); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + const res = await request(ctx.app).post('/network/wizard/complete'); + expect(res.status).toBe(409); + expect(res.body.code).toBe('incomplete'); + expect(res.body.details.missing).toEqual(expect.arrayContaining([ + 'providerConnected', 'dnsConfigured', 'caConfigured', 'adminCreated', + ])); + const state = await request(ctx.app).get('/network/wizard/state'); + expect(state.body.completedAt).toBeNull(); + }); + + test('all green → completedAt set', async () => { + ctx = createApp(); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); + await request(ctx.app).post('/network/wizard/connect'); + await request(ctx.app).post('/network/wizard/dns'); // skipped path (no technitium) + await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'CA' }); // skipped path + await request(ctx.app).post('/network/wizard/admin').send({ displayName: 'Sami' }); + const res = await request(ctx.app).post('/network/wizard/complete'); + expect(res.status).toBe(200); + expect(res.body.completed).toBe(true); + expect(res.body.completedAt).toBeTruthy(); + const state = await request(ctx.app).get('/network/wizard/state'); + expect(state.body.completedAt).toBeTruthy(); + }); +}); + +/* ── status aggregation ──────────────────────────────────────────── */ + +describe('GET /network/wizard/status', () => { + let ctx; + afterEach(() => cleanup(ctx.dir)); + + test('aggregation shape: wizard, online, recentConnections, sync, driftBanner', async () => { + ctx = createApp({ + connectionLogReader: async () => ({ events: [{ at: 't', ip: '100.64.0.2' }], note: 'event-store' }), + aclStateReader: async () => ({ hash: 'abc123', lastPushAt: 't', drifted: false }), + firewallStateReader: async () => ({ applied: true, lastApplyAt: 't', drifted: false }), + }); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + const res = await request(ctx.app).get('/network/wizard/status'); + expect(res.status).toBe(200); + expect(res.body.wizard).toMatchObject({ mode: 'tailscale', privateTld: null }); + expect(res.body.online).toMatchObject({ provider: 'tailscale', devicesTotal: 2, devicesOnline: 1 }); + expect(Array.isArray(res.body.online.devices)).toBe(true); + expect(res.body.recentConnections.events).toHaveLength(1); + expect(res.body.sync.acl.hash).toBe('abc123'); + expect(res.body.sync.firewall.applied).toBe(true); + expect(res.body.driftBanner).toBe(false); + }); + + test('no connectionLogReader → empty list with a note (not an error)', async () => { + ctx = createApp(); + const res = await request(ctx.app).get('/network/wizard/status'); + expect(res.status).toBe(200); + expect(res.body.recentConnections.events).toEqual([]); + expect(res.body.recentConnections.note).toBeTruthy(); + expect(res.body.sync).toEqual({ acl: null, firewall: null }); + }); + + test('drift in either reader flips driftBanner', async () => { + ctx = createApp({ + aclStateReader: async () => ({ hash: 'x', drifted: true }), + }); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + const res = await request(ctx.app).get('/network/wizard/status'); + expect(res.status).toBe(200); + expect(res.body.driftBanner).toBe(true); + }); + + test('provider status failure surfaces as online.error, still 200', async () => { + // listDevices works (mode validation passes); getStatus fails → the + // status aggregation surfaces the error instead of 500ing. + const tailscale = makeProvider({ + id: () => 'tailscale', + getStatus: async () => { throw new Error('daemon down'); }, + }); + ctx = createApp({ tailscale }); + await request(ctx.app).post('/network/wizard/mode').send({ + mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, + }); + const res = await request(ctx.app).get('/network/wizard/status'); + expect(res.status).toBe(200); + expect(res.body.online.error).toBe('daemon down'); + }); + + test('before any mode: honest empty shape', async () => { + ctx = createApp(); + const res = await request(ctx.app).get('/network/wizard/status'); + expect(res.status).toBe(200); + expect(res.body.wizard.mode).toBeNull(); + expect(res.body.online.devices).toEqual([]); + expect(res.body.driftBanner).toBe(false); + }); +}); diff --git a/dashcaddy-api/routes/network-wizard.js b/dashcaddy-api/routes/network-wizard.js new file mode 100644 index 0000000..dcc8616 --- /dev/null +++ b/dashcaddy-api/routes/network-wizard.js @@ -0,0 +1,229 @@ +/** + * 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'); +}; diff --git a/dashcaddy-api/src/network/wizard.js b/dashcaddy-api/src/network/wizard.js new file mode 100644 index 0000000..60d3721 --- /dev/null +++ b/dashcaddy-api/src/network/wizard.js @@ -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 };