/** * 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 { LockoutError } = require('../acl-compiler'); const { AclPushError } = require('../acl-push'); const { createAclSync } = require('../acl-sync'); 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). * @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) { 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 honest, not fully reversible (corrected spec): key expiry * cannot be undone — there is no Tailscale API to un-expire a device * key. This method re-allows the person at the ACL level only (the ACL * compiler re-push on unblock drops the network deny); the user must * RE-AUTHENTICATE each device for it to rejoin the tailnet. Kept in the * contract so the orchestration layer (DC-143) can call it uniformly. * @returns {{ restored: boolean, reauthRequired: boolean }} */ async unblockPerson() { await Promise.resolve(); // uniform async surface (contract: all async) return { restored: true, reauthRequired: 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 + 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(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, }; }, /** * 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; // 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; }, }; return provider; } module.exports = { createTailscaleProvider, toProviderError };