merge dc/DC-152 into p7-wave1
This commit is contained in:
3 files changed
+1598
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user