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