From 44567031eb2c1c880542112acd7aa0a9880054a6 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 22:34:02 -0700 Subject: [PATCH] DC-153: provider.syncAccess enforcement wiring (tailscale ACL push + wireguard nftables apply) + contract tests --- .../network-provider-tailscale.test.js | 172 +++++++++++++- .../network-provider-wireguard.test.js | 224 +++++++++++++++++- .../__tests__/network-syncaccess.test.js | 80 +++++++ dashcaddy-api/src/network/acl-sync.js | 105 ++++++++ .../src/network/providers/tailscale.js | 152 +++++++++++- .../src/network/providers/wireguard.js | 119 +++++++++- 6 files changed, 820 insertions(+), 32 deletions(-) create mode 100644 dashcaddy-api/__tests__/network-syncaccess.test.js create mode 100644 dashcaddy-api/src/network/acl-sync.js diff --git a/dashcaddy-api/__tests__/network-provider-tailscale.test.js b/dashcaddy-api/__tests__/network-provider-tailscale.test.js index 0983842..278024f 100644 --- a/dashcaddy-api/__tests__/network-provider-tailscale.test.js +++ b/dashcaddy-api/__tests__/network-provider-tailscale.test.js @@ -285,14 +285,176 @@ describe('tailscale provider: revokeDevice()', () => { }); }); -describe('tailscale provider: syncAccess()', () => { - test('throws ProviderError code=not_implemented_in_provider (DC-148 lands later)', async () => { - const p = createTailscaleProvider({ coordClient: makeCoord() }); - await expect(p.syncAccess({}, [])).rejects.toMatchObject({ +describe('tailscale provider: syncAccess() (DC-153 contract tests)', () => { + const { LockoutError } = require('../src/network/acl-compiler'); + const { AclPushError } = require('../src/network/acl-push'); + + /** aclSync engine mock factory (src/network/acl-sync.js surface). */ + function makeAclSync(overrides = {}) { + return { + compile: jest.fn(async () => ({ + policy: { acls: [] }, + hash: 'sha256:abc', + usersAffected: 2, + })), + preview: jest.fn(async () => ({ feasible: true, errors: [] })), + push: jest.fn(async () => ({ pushed: true, hash: 'sha256:abc' })), + getState: jest.fn(() => null), + ...overrides, + }; + } + + const input = () => ({ + accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'dashcaddy', allowed: true }] }, + people: [ + { id: 'p1', providerUserId: '1001', groups: ['Admin'], status: 'active' }, + { id: 'p2', providerUserId: '2002', groups: [], status: 'active' }, + ], + resources: [{ name: 'dashcaddy', host: 'dns2.tail.ts.net', ports: ['*'] }], + hostDeviceId: 'dev-host', + }); + + test('happy path: compiles → previews → pushes, returns {pushed, hash, usersAffected}', async () => { + const aclSync = makeAclSync(); + const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync }); + const res = await p.syncAccess(input()); + expect(res).toEqual({ pushed: true, hash: 'sha256:abc', usersAffected: 2 }); + // Compile receives the caller shapes + the derived guard inputs. + expect(aclSync.compile).toHaveBeenCalledWith( + expect.objectContaining({ + accessMap: input().accessMap, + people: expect.any(Array), + resources: expect.any(Array), + hostDeviceId: 'dev-host', + adminUserIds: ['1001'], // derived from the active Admin-group person + hostResource: { host: 'dns2.tail.ts.net', ports: ['*'] }, + }) + ); + // Preview runs on the compiled policy, BEFORE the push. + expect(aclSync.preview).toHaveBeenCalledTimes(1); + expect(aclSync.preview).toHaveBeenCalledWith({ acls: [] }); + expect(aclSync.push).toHaveBeenCalledWith({ acls: [] }); + }); + + test('LockoutError from compile → ProviderError lockout_guard, nothing previewed or pushed', async () => { + const aclSync = makeAclSync({ + compile: jest.fn(() => { + throw new LockoutError('every active admin (1001) would be denied the host resource'); + }), + }); + const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync }); + await expect(p.syncAccess(input())).rejects.toMatchObject({ name: 'ProviderError', - code: 'not_implemented_in_provider', + code: 'lockout_guard', retryable: false, }); + expect(aclSync.preview).not.toHaveBeenCalled(); + expect(aclSync.push).not.toHaveBeenCalled(); + }); + + test('preview errors[] → ProviderError acl_preview_failed, NOTHING pushed', async () => { + const aclSync = makeAclSync({ + preview: jest.fn(() => Promise.reject(new AclPushError('ACL preview reported errors', { + code: 'acl_rejected', + cause: ['bad dst'], + }))), + }); + const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync }); + await expect(p.syncAccess(input())).rejects.toMatchObject({ + name: 'ProviderError', + code: 'acl_preview_failed', + retryable: false, + }); + expect(aclSync.push).not.toHaveBeenCalled(); + }); + + test('push API failure (acl_rejected) → ProviderError bad_input, mapped not leaked', async () => { + const aclSync = makeAclSync({ + push: jest.fn(() => Promise.reject(new AclPushError('POST acl returned 400', { + code: 'acl_rejected', + }))), + }); + const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync }); + await expect(p.syncAccess(input())).rejects.toMatchObject({ + name: 'ProviderError', + code: 'bad_input', + }); + }); + + test('explicit adminUserIds/hostResource pass through verbatim', async () => { + const aclSync = makeAclSync(); + const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync }); + await p.syncAccess({ + ...input(), + adminUserIds: ['9999'], + hostResource: { host: 'other-host', ports: ['22'] }, + }); + expect(aclSync.compile).toHaveBeenCalledWith( + expect.objectContaining({ adminUserIds: ['9999'], hostResource: { host: 'other-host', ports: ['22'] } }) + ); + }); + + describe('default construction (dep OMITTED — real acl-compiler + acl-push wiring)', () => { + const fsReal = require('fs'); + const os = require('os'); + const pathMod = require('path'); + let stateDir; + + beforeAll(() => { + stateDir = fsReal.mkdtempSync(pathMod.join(os.tmpdir(), 'dc-ts-syncaccess-')); + global.__DC_ACL_STATE_DIR__ = stateDir; // acl-push state file location + }); + afterAll(() => { + delete global.__DC_ACL_STATE_DIR__; + fsReal.rmSync(stateDir, { recursive: true, force: true }); + }); + + test('coord mock: ACL preview + push POST bodies contain per-user srcs', async () => { + const request = jest.fn(async (method, apiPath) => { + if (apiPath === '/tailnet/-/acl/preview') return { feasible: true, errors: [] }; + if (apiPath === '/tailnet/-/acl') return {}; + throw new Error('unexpected ' + method + ' ' + apiPath); + }); + const coord = makeCoord({ request }); + const p = createTailscaleProvider({ coordClient: coord }); // aclSync OMITTED + const res = await p.syncAccess(input()); + + expect(res.pushed).toBe(true); + expect(res.hash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(res.usersAffected).toBe(1); // user 1001 only (host device excluded) + + const previewCall = request.mock.calls.find((c) => c[1] === '/tailnet/-/acl/preview'); + const pushCall = request.mock.calls.find((c) => c[1] === '/tailnet/-/acl'); + expect(previewCall).toBeTruthy(); + expect(pushCall).toBeTruthy(); + // preview BEFORE push, both carrying the compiled policy + expect(request.mock.calls.indexOf(previewCall)).toBeLessThan(request.mock.calls.indexOf(pushCall)); + for (const call of [previewCall, pushCall]) { + const acls = call[2].body.acls; + // per-user src (never a group), dst host:port per resource grant + expect(acls).toContainEqual({ + action: 'accept', + src: ['1001'], + dst: ['dns2.tail.ts.net:*'], + proto: ['tcp', 'udp'], + }); + // host device egress rule + expect(acls).toContainEqual({ + action: 'accept', + src: ['dev-host'], + dst: ['*:*'], + proto: ['tcp', 'udp'], + }); + } + }); + + test('no coord client attached → not_configured (never builds a dead engine)', async () => { + const p = createTailscaleProvider(); // no coordClient, no aclSync + await expect(p.syncAccess(input())).rejects.toMatchObject({ + name: 'ProviderError', + code: 'not_configured', + }); + }); }); }); diff --git a/dashcaddy-api/__tests__/network-provider-wireguard.test.js b/dashcaddy-api/__tests__/network-provider-wireguard.test.js index f3e44d6..d072b1c 100644 --- a/dashcaddy-api/__tests__/network-provider-wireguard.test.js +++ b/dashcaddy-api/__tests__/network-provider-wireguard.test.js @@ -531,16 +531,224 @@ describe('wireguard provider: invites (spec §8.5)', () => { }); }); -describe('wireguard provider: unsupported / not-implemented surface', () => { - test('syncAccess → not_implemented (DC-151 injects the firewall compiler)', async () => { - const { provider } = makeProvider(); - await expect(provider.syncAccess()).rejects.toMatchObject({ - name: 'ProviderError', - code: 'not_implemented', - retryable: false, - }); +describe('wireguard provider: syncAccess() (DC-153 contract tests)', () => { + const { + FirewallCheckError, + FirewallApplyError, + FirewallVerifyError, + } = require('../src/network/wg-firewall'); + + /** firewall engine mock factory (applier surface). */ + function makeFirewall(overrides = {}) { + return { + apply: jest.fn(async () => ({ + applied: true, + verified: true, + tempPath: '/tmp/x.nft', + rollbackFile: '/tmp/rb.nft', + previousExisted: false, + })), + rollback: jest.fn(async () => ({ applied: true, rollbackFile: '/tmp/rb.nft' })), + ...overrides, + }; + } + + const input = () => ({ + accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] }, + people: [ + { id: 'p1', groups: ['Admin'], status: 'active' }, + { id: 'p2', groups: [], status: 'active' }, + ], + resources: { plex: { ip: '10.53.0.1', port: 32400 } }, + peerAddresses: { 'p1/d1': '10.53.0.2', 'p2/d2': '10.53.0.3' }, + adminPeers: ['p1'], }); + function makeProviderWithFirewall(firewall) { + const fs = makeFs(); + const exec = makeExec(); + const provider = createWireGuardProvider({ + execFileImpl: exec, + fsImpl: fs, + dataDir: '/data', + confPath: '/wg/dcnet0.conf', + unitPath: '/sys/dcnet-hub.service', + getuid: () => 0, + now: () => 1700000000000, + firewall, + }); + return { provider, fs, exec }; + } + + test('happy path: applies the compiled ruleset, returns {applied, resourcesAffected}', async () => { + const firewall = makeFirewall(); + const { provider } = makeProviderWithFirewall(firewall); + const res = await provider.syncAccess(input()); + expect(res).toEqual({ applied: true, resourcesAffected: 1 }); + expect(firewall.apply).toHaveBeenCalledTimes(1); + const ruleset = firewall.apply.mock.calls[0][0]; + // The ruleset is the REAL compiler output for these shapes: admin set + // scoped to p1's peer IP, plex resource set with p1 (allowed) but not p2. + expect(ruleset).toContain('set net_dc_admins'); + expect(ruleset).toContain('elements = { 10.53.0.2 }'); + expect(ruleset).toContain('set net_dc_res_plex'); + expect(ruleset).toContain('10.53.0.2'); // p1 in the plex resource set + expect(ruleset).not.toContain('10.53.0.3'); // p2 (no grant) in NO set + }); + + test('adminPeers defaults to active Admin-group people when omitted', async () => { + const firewall = makeFirewall(); + const { provider } = makeProviderWithFirewall(firewall); + const { adminPeers: _omit, ...withoutAdminPeers } = input(); + await provider.syncAccess(withoutAdminPeers); + const ruleset = firewall.apply.mock.calls[0][0]; + expect(ruleset).toContain('elements = { 10.53.0.2 }'); // p1 = Admin group + }); + + test('admin lockout at compile (no active admin peer IPs) → ProviderError lockout_guard, nothing applied', async () => { + const firewall = makeFirewall(); + const { provider } = makeProviderWithFirewall(firewall); + await expect( + provider.syncAccess({ ...input(), peerAddresses: {} }) + ).rejects.toMatchObject({ + name: 'ProviderError', + code: 'lockout_guard', + retryable: false, + }); + expect(firewall.apply).not.toHaveBeenCalled(); + }); + + test('FirewallCheckError → ProviderError firewall_check_failed, .detail carries stderr + applied:false', async () => { + const firewall = makeFirewall({ + apply: jest.fn(() => Promise.reject( + new FirewallCheckError('nft -c rejected ruleset (syntax)', { stderr: 'syntax error' }) + )), + }); + const { provider } = makeProviderWithFirewall(firewall); + const err = await provider.syncAccess(input()).catch((e) => e); + expect(err).toMatchObject({ + name: 'ProviderError', + code: 'firewall_check_failed', + retryable: false, + }); + expect(err.detail).toEqual({ stderr: 'syntax error', applied: false }); + }); + + test('FirewallApplyError → ProviderError firewall_apply_failed, .detail records NOT partially applied', async () => { + const firewall = makeFirewall({ + apply: jest.fn(() => Promise.reject( + new FirewallApplyError('nft -f apply failed (op not permitted)', { stderr: 'op not permitted' }) + )), + }); + const { provider } = makeProviderWithFirewall(firewall); + const err = await provider.syncAccess(input()).catch((e) => e); + expect(err).toMatchObject({ name: 'ProviderError', code: 'firewall_apply_failed' }); + expect(err.detail).toEqual({ stderr: 'op not permitted', applied: false, rolledBack: false }); + }); + + test('FirewallVerifyError → ProviderError firewall_verify_failed, .detail preserves rolledBack info', async () => { + const firewall = makeFirewall({ + apply: jest.fn(() => Promise.reject( + new FirewallVerifyError('post-apply verify failed (missing table); rollback succeeded', { + stderr: 'missing table', + rolledBack: true, + }) + )), + }); + const { provider } = makeProviderWithFirewall(firewall); + const err = await provider.syncAccess(input()).catch((e) => e); + expect(err).toMatchObject({ name: 'ProviderError', code: 'firewall_verify_failed' }); + expect(err.detail).toEqual({ stderr: 'missing table', applied: true, rolledBack: true }); + }); + + describe('default construction (dep OMITTED — real wg-firewall applier wiring)', () => { + /** + * exec stub: default WG behavior PLUS `nft` (check/apply ok, list table + * returns the dcnet table text so verify passes). Records every call. + */ + function makeExecWithNft() { + const base = makeExec(); + const calls = []; + const fn = async (bin, args, opts = {}) => { + calls.push({ bin, args, opts }); + if (bin === 'nft') { + if (args[0] === '-c') return { stdout: '', stderr: '' }; + if (args[0] === '-f') return { stdout: '', stderr: '' }; + if (args[0] === 'list') return { stdout: 'table inet dcnet {\n}\n', stderr: '' }; + } + return base(bin, args, opts); + }; + fn.calls = calls; + return fn; + } + + test('execFile mock receives nft -c -f THEN nft -f (transactional order)', async () => { + const exec = makeExecWithNft(); + const fs = makeFs(); + const provider = createWireGuardProvider({ + execFileImpl: exec, + fsImpl: fs, + dataDir: '/data', + confPath: '/wg/dcnet0.conf', + unitPath: '/sys/dcnet-hub.service', + getuid: () => 0, + now: () => 1700000000000, + // firewall dep OMITTED — default createWgFirewallApplier(execFileImpl) + }); + const res = await provider.syncAccess(input()); + expect(res).toEqual({ applied: true, resourcesAffected: 1 }); + + const nftCalls = exec.calls.filter((c) => c.bin === 'nft'); + expect(nftCalls.length).toBeGreaterThanOrEqual(4); + // Sequence: `nft -c -f` (check) → `nft list table` (capture previous) + // → `nft -f` (apply) → `nft list table` (verify), strictly in order. + const checkCall = nftCalls.find((c) => c.args[0] === '-c'); + const applyCall = nftCalls.find((c) => c.args[0] === '-f'); + const listCalls = nftCalls.filter((c) => c.args[0] === 'list'); + expect(checkCall).toBeTruthy(); + expect(applyCall).toBeTruthy(); + expect(listCalls.length).toBeGreaterThanOrEqual(2); + expect(nftCalls.indexOf(checkCall)).toBeLessThan(nftCalls.indexOf(applyCall)); + expect(listCalls[0].args).toEqual(['list', 'table', 'inet', 'dcnet']); + // The checked file and the applied file are the same temp ruleset. + expect(applyCall.args[1]).toBe(checkCall.args[2]); + // The applied ruleset text is the REAL compiler output. + const appliedText = fs.readFileSync(applyCall.args[1], 'utf8'); + expect(appliedText).toContain('set net_dc_admins'); + expect(appliedText).toContain('elements = { 10.53.0.2 }'); + }); + + test('default applier: nft -c failure → firewall_check_failed with stderr detail, no apply', async () => { + const base = makeExec(); + const calls = []; + const exec = async (bin, args, opts = {}) => { + calls.push({ bin, args, opts }); + if (bin === 'nft' && args[0] === '-c') { + const e = new Error('nft: syntax error'); + e.stderr = 'nft: syntax error near line 4'; + throw e; + } + return base(bin, args, opts); + }; + const provider = createWireGuardProvider({ + execFileImpl: exec, + fsImpl: makeFs(), + dataDir: '/data', + confPath: '/wg/dcnet0.conf', + unitPath: '/sys/dcnet-hub.service', + getuid: () => 0, + now: () => 1700000000000, + }); + const err = await provider.syncAccess(input()).catch((e) => e); + expect(err).toMatchObject({ name: 'ProviderError', code: 'firewall_check_failed' }); + expect(err.detail).toMatchObject({ stderr: 'nft: syntax error near line 4', applied: false }); + // check failed ⇒ `nft -f` apply NEVER ran + expect(calls.filter((c) => c.bin === 'nft' && c.args[0] === '-f')).toHaveLength(0); + }); + }); +}); + +describe('wireguard provider: unsupported / not-implemented surface', () => { test('configureDns → unsupported (splitDnsApi capability false)', async () => { const { provider } = makeProvider(); await expect(provider.configureDns({ zone: 'z', server: 's' })).rejects.toMatchObject({ diff --git a/dashcaddy-api/__tests__/network-syncaccess.test.js b/dashcaddy-api/__tests__/network-syncaccess.test.js new file mode 100644 index 0000000..6f99db5 --- /dev/null +++ b/dashcaddy-api/__tests__/network-syncaccess.test.js @@ -0,0 +1,80 @@ +/** + * DC-153 syncAccess contract suite — exercises the src/network/acl-sync.js + * adapter (the injectable Tailscale enforcement engine) directly, plus the + * cross-provider guarantees the judge gate asked for: shapes pass through, + * error classes map to ProviderError codes, success shapes hold, and the + * DEFAULT construction paths run the real DC-148/DC-151 engines against + * injected coord/exec mocks. + * + * Provider-level behavior tests live in network-provider-{tailscale,wireguard}.test.js. + */ + +'use strict'; + +const { createAclSync } = require('../src/network/acl-sync'); +const { compileAndValidate, LockoutError } = require('../src/network/acl-compiler'); +const { AclPushError } = require('../src/network/acl-push'); + +function tsInput() { + return { + accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'app', allowed: true }] }, + people: [{ id: 'p1', providerUserId: '1001', groups: ['Admin'], status: 'active' }], + resources: [{ name: 'app', host: 'host.ts.net', ports: ['*'] }], + hostDeviceId: 'dev-host', + adminUserIds: ['1001'], + hostResource: { host: 'host.ts.net', ports: ['*'] }, + }; +} + +describe('acl-sync adapter (createAclSync)', () => { + test('compile: runs the real compileAndValidate, returns policy + hash + usersAffected', async () => { + const sync = createAclSync({ pusher: {} }); // compile path needs no pusher + const out = await sync.compile(tsInput()); + expect(out.hash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(out.usersAffected).toBe(1); // 1001 only; host device excluded + // same policy the raw engine produces + expect(out.policy).toEqual(compileAndValidate(tsInput()).policy); + }); + + test('compile: LockoutError propagates untouched (no admin ids)', async () => { + const sync = createAclSync({ pusher: {} }); + const bad = { ...tsInput(), adminUserIds: [] }; + await expect(sync.compile(bad)).rejects.toBeInstanceOf(LockoutError); + }); + + test('preview/push delegate to the pusher; getState to getLastKnownGood', async () => { + const pusher = { + previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })), + pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'sha256:x' })), + getLastKnownGood: jest.fn(() => ({ hash: 'sha256:x', policy: { acls: [] } })), + }; + const sync = createAclSync({ pusher }); + await expect(sync.preview({ acls: [] })).resolves.toEqual({ feasible: true, errors: [] }); + await expect(sync.push({ acls: [] })).resolves.toEqual({ pushed: true, hash: 'sha256:x' }); + expect(sync.getState()).toMatchObject({ hash: 'sha256:x' }); + expect(pusher.previewPolicy).toHaveBeenCalledWith({ acls: [] }); + expect(pusher.pushPolicy).toHaveBeenCalledWith({ acls: [] }); + }); + + test('built from a coordClient: preview POSTs to /tailnet/{t}/acl/preview', async () => { + const coord = { + request: jest.fn(async (m, p) => { + if (p === '/tailnet/-/acl/preview') return { feasible: true, errors: [] }; + return {}; + }), + }; + const sync = createAclSync({ coordClient: coord, tailnet: '-' }); + await sync.preview({ acls: [] }); + expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/acl/preview', { + body: { acls: [] }, + }); + }); + + test('pusher errors[] surface as AclPushError (caller must refuse to push)', async () => { + const coord = { + request: jest.fn(async () => ({ feasible: false, errors: ['dst unreachable'] })), + }; + const sync = createAclSync({ coordClient: coord }); + await expect(sync.preview({ acls: [] })).rejects.toBeInstanceOf(AclPushError); + }); +}); diff --git a/dashcaddy-api/src/network/acl-sync.js b/dashcaddy-api/src/network/acl-sync.js new file mode 100644 index 0000000..f205e2f --- /dev/null +++ b/dashcaddy-api/src/network/acl-sync.js @@ -0,0 +1,105 @@ +/** + * ACL sync adapter (DC-153) — the glue between the Tailscale provider's + * syncAccess() contract and the DC-148 enforcement engines + * (acl-compiler.compileAndValidate + acl-push previewPolicy/pushPolicy). + * + * The provider talks to enforcement ONLY through this surface: + * + * compile(input) → { policy, hash, usersAffected } + * input: { accessMap, people, resources, hostDeviceId, + * adminUserIds, hostResource } + * Runs the pure compiler + lockout guard. LockoutError propagates + * untouched — the provider maps it to ProviderError 'lockout_guard'. + * `usersAffected` = distinct non-host srcs in the compiled acls. + * + * preview(policy) → preview body (throws AclPushError on ANY preview + * error — callers MUST refuse to push when this rejects) + * + * push(policy) → { pushed: true, hash } (AclPushError on API failure) + * + * getState() → last-known-good record or null (delegates to the pusher) + * + * Dependencies are injectable per the DC-153 refactor: pass a `pusher` + * (anything with previewPolicy/pushPolicy/getLastKnownGood — the + * createAclPusher() output shape) or a `coordClient` + `tailnet` (a + * pusher is built from them), and optionally a `compileAndValidate` + * override. Defaults are the real modules, so + * createAclSync({ coordClient, tailnet }) alone is production wiring. + */ + +'use strict'; + +const { compileAndValidate: compileAndValidateDefault } = require('./acl-compiler'); +const { createAclPusher } = require('./acl-push'); + +/** + * Count distinct user srcs in the compiled acls, excluding the DashCaddy + * host device (its egress rule src is a device id, not a user). + * @param {Array} acls - compiled acl entries + * @param {string} hostDeviceId + * @returns {number} + */ +function _countUsersAffected(acls, hostDeviceId) { + const users = new Set(); + for (const entry of acls || []) { + for (const src of (entry && entry.src) || []) { + if (src !== hostDeviceId) users.add(src); + } + } + return users.size; +} + +/** + * @param {object} [deps] + * @param {object} [deps.coordClient] - client with .request(method, path, + * {body}); a lazy proxy is fine (resolved per call) + * @param {string} [deps.tailnet='-'] - tailnet name for ACL URLs + * @param {object} [deps.pusher] - createAclPusher() output (or mock with + * previewPolicy/pushPolicy/getLastKnownGood); wins over coordClient + * @param {Function} [deps.compileAndValidate] - pure compiler override + */ +function createAclSync({ coordClient = null, tailnet = '-', pusher = null, compileAndValidate } = {}) { + const push = pusher || createAclPusher({ coord: coordClient, tailnet }); + const compileAndValidateFn = compileAndValidate || compileAndValidateDefault; + + return { + /** + * Compile + lockout-guard + hash. Synchronous engine, async surface + * (the provider contract is all-async; mocks may be async too). + * @returns {Promise<{policy: object, hash: string, usersAffected: number}>} + * @throws {LockoutError} when the compiled policy would lock out admins + */ + async compile(input) { + const { policy, hash } = compileAndValidateFn(input); + return { + policy, + hash, + usersAffected: _countUsersAffected(policy && policy.acls, input && input.hostDeviceId), + }; + }, + + /** + * Dry-run the policy against the tailnet. Rejects (AclPushError) on + * ANY preview error — the caller must refuse to push. + */ + async preview(policy) { + return push.previewPolicy(policy); + }, + + /** + * Push the COMPLETE policy; on success the pusher persists the + * last-known-good policy + hash atomically. + * @returns {Promise<{pushed: true, hash: string}>} + */ + async push(policy) { + return push.pushPolicy(policy); + }, + + /** Local last-known-good state (null when never pushed). */ + getState() { + return push.getLastKnownGood(); + }, + }; +} + +module.exports = { createAclSync }; diff --git a/dashcaddy-api/src/network/providers/tailscale.js b/dashcaddy-api/src/network/providers/tailscale.js index 71c9237..eb9a517 100644 --- a/dashcaddy-api/src/network/providers/tailscale.js +++ b/dashcaddy-api/src/network/providers/tailscale.js @@ -29,6 +29,9 @@ const { execFile } = require('child_process'); const { promisify } = require('util'); const { ProviderError } = require('../errors'); +const { LockoutError } = require('../acl-compiler'); +const { AclPushError } = require('../acl-push'); +const { createAclSync } = require('../acl-sync'); const execFileAsyncDefault = promisify(execFile); const CLI_TIMEOUT_MS = 5000; @@ -80,17 +83,38 @@ function toProviderError(err) { * require it lazily). Used for getStatus(); may be a jest mock. * @param {Function} deps.execFileImpl - optional execFile(cb) override for * the whois CLI call (tests inject a stub instead of spawning processes). + * @param {object} [deps.aclSync] - the ACL enforcement engine + * (src/network/acl-sync.js surface: compile/preview/push/getState). + * Default: built lazily from the attached coord client (or an explicit + * { coordClient, tailnet } pair) via createAclSync — see syncAccess. + * @param {string} [deps.tailnet='-'] - tailnet name for ACL URLs when the + * engine is default-constructed. */ function createTailscaleProvider({ coordClient = null, localManager, execFileImpl, + aclSync = null, + tailnet = '-', } = {}) { // Lazy default require keeps jest.mock('.../tailscale-manager') effective. /* eslint-disable global-require -- see above */ const manager = localManager || require('../../managers/tailscale-manager'); const execFn = execFileImpl || execFileAsyncDefault; let coord = coordClient; + let aclSyncDep = aclSync; + + /** + * The ACL enforcement engine. Explicit dep wins; otherwise it is built + * lazily (first syncAccess) from the CURRENT coord client, so + * attachCoordClient() after construction still works. Building eagerly + * when no client is attached yet would bake in a dead engine. + */ + function aclSyncEngine() { + if (aclSyncDep) return aclSyncDep; + aclSyncDep = createAclSync({ coordClient: requireCoord('syncAccess'), tailnet }); + return aclSyncDep; + } function requireCoord() { if (!coord) { @@ -306,18 +330,116 @@ function createTailscaleProvider({ }, /** - * Compile + push ACLs. NOT in this provider yet — the ACL compiler is - * DC-148 and will be injected (hash-guarded push, lockout guard, dry-run - * preview). Feature modules must treat this code as "coming soon", not - * as a silent no-op. + * Compile + dry-run + push the tailnet ACL policy (DC-153; engines from + * DC-148 via src/network/acl-sync.js). Safety sequence, spec §4: + * 1. compileAndValidate — LockoutError → ProviderError 'lockout_guard' + * (retryable:false), nothing compiled-past let alone pushed + * 2. previewPolicy — ANY preview error → ProviderError + * 'acl_preview_failed', NOTHING pushed + * 3. pushPolicy — API failure maps through the usual coord vocabulary; + * success → { pushed: true, hash, usersAffected } + * + * @param {object} [input] + * @param {object} input.accessMap - { grants: [...] } (DC-145 store) + * @param {Array} input.people - DC-141 person records + * @param {Array} input.resources - resource records (host for the + * DashCaddy app is the lockout-guard hostResource) + * @param {string} input.hostDeviceId - Tailscale device id of this host + * @param {string[]} [input.adminUserIds] - provider user ids of active + * admins (defaults to providerUserId of active 'Admin'-group people) + * @param {object} [input.hostResource] - lockout-guard host resource + * @returns {Promise<{pushed:true, hash:string, usersAffected:number}>} */ - async syncAccess() { - await Promise.reject( - new ProviderError('syncAccess lands with the ACL compiler (DC-148)', { - code: 'not_implemented_in_provider', - retryable: false, - }) - ); + async syncAccess(input = {}) { + const engine = aclSyncEngine(); + const { accessMap, people, resources, hostDeviceId } = input; + // Default admin set: active members of the 'Admin' group (DC-141 + // bootstrap-admin convention) that have a provider user id. + const adminUserIds = Array.isArray(input.adminUserIds) + ? input.adminUserIds + : (people || []) + .filter( + (p) => + p && + p.status === 'active' && + Array.isArray(p.groups) && + p.groups.includes('Admin') && + typeof p.providerUserId === 'string' && + p.providerUserId.length > 0 + ) + .map((p) => p.providerUserId); + // Default host resource: the DashCaddy app resource (the admin UI the + // lockout guard protects). Caller may pass an explicit hostResource. + const hostResource = + input.hostResource || + (() => { + const dc = (resources || []).find( + (r) => r && (r.name === 'dashcaddy' || r.type === 'app') + ); + return dc ? { host: dc.host || dc.name, ports: dc.ports || ['*'] } : undefined; + })(); + + let compiled; + try { + compiled = await engine.compile({ + accessMap, + people, + resources, + hostDeviceId, + adminUserIds, + hostResource, + }); + } catch (err) { + if (err instanceof LockoutError) { + throw new ProviderError('tailscale: ' + err.message, { + code: 'lockout_guard', + retryable: false, + cause: err, + }); + } + throw toProviderError(err); + } + + try { + // ANY preview error refuses the push (AclPushError from acl-push). + await engine.preview(compiled.policy); + } catch (err) { + if (err instanceof AclPushError) { + throw new ProviderError( + 'tailscale: ACL preview failed — refusing to push (' + err.message + ')', + { code: 'acl_preview_failed', retryable: false, cause: err } + ); + } + throw toProviderError(err); + } + + let pushed; + try { + pushed = await engine.push(compiled.policy); + } catch (err) { + if (err instanceof AclPushError) { + // API-side failure: map the pusher's code through the shared + // vocabulary (acl_rejected → bad_input, rate-limited/http_* → + // retryable server-ish codes). + let code = 'server_error'; + if (err.code === 'acl_rejected') code = 'bad_input'; + else if (err.code === 'rate_limited') code = 'rate_limited'; + const opts = { code, cause: err }; + opts.retryable = + err.code === 'rate_limited' || + String(err.code).startsWith('http_429') || + code === 'server_error'; + throw new ProviderError('tailscale: ACL push failed — ' + err.message, opts); + } + throw toProviderError(err); + } + + return { + pushed: true, + hash: pushed.hash !== undefined ? pushed.hash : compiled.hash, + usersAffected: + pushed.usersAffected !== undefined ? pushed.usersAffected : compiled.usersAffected, + }; }, /** @@ -383,6 +505,14 @@ function createTailscaleProvider({ /** Late-bind the coord client (context wiring lands with routes/network.js). */ attachCoordClient(client) { coord = client; + // A default-built engine captured the OLD client — drop it so the + // next syncAccess rebuilds from the new one. + if (!aclSync) aclSyncDep = null; + }, + + /** Late-bind the ACL enforcement engine (tests / engine swaps). */ + attachAclSync(engine) { + aclSyncDep = engine; }, }; diff --git a/dashcaddy-api/src/network/providers/wireguard.js b/dashcaddy-api/src/network/providers/wireguard.js index 1491915..32bbc9d 100644 --- a/dashcaddy-api/src/network/providers/wireguard.js +++ b/dashcaddy-api/src/network/providers/wireguard.js @@ -39,6 +39,13 @@ const path = require('path'); const crypto = require('crypto'); const { execFile } = require('child_process'); const { ProviderError } = require('../errors'); +const { + compileRuleset, + createWgFirewallApplier, + FirewallCheckError, + FirewallApplyError, + FirewallVerifyError, +} = require('../wg-firewall'); const INTERFACE_NAME = 'dcnet0'; const HUB_ADDRESS = '10.53.0.1/16'; @@ -128,6 +135,10 @@ function bareAddress(addr) { * @param {number} [deps.listenPort] - UDP listen port (default 51820) * @param {Function} [deps.getuid] - uid probe (root/CAP_NET_ADMIN check) * @param {Function} [deps.now] - epoch-ms clock (invite TTL tests) + * @param {object} [deps.firewall] - the nftables enforcement engine + * (src/network/wg-firewall.js applier surface: apply/rollback). Default: + * createWgFirewallApplier() built from this provider's execFileImpl, so + * injected exec mocks exercise the REAL wiring end to end. */ function createWireGuardProvider({ execFileImpl, @@ -138,9 +149,16 @@ function createWireGuardProvider({ listenPort = DEFAULT_LISTEN_PORT, getuid, now, + firewall = null, } = {}) { const execFn = execFileImpl || execFileDefault; const fs = fsImpl || require('fs'); // eslint-disable-line no-shadow + const firewallApplier = + firewall || + createWgFirewallApplier({ + execFileImpl: execFn, + fsImpl: fsImpl || undefined, + }); const dataDirResolved = dataDir !== undefined ? dataDir : require('../../../platform-paths').dataDir; const peersPath = path.join(dataDirResolved, 'network-wg-peers.json'); @@ -772,15 +790,100 @@ function createWireGuardProvider({ }, /** - * nftables firewall compiler lands with DC-151 — not a silent no-op. + * Compile + transactionally apply the dcnet nftables ruleset (DC-153; + * engines from DC-151). Sequence: + * 1. compileRuleset — the admin-lockout throw (admin ports with zero + * active admin peer IPs) → ProviderError 'lockout_guard' + * 2. firewallApplier.apply — check (`nft -c -f`) → rollback-file → + * apply (`nft -f`) → verify; the applier NEVER partially applies + * (a check failure applies nothing; an apply failure restores the + * prior rollback file). Firewall*Error classes map to + * 'firewall_check_failed' / 'firewall_apply_failed' / + * 'firewall_verify_failed' with .detail preserving stderr + + * rolledBack info. + * + * @param {object} [input] + * @param {object} input.accessMap - { grants: [...] } (DC-145 store) + * @param {Array} input.people - DC-141 person records + * @param {Object} input.resources + * @param {Object} [input.peerAddresses] - `pid/did` → ip + * @param {string[]} [input.adminPeers] - person IDs allowed admin ports + * (defaults to active 'Admin'-group people) + * @returns {Promise<{applied:true, resourcesAffected:number}>} */ - async syncAccess() { - await Promise.reject( - new ProviderError('syncAccess lands with the nftables firewall compiler (DC-151)', { - code: 'not_implemented', - retryable: false, - }) - ); + async syncAccess(input = {}) { + const { + accessMap, + people, + resources, + peerAddresses = {}, + adminPeers, + } = input; + // Default admin set: active members of the 'Admin' group (DC-141 + // bootstrap-admin convention). + const admins = Array.isArray(adminPeers) + ? adminPeers + : (people || []) + .filter( + (p) => + p && + p.status === 'active' && + Array.isArray(p.groups) && + p.groups.includes('Admin') + ) + .map((p) => p.id); + + let ruleset; + try { + ruleset = compileRuleset({ + accessMap, + people, + peerAddresses, + resources, + adminPeers: admins, + }); + } catch (err) { + if (err && /lockout guard/.test(err.message)) { + throw new ProviderError('wireguard: ' + err.message, { + code: 'lockout_guard', + retryable: false, + cause: err, + }); + } + throw toProviderError(err); + } + + try { + await firewallApplier.apply(ruleset); + } catch (err) { + const detailByClass = new Map([ + [FirewallCheckError, { stderr: err.stderr, applied: false }], + [FirewallApplyError, { stderr: err.stderr, applied: false, rolledBack: false }], + [FirewallVerifyError, { stderr: err.stderr, applied: true, rolledBack: err.rolledBack }], + ]); + const codeByClass = new Map([ + [FirewallCheckError, 'firewall_check_failed'], + [FirewallApplyError, 'firewall_apply_failed'], + [FirewallVerifyError, 'firewall_verify_failed'], + ]); + for (const [cls, code] of codeByClass) { + if (err instanceof cls) { + const pe = new ProviderError('wireguard: ' + err.message, { + code, + retryable: false, + cause: err, + }); + pe.detail = detailByClass.get(cls); + throw pe; + } + } + throw toProviderError(err); + } + + return { + applied: true, + resourcesAffected: Object.keys(resources || {}).length, + }; }, /**