DC-140: network provider abstraction (src/network/) + tailscale provider + registry + tests

This commit is contained in:
Hermes committed 2026-10-03 21:16:20 -07:00
1 parent 420fa2ba2a
commit 7ef3914730
7 files changed
+1308

No files matched your search

@@ -0,0 +1,389 @@
/**
* Tailscale provider for the DashCaddy Private Network (DC-140,
* specs/PRIVATE-NETWORK-SPEC.md §3).
*
* Wraps — and does not duplicate — the two existing tailscale modules:
* - src/managers/tailscale-coord.js — cloud control-plane client
* (api.tailscale.com: devices, users, user-invites, device expire,
* device delete, split-DNS preferences)
* - src/managers/tailscale-manager.js — local CLI daemon (status, whois)
*
* This adapter adds the provider-contract semantics on top: people
* projection, device key expiry as "block", error normalization into
* ProviderError, and capability flags.
*
* API facts (Tailscale API v2, Oct 2026):
* POST /tailnet/{t}/user-invites email optional → { inviteUrl, id }
* DELETE /tailnet/{t}/user-invites/{id}
* GET /tailnet/{t}/user-invites
* POST /device/{id}/expire expire device key (reversible: user re-auths)
* DELETE /device/{id} remove device
* POST /tailnet/{t}/dns/nameservers split-DNS search domains
* POST /tailnet/{t}/preferences feature prefs incl. MagicDNS
* Free-plan fact: ACL groups capped at 3, so compiled ACLs must reference
* users directly — handled by the ACL compiler (DC-148), not here.
*/
'use strict';
const { execFile } = require('child_process');
const { promisify } = require('util');
const { ProviderError } = require('../errors');
const execFileAsyncDefault = promisify(execFile);
const CLI_TIMEOUT_MS = 5000;
/** Map a TailscaleCoordError code to the shared ProviderError vocabulary. */
const COORD_CODE_MAP = {
not_configured: 'not_configured',
unauthorized: 'unauthorized',
not_found: 'not_found',
rate_limited: 'rate_limited',
server_error: 'server_error',
network_error: 'network_error',
bad_input: 'bad_input',
};
/**
* Normalize any thrown value into a ProviderError. Coord errors keep their
* code (mapped) + retryAfter; unknown errors land as retryable
* 'provider_error' so callers can distinguish transport-ish failures from
* contract violations.
* @returns {ProviderError}
*/
function toProviderError(err) {
if (err instanceof ProviderError) return err;
if (err && err.name === 'TailscaleCoordError') {
const code = COORD_CODE_MAP[err.code] || 'provider_error';
const opts = { code, cause: err };
if (err.retryAfter !== undefined) opts.retryAfter = err.retryAfter;
return new ProviderError('tailscale: ' + err.message, opts);
}
const message = (err && err.message) ? err.message : String(err);
return new ProviderError('tailscale: ' + message, {
code: 'provider_error',
retryable: true,
cause: err,
});
}
/**
* Build the Tailscale NetworkProvider.
*
* @param {object} deps - all injectable, all optional
* @param {object} deps.coordClient - object with the TailscaleCoordClient
* method surface. When omitted the provider expects the registry/caller
* to supply one via `provider.attachCoordClient(client)` (wiring to the
* context singleton lands with routes/network.js — keeps this module
* free of app-context requires).
* @param {object} deps.localManager - tailscale-manager module (default:
* 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).
*/
function createTailscaleProvider({
coordClient = null,
localManager,
execFileImpl,
} = {}) {
// 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;
function requireCoord() {
if (!coord) {
throw new ProviderError(
'Tailscale coordination client not attached (API token not configured?)',
{ code: 'not_configured', retryable: false }
);
}
return coord;
}
/** Normalized single-flight wrapper for coord calls. */
async function coordCall(_methodName, fn) {
try {
return await fn(requireCoord(_methodName));
} catch (err) {
throw toProviderError(err);
}
}
const provider = {
id() {
return 'tailscale';
},
capabilities() {
return {
invites: true,
splitDnsApi: true,
perPeerFirewall: false,
selfHosted: false,
};
},
/**
* Health/summary from the LOCAL daemon (existing manager, cached there).
* Returns null-ish manager output unchanged — the manager already
* degrades gracefully ({ installed: false } / { connected: false }).
*/
async getStatus() {
try {
const summary = await manager.getSummary();
return {
provider: 'tailscale',
installed: !!(summary && summary.installed),
connected: !!(summary && summary.connected),
summary,
};
} catch (err) {
throw toProviderError(err, 'getStatus');
}
},
/** Devices from the API, online state merged from local status. */
async listDevices() {
try {
const [devices, local] = await Promise.all([
coordCall('listDevices', (c) => c.listDevices({ skipCache: false })),
Promise.resolve(manager.getStatus()).catch(() => null),
]);
const localPeers = {};
if (local && local.Peer && typeof local.Peer === 'object') {
for (const [nodeKey, peer] of Object.entries(local.Peer)) {
if (peer && Array.isArray(peer.TailscaleIPs) && peer.TailscaleIPs[0]) {
localPeers[peer.TailscaleIPs[0]] = { nodeKey, online: !!peer.Online };
}
}
}
return (devices || []).map((d) => {
const addr = Array.isArray(d.addresses) ? d.addresses[0] : null;
const localInfo = addr ? localPeers[addr] : undefined;
return {
id: d.id,
name: d.name || d.hostname || null,
hostname: d.hostname || null,
ip: addr,
os: d.os || null,
online: localInfo ? localInfo.online : !!d.online,
lastSeen: d.lastSeen || null,
userId: d.user !== undefined ? d.user : null,
tags: d.tags || [],
keyExpiry: d.keyExpiry || null,
};
});
} catch (err) {
throw err instanceof ProviderError ? err : toProviderError(err, 'listDevices');
}
},
/** API users → people projection { id, displayName, email, createdAt }. */
listPeople() {
return coordCall('listPeople', async (c) => {
const users = await c.listUsers({ skipCache: false });
return (users || []).map((u) => ({
id: u.id,
displayName: u.displayName || u.loginName || null,
email: u.loginName && u.loginName.includes('@') ? u.loginName : null,
createdAt: u.created || null,
}));
});
},
/**
* Create a user invite. Email is OPTIONAL — when omitted, Tailscale
* returns an inviteUrl WITHOUT sending email (link-first, spec §5).
* The inviteUrl is the artifact; never log it raw (routes enforce).
* @returns {{ inviteUrl: string, id: string }}
*/
async createInvite({ role, email } = {}) {
if (!role || typeof role !== 'string') {
throw new ProviderError('createInvite: role is required', { code: 'bad_input' });
}
if (email !== undefined && (typeof email !== 'string' || !email.includes('@'))) {
throw new ProviderError('createInvite: email must be a valid address when provided', {
code: 'bad_input',
});
}
// Input validation above throws synchronously by design (bad_input);
// everything below is async and normalized through coordCall.
const invitePromise = coordCall('createInvite', async (c) => {
const body = { inviteTailnet: true };
if (email) body.email = email;
const res = await c.request('POST', '/tailnet/-/user-invites', { body });
if (!res || !res.inviteUrl) {
throw new ProviderError('Tailscale user-invite response missing inviteUrl', {
code: 'provider_error',
retryable: true,
});
}
return { inviteUrl: res.inviteUrl, id: res.id || null };
});
return await invitePromise.catch((err) => Promise.reject(toProviderError(err)));
},
/** Revoke a user invite: DELETE /tailnet/{t}/user-invites/{id}. */
async revokeInvite(id) {
if (!id) {
throw new ProviderError('revokeInvite: id is required', { code: 'bad_input' });
}
return await coordCall('revokeInvite', async (c) => {
await c.request('DELETE', '/tailnet/-/user-invites/' + encodeURIComponent(id));
return { revoked: true };
});
},
/**
* Block a person at the network level: expire the key of every device
* they own (POST /device/{id}/expire). Reversible — unexpired devices
* re-authenticate; DashCaddy re-grants via syncAccess on unblock.
* @returns {{ expired: string[], count: number }}
*/
async blockPerson(userId) {
if (!userId) {
throw new ProviderError('blockPerson: userId is required', { code: 'bad_input' });
}
return await coordCall('blockPerson', async (c) => {
const devices = await c.listDevices({ skipCache: true });
const owned = (devices || []).filter((d) => String(d.user) === String(userId));
const expired = [];
for (const d of owned) {
await c.request('POST', '/device/' + encodeURIComponent(d.id) + '/expire', { body: {} });
expired.push(d.id);
}
return { expired, count: expired.length };
});
},
/**
* Unblock is a no-op at the Tailscale level: key expiry is reversed by
* the user re-authenticating, and access restoration happens when
* DashCaddy re-syncs the ACL (syncAccess). Kept in the contract so the
* orchestration layer (DC-143) can call it uniformly across providers.
*/
async unblockPerson() {
await Promise.resolve(); // uniform async surface (contract: all async)
return { restored: true };
},
/**
* Remove a person permanently: DELETE every device they own. (Invite
* revocation and the DashCaddy person record are handled by the
* orchestration layer, which owns that state.)
* @returns {{ deleted: string[], count: number }}
*/
async removePerson(userId) {
if (!userId) {
throw new ProviderError('removePerson: userId is required', { code: 'bad_input' });
}
return await coordCall('removePerson', async (c) => {
const devices = await c.listDevices({ skipCache: true });
const owned = (devices || []).filter((d) => String(d.user) === String(userId));
const deleted = [];
for (const d of owned) {
await c.deleteDevice(d.id);
deleted.push(d.id);
}
return { deleted, count: deleted.length };
});
},
/** Expire one device's key (kick a single machine, keep the person). */
async revokeDevice(deviceId) {
if (!deviceId) {
throw new ProviderError('revokeDevice: deviceId is required', { code: 'bad_input' });
}
return await coordCall('revokeDevice', async (c) => {
await c.request('POST', '/device/' + encodeURIComponent(deviceId) + '/expire', { body: {} });
return { expired: deviceId };
});
},
/**
* 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.
*/
async syncAccess() {
await Promise.reject(
new ProviderError('syncAccess lands with the ACL compiler (DC-148)', {
code: 'not_implemented_in_provider',
retryable: false,
})
);
},
/**
* Who owns the device at this tailnet IP? Local CLI only — never the
* cloud API — because forward_auth sits on the same host as tailscaled.
* Returns null when the CLI cannot resolve the ip (offline peer,
* unknown address) rather than throwing.
* @returns {Promise<{nodeKey:string, userId:number|null, loginName:string|null, hostname:string|null}|null>}
*/
async whois(ip) {
if (!ip || typeof ip !== 'string') {
throw new ProviderError('whois: ip is required', { code: 'bad_input' });
}
let out;
try {
out = await execFn('tailscale', ['whois', ip], { timeout: CLI_TIMEOUT_MS });
} catch (err) {
// CLI failure (not installed / daemon down / unknown ip): identity
// unknown is a legitimate answer for forward_auth, not an error.
return null;
}
const stdout = (out && out.stdout) || '';
if (!stdout.trim()) return null;
// `tailscale whois` prints lines "key=value" per detail, e.g.
// user: sami@example.com
// nodeKey: nodekey:abc...
// node: dns2-sami.tail3e209.ts.net
// plus a "via" line when routed. Parse defensively.
const fields = {};
for (const line of stdout.split('\n')) {
const m = line.match(/^\s*([a-zA-Z]+):\s*(.+)$/);
if (m) fields[m[1].toLowerCase()] = m[2].trim();
}
if (!fields.user && !fields.nodekey) return null;
return {
nodeKey: fields.nodekey || null,
userId: fields.userid ? Number(fields.userid) : null,
loginName: fields.user || null,
hostname: fields.node || null,
};
},
/**
* Split-DNS via the coordination API (capability-flagged: splitDnsApi).
* Sets the tailnet's global nameservers to the Technitium server for the
* given zone. Callers must check capabilities().splitDnsApi first.
*/
async configureDns({ zone, server } = {}) {
if (!zone || typeof zone !== 'string') {
throw new ProviderError('configureDns: zone is required', { code: 'bad_input' });
}
if (!server || typeof server !== 'string') {
throw new ProviderError('configureDns: server is required', { code: 'bad_input' });
}
return await coordCall('configureDns', async (c) => {
await c.request('POST', '/tailnet/-/dns/nameservers', {
body: { dns: [server], domains: [zone] },
});
return { configured: true, zone, server };
});
},
/** Late-bind the coord client (context wiring lands with routes/network.js). */
attachCoordClient(client) {
coord = client;
},
};
return provider;
}
module.exports = { createTailscaleProvider, toProviderError };