/** * Tailscale Manager — real implementation of the Tailscale API surface that * routes/tailscale.js and src/utilities/middleware.js have been calling into * via ctx.tailscale.* for months but always getting `null` back. * * Why this exists: the previous `getTailscaleStatus()` in src/app.js was a * hard-coded `return null` stub with a comment saying it would be populated * later. The route file calls tailscale.getStatus() / getLocalIP() / * isTailscaleIP() and got undefined back, silently returning empty device * lists. The tailscaleAuthMiddleware's allowedTailnet check (DC-121) was * dead code for the same reason. * * Strategy: shell out to the host's `tailscale` CLI and parse its JSON output. * `tailscale status --json` returns the full local node + peer map with all * the fields the dashboard cares about (TailscaleIPs, HostName, OS, Online, * LastSeen, UserID, KeyExpiry, Tags, etc.). Cache for 5 minutes to avoid * spawning a CLI on every request. * * Failure modes handled gracefully: * - `tailscale` CLI not installed on host → return { installed: false } * - tailscaled not running → return { installed: true, connected: false } * - CLI exits non-zero → return null, log warning, fall through to caller * - JSON malformed → return null, log error * * The `isTailscaleIP()` function re-exports the one from network-detector.js * (DC-031) so there's one source of truth for Tailscale CGNAT classification. */ 'use strict'; const { execFile } = require('child_process'); const { promisify } = require('util'); const { isTailscaleIP } = require('../utilities/network-detector'); const execFileAsync = promisify(execFile); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const CLI_TIMEOUT_MS = 5000; const CLI_BIN = process.env.TAILSCALE_BIN || '/usr/bin/tailscale'; let _cache = { data: null, fetchedAt: 0, }; /** * Internal: invoke `tailscale status --json` and parse the result. * Returns null on any failure (caller decides how to present). */ async function _fetchStatusRaw() { try { const { stdout, stderr } = await execFileAsync(CLI_BIN, ['status', '--json'], { timeout: CLI_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, // 4 MB — peer maps can be large }); if (stderr && !stdout) { // CLI wrote to stderr and nothing to stdout — likely "tailscaled not running" return null; } return JSON.parse(stdout); } catch (err) { // ENOENT: tailscale not installed // EACCES: not in the right group // non-zero exit: tailscaled down // JSON parse: corrupted output return null; } } /** * Check whether the tailscale CLI is reachable on this host. * Result is cached separately because it's rare to install/uninstall. */ let _installedCache = { value: null, fetchedAt: 0 }; const INSTALLED_TTL_MS = 60 * 60 * 1000; // 1 hour async function _isInstalled() { const now = Date.now(); if (_installedCache.value !== null && (now - _installedCache.fetchedAt) < INSTALLED_TTL_MS) { return _installedCache.value; } try { await execFileAsync(CLI_BIN, ['version'], { timeout: 2000 }); _installedCache = { value: true, fetchedAt: now }; return true; } catch (err) { _installedCache = { value: false, fetchedAt: now }; return false; } } /** * Get the full Tailscale status (self + peers + backend state). * Returns null if tailscale is not installed or tailscaled is not running. * Results are cached for 5 minutes. */ async function getStatus() { const now = Date.now(); if (_cache.data !== null && (now - _cache.fetchedAt) < CACHE_TTL_MS) { return _cache.data; } const installed = await _isInstalled(); if (!installed) { // Don't cache the negative result beyond the installed TTL return null; } const data = await _fetchStatusRaw(); if (data !== null) { _cache = { data, fetchedAt: now }; } return data; } /** * Get the local node's first Tailscale IPv4 address (e.g. "100.121.150.22"). * Returns null if no Tailscale IPv4 is assigned. */ async function getLocalIP() { const status = await getStatus(); if (!status || !status.Self || !Array.isArray(status.Self.TailscaleIPs)) { return null; } return status.Self.TailscaleIPs.find(ip => ip && ip.includes('.') && !ip.includes(':')) || null; } /** * Force-refresh the status cache (e.g. after a config change). */ function invalidateCache() { _cache = { data: null, fetchedAt: 0 }; _installedCache = { value: null, fetchedAt: 0 }; } /** * Get a friendly structured summary suitable for the dashboard. * Returns: * { installed: false } if CLI is missing * { installed: true, connected: false, ... } if tailscaled is down * { installed: true, connected: true, hostname, ip, peerCount, ... } on success */ async function getSummary() { const installed = await _isInstalled(); if (!installed) { return { installed: false, connected: false, message: 'tailscale CLI not found' }; } const status = await getStatus(); if (!status) { return { installed: true, connected: false, message: 'tailscaled not reachable' }; } return { installed: true, connected: status.BackendState === 'Running', backendState: status.BackendState || null, hostname: status.Self?.HostName || null, ip: status.Self?.TailscaleIPs?.find(ip => ip && ip.includes('.') && !ip.includes(':')) || null, ipv6: status.Self?.TailscaleIPs?.find(ip => ip && ip.includes(':')) || null, peerCount: Object.keys(status.Peer || {}).length, onlinePeerCount: Object.values(status.Peer || {}).filter(p => p.Online).length, }; } /** * Get the enriched device list (peers) for the dashboard. * Each entry has the fields the dashboard UI cares about. */ async function getDevices() { const status = await getStatus(); if (!status || !status.Peer) { return []; } return Object.entries(status.Peer).map(([id, peer]) => ({ id, hostname: peer.HostName, dnsName: peer.DNSName, ip: peer.TailscaleIPs?.[0] || null, ips: peer.TailscaleIPs || [], os: peer.OS, online: !!peer.Online, lastSeen: peer.LastSeen || null, user: peer.UserID || null, tags: peer.Tags || [], keyExpiry: peer.KeyExpiry || null, isExitNode: !!peer.ExitNode, rxBytes: peer.RxBytes || 0, txBytes: peer.TxBytes || 0, })); } /** * OAuth access token retrieval — stub for now. * The OAuth flow is implemented in routes/tailscale.js but requires the * configured OAuth credentials from disk. The token exchange itself * happens in the route handler; this is a placeholder so ctx.tailscale has * a complete API surface. Returns null (no token cached) by default. */ // eslint-disable-next-line require-await -- stub, will gain await when OAuth flow lands async function getAccessToken() { return null; } /** * Background sync timer — stub. * The Tailscale API sync (oauth-config + sync routes) uses an in-process * interval. This is a placeholder for parity with the ctx.tailscale surface. */ let _syncInterval = null; function startSyncTimer(intervalMs = 5 * 60 * 1000, onSync = () => {}) { if (_syncInterval) return; _syncInterval = setInterval(() => { invalidateCache(); onSync(); }, intervalMs); if (_syncInterval.unref) _syncInterval.unref(); } function stopSyncTimer() { if (_syncInterval) { clearInterval(_syncInterval); _syncInterval = null; } } /** * Force a sync from the Tailscale API — stub for now. * Real implementation would use OAuth credentials to fetch devices/ACL. */ // eslint-disable-next-line require-await -- stub, will gain await when API client lands async function syncAPI() { invalidateCache(); return { synced: true, at: new Date().toISOString() }; } module.exports = { getStatus, getLocalIP, getSummary, getDevices, isTailscaleIP, invalidateCache, getAccessToken, startSyncTimer, stopSyncTimer, syncAPI, // Exposed for tests _CLI_BIN: CLI_BIN, _CACHE_TTL_MS: CACHE_TTL_MS, };