DC-140: network provider abstraction (src/network/) + tailscale provider + registry + tests
This commit is contained in:
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 };
|
||||
Reference in new issue
Block a user