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()', () => {
|
describe('tailscale provider: syncAccess() (DC-153 contract tests)', () => {
|
||||||
test('throws ProviderError code=not_implemented_in_provider (DC-148 lands later)', async () => {
|
const { LockoutError } = require('../src/network/acl-compiler');
|
||||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
const { AclPushError } = require('../src/network/acl-push');
|
||||||
await expect(p.syncAccess({}, [])).rejects.toMatchObject({
|
|
||||||
|
/** 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',
|
name: 'ProviderError',
|
||||||
code: 'not_implemented_in_provider',
|
code: 'lockout_guard',
|
||||||
retryable: false,
|
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', () => {
|
describe('wireguard provider: syncAccess() (DC-153 contract tests)', () => {
|
||||||
test('syncAccess → not_implemented (DC-151 injects the firewall compiler)', async () => {
|
const {
|
||||||
const { provider } = makeProvider();
|
FirewallCheckError,
|
||||||
await expect(provider.syncAccess()).rejects.toMatchObject({
|
FirewallApplyError,
|
||||||
name: 'ProviderError',
|
FirewallVerifyError,
|
||||||
code: 'not_implemented',
|
} = require('../src/network/wg-firewall');
|
||||||
retryable: false,
|
|
||||||
});
|
/** 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 () => {
|
test('configureDns → unsupported (splitDnsApi capability false)', async () => {
|
||||||
const { provider } = makeProvider();
|
const { provider } = makeProvider();
|
||||||
await expect(provider.configureDns({ zone: 'z', server: 's' })).rejects.toMatchObject({
|
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 { execFile } = require('child_process');
|
||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
const { ProviderError } = require('../errors');
|
const { ProviderError } = require('../errors');
|
||||||
|
const { LockoutError } = require('../acl-compiler');
|
||||||
|
const { AclPushError } = require('../acl-push');
|
||||||
|
const { createAclSync } = require('../acl-sync');
|
||||||
|
|
||||||
const execFileAsyncDefault = promisify(execFile);
|
const execFileAsyncDefault = promisify(execFile);
|
||||||
const CLI_TIMEOUT_MS = 5000;
|
const CLI_TIMEOUT_MS = 5000;
|
||||||
@@ -80,17 +83,38 @@ function toProviderError(err) {
|
|||||||
* require it lazily). Used for getStatus(); may be a jest mock.
|
* require it lazily). Used for getStatus(); may be a jest mock.
|
||||||
* @param {Function} deps.execFileImpl - optional execFile(cb) override for
|
* @param {Function} deps.execFileImpl - optional execFile(cb) override for
|
||||||
* the whois CLI call (tests inject a stub instead of spawning processes).
|
* 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({
|
function createTailscaleProvider({
|
||||||
coordClient = null,
|
coordClient = null,
|
||||||
localManager,
|
localManager,
|
||||||
execFileImpl,
|
execFileImpl,
|
||||||
|
aclSync = null,
|
||||||
|
tailnet = '-',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
// Lazy default require keeps jest.mock('.../tailscale-manager') effective.
|
// Lazy default require keeps jest.mock('.../tailscale-manager') effective.
|
||||||
/* eslint-disable global-require -- see above */
|
/* eslint-disable global-require -- see above */
|
||||||
const manager = localManager || require('../../managers/tailscale-manager');
|
const manager = localManager || require('../../managers/tailscale-manager');
|
||||||
const execFn = execFileImpl || execFileAsyncDefault;
|
const execFn = execFileImpl || execFileAsyncDefault;
|
||||||
let coord = coordClient;
|
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() {
|
function requireCoord() {
|
||||||
if (!coord) {
|
if (!coord) {
|
||||||
@@ -306,18 +330,116 @@ function createTailscaleProvider({
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile + push ACLs. NOT in this provider yet — the ACL compiler is
|
* Compile + dry-run + push the tailnet ACL policy (DC-153; engines from
|
||||||
* DC-148 and will be injected (hash-guarded push, lockout guard, dry-run
|
* DC-148 via src/network/acl-sync.js). Safety sequence, spec §4:
|
||||||
* preview). Feature modules must treat this code as "coming soon", not
|
* 1. compileAndValidate — LockoutError → ProviderError 'lockout_guard'
|
||||||
* as a silent no-op.
|
* (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() {
|
async syncAccess(input = {}) {
|
||||||
await Promise.reject(
|
const engine = aclSyncEngine();
|
||||||
new ProviderError('syncAccess lands with the ACL compiler (DC-148)', {
|
const { accessMap, people, resources, hostDeviceId } = input;
|
||||||
code: 'not_implemented_in_provider',
|
// Default admin set: active members of the 'Admin' group (DC-141
|
||||||
retryable: false,
|
// 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). */
|
/** Late-bind the coord client (context wiring lands with routes/network.js). */
|
||||||
attachCoordClient(client) {
|
attachCoordClient(client) {
|
||||||
coord = 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 crypto = require('crypto');
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
const { ProviderError } = require('../errors');
|
const { ProviderError } = require('../errors');
|
||||||
|
const {
|
||||||
|
compileRuleset,
|
||||||
|
createWgFirewallApplier,
|
||||||
|
FirewallCheckError,
|
||||||
|
FirewallApplyError,
|
||||||
|
FirewallVerifyError,
|
||||||
|
} = require('../wg-firewall');
|
||||||
|
|
||||||
const INTERFACE_NAME = 'dcnet0';
|
const INTERFACE_NAME = 'dcnet0';
|
||||||
const HUB_ADDRESS = '10.53.0.1/16';
|
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 {number} [deps.listenPort] - UDP listen port (default 51820)
|
||||||
* @param {Function} [deps.getuid] - uid probe (root/CAP_NET_ADMIN check)
|
* @param {Function} [deps.getuid] - uid probe (root/CAP_NET_ADMIN check)
|
||||||
* @param {Function} [deps.now] - epoch-ms clock (invite TTL tests)
|
* @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({
|
function createWireGuardProvider({
|
||||||
execFileImpl,
|
execFileImpl,
|
||||||
@@ -138,9 +149,16 @@ function createWireGuardProvider({
|
|||||||
listenPort = DEFAULT_LISTEN_PORT,
|
listenPort = DEFAULT_LISTEN_PORT,
|
||||||
getuid,
|
getuid,
|
||||||
now,
|
now,
|
||||||
|
firewall = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const execFn = execFileImpl || execFileDefault;
|
const execFn = execFileImpl || execFileDefault;
|
||||||
const fs = fsImpl || require('fs'); // eslint-disable-line no-shadow
|
const fs = fsImpl || require('fs'); // eslint-disable-line no-shadow
|
||||||
|
const firewallApplier =
|
||||||
|
firewall ||
|
||||||
|
createWgFirewallApplier({
|
||||||
|
execFileImpl: execFn,
|
||||||
|
fsImpl: fsImpl || undefined,
|
||||||
|
});
|
||||||
const dataDirResolved =
|
const dataDirResolved =
|
||||||
dataDir !== undefined ? dataDir : require('../../../platform-paths').dataDir;
|
dataDir !== undefined ? dataDir : require('../../../platform-paths').dataDir;
|
||||||
const peersPath = path.join(dataDirResolved, 'network-wg-peers.json');
|
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() {
|
async syncAccess(input = {}) {
|
||||||
await Promise.reject(
|
const {
|
||||||
new ProviderError('syncAccess lands with the nftables firewall compiler (DC-151)', {
|
accessMap,
|
||||||
code: 'not_implemented',
|
people,
|
||||||
retryable: false,
|
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