523 lines
20 KiB
JavaScript
523 lines
20 KiB
JavaScript
/**
|
|
* 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 };
|