merge dc/DC-153 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 22:34:32 -07:00
commit a5ef5b345e
6 files changed
+820 -32

No files matched your search

+105
View File
@@ -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 };
+141 -11
View File
@@ -29,6 +29,9 @@
const { execFile } = require('child_process');
const { promisify } = require('util');
const { ProviderError } = require('../errors');
const { LockoutError } = require('../acl-compiler');
const { AclPushError } = require('../acl-push');
const { createAclSync } = require('../acl-sync');
const execFileAsyncDefault = promisify(execFile);
const CLI_TIMEOUT_MS = 5000;
@@ -80,17 +83,38 @@ function toProviderError(err) {
* require it lazily). Used for getStatus(); may be a jest mock.
* @param {Function} deps.execFileImpl - optional execFile(cb) override for
* the whois CLI call (tests inject a stub instead of spawning processes).
* @param {object} [deps.aclSync] - the ACL enforcement engine
* (src/network/acl-sync.js surface: compile/preview/push/getState).
* Default: built lazily from the attached coord client (or an explicit
* { coordClient, tailnet } pair) via createAclSync — see syncAccess.
* @param {string} [deps.tailnet='-'] - tailnet name for ACL URLs when the
* engine is default-constructed.
*/
function createTailscaleProvider({
coordClient = null,
localManager,
execFileImpl,
aclSync = null,
tailnet = '-',
} = {}) {
// Lazy default require keeps jest.mock('.../tailscale-manager') effective.
/* eslint-disable global-require -- see above */
const manager = localManager || require('../../managers/tailscale-manager');
const execFn = execFileImpl || execFileAsyncDefault;
let coord = coordClient;
let aclSyncDep = aclSync;
/**
* The ACL enforcement engine. Explicit dep wins; otherwise it is built
* lazily (first syncAccess) from the CURRENT coord client, so
* attachCoordClient() after construction still works. Building eagerly
* when no client is attached yet would bake in a dead engine.
*/
function aclSyncEngine() {
if (aclSyncDep) return aclSyncDep;
aclSyncDep = createAclSync({ coordClient: requireCoord('syncAccess'), tailnet });
return aclSyncDep;
}
function requireCoord() {
if (!coord) {
@@ -306,18 +330,116 @@ function createTailscaleProvider({
},
/**
* Compile + push ACLs. NOT in this provider yet — the ACL compiler is
* DC-148 and will be injected (hash-guarded push, lockout guard, dry-run
* preview). Feature modules must treat this code as "coming soon", not
* as a silent no-op.
* Compile + dry-run + push the tailnet ACL policy (DC-153; engines from
* DC-148 via src/network/acl-sync.js). Safety sequence, spec §4:
* 1. compileAndValidate — LockoutError → ProviderError 'lockout_guard'
* (retryable:false), nothing compiled-past let alone pushed
* 2. previewPolicy — ANY preview error → ProviderError
* 'acl_preview_failed', NOTHING pushed
* 3. pushPolicy — API failure maps through the usual coord vocabulary;
* success → { pushed: true, hash, usersAffected }
*
* @param {object} [input]
* @param {object} input.accessMap - { grants: [...] } (DC-145 store)
* @param {Array} input.people - DC-141 person records
* @param {Array} input.resources - resource records (host for the
* DashCaddy app is the lockout-guard hostResource)
* @param {string} input.hostDeviceId - Tailscale device id of this host
* @param {string[]} [input.adminUserIds] - provider user ids of active
* admins (defaults to providerUserId of active 'Admin'-group people)
* @param {object} [input.hostResource] - lockout-guard host resource
* @returns {Promise<{pushed:true, hash:string, usersAffected:number}>}
*/
async syncAccess() {
await Promise.reject(
new ProviderError('syncAccess lands with the ACL compiler (DC-148)', {
code: 'not_implemented_in_provider',
retryable: false,
})
);
async syncAccess(input = {}) {
const engine = aclSyncEngine();
const { accessMap, people, resources, hostDeviceId } = input;
// Default admin set: active members of the 'Admin' group (DC-141
// bootstrap-admin convention) that have a provider user id.
const adminUserIds = Array.isArray(input.adminUserIds)
? input.adminUserIds
: (people || [])
.filter(
(p) =>
p &&
p.status === 'active' &&
Array.isArray(p.groups) &&
p.groups.includes('Admin') &&
typeof p.providerUserId === 'string' &&
p.providerUserId.length > 0
)
.map((p) => p.providerUserId);
// Default host resource: the DashCaddy app resource (the admin UI the
// lockout guard protects). Caller may pass an explicit hostResource.
const hostResource =
input.hostResource ||
(() => {
const dc = (resources || []).find(
(r) => r && (r.name === 'dashcaddy' || r.type === 'app')
);
return dc ? { host: dc.host || dc.name, ports: dc.ports || ['*'] } : undefined;
})();
let compiled;
try {
compiled = await engine.compile({
accessMap,
people,
resources,
hostDeviceId,
adminUserIds,
hostResource,
});
} catch (err) {
if (err instanceof LockoutError) {
throw new ProviderError('tailscale: ' + err.message, {
code: 'lockout_guard',
retryable: false,
cause: err,
});
}
throw toProviderError(err);
}
try {
// ANY preview error refuses the push (AclPushError from acl-push).
await engine.preview(compiled.policy);
} catch (err) {
if (err instanceof AclPushError) {
throw new ProviderError(
'tailscale: ACL preview failed — refusing to push (' + err.message + ')',
{ code: 'acl_preview_failed', retryable: false, cause: err }
);
}
throw toProviderError(err);
}
let pushed;
try {
pushed = await engine.push(compiled.policy);
} catch (err) {
if (err instanceof AclPushError) {
// API-side failure: map the pusher's code through the shared
// vocabulary (acl_rejected → bad_input, rate-limited/http_* →
// retryable server-ish codes).
let code = 'server_error';
if (err.code === 'acl_rejected') code = 'bad_input';
else if (err.code === 'rate_limited') code = 'rate_limited';
const opts = { code, cause: err };
opts.retryable =
err.code === 'rate_limited' ||
String(err.code).startsWith('http_429') ||
code === 'server_error';
throw new ProviderError('tailscale: ACL push failed — ' + err.message, opts);
}
throw toProviderError(err);
}
return {
pushed: true,
hash: pushed.hash !== undefined ? pushed.hash : compiled.hash,
usersAffected:
pushed.usersAffected !== undefined ? pushed.usersAffected : compiled.usersAffected,
};
},
/**
@@ -383,6 +505,14 @@ function createTailscaleProvider({
/** Late-bind the coord client (context wiring lands with routes/network.js). */
attachCoordClient(client) {
coord = client;
// A default-built engine captured the OLD client — drop it so the
// next syncAccess rebuilds from the new one.
if (!aclSync) aclSyncDep = null;
},
/** Late-bind the ACL enforcement engine (tests / engine swaps). */
attachAclSync(engine) {
aclSyncDep = engine;
},
};
@@ -39,6 +39,13 @@ const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');
const { ProviderError } = require('../errors');
const {
compileRuleset,
createWgFirewallApplier,
FirewallCheckError,
FirewallApplyError,
FirewallVerifyError,
} = require('../wg-firewall');
const INTERFACE_NAME = 'dcnet0';
const HUB_ADDRESS = '10.53.0.1/16';
@@ -128,6 +135,10 @@ function bareAddress(addr) {
* @param {number} [deps.listenPort] - UDP listen port (default 51820)
* @param {Function} [deps.getuid] - uid probe (root/CAP_NET_ADMIN check)
* @param {Function} [deps.now] - epoch-ms clock (invite TTL tests)
* @param {object} [deps.firewall] - the nftables enforcement engine
* (src/network/wg-firewall.js applier surface: apply/rollback). Default:
* createWgFirewallApplier() built from this provider's execFileImpl, so
* injected exec mocks exercise the REAL wiring end to end.
*/
function createWireGuardProvider({
execFileImpl,
@@ -138,9 +149,16 @@ function createWireGuardProvider({
listenPort = DEFAULT_LISTEN_PORT,
getuid,
now,
firewall = null,
} = {}) {
const execFn = execFileImpl || execFileDefault;
const fs = fsImpl || require('fs'); // eslint-disable-line no-shadow
const firewallApplier =
firewall ||
createWgFirewallApplier({
execFileImpl: execFn,
fsImpl: fsImpl || undefined,
});
const dataDirResolved =
dataDir !== undefined ? dataDir : require('../../../platform-paths').dataDir;
const peersPath = path.join(dataDirResolved, 'network-wg-peers.json');
@@ -772,15 +790,100 @@ function createWireGuardProvider({
},
/**
* nftables firewall compiler lands with DC-151 — not a silent no-op.
* Compile + transactionally apply the dcnet nftables ruleset (DC-153;
* engines from DC-151). Sequence:
* 1. compileRuleset — the admin-lockout throw (admin ports with zero
* active admin peer IPs) → ProviderError 'lockout_guard'
* 2. firewallApplier.apply — check (`nft -c -f`) → rollback-file →
* apply (`nft -f`) → verify; the applier NEVER partially applies
* (a check failure applies nothing; an apply failure restores the
* prior rollback file). Firewall*Error classes map to
* 'firewall_check_failed' / 'firewall_apply_failed' /
* 'firewall_verify_failed' with .detail preserving stderr +
* rolledBack info.
*
* @param {object} [input]
* @param {object} input.accessMap - { grants: [...] } (DC-145 store)
* @param {Array} input.people - DC-141 person records
* @param {Object<string,{ip:string,port:number}>} input.resources
* @param {Object<string,string>} [input.peerAddresses] - `pid/did` → ip
* @param {string[]} [input.adminPeers] - person IDs allowed admin ports
* (defaults to active 'Admin'-group people)
* @returns {Promise<{applied:true, resourcesAffected:number}>}
*/
async syncAccess() {
await Promise.reject(
new ProviderError('syncAccess lands with the nftables firewall compiler (DC-151)', {
code: 'not_implemented',
retryable: false,
})
);
async syncAccess(input = {}) {
const {
accessMap,
people,
resources,
peerAddresses = {},
adminPeers,
} = input;
// Default admin set: active members of the 'Admin' group (DC-141
// bootstrap-admin convention).
const admins = Array.isArray(adminPeers)
? adminPeers
: (people || [])
.filter(
(p) =>
p &&
p.status === 'active' &&
Array.isArray(p.groups) &&
p.groups.includes('Admin')
)
.map((p) => p.id);
let ruleset;
try {
ruleset = compileRuleset({
accessMap,
people,
peerAddresses,
resources,
adminPeers: admins,
});
} catch (err) {
if (err && /lockout guard/.test(err.message)) {
throw new ProviderError('wireguard: ' + err.message, {
code: 'lockout_guard',
retryable: false,
cause: err,
});
}
throw toProviderError(err);
}
try {
await firewallApplier.apply(ruleset);
} catch (err) {
const detailByClass = new Map([
[FirewallCheckError, { stderr: err.stderr, applied: false }],
[FirewallApplyError, { stderr: err.stderr, applied: false, rolledBack: false }],
[FirewallVerifyError, { stderr: err.stderr, applied: true, rolledBack: err.rolledBack }],
]);
const codeByClass = new Map([
[FirewallCheckError, 'firewall_check_failed'],
[FirewallApplyError, 'firewall_apply_failed'],
[FirewallVerifyError, 'firewall_verify_failed'],
]);
for (const [cls, code] of codeByClass) {
if (err instanceof cls) {
const pe = new ProviderError('wireguard: ' + err.message, {
code,
retryable: false,
cause: err,
});
pe.detail = detailByClass.get(cls);
throw pe;
}
}
throw toProviderError(err);
}
return {
applied: true,
resourcesAffected: Object.keys(resources || {}).length,
};
},
/**