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:
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Tests for src/managers/tailscale-manager.js
|
||||
*
|
||||
* Strategy: stub `child_process.execFile` so the manager calls a fake `tailscale`
|
||||
* CLI we control in-memory. This lets us exercise every code path — success,
|
||||
* CLI missing, tailscaled down, malformed JSON, cache hit/miss, IPv4 vs IPv6
|
||||
* selection, device-list shape — without depending on a real Tailscale install.
|
||||
*
|
||||
* The mock is a single function that inspects the args and resolves accordingly,
|
||||
* so we don't have to count invocations.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
execFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const tm = require('../src/managers/tailscale-manager');
|
||||
|
||||
/**
|
||||
* Configure the mock to behave like a specific tailscale binary.
|
||||
*
|
||||
* mode: 'ok-version' → `tailscale version` returns 1.98.4; everything else fails
|
||||
* mode: 'ok-status' → `tailscale version` AND `tailscale status --json` return ok
|
||||
* with the given status fixture
|
||||
* mode: 'no-cli' → everything rejects with ENOENT
|
||||
* mode: 'no-daemon' → version ok, status rejects with code 1
|
||||
* mode: 'malformed-status' → version ok, status returns malformed JSON
|
||||
*/
|
||||
function configureMock(mode, opts = {}) {
|
||||
execFile.mockImplementation((cmd, args, optsArg, cb) => {
|
||||
// Handle both 3-arg and 4-arg call shapes (promisify passes 3, manual passes 4)
|
||||
if (typeof optsArg === 'function') {
|
||||
cb = optsArg;
|
||||
}
|
||||
const isVersion = Array.isArray(args) && args[0] === 'version';
|
||||
const isStatus = Array.isArray(args) && args[0] === 'status';
|
||||
|
||||
// Match the real `execFile` callback signature: cb(err, {stdout, stderr})
|
||||
// (modern util.promisify(execFile) resolves with {stdout, stderr})
|
||||
const ok = (out, err = '') => process.nextTick(() => cb(null, { stdout: out, stderr: err }));
|
||||
const fail = (err) => process.nextTick(() => cb(err));
|
||||
|
||||
if (mode === 'no-cli') {
|
||||
const err = new Error('spawn tailscale ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
return fail(err);
|
||||
}
|
||||
|
||||
if (isVersion) {
|
||||
if (mode === 'ok-version' || mode === 'ok-status' || mode === 'no-daemon' || mode === 'malformed-status') {
|
||||
return ok('1.98.4\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (isStatus) {
|
||||
if (mode === 'no-daemon') {
|
||||
const err = new Error('tailscaled not running');
|
||||
err.code = 1;
|
||||
return fail(err);
|
||||
}
|
||||
if (mode === 'malformed-status') {
|
||||
return ok('not json{{{');
|
||||
}
|
||||
if (mode === 'ok-status') {
|
||||
return ok(JSON.stringify(opts.status || {}));
|
||||
}
|
||||
}
|
||||
|
||||
// Default: fail
|
||||
const err = new Error(`unhandled mock invocation: ${cmd} ${(args||[]).join(' ')}`);
|
||||
err.code = 1;
|
||||
fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
const RUNNING_STATUS = {
|
||||
Version: '1.98.4',
|
||||
BackendState: 'Running',
|
||||
Self: {
|
||||
HostName: 'vmi3080415',
|
||||
TailscaleIPs: ['100.121.150.22', 'fd7a:115c:a1e0::1539:9616'],
|
||||
},
|
||||
Peer: {
|
||||
p1: {
|
||||
ID: 'p1',
|
||||
HostName: 'peer1',
|
||||
DNSName: 'peer1.tail.ts.net',
|
||||
TailscaleIPs: ['100.100.100.1', 'fd7a::5'],
|
||||
OS: 'linux',
|
||||
Online: true,
|
||||
LastSeen: '2026-07-06T10:00:00Z',
|
||||
UserID: 'u1',
|
||||
KeyExpiry: '2026-08-01T00:00:00Z',
|
||||
Tags: ['tag:server'],
|
||||
ExitNode: false,
|
||||
RxBytes: 100,
|
||||
TxBytes: 200,
|
||||
},
|
||||
p2: {
|
||||
ID: 'p2',
|
||||
HostName: 'peer2',
|
||||
TailscaleIPs: ['100.100.100.2'],
|
||||
OS: 'iOS',
|
||||
Online: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tm.invalidateCache();
|
||||
execFile.mockReset();
|
||||
});
|
||||
|
||||
describe('tailscale-manager', () => {
|
||||
describe('isTailscaleIP() — re-exported from network-detector (DC-031)', () => {
|
||||
test.each([
|
||||
['100.64.0.1', true],
|
||||
['100.121.150.22', true],
|
||||
['100.127.255.255', true],
|
||||
['100.63.255.255', false],
|
||||
['100.128.0.0', false],
|
||||
['192.168.1.5', false],
|
||||
['172.17.0.6', false],
|
||||
['', false],
|
||||
[null, false],
|
||||
[undefined, false],
|
||||
['not.an.ip', false],
|
||||
['100.x.y.z', false],
|
||||
['100.999.0.0', false],
|
||||
])('isTailscaleIP(%p) === %p', (input, expected) => {
|
||||
expect(tm.isTailscaleIP(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus()', () => {
|
||||
test('returns parsed JSON on success', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
const s = await tm.getStatus();
|
||||
expect(s.BackendState).toBe('Running');
|
||||
expect(s.Self.HostName).toBe('vmi3080415');
|
||||
expect(s.Peer.p1.HostName).toBe('peer1');
|
||||
expect(s.Peer.p2.Online).toBe(false);
|
||||
});
|
||||
|
||||
test('returns null when CLI is missing', async () => {
|
||||
configureMock('no-cli');
|
||||
expect(await tm.getStatus()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when tailscaled is down', async () => {
|
||||
configureMock('no-daemon');
|
||||
expect(await tm.getStatus()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when stdout is malformed JSON', async () => {
|
||||
configureMock('malformed-status');
|
||||
expect(await tm.getStatus()).toBeNull();
|
||||
});
|
||||
|
||||
test('caches successful results within CACHE_TTL_MS', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
const a = await tm.getStatus();
|
||||
const b = await tm.getStatus();
|
||||
expect(a).toBe(b); // same reference
|
||||
});
|
||||
|
||||
test('does NOT cache failed status fetches (so we retry on next call)', async () => {
|
||||
// version succeeds (CLI present) but status returns malformed JSON.
|
||||
// _isInstalled caches the positive result for 1 hour (correctly —
|
||||
// we don't want to re-probe for the CLI on every request). However,
|
||||
// a failed status fetch returns null without being cached, so the
|
||||
// next getStatus() must retry the status command.
|
||||
configureMock('malformed-status');
|
||||
await tm.getStatus();
|
||||
const before = execFile.mock.calls.length;
|
||||
await tm.getStatus();
|
||||
const after = execFile.mock.calls.length;
|
||||
// Second getStatus: _isInstalled hits cache (no call); status re-exec'd (1 call).
|
||||
// So we expect exactly 1 additional execFile call from the second getStatus.
|
||||
expect(after - before).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalIP()', () => {
|
||||
test('returns the first IPv4 TailscaleIP from Self', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
expect(await tm.getLocalIP()).toBe('100.121.150.22');
|
||||
});
|
||||
|
||||
test('returns null when status is null (CLI missing)', async () => {
|
||||
configureMock('no-cli');
|
||||
expect(await tm.getLocalIP()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when Self has no TailscaleIPs', async () => {
|
||||
configureMock('ok-status', { status: { BackendState: 'Running', Self: {}, Peer: {} } });
|
||||
expect(await tm.getLocalIP()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when only IPv6 is assigned', async () => {
|
||||
configureMock('ok-status', {
|
||||
status: { BackendState: 'Running', Self: { TailscaleIPs: ['fd7a:115c::1'] }, Peer: {} },
|
||||
});
|
||||
expect(await tm.getLocalIP()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when Self is missing entirely', async () => {
|
||||
configureMock('ok-status', { status: { BackendState: 'Running', Peer: {} } });
|
||||
expect(await tm.getLocalIP()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary()', () => {
|
||||
test('returns installed:false when CLI missing', async () => {
|
||||
configureMock('no-cli');
|
||||
const s = await tm.getSummary();
|
||||
expect(s.installed).toBe(false);
|
||||
expect(s.connected).toBe(false);
|
||||
expect(s.message).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
test('returns installed:true, connected:false when tailscaled down', async () => {
|
||||
configureMock('no-daemon');
|
||||
const s = await tm.getSummary();
|
||||
expect(s.installed).toBe(true);
|
||||
expect(s.connected).toBe(false);
|
||||
expect(s.message).toMatch(/not reachable/i);
|
||||
});
|
||||
|
||||
test('returns full summary on success', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
const s = await tm.getSummary();
|
||||
expect(s.installed).toBe(true);
|
||||
expect(s.connected).toBe(true);
|
||||
expect(s.backendState).toBe('Running');
|
||||
expect(s.hostname).toBe('vmi3080415');
|
||||
expect(s.ip).toBe('100.121.150.22');
|
||||
expect(s.ipv6).toBe('fd7a:115c:a1e0::1539:9616');
|
||||
expect(s.peerCount).toBe(2);
|
||||
expect(s.onlinePeerCount).toBe(1);
|
||||
});
|
||||
|
||||
test('handles missing Peer field', async () => {
|
||||
configureMock('ok-status', {
|
||||
status: { BackendState: 'Running', Self: RUNNING_STATUS.Self },
|
||||
});
|
||||
const s = await tm.getSummary();
|
||||
expect(s.peerCount).toBe(0);
|
||||
expect(s.onlinePeerCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDevices()', () => {
|
||||
test('returns empty array when CLI missing', async () => {
|
||||
configureMock('no-cli');
|
||||
expect(await tm.getDevices()).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty array when Peer is missing', async () => {
|
||||
configureMock('ok-status', { status: { BackendState: 'Running', Self: RUNNING_STATUS.Self } });
|
||||
expect(await tm.getDevices()).toEqual([]);
|
||||
});
|
||||
|
||||
test('shapes each peer into dashboard-friendly form', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
const devices = await tm.getDevices();
|
||||
expect(devices).toHaveLength(2);
|
||||
const d1 = devices.find(d => d.id === 'p1');
|
||||
expect(d1.hostname).toBe('peer1');
|
||||
expect(d1.dnsName).toBe('peer1.tail.ts.net');
|
||||
expect(d1.ip).toBe('100.100.100.1');
|
||||
expect(d1.ips).toEqual(['100.100.100.1', 'fd7a::5']);
|
||||
expect(d1.os).toBe('linux');
|
||||
expect(d1.online).toBe(true);
|
||||
expect(d1.user).toBe('u1');
|
||||
expect(d1.tags).toEqual(['tag:server']);
|
||||
expect(d1.isExitNode).toBe(false);
|
||||
expect(d1.rxBytes).toBe(100);
|
||||
expect(d1.txBytes).toBe(200);
|
||||
expect(d1.keyExpiry).toBe('2026-08-01T00:00:00Z');
|
||||
});
|
||||
|
||||
test('handles missing optional peer fields gracefully', async () => {
|
||||
configureMock('ok-status', {
|
||||
status: { BackendState: 'Running', Peer: { minimal: { HostName: 'min' } } },
|
||||
});
|
||||
const devices = await tm.getDevices();
|
||||
expect(devices).toHaveLength(1);
|
||||
expect(devices[0].hostname).toBe('min');
|
||||
expect(devices[0].ip).toBeNull();
|
||||
expect(devices[0].ips).toEqual([]);
|
||||
expect(devices[0].tags).toEqual([]);
|
||||
expect(devices[0].online).toBe(false);
|
||||
expect(devices[0].isExitNode).toBe(false);
|
||||
expect(devices[0].rxBytes).toBe(0);
|
||||
expect(devices[0].txBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken()', () => {
|
||||
test('returns null (placeholder for OAuth-cached token)', async () => {
|
||||
expect(await tm.getAccessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncAPI()', () => {
|
||||
test('returns a synced result with ISO timestamp', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
const r = await tm.syncAPI();
|
||||
expect(r.synced).toBe(true);
|
||||
expect(r.at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test('invalidates the cache so next getStatus re-execs', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
// Prime cache
|
||||
await tm.getStatus();
|
||||
const callsBeforeSync = execFile.mock.calls.length;
|
||||
await tm.syncAPI();
|
||||
await tm.getStatus();
|
||||
const callsAfterSync = execFile.mock.calls.length;
|
||||
// After sync, getStatus should re-exec (version + status = 2 calls)
|
||||
expect(callsAfterSync).toBeGreaterThan(callsBeforeSync);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startSyncTimer / stopSyncTimer', () => {
|
||||
afterEach(() => {
|
||||
tm.stopSyncTimer();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('fires the callback on interval and stops cleanly', () => {
|
||||
jest.useFakeTimers();
|
||||
const cb = jest.fn();
|
||||
tm.startSyncTimer(1000, cb);
|
||||
jest.advanceTimersByTime(3500);
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
tm.stopSyncTimer();
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('second startSyncTimer call is a no-op while one is running', () => {
|
||||
jest.useFakeTimers();
|
||||
const cb1 = jest.fn();
|
||||
const cb2 = jest.fn();
|
||||
tm.startSyncTimer(1000, cb1);
|
||||
tm.startSyncTimer(1000, cb2);
|
||||
jest.advanceTimersByTime(2500);
|
||||
// Only the first callback should fire
|
||||
expect(cb1).toHaveBeenCalled();
|
||||
expect(cb2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateCache()', () => {
|
||||
test('forces a re-fetch on next getStatus', async () => {
|
||||
configureMock('ok-status', { status: RUNNING_STATUS });
|
||||
await tm.getStatus();
|
||||
const callsBefore = execFile.mock.calls.length;
|
||||
tm.invalidateCache();
|
||||
await tm.getStatus();
|
||||
const callsAfter = execFile.mock.calls.length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('module API surface (regression guard)', () => {
|
||||
test('exports the documented functions', () => {
|
||||
const expected = [
|
||||
'getStatus', 'getLocalIP', 'getSummary', 'getDevices',
|
||||
'isTailscaleIP', 'invalidateCache', 'getAccessToken',
|
||||
'startSyncTimer', 'stopSyncTimer', 'syncAPI',
|
||||
];
|
||||
for (const fn of expected) {
|
||||
expect(typeof tm[fn]).toBe('function');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI binary path', () => {
|
||||
test('default is /usr/bin/tailscale', () => {
|
||||
expect(tm._CLI_BIN).toBe('/usr/bin/tailscale');
|
||||
});
|
||||
|
||||
test('respects TAILSCALE_BIN env var', () => {
|
||||
jest.resetModules();
|
||||
process.env.TAILSCALE_BIN = '/custom/path/tailscale';
|
||||
const tm2 = require('../src/managers/tailscale-manager');
|
||||
expect(tm2._CLI_BIN).toBe('/custom/path/tailscale');
|
||||
delete process.env.TAILSCALE_BIN;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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