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,59 @@
|
||||
/**
|
||||
* Provider error types for the DashCaddy Private Network abstraction
|
||||
* (DC-140, specs/PRIVATE-NETWORK-SPEC.md §3).
|
||||
*
|
||||
* Every provider-facing failure surfaces as a ProviderError carrying a
|
||||
* machine-readable `code` and a `retryable` hint, so feature modules can
|
||||
* degrade cleanly without knowing which provider is active.
|
||||
*
|
||||
* Canonical codes (providers may add, but these are the shared vocabulary):
|
||||
* - not_configured provider has no credentials yet
|
||||
* - unauthorized token rejected (401/403)
|
||||
* - not_found entity does not exist upstream
|
||||
* - rate_limited upstream 429 (retryable, honor retryAfter)
|
||||
* - server_error upstream 5xx (retryable)
|
||||
* - network_error transport failure (retryable)
|
||||
* - bad_input caller passed invalid arguments
|
||||
* - not_implemented_in_provider capability exists but lands in a later DC
|
||||
* - provider_not_active registry has no active provider
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** Codes for which a retry (possibly after a backoff) is reasonable. */
|
||||
const RETRYABLE_CODES = new Set(['rate_limited', 'server_error', 'network_error']);
|
||||
|
||||
class ProviderError extends Error {
|
||||
/**
|
||||
* @param {string} message - human-readable description
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.code='unknown'] - machine-readable error code
|
||||
* @param {boolean} [opts.retryable] - defaults to derived-from-code
|
||||
* @param {*} [opts.cause] - original error (e.g. TailscaleCoordError)
|
||||
* @param {*} [opts.retryAfter] - upstream retry-after hint, seconds
|
||||
*/
|
||||
constructor(message, { code, retryable, cause, retryAfter } = {}) {
|
||||
super(message);
|
||||
this.name = 'ProviderError';
|
||||
this.code = code || 'unknown';
|
||||
this.retryable = retryable !== undefined ? !!retryable : RETRYABLE_CODES.has(this.code);
|
||||
if (retryAfter !== undefined) this.retryAfter = retryAfter;
|
||||
if (cause !== undefined) this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the registry when no provider has been activated yet
|
||||
* (default state until the setup wizard connects one — DC-152).
|
||||
*/
|
||||
class ProviderNotActiveError extends ProviderError {
|
||||
constructor(message) {
|
||||
super(message || 'No active network provider configured', {
|
||||
code: 'provider_not_active',
|
||||
retryable: false,
|
||||
});
|
||||
this.name = 'ProviderNotActiveError';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProviderError, ProviderNotActiveError };
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* src/network entry point (DC-140).
|
||||
*
|
||||
* Registers the built-in providers with the registry on first require.
|
||||
* The WireGuard provider (DC-149) registers itself here when it lands.
|
||||
*
|
||||
* Usage (feature modules — the ONLY sanctioned import):
|
||||
* const { registry } = require('../network');
|
||||
* const provider = registry.getActiveProvider(); // throws if unset
|
||||
* if (registry.getCapabilities().invites) { ... }
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const registry = require('./registry');
|
||||
const { createTailscaleProvider } = require('./providers/tailscale');
|
||||
|
||||
registry.registerProvider('tailscale', createTailscaleProvider);
|
||||
|
||||
module.exports = {
|
||||
registry,
|
||||
errors: require('./errors'),
|
||||
CONTRACT_METHODS: require('./provider').CONTRACT_METHODS,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* NetworkProvider interface — the documented contract for DashCaddy
|
||||
* Private Network providers (DC-140, specs/PRIVATE-NETWORK-SPEC.md §3).
|
||||
*
|
||||
* Everything above the provider (people, groups, resources, access map,
|
||||
* forward_auth, Technitium names, the CA, the UI) is shared and talks to
|
||||
* the network ONLY through this contract — never to a provider module
|
||||
* directly. Resolution goes through src/network/registry.js.
|
||||
*
|
||||
* ## Capability flags
|
||||
*
|
||||
* `capabilities()` returns:
|
||||
* - invites provider can mint invite URLs itself (TS user-invites /
|
||||
* WG one-time token). When false, DashCaddy mints its own.
|
||||
* - splitDnsApi provider exposes a split-DNS API (Tailscale does;
|
||||
* WireGuard writes Technitium directly instead).
|
||||
* - perPeerFirewall provider enforces per-peer rules natively (WG nftables;
|
||||
* Tailscale relies on ACLs, so false).
|
||||
* - selfHosted no outside control-plane dependency (WG true, TS false).
|
||||
*
|
||||
* Feature modules MUST check capabilities and degrade cleanly.
|
||||
*
|
||||
* ## Methods (all async, all return plain JSON, all validated)
|
||||
*
|
||||
* id() → 'tailscale' | 'wireguard'
|
||||
* capabilities() → { invites, splitDnsApi, perPeerFirewall, selfHosted }
|
||||
* getStatus() → provider health/summary (TS: local `tailscale status`;
|
||||
* WG: `wg show dump` + iface state)
|
||||
* listDevices() → device list merged with local online state
|
||||
* listPeople() → provider users projected to
|
||||
* { id, displayName, email, createdAt }
|
||||
* createInvite({ role, email? }) → { inviteUrl, id } — email is OPTIONAL;
|
||||
* when omitted, the provider returns a link without sending
|
||||
* (link-first, DC-085 pattern; never log inviteUrl raw)
|
||||
* revokeInvite(id) → { revoked: true }
|
||||
* blockPerson(userId) → reversible network-level block (TS: expire every
|
||||
* device key owned by the user; WG: drop peer + deny set)
|
||||
* unblockPerson(userId) → reverse of blockPerson (TS: no-op at provider
|
||||
* level, DashCaddy re-grants via syncAccess)
|
||||
* removePerson(userId) → delete provider devices + revoke invites
|
||||
* revokeDevice(deviceId) → expire/remove a single device's access
|
||||
* syncAccess(accessMap, resources) → compile + dry-run + push provider
|
||||
* rules (hash-guarded). Injected per provider in later DCs
|
||||
* (TS ACL compiler = DC-148; WG nftables = DC-150).
|
||||
* whois(ip) → which person owns the connecting device
|
||||
* configureDns({ zone, server }) → split-DNS setup when splitDnsApi
|
||||
*
|
||||
* ## Error contract
|
||||
*
|
||||
* All failures throw ProviderError (src/network/errors.js) with a `code`
|
||||
* and `retryable` hint. Providers map their native errors at the boundary
|
||||
* so no provider-specific error type ever escapes this package.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
/** Method names every provider must implement (documented contract). */
|
||||
CONTRACT_METHODS: [
|
||||
'id',
|
||||
'capabilities',
|
||||
'getStatus',
|
||||
'listDevices',
|
||||
'listPeople',
|
||||
'createInvite',
|
||||
'revokeInvite',
|
||||
'blockPerson',
|
||||
'unblockPerson',
|
||||
'removePerson',
|
||||
'revokeDevice',
|
||||
'syncAccess',
|
||||
'whois',
|
||||
'configureDns',
|
||||
],
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Network provider registry (DC-140, specs/PRIVATE-NETWORK-SPEC.md §3).
|
||||
*
|
||||
* Single resolution point between feature modules and providers: no feature
|
||||
* code may import a provider module directly — they call getActiveProvider().
|
||||
*
|
||||
* State is deliberately process-local and defaults to INACTIVE: no provider
|
||||
* is active until the setup wizard (DC-152) connects one. Until then,
|
||||
* getActiveProvider()/getCapabilities() throw ProviderNotActiveError.
|
||||
*
|
||||
* registerProvider(factory) — factory is a function ({ getCoordClient,
|
||||
* localManager, execFileImpl } = {}) → provider instance. The tailscale
|
||||
* factory defaults to lazily binding the existing singletons
|
||||
* (src/context getCoordClient pattern + src/managers/tailscale-manager),
|
||||
* but every dependency is injectable so tests stay hermetic.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ProviderNotActiveError } = require('./errors');
|
||||
const { CONTRACT_METHODS } = require('./provider');
|
||||
|
||||
// id → factory(provider deps) → provider instance
|
||||
const _factories = new Map();
|
||||
let _activeId = null;
|
||||
let _activeProvider = null;
|
||||
|
||||
/** Lately-bound defaults (required lazily so jest.mock stays effective). */
|
||||
function _defaultDeps() {
|
||||
/* eslint-disable global-require -- lazy on purpose: keeps registry require-time
|
||||
side effects at zero and respects jest module mocks. */
|
||||
return {
|
||||
localManager: require('../managers/tailscale-manager'),
|
||||
execFileImpl: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider factory under its id.
|
||||
* @param {string} id - provider id, e.g. 'tailscale' (must match provider.id())
|
||||
* @param {Function} factory - (deps) => provider instance
|
||||
* @returns {void}
|
||||
*/
|
||||
function registerProvider(id, factory) {
|
||||
if (typeof id !== 'string' || !id) {
|
||||
throw new TypeError('registerProvider: id must be a non-empty string');
|
||||
}
|
||||
if (typeof factory !== 'function') {
|
||||
throw new TypeError('registerProvider: factory must be a function');
|
||||
}
|
||||
_factories.set(id, factory);
|
||||
// If this id is currently active, drop the cached instance so the next
|
||||
// getActiveProvider() rebuilds from the (possibly new) factory.
|
||||
if (_activeId === id) _activeProvider = null;
|
||||
}
|
||||
|
||||
/** Build (or rebuild) the instance for `_activeId` from its factory. */
|
||||
function _buildActive() {
|
||||
const factory = _factories.get(_activeId);
|
||||
const provider = factory ? factory(_defaultDeps()) : null;
|
||||
if (!_isContractProvider(provider)) {
|
||||
throw new Error('Provider "' + _activeId + '" does not satisfy the NetworkProvider contract');
|
||||
}
|
||||
_activeProvider = provider;
|
||||
return _activeProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the id refers to a registered provider whose instances satisfy
|
||||
* the NetworkProvider contract surface (all CONTRACT_METHODS present).
|
||||
*/
|
||||
function _isContractProvider(p) {
|
||||
if (!p) return false;
|
||||
return CONTRACT_METHODS.every((m) => typeof p[m] === 'function');
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a registered provider. Rebuilding the instance resets any state
|
||||
* the previous one carried.
|
||||
* @param {string} id
|
||||
* @returns {object} the newly active provider instance
|
||||
* @throws {Error} when the id is not registered, or the instance built from
|
||||
* it does not satisfy the documented contract.
|
||||
*/
|
||||
function setActiveProvider(id) {
|
||||
const factory = _factories.get(id);
|
||||
if (!factory) {
|
||||
throw new Error('Unknown network provider: ' + String(id));
|
||||
}
|
||||
const provider = factory(_defaultDeps());
|
||||
if (!_isContractProvider(provider)) {
|
||||
throw new Error('Provider "' + id + '" does not satisfy the NetworkProvider contract');
|
||||
}
|
||||
if (typeof provider.id !== 'function' || provider.id() !== id) {
|
||||
throw new Error('Provider "' + id + '" reports a different id');
|
||||
}
|
||||
_activeId = id;
|
||||
_activeProvider = provider;
|
||||
return _activeProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active provider instance. Feature modules' ONLY entry point into
|
||||
* provider functionality.
|
||||
* @throws {ProviderNotActiveError} when no provider has been activated
|
||||
*/
|
||||
function getActiveProvider() {
|
||||
if (!_activeId) {
|
||||
throw new ProviderNotActiveError(
|
||||
'No active network provider. Complete the Private Network setup wizard first.'
|
||||
);
|
||||
}
|
||||
// Factory may have been re-registered since activation → rebuild lazily.
|
||||
return _activeProvider || _buildActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability flags of the active provider (spec §3): feature modules check
|
||||
* these and degrade cleanly when a capability is absent.
|
||||
* @returns {{invites:boolean, splitDnsApi:boolean, perPeerFirewall:boolean, selfHosted:boolean}}
|
||||
* @throws {ProviderNotActiveError} when no provider has been activated
|
||||
*/
|
||||
function getCapabilities() {
|
||||
return getActiveProvider().capabilities();
|
||||
}
|
||||
|
||||
/** List registered provider ids (setup wizard mode picker). */
|
||||
function listProviderIds() {
|
||||
return [..._factories.keys()];
|
||||
}
|
||||
|
||||
/** Deactivate (setup wizard disconnect / tests). Next getActiveProvider() throws. */
|
||||
function reset() {
|
||||
_activeId = null;
|
||||
_activeProvider = null;
|
||||
}
|
||||
|
||||
/** Currently active provider id, or null — read-only introspection. */
|
||||
function getActiveProviderId() {
|
||||
return _activeId;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerProvider,
|
||||
setActiveProvider,
|
||||
getActiveProvider,
|
||||
getCapabilities,
|
||||
listProviderIds,
|
||||
getActiveProviderId,
|
||||
reset,
|
||||
};
|
||||
Reference in new issue
Block a user