DC-153: provider.syncAccess enforcement wiring (tailscale ACL push + wireguard nftables apply) + contract tests
This commit is contained in:
6 files changed
+820
-32
No files matched your search
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string,{ip:string,port:number}>} input.resources
|
||||
* @param {Object<string,string>} [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,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user