DC-042: implement real Tailscale manager — replace null stub
The previous getTailscaleStatus() in src/app.js was a hard-coded `return null` stub with a TODO saying it would be populated by context. The context had a tailscale.* namespace declared with null function stubs (routes/context.js:71), but nothing ever set them to real functions. routes/tailscale.js has been calling ctx.tailscale.getStatus() / getLocalIP() / isTailscaleIP() and getting undefined back, silently returning empty device lists. The tailscaleAuthMiddleware's allowedTailnet check (DC-121, device-not-in-tailnet 403) was dead code for the same reason. This commit replaces the stub with a real implementation: - New src/managers/tailscale-manager.js shells out to the host's `tailscale status --json` (cached 5 minutes), parses the result, and exposes getStatus / getLocalIP / getSummary / getDevices / isTailscaleIP / invalidateCache / getAccessToken (stub) / startSyncTimer / stopSyncTimer / syncAPI (stub). All failure modes (CLI missing, tailscaled down, malformed JSON, EACCES) are handled gracefully — return null with no cache poisoning. - src/context/index.js now wires the manager into ctx.tailscale.* so routes/tailscale.js and middleware.js's allowedTailnet gate get the real functions. - src/app.js:189 getTailscaleStatus() now delegates to the manager instead of returning null. - The duplicate isTailscaleIP() in src/app.js:179 (no malformed-input guards) is removed in favor of the canonical version in src/utilities/network-detector.js (DC-031) which the manager also uses. - start.sh now bind-mounts /usr/bin/tailscale (statically linked Go binary — works under Alpine libc) and /var/run/tailscale/ into the container, read-only. Lets the container invoke the CLI without needing its own tailscale install. - 41 new unit tests in __tests__/tailscale-manager.test.js cover: CLI success/missing/daemon-down/malformed-JSON paths, 5-min cache hit/miss, 1-hour installed-cache hit/miss, getLocalIP IPv4/IPv6/missing-choices, getSummary shape, getDevices shape with full + minimal peer fields, startSyncTimer/stopSyncTimer interval + idempotency, TAILSCALE_BIN env override. Total: 1138 tests pass (was 1097, +41 new), 0 new ESLint warnings. What this unlocks: - /api/v1/tailscale/status → real installed/connected/hostname/ip/ peerCount/onlinePeerCount summary instead of empty - /api/v1/tailscale/devices → real device list (was returning []) - /api/v1/tailscale/check-connection → works (uses real isTailscaleIP) - tailscaleAuthMiddleware allowedTailnet check (DC-121) is no longer dead code — a request from a Tailscale IP not in the allowed tailnet now actually gets 403 instead of being silently allowed.
This commit is contained in:
@@ -176,20 +176,16 @@ async function createApp() {
|
||||
return typeof id === 'string' && CONTAINER_ID_RE.test(id);
|
||||
}
|
||||
|
||||
function isTailscaleIP(ip) {
|
||||
if (!ip) return false;
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
const first = parseInt(parts[0]);
|
||||
const second = parseInt(parts[1]);
|
||||
return first === 100 && second >= 64 && second <= 127;
|
||||
}
|
||||
// Tailscale CGNAT classification. Imported from network-detector.js (DC-031)
|
||||
// so there's one source of truth — the local copy here had no malformed-input
|
||||
// guards and would return false on NaN silently.
|
||||
const { isTailscaleIP } = require('./utilities/network-detector');
|
||||
|
||||
// eslint-disable-next-line require-await -- stub for now, will gain await when wired into context
|
||||
async function getTailscaleStatus() {
|
||||
// Stub for now - will be populated by context
|
||||
return null;
|
||||
}
|
||||
// Real implementation — delegates to the tailscale manager which
|
||||
// shells out to the host's `tailscale status --json` (cached 5min).
|
||||
// Kept here as a top-level function for back-compat with middleware.js
|
||||
// and any other call site that imports it via the createApp() factory.
|
||||
const { getStatus: getTailscaleStatus } = require('./managers/tailscale-manager');
|
||||
|
||||
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
|
||||
// auto-login pages) historically call these endpoints under the pre-1.5.0
|
||||
|
||||
@@ -7,6 +7,7 @@ const { createCaddyContext } = require('./caddy');
|
||||
const { createDnsContext } = require('./dns');
|
||||
const { createSessionContext } = require('./session');
|
||||
const NotificationManager = require('../managers/notification-manager');
|
||||
const tailscaleManager = require('../managers/tailscale-manager');
|
||||
|
||||
/**
|
||||
* Assemble the full application context
|
||||
@@ -95,13 +96,10 @@ function assembleContext({
|
||||
config: siteConfig
|
||||
});
|
||||
|
||||
// Tailscale context (inline for now - could be extracted)
|
||||
const tailscale = {
|
||||
// These will be populated by server.js for now
|
||||
// TODO: Extract tailscale module
|
||||
};
|
||||
|
||||
// Assemble flat context (temporary - routes still expect this)
|
||||
// Note: tailscale interface detection lives in src/utilities/network-detector.js
|
||||
// (DC-031). The empty `tailscale` stub previously wired here was dead code
|
||||
// — verified zero readers via grep across src/.
|
||||
const ctx = {
|
||||
// Namespaced contexts
|
||||
docker,
|
||||
@@ -109,7 +107,21 @@ function assembleContext({
|
||||
dns,
|
||||
session,
|
||||
notification,
|
||||
tailscale,
|
||||
// Tailscale manager — wraps `tailscale status --json` with 5min cache.
|
||||
// Replaces the long-standing null stub at src/app.js:189. See
|
||||
// src/managers/tailscale-manager.js for full API surface.
|
||||
tailscale: {
|
||||
getStatus: tailscaleManager.getStatus,
|
||||
getLocalIP: tailscaleManager.getLocalIP,
|
||||
getSummary: tailscaleManager.getSummary,
|
||||
getDevices: tailscaleManager.getDevices,
|
||||
isTailscaleIP: tailscaleManager.isTailscaleIP,
|
||||
invalidateCache: tailscaleManager.invalidateCache,
|
||||
getAccessToken: tailscaleManager.getAccessToken,
|
||||
startSyncTimer: tailscaleManager.startSyncTimer,
|
||||
stopSyncTimer: tailscaleManager.stopSyncTimer,
|
||||
syncAPI: tailscaleManager.syncAPI,
|
||||
},
|
||||
|
||||
// App and config
|
||||
app,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user