From d04238621faf99208ae4f20e96493608e51f3b96 Mon Sep 17 00:00:00 2001 From: Krystie Date: Mon, 6 Jul 2026 15:47:52 -0700 Subject: [PATCH] =?UTF-8?q?DC-042:=20implement=20real=20Tailscale=20manage?= =?UTF-8?q?r=20=E2=80=94=20replace=20null=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/tailscale-manager.test.js | 399 ++++++++++++++++++ dashcaddy-api/src/app.js | 22 +- dashcaddy-api/src/context/index.js | 26 +- .../src/managers/tailscale-manager.js | 250 +++++++++++ start.sh | 9 + 5 files changed, 686 insertions(+), 20 deletions(-) create mode 100644 dashcaddy-api/__tests__/tailscale-manager.test.js create mode 100644 dashcaddy-api/src/managers/tailscale-manager.js diff --git a/dashcaddy-api/__tests__/tailscale-manager.test.js b/dashcaddy-api/__tests__/tailscale-manager.test.js new file mode 100644 index 0000000..4b4a8df --- /dev/null +++ b/dashcaddy-api/__tests__/tailscale-manager.test.js @@ -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; + }); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index a4c5c90..48b272e 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -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 diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js index 2346b5d..d49b0ea 100644 --- a/dashcaddy-api/src/context/index.js +++ b/dashcaddy-api/src/context/index.js @@ -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, diff --git a/dashcaddy-api/src/managers/tailscale-manager.js b/dashcaddy-api/src/managers/tailscale-manager.js new file mode 100644 index 0000000..24123e0 --- /dev/null +++ b/dashcaddy-api/src/managers/tailscale-manager.js @@ -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, +}; \ No newline at end of file diff --git a/start.sh b/start.sh index be7a8b7..37ef70d 100755 --- a/start.sh +++ b/start.sh @@ -50,6 +50,13 @@ if docker ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then docker rm -f ${CONTAINER_NAME} fi +# Tailscale CLI + control socket — lets the container invoke +# `tailscale status --json` to populate /api/v1/tailscale/status etc. +# The binary is statically linked (Go), so the bind-mount works under +# the container's Alpine libc without any library forwarding. +# Both mounts are read-only: `tailscale status --json` is a read query +# that the local tailscaled handles; we never need to mutate state +# from inside the container. echo "[start.sh] Creating container with full config..." docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ --add-host=get.dashcaddy.net:194.233.88.206 \ @@ -65,6 +72,8 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ -v ${ASSETS_DIR}:/app/assets \ -v ${UPDATES_DIR}:/app/updates \ -v /opt/sami-files/logs:/opt/sami-files/logs:ro \ + -v /usr/bin/tailscale:/usr/bin/tailscale:ro \ + -v /var/run/tailscale:/var/run/tailscale:ro \ -e NODE_ENV=production \ -e SERVICES_FILE=/app/data/services.json \ -e CONFIG_FILE=/app/data/config.json \