P7 Private Network (DC-140..DC-154 + judge fixes) #1
No files matched your search
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Tests for src/network/acl-compiler.js — DC-148.
|
||||
*
|
||||
* Golden-policy cases, lockout adversarial cases (each hard rule), drift
|
||||
* detection both ways, baseline-section passthrough. 100% pure — no I/O,
|
||||
* no jest mocks needed.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable require-await, no-unused-vars */
|
||||
// require-await: helper stubs are async to match real signatures without awaiting.
|
||||
// no-unused-vars: fixture records exist to document the people shape.
|
||||
|
||||
const {
|
||||
compilePolicy,
|
||||
assertNoLockout,
|
||||
detectDrift,
|
||||
compileAndValidate,
|
||||
hashAcls,
|
||||
LockoutError,
|
||||
} = require('../src/network/acl-compiler');
|
||||
|
||||
// ── fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ADMIN = { id: 'p-admin', providerUserId: '1001', groups: ['Admin'], status: 'active' };
|
||||
const WIFE = { id: 'p-wife', providerUserId: '1002', groups: ['Family'], status: 'active' };
|
||||
const KID = { id: 'p-kid', providerUserId: '1003', groups: ['Family'], status: 'active' };
|
||||
const BLOCKED = { id: 'p-bad', providerUserId: '1004', groups: ['Family'], status: 'blocked' };
|
||||
const REMOVED = { id: 'p-gone', providerUserId: '1005', groups: ['Admin'], status: 'removed' };
|
||||
|
||||
const RESOURCES = [
|
||||
{ name: 'dashcaddy', host: 'dashcaddy.internal', ports: ['80', '443', '8080'] },
|
||||
{ name: 'plex', host: 'plex.internal', ports: ['32400'] },
|
||||
{ name: 'nas', host: 'nas.internal' }, // ports default → ['*']
|
||||
];
|
||||
|
||||
const HOST_DEVICE = 'dev-host-123';
|
||||
|
||||
function grants(...list) {
|
||||
return { grants: list.map(([type, id, resource, allowed]) =>
|
||||
({ principal: { type, id }, resource, allowed })) };
|
||||
}
|
||||
|
||||
const BASELINE = {
|
||||
ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }],
|
||||
tagOwners: { 'tag:dc-host': ['autogroup:admin'] },
|
||||
};
|
||||
|
||||
// ── golden-policy: compilePolicy ──────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: golden policies', () => {
|
||||
test('single admin with all-resource grants → per-user src entries + host egress', () => {
|
||||
const map = grants(
|
||||
['person', 'p-admin', 'dashcaddy', true],
|
||||
['person', 'p-admin', 'plex', true],
|
||||
['person', 'p-admin', 'nas', true],
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toEqual([
|
||||
{ action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:80', 'dashcaddy.internal:443', 'dashcaddy.internal:8080'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: ['1001'], dst: ['plex.internal:32400'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: ['1001'], dst: ['nas.internal:*'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('family group grant expands to each active member (no Tailscale groups)', () => {
|
||||
const map = grants(['group', 'Family', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN, WIFE, KID, BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
const srcs = policy.acls.map(e => e.src[0]);
|
||||
expect(srcs).toContain('1002');
|
||||
expect(srcs).toContain('1003');
|
||||
expect(srcs).not.toContain('1004'); // blocked compiles to nothing
|
||||
expect(srcs).not.toContain('1005'); // removed compiles to nothing
|
||||
// No group object is ever emitted in dst (free-plan rule)
|
||||
for (const e of policy.acls) {
|
||||
for (const d of e.dst) expect(d.startsWith('group:')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('blocked person is absent even with an explicit personal allow grant', () => {
|
||||
const map = grants(
|
||||
['person', 'p-bad', 'plex', true],
|
||||
['group', 'Family', 'plex', true],
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toEqual([
|
||||
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('person-level override beats group grant (deny and allow)', () => {
|
||||
const map = grants(
|
||||
['group', 'Family', 'plex', true],
|
||||
['person', 'p-kid', 'plex', false], // kid explicitly denied despite Family
|
||||
['group', 'Family', 'nas', false],
|
||||
['person', 'p-wife', 'nas', true], // wife explicitly allowed despite Family deny
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [WIFE, KID], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
const wife = policy.acls.filter(e => e.src.includes('1002'));
|
||||
const kid = policy.acls.filter(e => e.src.includes('1003'));
|
||||
expect(wife.map(e => e.dst[0]).sort()).toEqual(['nas.internal:*', 'plex.internal:32400']); // nas via override + plex via group
|
||||
expect(kid).toEqual([]); // plex denied by override, nas denied by group
|
||||
});
|
||||
|
||||
test('default-deny: ungranted resources and people compile to nothing', () => {
|
||||
const map = grants(['group', 'Family', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
// only the host egress rule — no catch-all accept, nothing for ADMIN
|
||||
expect(policy.acls).toEqual([
|
||||
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
]);
|
||||
const flat = JSON.stringify(policy.acls);
|
||||
expect(flat.includes('*:*')).toBe(true); // host device egress only
|
||||
expect(policy.acls.filter(e => e.src.includes('1001'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('grant for an unknown resource compiles to nothing', () => {
|
||||
const map = grants(['person', 'p-admin', 'ghost-app', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toHaveLength(1); // host egress only
|
||||
});
|
||||
|
||||
test('person without providerUserId compiles to nothing', () => {
|
||||
const nobody = { ...ADMIN, id: 'p-noid', providerUserId: '' };
|
||||
const map = grants(['person', 'p-noid', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [nobody], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toHaveLength(1); // host egress only
|
||||
});
|
||||
|
||||
test('baseline ssh + tagOwners pass through untouched; acls replaced', () => {
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: BASELINE });
|
||||
expect(policy.ssh).toEqual(BASELINE.ssh);
|
||||
expect(policy.tagOwners).toEqual(BASELINE.tagOwners);
|
||||
expect(Object.keys(policy).sort()).toEqual(['acls', 'ssh', 'tagOwners']);
|
||||
});
|
||||
|
||||
test('baseline acls section is dropped (fully DC-managed)', () => {
|
||||
const withOldAcls = { ...BASELINE, acls: [{ action: 'accept', src: ['stale'], dst: ['x:*'] }] };
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: withOldAcls });
|
||||
expect(policy.acls.some(e => e.src.includes('stale'))).toBe(false);
|
||||
});
|
||||
|
||||
test('input validation throws TypeError', () => {
|
||||
expect(() => compilePolicy()).toThrow(TypeError);
|
||||
expect(() => compilePolicy({ accessMap: {} })).toThrow(TypeError);
|
||||
expect(() => compilePolicy({ accessMap: { grants: [] }, people: [] })).toThrow(TypeError);
|
||||
expect(() => compilePolicy({ accessMap: { grants: [] }, people: [], resources: [] })).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── lockout adversarial ───────────────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: lockout guard (assertNoLockout)', () => {
|
||||
const hostResource = RESOURCES[0]; // dashcaddy
|
||||
|
||||
test('last-admin-denied throws LockoutError', () => {
|
||||
// admin granted plex only — dashcaddy host resource NOT granted
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).toThrow(LockoutError);
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource }))
|
||||
.toThrow(/every active admin/);
|
||||
});
|
||||
|
||||
test('host-egress-missing throws LockoutError', () => {
|
||||
const map = grants(['person', 'p-admin', 'dashcaddy', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
// simulate a policy that lost the egress rule
|
||||
policy.acls = policy.acls.filter(e => !e.dst.includes('*:*'));
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource, hostDeviceId: HOST_DEVICE }))
|
||||
.toThrow(/egress/);
|
||||
});
|
||||
|
||||
test('two admins, one denied, one allowed → passes (not a lockout)', () => {
|
||||
const admin2 = { id: 'p-admin2', providerUserId: '1006', groups: ['Admin'], status: 'active' };
|
||||
const map = grants(
|
||||
['person', 'p-admin', 'plex', true], // admin1: NO dashcaddy
|
||||
['person', 'p-admin2', 'dashcaddy', true], // admin2: has dashcaddy
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN, admin2], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001', '1006'], hostResource })).not.toThrow();
|
||||
});
|
||||
|
||||
test('empty adminUserIds throws (no admin at all is a lockout)', () => {
|
||||
const map = grants(['person', 'p-wife', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [WIFE], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(LockoutError);
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(/no active admin/);
|
||||
});
|
||||
|
||||
test('admin allowed via group grant counts (Family granted dashcaddy)', () => {
|
||||
const adminViaGroup = { ...ADMIN, groups: ['Family'] };
|
||||
const map = grants(['group', 'Family', 'dashcaddy', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [adminViaGroup], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).not.toThrow();
|
||||
});
|
||||
|
||||
test('requires a policy object (TypeError)', () => {
|
||||
expect(() => assertNoLockout(null, {})).toThrow(TypeError);
|
||||
expect(() => assertNoLockout({}, {})).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('LockoutError shape: code, statusCode, name', () => {
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
try {
|
||||
assertNoLockout(policy, { adminUserIds: ['1001'], hostResource });
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(LockoutError);
|
||||
expect(err.name).toBe('LockoutError');
|
||||
expect(err.code).toBe('DC_ACL_LOCKOUT');
|
||||
expect(err.statusCode).toBe(409);
|
||||
expect(err.isOperational).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── drift detection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: drift detection (detectDrift)', () => {
|
||||
const map = grants(['person', 'p-admin', 'dashcaddy', true], ['person', 'p-admin', 'plex', true]);
|
||||
const { policy, hash } = compileAndValidate(_fullInput());
|
||||
|
||||
function _fullInput() {
|
||||
return { accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, adminUserIds: ['1001'], hostResource: RESOURCES[0] };
|
||||
}
|
||||
|
||||
test('no drift: identical remote acls → drifted:false', () => {
|
||||
const remote = { acls: JSON.parse(JSON.stringify(policy.acls)) };
|
||||
const res = detectDrift(hash, remote);
|
||||
expect(res.drifted).toBe(false);
|
||||
expect(res.remoteHash).toBe(hash);
|
||||
});
|
||||
|
||||
test('drift: outside edit added a rule → drifted:true', () => {
|
||||
const remote = { acls: [...policy.acls, { action: 'accept', src: ['outsider'], dst: ['plex.internal:32400'] }] };
|
||||
expect(detectDrift(hash, remote).drifted).toBe(true);
|
||||
});
|
||||
|
||||
test('drift: rule removed remotely → drifted:true', () => {
|
||||
const remote = { acls: policy.acls.slice(0, -1) };
|
||||
expect(detectDrift(hash, remote).drifted).toBe(true);
|
||||
});
|
||||
|
||||
test('baseline sections changing is NOT drift (only acls hashed)', () => {
|
||||
const remote = { acls: JSON.parse(JSON.stringify(policy.acls)), ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] };
|
||||
expect(detectDrift(hash, remote).drifted).toBe(false);
|
||||
});
|
||||
|
||||
test('hash is order-stable (sorted keys) and content-sensitive', () => {
|
||||
expect(hashAcls(policy.acls)).toBe(hash);
|
||||
expect(hashAcls([])).not.toBe(hash);
|
||||
// key order inside entries does not matter
|
||||
const shuffled = policy.acls.map(e => ({ proto: e.proto, dst: e.dst, src: e.src, action: e.action }));
|
||||
expect(hashAcls(shuffled)).toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ── compileAndValidate ────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: compileAndValidate', () => {
|
||||
test('happy path returns { policy, hash } and passes the guard', () => {
|
||||
const map = grants(['person', 'p-admin', 'dashcaddy', true]);
|
||||
const out = compileAndValidate({
|
||||
accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE,
|
||||
adminUserIds: ['1001'], hostResource: RESOURCES[0],
|
||||
});
|
||||
expect(out.policy.acls.some(e => e.src.includes('1001'))).toBe(true);
|
||||
expect(out.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('throws LockoutError (no TypeError leak) when the guard trips', () => {
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
expect(() => compileAndValidate({
|
||||
accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE,
|
||||
adminUserIds: ['1001'], hostResource: RESOURCES[0],
|
||||
})).toThrow(LockoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Tests for src/network/acl-push.js — DC-148.
|
||||
*
|
||||
* Hermetic: no network. The coord client is stubbed with the same
|
||||
* makeFetch rigor as __tests__/tailscale-coord.test.js — a route map
|
||||
* keyed by 'METHOD path' returning { status, body }, defaulting to 404.
|
||||
* dataDir points at a fresh tmp dir per test so the last-known-good
|
||||
* state file never leaks between tests.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable require-await */
|
||||
// Helper stubs are async to match real client signatures without awaiting.
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { createAclPusher, AclPushError } = require('../src/network/acl-push');
|
||||
const { hashAcls } = require('../src/network/acl-compiler');
|
||||
|
||||
const POLICY = {
|
||||
acls: [
|
||||
{ action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:443'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: ['dev-host-1'], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
],
|
||||
ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }],
|
||||
tagOwners: { 'tag:dc-host': ['autogroup:admin'] },
|
||||
};
|
||||
const OTHER_POLICY = {
|
||||
acls: [{ action: 'accept', src: ['9999'], dst: ['x:*'], proto: ['tcp', 'udp'] }],
|
||||
};
|
||||
|
||||
/** Route-map fetch stub (tailscale-coord.test.js rigor). */
|
||||
function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) {
|
||||
const calls = [];
|
||||
const fn = jest.fn(async (method, apiPath, opts) => {
|
||||
calls.push({ method, path: apiPath, opts });
|
||||
const match = routes[method + ' ' + apiPath];
|
||||
if (match) {
|
||||
return {
|
||||
status: match.status,
|
||||
body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
};
|
||||
}
|
||||
return { status: defaultStatus, body: JSON.stringify(defaultBody) };
|
||||
});
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** Coord-client-shaped stub for the coord injection path. */
|
||||
function makeCoord(routes) {
|
||||
const fetchImpl = makeFetch(routes);
|
||||
return {
|
||||
fetchImpl,
|
||||
request: async (method, apiPath, opts) => {
|
||||
const res = await fetchImpl(method, '/api/v2' + apiPath, opts);
|
||||
if (res.status >= 400) {
|
||||
throw Object.assign(new Error(`coord ${res.status}`), { code: res.status === 400 ? 'bad_input' : 'http_error', status: res.status });
|
||||
}
|
||||
try { return JSON.parse(res.body); } catch (_) { return res.body; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dc-acl-push-'));
|
||||
}
|
||||
|
||||
function readState(dir) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, 'network-acl-state.json'), 'utf8'));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── previewPolicy ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: previewPolicy', () => {
|
||||
test('posts the full policy to /acl/preview and returns the body', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [], warnings: [] } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
const out = await pusher.previewPolicy(POLICY);
|
||||
expect(out.feasible).toBe(true);
|
||||
expect(fetchImpl.calls).toHaveLength(1);
|
||||
const { method, path: p, opts } = fetchImpl.calls[0];
|
||||
expect(method).toBe('POST');
|
||||
expect(p).toBe('/api/v2/tailnet/-/acl/preview');
|
||||
expect(opts.body).toEqual(POLICY); // COMPLETE policy object, never partial
|
||||
});
|
||||
|
||||
test('preview errors[] → AclPushError acl_rejected (refuse to push)', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': {
|
||||
status: 200,
|
||||
body: { feasible: false, errors: [{ text: 'port 99999 invalid' }] },
|
||||
},
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
||||
name: 'AclPushError', code: 'acl_rejected',
|
||||
});
|
||||
});
|
||||
|
||||
test('preview HTTP 400 → AclPushError (mapped)', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 400, body: { message: 'bad acl' } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
||||
name: 'AclPushError', code: 'acl_rejected',
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch throw → AclPushError coord_error (network failure mapped)', async () => {
|
||||
const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
||||
name: 'AclPushError', code: 'coord_error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── pushPolicy ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: pushPolicy', () => {
|
||||
test('posts the complete policy and stores last-known-good body + hash', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
const out = await pusher.pushPolicy(POLICY);
|
||||
expect(out.pushed).toBe(true);
|
||||
expect(out.hash).toBe(hashAcls(POLICY.acls));
|
||||
// request shape: complete object
|
||||
expect(fetchImpl.calls[0].opts.body).toEqual(POLICY);
|
||||
expect(fetchImpl.calls[0].opts.body.ssh).toBeDefined();
|
||||
expect(fetchImpl.calls[0].opts.body.tagOwners).toBeDefined();
|
||||
// state file persisted atomically with policy + hash
|
||||
const state = readState(dir);
|
||||
expect(state.policy).toEqual(POLICY);
|
||||
expect(state.hash).toBe(hashAcls(POLICY.acls));
|
||||
expect(state.pushedAt).toBeTruthy();
|
||||
expect(pusher.getLastKnownGood().hash).toBe(hashAcls(POLICY.acls));
|
||||
});
|
||||
|
||||
test('failed push (500) stores nothing', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 500, body: { message: 'boom' } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await expect(pusher.pushPolicy(POLICY)).rejects.toMatchObject({ code: 'http_500' });
|
||||
expect(pusher.getLastKnownGood()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── rollback ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: rollback', () => {
|
||||
test('re-pushes the stored last-known-good policy', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
const out = await pusher.rollback();
|
||||
expect(out.rolledBack).toBe(true);
|
||||
expect(out.hash).toBe(hashAcls(POLICY.acls));
|
||||
// the rollback push carries the ORIGINAL stored body verbatim
|
||||
expect(fetchImpl.calls[0].opts.body).toEqual(POLICY);
|
||||
});
|
||||
|
||||
test('rollback without a stored policy throws no_last_known_good', async () => {
|
||||
const pusher = createAclPusher({ fetchImpl: makeFetch({}), dataDir: tmpDir() });
|
||||
await expect(pusher.rollback()).rejects.toMatchObject({ code: 'no_last_known_good' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── pushIfNoDrift ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: pushIfNoDrift', () => {
|
||||
test('no drift → previews then pushes and updates state', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: POLICY },
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY); // seed stored hash
|
||||
fetchImpl.calls.length = 0;
|
||||
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out).toEqual({ pushed: true, hash: hashAcls(POLICY.acls) });
|
||||
const paths = fetchImpl.calls.map(c => c.method + ' ' + c.path);
|
||||
expect(paths).toEqual([
|
||||
'GET /api/v2/tailnet/-/acl',
|
||||
'POST /api/v2/tailnet/-/acl/preview',
|
||||
'POST /api/v2/tailnet/-/acl',
|
||||
]);
|
||||
});
|
||||
|
||||
test('drift (remote acls changed outside DashCaddy) → {drifted:true, pushed:false}, NO push', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: OTHER_POLICY }, // outside edit
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out).toEqual({ drifted: true, pushed: false });
|
||||
// only the GET happened — never preview, never a silent overwrite push
|
||||
expect(fetchImpl.calls.map(c => c.method)).toEqual(['GET']);
|
||||
});
|
||||
|
||||
test('baseline-only remote change is NOT drift (ssh edited outside)', async () => {
|
||||
const dir = tmpDir();
|
||||
const editedBaseline = { ...POLICY, ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] };
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: editedBaseline },
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out.pushed).toBe(true);
|
||||
});
|
||||
|
||||
test('first-ever push (no stored state) skips the drift check', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out.pushed).toBe(true);
|
||||
expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST', 'POST']); // no GET
|
||||
});
|
||||
|
||||
test('preview failure during pushIfNoDrift refuses the push (throws)', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: false, errors: [{ text: 'nope' }] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ code: 'acl_rejected' });
|
||||
expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST']); // preview only, no push
|
||||
});
|
||||
|
||||
test('remote read failure during drift check maps to AclPushError', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
'GET /api/v2/tailnet/-/acl': { status: 500, body: { message: 'ctrl down' } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ name: 'AclPushError' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── coord injection + misc ────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: injectable coord client + config', () => {
|
||||
test('coord.request is used instead of fetchImpl when both given', async () => {
|
||||
const coord = makeCoord({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
});
|
||||
const fetchImpl = jest.fn();
|
||||
const pusher = createAclPusher({ coord: { request: coord.request }, fetchImpl, dataDir: tmpDir() });
|
||||
const out = await pusher.previewPolicy(POLICY);
|
||||
expect(out.feasible).toBe(true);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('coord error (TailscaleCoordError-like) is mapped with its code', async () => {
|
||||
const coordErr = Object.assign(new Error('unauthorized'), { code: 'unauthorized', status: 401 });
|
||||
const coord = { request: async () => { throw coordErr; } };
|
||||
const pusher = createAclPusher({ coord, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({ code: 'unauthorized' });
|
||||
});
|
||||
|
||||
test('no coord and no fetchImpl → TypeError', () => {
|
||||
expect(() => createAclPusher({ dataDir: tmpDir() })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('custom tailnet name is used in URLs', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/example.ts.net/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir(), tailnet: 'example.ts.net' });
|
||||
await pusher.previewPolicy(POLICY);
|
||||
expect(fetchImpl.calls[0].path).toBe('/api/v2/tailnet/example.ts.net/acl/preview');
|
||||
});
|
||||
|
||||
test('AclPushError is exported and operational', () => {
|
||||
const e = new AclPushError('x', { code: 'test' });
|
||||
expect(e.name).toBe('AclPushError');
|
||||
expect(e.code).toBe('test');
|
||||
expect(e.isOperational).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Tailscale ACL compiler + lockout guard — DC-148.
|
||||
*
|
||||
* Pure functions, NO I/O. Turns the DashCaddy access map (DC-145) plus the
|
||||
* people store (DC-141) into a Tailscale HuJSON policy object, and enforces
|
||||
* the two hard lockout rules from specs/PRIVATE-NETWORK-SPEC.md §4:
|
||||
*
|
||||
* (a) the last active admin must keep access to the DashCaddy host
|
||||
* resource — refuse any policy that would deny EVERY active admin;
|
||||
* (b) the DashCaddy host device must keep an egress rule.
|
||||
*
|
||||
* Compilation rules (normative, spec §2/§4):
|
||||
* - DashCaddy groups NEVER map to Tailscale groups (free plan caps ACL
|
||||
* groups at 3). Every src entry references a Tailscale user id
|
||||
* (person.providerUserId) directly.
|
||||
* - dst is `host:port` per resource grant.
|
||||
* - Default-deny: no catch-all accept is ever emitted; a resource with no
|
||||
* allow grant compiles to nothing.
|
||||
* - Blocked/removed people compile to nothing.
|
||||
* - Baseline non-DC sections (ssh, tagOwners, …) are passed through
|
||||
* untouched; ONLY the `acls` section is DashCaddy-managed.
|
||||
*
|
||||
* Fleet-verified Tailscale gotchas honored here:
|
||||
* - autogroup:member is valid ONLY as src — never emitted as dst.
|
||||
* - POST /tailnet/{t}/acl must carry the COMPLETE policy (acls + ssh +
|
||||
* tagOwners) — compilePolicy returns the whole merged object for that.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const MANAGED_SECTION = 'acls';
|
||||
|
||||
/** Thrown by assertNoLockout / compileAndValidate when a compiled policy would lock admins out. */
|
||||
class LockoutError extends Error {
|
||||
constructor(reason) {
|
||||
super(`ACL lockout guard: ${reason}`);
|
||||
this.name = 'LockoutError';
|
||||
this.code = 'DC_ACL_LOCKOUT';
|
||||
this.reason = reason;
|
||||
this.statusCode = 409;
|
||||
this.isOperational = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable hash of the DC-managed section: sha256 of canonical (sorted-key) JSON. */
|
||||
function hashAcls(acls) {
|
||||
const canonical = _canonicalJson(acls === undefined ? null : acls);
|
||||
return 'sha256:' + crypto.createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
function _canonicalJson(value) {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return '[' + value.map(_canonicalJson).join(',') + ']';
|
||||
const keys = Object.keys(value).sort();
|
||||
return '{' + keys.map(k => JSON.stringify(k) + ':' + _canonicalJson(value[k])).join(',') + '}';
|
||||
}
|
||||
|
||||
/** Effective allowed resources for one person, mirroring DC-145 semantics on a raw map (pure). */
|
||||
function _effectiveAllowed(grants, person) {
|
||||
const groupSet = new Set((person.groups || []).filter(g => typeof g === 'string'));
|
||||
const resources = {};
|
||||
for (const g of grants) {
|
||||
if (!g || !g.principal) continue;
|
||||
if (g.principal.type === 'group' && groupSet.has(g.principal.id)) {
|
||||
resources[g.resource] = g.allowed; // union of group grants
|
||||
}
|
||||
}
|
||||
for (const g of grants) {
|
||||
if (!g || !g.principal) continue;
|
||||
if (g.principal.type === 'person' && g.principal.id === person.id) {
|
||||
resources[g.resource] = g.allowed; // person-level OVERRIDES group
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
function _resourceTargets(resource) {
|
||||
const host = resource.host || resource.name;
|
||||
const ports = Array.isArray(resource.ports) && resource.ports.length > 0
|
||||
? resource.ports
|
||||
: ['*'];
|
||||
return ports.map(p => `${host}:${p}`);
|
||||
}
|
||||
|
||||
function _hostEgressEntry(hostDeviceId) {
|
||||
// Host device keeps egress to everything. src is a device id (valid src);
|
||||
// dst is the catch-all — the ONLY wildcard dst we ever emit, and it belongs
|
||||
// to the trusted DashCaddy host device itself, not to any user.
|
||||
return { action: 'accept', src: [hostDeviceId], dst: ['*:*'], proto: ['tcp', 'udp'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* compilePolicy({ accessMap, people, resources, hostDeviceId, baseline }) → HuJSON policy object.
|
||||
*
|
||||
* accessMap { grants: [...] } from the DC-145 store (shape only — pure input)
|
||||
* people Person[] (DC-141 records: id, providerUserId, groups, status)
|
||||
* resources [{ name, host?, ports? }] — host defaults to name, ports to ['*']
|
||||
* hostDeviceId Tailscale device id of the DashCaddy host (egress rule src)
|
||||
* baseline optional current remote policy — every non-`acls` key
|
||||
* (ssh, tagOwners, …) is copied through VERBATIM
|
||||
*/
|
||||
function _validateCompileInputs({ accessMap, people, resources, hostDeviceId }) {
|
||||
if (!accessMap || typeof accessMap !== 'object') {
|
||||
throw new TypeError('compilePolicy requires accessMap');
|
||||
}
|
||||
if (!Array.isArray(people)) throw new TypeError('compilePolicy requires people array');
|
||||
if (!Array.isArray(resources)) throw new TypeError('compilePolicy requires resources array');
|
||||
if (typeof hostDeviceId !== 'string' || hostDeviceId.length === 0) {
|
||||
throw new TypeError('compilePolicy requires hostDeviceId');
|
||||
}
|
||||
}
|
||||
|
||||
/** One ACL entry per (allowed person × allowed resource) pair. */
|
||||
function _compileUserEntries(grants, people, byName) {
|
||||
const acls = [];
|
||||
for (const person of people) {
|
||||
if (!person || person.status !== 'active') continue; // blocked/removed → nothing
|
||||
const userId = person.providerUserId;
|
||||
if (typeof userId !== 'string' || userId.length === 0) continue;
|
||||
const allowed = _effectiveAllowed(grants, person);
|
||||
for (const [resourceName, isAllowed] of Object.entries(allowed)) {
|
||||
if (isAllowed !== true) continue; // default-deny: only explicit allows emit rules
|
||||
const resource = byName.get(resourceName);
|
||||
if (!resource) continue; // grant for an unknown resource → nothing
|
||||
acls.push({
|
||||
action: 'accept',
|
||||
src: [userId], // per-USER src, never a custom group
|
||||
dst: _resourceTargets(resource),
|
||||
proto: ['tcp', 'udp'],
|
||||
});
|
||||
}
|
||||
}
|
||||
return acls;
|
||||
}
|
||||
|
||||
function compilePolicy({ accessMap, people, resources, hostDeviceId, baseline } = {}) {
|
||||
_validateCompileInputs({ accessMap, people, resources, hostDeviceId });
|
||||
|
||||
const grants = Array.isArray(accessMap.grants) ? accessMap.grants : [];
|
||||
const byName = new Map(resources.map(r => [r.name, r]));
|
||||
const acls = _compileUserEntries(grants, people, byName);
|
||||
acls.push(_hostEgressEntry(hostDeviceId));
|
||||
|
||||
// Whole-policy output: baseline sections pass through untouched; `acls` is ours.
|
||||
const policy = {};
|
||||
if (baseline && typeof baseline === 'object' && !Array.isArray(baseline)) {
|
||||
for (const [k, v] of Object.entries(baseline)) {
|
||||
if (k !== MANAGED_SECTION) policy[k] = v;
|
||||
}
|
||||
}
|
||||
policy[MANAGED_SECTION] = acls;
|
||||
return policy;
|
||||
}
|
||||
|
||||
function _dstMatchesHost(dstEntries, hostResource) {
|
||||
const host = typeof hostResource === 'string' ? hostResource : hostResource && hostResource.host;
|
||||
const ports = hostResource && Array.isArray(hostResource.ports) && hostResource.ports.length > 0
|
||||
? hostResource.ports.map(String)
|
||||
: null;
|
||||
for (const d of dstEntries) {
|
||||
const sep = d.lastIndexOf(':');
|
||||
if (sep < 0) continue;
|
||||
const h = d.slice(0, sep);
|
||||
const p = d.slice(sep + 1);
|
||||
if (h !== host) continue;
|
||||
if (ports === null || p === '*' || ports.includes(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _hasHostEgress(policy, hostDeviceId) {
|
||||
for (const entry of policy.acls || []) {
|
||||
if (entry && entry.action === 'accept'
|
||||
&& Array.isArray(entry.src) && entry.src.includes(hostDeviceId)
|
||||
&& Array.isArray(entry.dst) && entry.dst.includes('*:*')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* assertNoLockout(policy, { adminUserIds, hostResource, hostDeviceId })
|
||||
*
|
||||
* Throws LockoutError when:
|
||||
* - the host device's egress rule is absent (hostDeviceId given), or
|
||||
* - EVERY active admin user id would be denied the host resource
|
||||
* (adminUserIds empty also throws — no admin at all is a lockout).
|
||||
*/
|
||||
function assertNoLockout(policy, { adminUserIds, hostResource, hostDeviceId } = {}) {
|
||||
if (!policy || typeof policy !== 'object' || !Array.isArray(policy.acls)) {
|
||||
throw new TypeError('assertNoLockout requires a compiled policy');
|
||||
}
|
||||
if (typeof hostDeviceId === 'string' && hostDeviceId.length > 0 && !_hasHostEgress(policy, hostDeviceId)) {
|
||||
throw new LockoutError(`host device ${hostDeviceId} would lose egress (no *:* accept rule)`);
|
||||
}
|
||||
const admins = Array.isArray(adminUserIds)
|
||||
? adminUserIds.filter(u => typeof u === 'string' && u.length > 0)
|
||||
: [];
|
||||
if (admins.length === 0) {
|
||||
throw new LockoutError('no active admin would retain access to the host resource');
|
||||
}
|
||||
for (const admin of admins) {
|
||||
for (const entry of policy.acls) {
|
||||
if (entry && entry.action === 'accept'
|
||||
&& Array.isArray(entry.src) && entry.src.includes(admin)
|
||||
&& Array.isArray(entry.dst) && _dstMatchesHost(entry.dst, hostResource)) {
|
||||
return true; // at least ONE admin keeps host access — not a lockout
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new LockoutError(
|
||||
`every active admin (${admins.join(', ')}) would be denied the host resource`);
|
||||
}
|
||||
|
||||
/**
|
||||
* detectDrift(localPolicyHash, remotePolicy) → { drifted }
|
||||
*
|
||||
* Compares the stored post-push hash against a stable hash of the remote
|
||||
* policy's DC-managed `acls` section. Non-DC sections never count as drift.
|
||||
*/
|
||||
function detectDrift(localPolicyHash, remotePolicy) {
|
||||
const remoteHash = hashAcls(remotePolicy && remotePolicy.acls);
|
||||
return { drifted: remoteHash !== localPolicyHash, remoteHash };
|
||||
}
|
||||
|
||||
/**
|
||||
* compileAndValidate(input) → { policy, hash }
|
||||
* compilePolicy + assertNoLockout + stable hash of the managed section,
|
||||
* ready for preview + push (acl-push.js).
|
||||
*/
|
||||
function compileAndValidate(input) {
|
||||
const policy = compilePolicy(input);
|
||||
assertNoLockout(policy, input && {
|
||||
adminUserIds: input.adminUserIds,
|
||||
hostResource: input.hostResource,
|
||||
hostDeviceId: input.hostDeviceId,
|
||||
});
|
||||
return { policy, hash: hashAcls(policy[MANAGED_SECTION]) };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compilePolicy,
|
||||
assertNoLockout,
|
||||
detectDrift,
|
||||
compileAndValidate,
|
||||
hashAcls,
|
||||
LockoutError,
|
||||
MANAGED_SECTION,
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* ACL push engine — DC-148 (the I/O half of the compiler).
|
||||
*
|
||||
* Owns every interaction with the Tailscale coordination plane plus the
|
||||
* local last-known-good state file (data/network-acl-state.json, atomic
|
||||
* write). Dependencies are injectable: a `coord` client (anything exposing
|
||||
* request/_request-style 'POST path' + getAcl(), e.g. TailscaleCoordClient)
|
||||
* or a raw fetch stub, and a logger.
|
||||
*
|
||||
* Safety sequence (spec §4, hard rules):
|
||||
* previewPolicy POST /tailnet/{t}/acl/preview — ANY error refuses the push
|
||||
* pushPolicy POST /tailnet/{t}/acl with the COMPLETE policy object,
|
||||
* then atomically store last-known-good body + hash
|
||||
* pushIfNoDrift compare stored hash vs remote acls hash first; drift ⇒
|
||||
* {drifted:true, pushed:false} — NEVER a silent overwrite
|
||||
* rollback re-push the stored last-known-good policy
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { atomicWriteJSON } = require('../utils/atomic-write');
|
||||
const { hashAcls, detectDrift } = require('./acl-compiler');
|
||||
|
||||
const ACL_STATE_FILE = 'network-acl-state.json';
|
||||
|
||||
/** Error thrown for mapped coordination-plane failures; carries `code`. */
|
||||
class AclPushError extends Error {
|
||||
constructor(message, { code = 'acl_push_failed', cause } = {}) {
|
||||
super(message);
|
||||
this.name = 'AclPushError';
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
this.isOperational = true;
|
||||
}
|
||||
}
|
||||
|
||||
function _readState(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return null;
|
||||
throw new AclPushError('failed to read network-acl-state.json', { code: 'state_read_failed', cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* createAclPusher({ coord, fetchImpl, dataDir, log, tailnet })
|
||||
*
|
||||
* coord client with .request(method, path, {body}) — the primary path
|
||||
* fetchImpl fallback when no coord: async (method, path, {body}) → {status, body}
|
||||
* tailnet tailnet name for URLs ('-' default)
|
||||
*/
|
||||
function createAclPusher(opts = {}) {
|
||||
const coord = opts.coord || null;
|
||||
const fetchImpl = opts.fetchImpl || null;
|
||||
const dataDir = opts.dataDir || (global.__DC_ACL_STATE_DIR__ || process.cwd());
|
||||
const log = opts.log || { info() {}, warn() {}, error() {} };
|
||||
const tailnet = opts.tailnet || '-';
|
||||
const stateFile = path.join(dataDir, ACL_STATE_FILE);
|
||||
|
||||
if (!coord && !fetchImpl) {
|
||||
throw new TypeError('createAclPusher requires coord or fetchImpl');
|
||||
}
|
||||
|
||||
// The coord client prefixes /api/v2 itself (_request); a raw fetch stub
|
||||
// gets the full control-plane path.
|
||||
const API_BASE = '/api/v2';
|
||||
|
||||
async function _call(method, apiPath, { body } = {}) {
|
||||
if (coord && typeof coord.request === 'function') {
|
||||
try {
|
||||
return await coord.request(method, apiPath, { body });
|
||||
} catch (err) {
|
||||
throw _mapError(`coord ${method} ${apiPath} failed`, err);
|
||||
}
|
||||
}
|
||||
const fullPath = API_BASE + apiPath;
|
||||
let res;
|
||||
try {
|
||||
res = await fetchImpl(method, fullPath, { body });
|
||||
} catch (err) {
|
||||
throw _mapError(`fetch ${method} ${fullPath} failed`, err);
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
throw new AclPushError(`${method} ${fullPath} returned ${res.status}`, {
|
||||
code: res.status === 400 ? 'acl_rejected' : `http_${res.status}`,
|
||||
cause: res.body,
|
||||
});
|
||||
}
|
||||
// Match the coord client contract: parsed JSON when parseable, else raw.
|
||||
if (typeof res.body === 'string') {
|
||||
try { return JSON.parse(res.body); } catch (_) { return res.body; }
|
||||
}
|
||||
return res.body;
|
||||
}
|
||||
|
||||
function _mapError(message, err) {
|
||||
if (err && err.name === 'AclPushError') return err;
|
||||
let code = 'coord_error';
|
||||
if (err && typeof err.code === 'string' && err.code) {
|
||||
code = err.code === 'bad_input' || err.status === 400 ? 'acl_rejected' : err.code;
|
||||
}
|
||||
return new AclPushError(`${message}: ${err && err.message}`, { code, cause: err });
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run the policy against the tailnet. ANY preview error throws
|
||||
* AclPushError — callers MUST refuse to push when this rejects.
|
||||
* Returns the preview response body (Tailscale: { feasible, errors[] }).
|
||||
*/
|
||||
async function previewPolicy(policy) {
|
||||
const body = await _call('POST', `/tailnet/${tailnet}/acl/preview`, { body: policy });
|
||||
if (body && Array.isArray(body.errors) && body.errors.length > 0) {
|
||||
throw new AclPushError('ACL preview reported errors', {
|
||||
code: 'acl_rejected',
|
||||
cause: body.errors,
|
||||
});
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Read the remote policy (GET /tailnet/{t}/acl). */
|
||||
async function getRemotePolicy() {
|
||||
return await _call('GET', `/tailnet/${tailnet}/acl`);
|
||||
}
|
||||
|
||||
function _storeLastKnownGood(policy, hash) {
|
||||
const state = {
|
||||
policy,
|
||||
hash,
|
||||
managedSection: 'acls',
|
||||
pushedAt: new Date().toISOString(),
|
||||
};
|
||||
atomicWriteJSON(stateFile, state);
|
||||
log.info('network-acl', 'stored last-known-good policy', { hash });
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the COMPLETE policy (never partial — caller merges baseline before
|
||||
* compile). On success the full body + managed-section hash are atomically
|
||||
* persisted as last-known-good.
|
||||
*/
|
||||
async function pushPolicy(policy) {
|
||||
await _call('POST', `/tailnet/${tailnet}/acl`, { body: policy });
|
||||
const hash = hashAcls(policy.acls);
|
||||
_storeLastKnownGood(policy, hash);
|
||||
return { pushed: true, hash };
|
||||
}
|
||||
|
||||
/** Read local last-known-good state (null when never pushed). */
|
||||
function getLastKnownGood() {
|
||||
return _readState(stateFile);
|
||||
}
|
||||
|
||||
/** Re-push the stored last-known-good policy (one-click rollback). */
|
||||
async function rollback() {
|
||||
const state = _readState(stateFile);
|
||||
if (!state || !state.policy) {
|
||||
throw new AclPushError('no last-known-good policy stored — cannot rollback', { code: 'no_last_known_good' });
|
||||
}
|
||||
await _call('POST', `/tailnet/${tailnet}/acl`, { body: state.policy });
|
||||
return { rolledBack: true, hash: state.hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare stored hash vs remote acls before overwriting. Outside edits ⇒
|
||||
* { drifted: true, pushed: false } — the caller shows a drift banner and
|
||||
* must NEVER silently overwrite. Only a clean (or first) push proceeds:
|
||||
* preview → push → store.
|
||||
*/
|
||||
async function pushIfNoDrift(policy) {
|
||||
const stored = _readState(stateFile);
|
||||
if (stored && stored.hash) {
|
||||
let remote;
|
||||
try {
|
||||
remote = await getRemotePolicy();
|
||||
} catch (err) {
|
||||
throw _mapError('drift check failed (cannot read remote ACL)', err);
|
||||
}
|
||||
const { drifted } = detectDrift(stored.hash, remote);
|
||||
if (drifted) {
|
||||
log.warn('network-acl', 'drift detected — refusing to overwrite remote ACL', {});
|
||||
return { drifted: true, pushed: false };
|
||||
}
|
||||
}
|
||||
await previewPolicy(policy); // refuses (throws) on any preview error
|
||||
return await pushPolicy(policy);
|
||||
}
|
||||
|
||||
return {
|
||||
previewPolicy,
|
||||
pushPolicy,
|
||||
pushIfNoDrift,
|
||||
rollback,
|
||||
getRemotePolicy,
|
||||
getLastKnownGood,
|
||||
AclPushError,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAclPusher,
|
||||
AclPushError,
|
||||
ACL_STATE_FILE,
|
||||
};
|
||||
Reference in new issue
Block a user