diff --git a/BACKLOG.md b/BACKLOG.md index 45eb246..7dc7572 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -213,6 +213,32 @@ - **details:** The host-side updater has zero integration coverage. The recent DC-033 incident showed this whole chain is one big untested path. Build a test harness that: (1) creates a temporary directory mimicking `/opt/dashcaddy/updates/staging/dashcaddy-api` with a known-good tarball. (2) writes a `trigger.json` to a test `UPDATES_DIR`. (3) runs `bash /opt/dashcaddy/scripts/dashcaddy-update.sh` with paths overridden via env vars. (4) asserts `result.json` has `success: true` and the version matches. (5) cleans up. Effort: ~2 hours. Risk: medium — the script uses `docker build` so the test needs either Docker-in-Docker (DinD) or mocking the docker calls. - **impact:** Closes the biggest untested surface in DashCaddy. Would have caught the v1.14.4 packaging bug immediately on the next release. +### DC-042: Replace null stubs in src/app.js getTailscaleStatus() with real Tailscale manager +- **status:** done (commit d042386, deployed to DNS2, pushed to origin 2026-07-07) +- **owner:** krystie +- **details:** The long-standing `return null` stub at src/app.js:189 (plus 8 null fn stubs on `ctx.tailscale`) made `/api/v1/tailscale/*` and the `tailscaleAuthMiddleware` dead code. New module `src/managers/tailscale-manager.js` shells out to the host's `tailscale status --json`, parses, caches for 5 min, gracefully handles missing-CLI / tailscaled-down / malformed-JSON. Re-exports `isTailscaleIP` from network-detector.js. Wired into `src/context/index.js`. start.sh on DNS2 gets two new bind mounts: `/usr/bin/tailscale` (statically-linked Go binary) and `/var/run/tailscale/`. Tests: 41 unit tests covering installed/missing/daemon-down/cache/malformed/IPv4-vs-IPv6/all 8 peer fields/timer stubs. Suite went 1097 → 1138 tests passing. +- **impact:** Dashboard's Tailscale card now shows real device list (8/9 online). `tailscaleAuthMiddleware`'s allowedTailnet check no longer dead code. Foundation for DC-043 share-invite flow. + +### DC-043: Tailscale coordination API client + admin/settings routes +- **status:** done (committed, deployed to DNS2, verified end-to-end with real token 2026-07-07) +- **owner:** krystie +- **details:** Companion to DC-042. New module `src/managers/tailscale-coord.js` is the *write-side* REST client for `https://api.tailscale.com/api/v2/`. Wraps: list/get/delete devices, create/list/delete pre-auth keys, list users, get/update ACL. New `ctx.tailscaleCoord` namespace with `getClient`/`loadMetadata`/`saveMetadata`/`setApiToken`/`hasApiToken` helpers. API token is stored encrypted via existing `credentialManager` (key: `tailscale.coord.apiToken`); metadata in plaintext `tailscale-config.json`. New routes in `routes/tailscale-admin.js`: + - `GET /api/v1/tailscale/settings` — returns `{configured, tailnetName, deviceCount, keyValidatedAt}`, NEVER the token + - `PUT /api/v1/tailscale/settings` — validates token by pinging /devices, stores encrypted, returns sanitized + - `DELETE /api/v1/tailscale/settings` — wipes token + metadata + - `POST /api/v1/tailscale/settings/test` — ping without saving, returns `{valid, tailnetName?, error?}` + - `GET /api/v1/tailscale/admin/devices` — full device list via coord API + - `DELETE /api/v1/tailscale/admin/devices/:id` — revoke device + - `GET /api/v1/tailscale/admin/users` — tailnet users + - `GET /api/v1/tailscale/admin/keys` — pre-auth key metadata + - `POST /api/v1/tailscale/admin/keys` — create pre-auth key (returns secret ONCE) + - `DELETE /api/v1/tailscale/admin/keys/:id` — revoke pre-auth key +- 74 unit + route tests (45 client + 29 route integration). Suite: 1214/1214 passing. +- **deployed to DNS2, verified:** `docker exec dashcaddy-api node ...` against the real token returned `ping: {domain: "tail3e209.ts.net", deviceCount: 9}`, `devices: 9`, `keys: 3`, `users: 3` — full field set per device (id, addresses, hostname, OS, lastSeen, nodeId, etc.). +- **API quirk discovered mid-build:** The `/api/v2/tailnet/-/preferences` endpoint that early doc references suggested for token-validity pings was **retired by Tailscale in 2026** (returns 404 with no fallback). ping() now hits `/tailnet/-/devices` and derives the tailnet name by extracting the `*.ts.net` suffix from the first device's `name` field. Also discovered `core.worktree` confusion mid-session — git thought `/opt/dashcaddy`'s repo lived at `/root/dashcaddy`, which caused the first commit to appear "lost" until I recovered via `git reset --hard ` from the reflog. +- **intentionally NOT built:** token auto-rotation / auto-renewal. Tailscale API keys don't auto-renew, and silently re-issuing admin credentials would erode the audit-trail checkpoint that token expiry provides. If a user needs rotation, they re-paste via the UI — explicit and intentional. +- **impact:** Foundation for DC-044 (Plex/whatever share-invite flow). With this, every DashCaddy install can manage its own tailnet from a single paste-the-key-once UI flow. + --- ## Backlog note (2026-07-05) diff --git a/dashcaddy-api/__tests__/routes/tailscale-admin.test.js b/dashcaddy-api/__tests__/routes/tailscale-admin.test.js new file mode 100644 index 0000000..0a5dc00 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/tailscale-admin.test.js @@ -0,0 +1,575 @@ +/** + * Integration tests for routes/tailscale-admin.js — the Tailscale settings + + * admin API surface (PUT/GET/DELETE settings, /admin/devices, /admin/keys). + * + * Strategy: + * - Use supertest against a real Express app mounting the router + * - Mock `tailscaleCoord` (the ctx namespace) so we don't hit real Tailscale + * - Mock `credentialManager` indirectly via the mocked `tailscaleCoord.setApiToken` + * - The route does `new TailscaleCoordClient(...)` inline for the validation + * path; we mock that whole module to inject a fake client + */ + +/* eslint-disable require-await, no-unused-vars */ +// require-await: many test helper stubs are `async () => value` to match the +// shape of the real function signatures — they don't need to await. +// no-unused-vars: `fakeClient = makeFakeClient()` in some tests exists only to +// satisfy the linter that the helper is reachable; tests that don't exercise a +// particular method intentionally leave it unused. + +'use strict'; + +const express = require('express'); +const request = require('supertest'); + +// --- Mock the coord client module so the PUT/POST routes can instantiate it +// without making real HTTP calls. +jest.mock('../../src/managers/tailscale-coord', () => { + const real = jest.requireActual('../../src/managers/tailscale-coord'); + return { + ...real, + TailscaleCoordClient: jest.fn(), + TailscaleCoordError: real.TailscaleCoordError, + }; +}); + +const { TailscaleCoordClient, TailscaleCoordError } = require('../../src/managers/tailscale-coord'); + +function asyncHandler(fn) { + return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next); +} + +function createApp({ initialMetadata = { configured: false }, initialToken = null, mockClient } = {}) { + const stored = { token: initialToken }; + let metadata = initialMetadata; + + const tailscaleCoord = { + loadMetadata: () => metadata, + saveMetadata: (m) => { metadata = m; }, + setApiToken: jest.fn(async (token) => { stored.token = token; }), + getClient: jest.fn(async () => { + // If a token is stored, hand back the mockClient; otherwise a fresh + // unconfigured mock + const FakeClient = jest.requireActual('../../src/managers/tailscale-coord').TailscaleCoordClient; + return new FakeClient({ apiToken: stored.token }); + }), + hasApiToken: jest.fn(async () => !!stored.token), + }; + + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, + asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + logError: jest.fn(), + })); + + return { app, tailscaleCoord, stored, getMetadata: () => metadata }; +} + +// Helper: builds a fake coord client instance the way the route uses it +function makeFakeClient({ apiToken = 'tskey-api-fake', ping, listDevices, listAuthKeys, listUsers, createAuthKey, deleteAuthKey, deleteDevice, getAcl, updateAcl } = {}) { + return { + apiToken, + isConfigured: () => !!apiToken, + setApiToken: jest.fn(), + ping: ping || jest.fn(async () => ({ domain: 'fake.ts.net' })), + listDevices: listDevices || jest.fn(async () => []), + listAuthKeys: listAuthKeys || jest.fn(async () => []), + listUsers: listUsers || jest.fn(async () => []), + createAuthKey: createAuthKey || jest.fn(async () => ({ id: 'k1', key: 'tskey-auth-fake' })), + deleteAuthKey: deleteAuthKey || jest.fn(async () => ({ success: true })), + deleteDevice: deleteDevice || jest.fn(async () => ({ success: true })), + getAcl: getAcl || jest.fn(async () => ({ acls: [] })), + updateAcl: updateAcl || jest.fn(async () => ({})), + }; +} + +describe('routes/tailscale-admin: GET /settings', () => { + test('returns configured:false when metadata is empty', async () => { + const { app } = createApp(); + const res = await request(app).get('/api/v1/tailscale/settings'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.configured).toBe(false); + }); + + test('returns tailnetName + deviceCount when configured', async () => { + const { app } = createApp({ + initialMetadata: { configured: true, tailnetName: 'foo.ts.net', deviceCount: 9, keyValidatedAt: '2026-07-07T00:00:00Z' }, + }); + const res = await request(app).get('/api/v1/tailscale/settings'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.tailnetName).toBe('foo.ts.net'); + expect(res.body.deviceCount).toBe(9); + expect(res.body.keyValidatedAt).toBe('2026-07-07T00:00:00Z'); + }); + + test('never returns the raw token (even if it would be in metadata)', async () => { + const { app } = createApp({ + initialMetadata: { configured: true, tailnetName: 'foo.ts.net', apiToken: 'SECRET-SHOULD-NOT-LEAK' }, + }); + const res = await request(app).get('/api/v1/tailscale/settings'); + expect(res.body.apiToken).toBeUndefined(); + expect(JSON.stringify(res.body)).not.toContain('SECRET-SHOULD-NOT-LEAK'); + }); +}); + +describe('routes/tailscale-admin: PUT /settings', () => { + test('400 on missing apiToken', async () => { + const { app } = createApp(); + const res = await request(app).put('/api/v1/tailscale/settings').send({}); + expect(res.status).toBe(400); + }); + + test('400 on apiToken not starting with tskey-api-', async () => { + const { app } = createApp(); + const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 'not-a-token' }); + expect(res.status).toBe(400); + }); + + test('200 + saves token + writes metadata on valid token', async () => { + const fakeClient = makeFakeClient({ + ping: jest.fn(async () => ({ domain: 'real.ts.net' })), + listDevices: jest.fn(async () => [{ id: 'd1' }, { id: 'd2' }, { id: 'd3' }]), + }); + TailscaleCoordClient.mockImplementation(() => fakeClient); + + const { app, tailscaleCoord, stored } = createApp(); + const res = await request(app) + .put('/api/v1/tailscale/settings') + .send({ apiToken: 'tskey-api-valid-token' }); + + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.tailnetName).toBe('real.ts.net'); + expect(res.body.deviceCount).toBe(3); + expect(tailscaleCoord.setApiToken).toHaveBeenCalledWith('tskey-api-valid-token'); + expect(stored.token).toBe('tskey-api-valid-token'); + }); + + test('401 on Tailscale rejection', async () => { + const fakeClient = makeFakeClient({ + ping: jest.fn(async () => { throw new TailscaleCoordError('unauthorized', { status: 401, code: 'unauthorized' }); }), + }); + TailscaleCoordClient.mockImplementation(() => fakeClient); + + const { app, tailscaleCoord } = createApp(); + const res = await request(app) + .put('/api/v1/tailscale/settings') + .send({ apiToken: 'tskey-api-bad-token' }); + + expect(res.status).toBe(401); + expect(tailscaleCoord.setApiToken).not.toHaveBeenCalled(); + }); + + test('502 on other Tailscale errors', async () => { + const fakeClient = makeFakeClient({ + ping: jest.fn(async () => { throw new TailscaleCoordError('server error', { status: 500, code: 'server_error' }); }), + }); + TailscaleCoordClient.mockImplementation(() => fakeClient); + + const { app } = createApp(); + const res = await request(app) + .put('/api/v1/tailscale/settings') + .send({ apiToken: 'tskey-api-fails' }); + + expect(res.status).toBe(502); + }); + + test('proceeds even if device count fetch fails', async () => { + const fakeClient = makeFakeClient({ + ping: jest.fn(async () => ({ domain: 'real.ts.net' })), + listDevices: jest.fn(async () => { throw new Error('boom'); }), + }); + TailscaleCoordClient.mockImplementation(() => fakeClient); + + const { app } = createApp(); + const res = await request(app) + .put('/api/v1/tailscale/settings') + .send({ apiToken: 'tskey-api-valid-token' }); + expect(res.status).toBe(200); + expect(res.body.deviceCount).toBeNull(); + expect(res.body.tailnetName).toBe('real.ts.net'); + }); +}); + +describe('routes/tailscale-admin: DELETE /settings', () => { + test('clears token + metadata, returns configured:false', async () => { + const { app, tailscaleCoord, stored, getMetadata } = createApp({ + initialMetadata: { configured: true, tailnetName: 'foo.ts.net' }, + initialToken: 'tskey-api-something', + }); + const res = await request(app).delete('/api/v1/tailscale/settings'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(false); + expect(tailscaleCoord.setApiToken).toHaveBeenCalledWith(null); + expect(stored.token).toBeNull(); + expect(getMetadata()).toEqual({ configured: false }); + }); +}); + +describe('routes/tailscale-admin: POST /settings/test', () => { + test('returns valid:false when no token configured', async () => { + const { app } = createApp({ initialToken: null }); + const res = await request(app).post('/api/v1/tailscale/settings/test').send({}); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.error).toMatch(/no Tailscale API token/i); + }); + + test('returns valid:true + tailnetName on successful ping (stored token)', async () => { + // Build an app where getClient returns a fake with our desired ping + const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const stored = { token: 'tskey-api-stored' }; + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).post('/api/v1/tailscale/settings/test').send({}); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(res.body.tailnetName).toBe('stored.ts.net'); + expect(fakeClient.ping).toHaveBeenCalledWith({ skipCache: true }); + }); + + test('returns valid:false on Tailscale unauthorized', async () => { + const fakeClient = makeFakeClient({ + ping: jest.fn(async () => { throw new TailscaleCoordError('unauthorized', { status: 401, code: 'unauthorized' }); }), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).post('/api/v1/tailscale/settings/test').send({}); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.error).toMatch(/unauthorized/); + }); + + test('uses body.apiToken override when provided', async () => { + const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'override.ts.net' })) }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: false }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), // pre-loaded fake + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app) + .post('/api/v1/tailscale/settings/test') + .send({ apiToken: 'tskey-api-test-only' }); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only'); + }); +}); + +describe('routes/tailscale-admin: GET /admin/devices', () => { + test('503 when no token configured', async () => { + const { app } = createApp({ initialToken: null }); + const res = await request(app).get('/api/v1/tailscale/admin/devices'); + expect(res.status).toBe(503); + }); + + test('returns devices list when configured', async () => { + const fakeClient = makeFakeClient({ + listDevices: jest.fn(async () => [{ id: 'd1', hostname: 'a' }, { id: 'd2', hostname: 'b' }]), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).get('/api/v1/tailscale/admin/devices'); + expect(res.status).toBe(200); + expect(res.body.devices).toHaveLength(2); + expect(res.body.count).toBe(2); + }); + + test('401 when token invalid', async () => { + const fakeClient = makeFakeClient({ + listDevices: jest.fn(async () => { throw new TailscaleCoordError('unauth', { status: 401, code: 'unauthorized' }); }), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).get('/api/v1/tailscale/admin/devices'); + expect(res.status).toBe(401); + }); +}); + +describe('routes/tailscale-admin: DELETE /admin/devices/:id', () => { + test('503 when no token configured', async () => { + const { app } = createApp({ initialToken: null }); + const res = await request(app).delete('/api/v1/tailscale/admin/devices/d1'); + expect(res.status).toBe(503); + }); + + test('returns success on 200', async () => { + const fakeClient = makeFakeClient({ deleteDevice: jest.fn(async () => ({ success: true })) }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).delete('/api/v1/tailscale/admin/devices/d1'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(fakeClient.deleteDevice).toHaveBeenCalledWith('d1'); + }); + + test('404 when device not found', async () => { + const fakeClient = makeFakeClient({ + deleteDevice: jest.fn(async () => { throw new TailscaleCoordError('not found', { status: 404, code: 'not_found' }); }), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).delete('/api/v1/tailscale/admin/devices/missing'); + expect(res.status).toBe(404); + }); +}); + +describe('routes/tailscale-admin: GET /admin/users', () => { + test('returns users list', async () => { + const fakeClient = makeFakeClient({ + listUsers: jest.fn(async () => [{ id: 'u1', displayName: 'Sami' }, { id: 'u2', displayName: 'Friend' }]), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).get('/api/v1/tailscale/admin/users'); + expect(res.status).toBe(200); + expect(res.body.users).toHaveLength(2); + }); + + test('503 when not configured', async () => { + const { app } = createApp({ initialToken: null }); + const res = await request(app).get('/api/v1/tailscale/admin/users'); + expect(res.status).toBe(503); + }); +}); + +describe('routes/tailscale-admin: pre-auth keys', () => { + test('GET /admin/keys returns keys list', async () => { + const fakeClient = makeFakeClient({ + listAuthKeys: jest.fn(async () => [{ id: 'k1', description: 'foo' }]), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).get('/api/v1/tailscale/admin/keys'); + expect(res.status).toBe(200); + expect(res.body.keys).toHaveLength(1); + expect(res.body.count).toBe(1); + }); + + test('POST /admin/keys creates a key and returns the secret', async () => { + const fakeClient = makeFakeClient({ + createAuthKey: jest.fn(async (opts) => ({ id: 'k1', key: 'tskey-auth-secret', ...opts })), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ + reusable: false, + ephemeral: true, + tags: ['tag:guest'], + description: 'Plex invite', + expirySeconds: 86400, + }); + expect(res.status).toBe(200); + expect(res.body.id).toBe('k1'); + expect(res.body.key).toBe('tskey-auth-secret'); + expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({ + tags: ['tag:guest'], + expirySeconds: 86400, + })); + }); + + test('POST /admin/keys rejects non-array tags', async () => { + const fakeClient = makeFakeClient(); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: 'tag:foo' }); + expect(res.status).toBe(400); + }); + + test('POST /admin/keys rejects negative expirySeconds', async () => { + const fakeClient = makeFakeClient(); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ expirySeconds: -1 }); + expect(res.status).toBe(400); + }); + + test('DELETE /admin/keys/:id returns success', async () => { + const fakeClient = makeFakeClient({ + deleteAuthKey: jest.fn(async () => ({ success: true })), + }); + const app = express(); + app.use(express.json()); + const routes = require('../../routes/tailscale-admin'); + const tailscaleCoord = { + loadMetadata: () => ({ configured: true }), + saveMetadata: jest.fn(), + setApiToken: jest.fn(), + getClient: jest.fn(async () => fakeClient), + hasApiToken: jest.fn(), + }; + app.use('/api/v1/tailscale', routes({ + tailscaleCoord, asyncHandler, + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const res = await request(app).delete('/api/v1/tailscale/admin/keys/k1'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(fakeClient.deleteAuthKey).toHaveBeenCalledWith('k1'); + }); +}); + +describe('routes/tailscale-admin: security boundary', () => { + test('GET /settings never leaks the apiToken field from metadata', async () => { + const { app } = createApp({ + initialMetadata: { configured: true, tailnetName: 'foo.ts.net', apiToken: 'RAW-LEAK', apiKey: 'LEAK2' }, + }); + const res = await request(app).get('/api/v1/tailscale/settings'); + expect(JSON.stringify(res.body)).not.toContain('RAW-LEAK'); + expect(JSON.stringify(res.body)).not.toContain('LEAK2'); + }); + + test('DELETE /settings wipes stored token', async () => { + const fakeClient = makeFakeClient(); + const { app, stored } = createApp({ initialToken: 'tskey-api-real' }); + await request(app).delete('/api/v1/tailscale/settings'); + expect(stored.token).toBeNull(); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/__tests__/tailscale-coord.test.js b/dashcaddy-api/__tests__/tailscale-coord.test.js new file mode 100644 index 0000000..2f86342 --- /dev/null +++ b/dashcaddy-api/__tests__/tailscale-coord.test.js @@ -0,0 +1,605 @@ +/** + * Tests for src/managers/tailscale-coord.js + * + * Strategy: inject a fake `fetchImpl` into the client so we can simulate + * every Tailscale API response shape without making real HTTP calls. Each + * test sets up a mock that responds to the URL path with a fixture body + * and the expected status code, then asserts the client's behavior. + * + * The mock is intentionally simple: a function (method, path, opts) → Promise<{ + * status, body, headers }>. We don't try to be exhaustive about request + * shape matching — just enough to verify the client's status handling, + * caching, error mapping, and JSON parsing. + */ + +'use strict'; + +/* eslint-disable require-await, no-unused-vars */ +// require-await: many helper functions in this file are `async () => ...` to +// match the shape of the real function signatures — they don't need to await. +// no-unused-vars: some tests destructure fields they don't exercise. + +const { TailscaleCoordClient, TailscaleCoordError } = require('../src/managers/tailscale-coord'); + +const VALID_TOKEN = 'tskey-api-kLD2XbydZ511CNTRL-CKorHnjoVpc11chfHcV8qcSz9hhjpUr3'; // realistic shape + +/** + * Build a fake fetchImpl from a route map. + * + * { + * 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [...] } }, + * 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1', key: 'tskey-auth-abc' } }, + * 'DELETE /api/v2/device/d1': { status: 200, body: '' }, + * } + * + * Unmatched routes return 404 by default (the client will then throw + * TailscaleCoordError with code='not_found'). + */ +function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) { + const calls = []; + const fn = jest.fn(async (method, path, opts) => { + calls.push({ method, path, opts }); + const key = method + ' ' + path; + const match = routes[key]; + if (match) { + return { + status: match.status, + body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body), + headers: match.headers || { 'content-type': 'application/json' }, + }; + } + return { + status: defaultStatus, + body: JSON.stringify(defaultBody), + headers: { 'content-type': 'application/json' }, + }; + }); + fn.calls = calls; + return fn; +} + +describe('tailscale-coord: configuration', () => { + test('isConfigured() returns false when no token set', () => { + const c = new TailscaleCoordClient(); + expect(c.isConfigured()).toBe(false); + }); + test('isConfigured() returns true after setApiToken()', () => { + const c = new TailscaleCoordClient(); + c.setApiToken('tskey-api-foo'); + expect(c.isConfigured()).toBe(true); + }); + test('setApiToken(null) clears the token', () => { + const c = new TailscaleCoordClient({ apiToken: 'foo' }); + c.setApiToken(null); + expect(c.isConfigured()).toBe(false); + }); + test('constructor accepts apiToken in opts', () => { + const c = new TailscaleCoordClient({ apiToken: 'x' }); + expect(c.isConfigured()).toBe(true); + }); +}); + +describe('tailscale-coord: not configured errors', () => { + test('listDevices throws not_configured when no token', async () => { + const c = new TailscaleCoordClient(); + await expect(c.listDevices()).rejects.toMatchObject({ code: 'not_configured' }); + }); + test('ping throws not_configured when no token', async () => { + const c = new TailscaleCoordClient(); + await expect(c.ping()).rejects.toMatchObject({ code: 'not_configured' }); + }); + test('createAuthKey throws not_configured when no token', async () => { + const c = new TailscaleCoordClient(); + await expect(c.createAuthKey({})).rejects.toMatchObject({ code: 'not_configured' }); + }); +}); + +describe('tailscale-coord: ping()', () => { + // ping() now hits /devices and derives tailnet name from magicDNSSuffix + // on the first device. (Tailscale retired /preferences in 2026.) + test('returns { domain, deviceCount } derived from /devices response', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 200, + body: { + devices: [ + { id: '1', hostname: 'dns2', name: 'dns2-sami.tail3e209.ts.net', addresses: ['100.121.150.22'] }, + { id: '2', hostname: 'laptop', name: 'laptop.tail3e209.ts.net', addresses: ['100.91.55.51'] }, + ], + }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + + const result = await c.ping(); + expect(result.domain).toBe('tail3e209.ts.net'); + expect(result.deviceCount).toBe(2); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + // Second call hits cache, no new HTTP request + const result2 = await c.ping(); + expect(result2.domain).toBe('tail3e209.ts.net'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('returns null domain when no .ts.net suffix is in name', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 200, + body: { + devices: [ + { id: '1', name: 'some-other-host.example.com', addresses: ['100.121.150.22'] }, + ], + }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const result = await c.ping(); + expect(result.domain).toBeNull(); + }); + + test('returns null domain when no useful name data is available', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 200, + body: { devices: [] }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const result = await c.ping(); + expect(result.domain).toBeNull(); + expect(result.deviceCount).toBe(0); + }); + + test('skipCache forces a fresh request', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 200, + body: { devices: [{ id: '1', name: 'foo.ts.net' }] }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.ping(); + await c.ping({ skipCache: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + test('401 surfaces as TailscaleCoordError code=unauthorized', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 401, body: { message: 'unauthorized' } }, + }); + const c = new TailscaleCoordClient({ apiToken: 'bad-token', fetchImpl }); + await expect(c.ping()).rejects.toBeInstanceOf(TailscaleCoordError); + await expect(c.ping()).rejects.toMatchObject({ status: 401, code: 'unauthorized' }); + }); +}); + +describe('tailscale-coord: listDevices()', () => { + const fixtureDevices = [ + { id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'], os: 'linux', online: true }, + { id: 'nodekey:2', hostname: 'laptop', addresses: ['100.91.55.51'], os: 'windows', online: true }, + { id: 'nodekey:3', hostname: 'phone', addresses: ['100.106.44.35'], os: 'android', online: false }, + ]; + + test('returns devices array on 200', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const devices = await c.listDevices(); + expect(devices).toHaveLength(3); + expect(devices[0].hostname).toBe('dns2'); + expect(devices[2].online).toBe(false); + }); + + test('empty devices array on 200 with no devices', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [] } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const devices = await c.listDevices(); + expect(devices).toEqual([]); + }); + + test('missing devices field returns []', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: {} }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const devices = await c.listDevices(); + expect(devices).toEqual([]); + }); + + test('caches list for TTL_DEVICES_MS (60s)', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.listDevices(); + await c.listDevices(); + await c.listDevices(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('5xx surfaces as server_error', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 503, body: { message: 'unavailable' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await expect(c.listDevices()).rejects.toMatchObject({ status: 503, code: 'server_error' }); + }); + + test('429 surfaces as rate_limited with retryAfter', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 429, + body: { message: 'too many requests' }, + headers: { 'content-type': 'application/json', 'retry-after': '30' }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await expect(c.listDevices()).rejects.toMatchObject({ + status: 429, + code: 'rate_limited', + retryAfter: '30', + }); + }); + + test('404 surfaces as not_found', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 404, body: { message: 'tailnet not found' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await expect(c.listDevices()).rejects.toMatchObject({ status: 404, code: 'not_found' }); + }); +}); + +describe('tailscale-coord: getDevice()', () => { + test('returns single device on 200', async () => { + const dev = { id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'] }; + const fetchImpl = makeFetch({ + // client URL-encodes the deviceId, so route key uses %3A + 'GET /api/v2/device/nodekey%3A1': { status: 200, body: dev }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const got = await c.getDevice('nodekey:1'); + expect(got.id).toBe('nodekey:1'); + }); + + test('encodes deviceId in URL', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/device/nodekey%3A1': { status: 200, body: { id: 'nodekey:1' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.getDevice('nodekey:1'); + expect(fetchImpl.calls[0].path).toBe('/api/v2/device/nodekey%3A1'); + }); + + test('throws bad_input when deviceId missing', async () => { + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); + await expect(c.getDevice('')).rejects.toMatchObject({ code: 'bad_input' }); + await expect(c.getDevice(null)).rejects.toMatchObject({ code: 'bad_input' }); + }); +}); + +describe('tailscale-coord: deleteDevice()', () => { + test('returns success on 200 and invalidates device caches', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: 'd1' }] } }, + 'DELETE /api/v2/device/d1': { status: 200, body: {} }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.listDevices(); // populates cache + expect(fetchImpl).toHaveBeenCalledTimes(1); + await c.deleteDevice('d1'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + // Next listDevices should re-fetch because cache was invalidated + await c.listDevices(); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + test('throws bad_input when deviceId missing', async () => { + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); + await expect(c.deleteDevice('')).rejects.toMatchObject({ code: 'bad_input' }); + }); +}); + +describe('tailscale-coord: createAuthKey()', () => { + test('sends correct body and returns key on 200', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/keys': { + status: 200, + body: { id: 'k1', key: 'tskey-auth-abc123', created: '2026-07-07T00:00:00Z' }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const result = await c.createAuthKey({ + reusable: false, + ephemeral: true, + preauthorized: true, + tags: ['tag:guest-plex'], + description: 'Plex invite for friend', + expirySeconds: 86400, + }); + expect(result.key).toBe('tskey-auth-abc123'); + expect(result.id).toBe('k1'); + + const sent = JSON.parse(fetchImpl.calls[0].opts.body); + expect(sent.reusable).toBe(false); + expect(sent.ephemeral).toBe(true); + expect(sent.preauthorized).toBe(true); + expect(sent.tags).toEqual(['tag:guest-plex']); + expect(sent.description).toBe('Plex invite for friend'); + expect(sent.expirySeconds).toBe(86400); + }); + + test('omits optional fields when not provided', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k2', key: 'k' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.createAuthKey({}); + const sent = JSON.parse(fetchImpl.calls[0].opts.body); + expect(sent.tags).toBeUndefined(); + expect(sent.description).toBeUndefined(); + expect(sent.expirySeconds).toBeUndefined(); + expect(sent.reusable).toBe(false); // default + expect(sent.ephemeral).toBe(false); // default + expect(sent.preauthorized).toBe(true); // default + }); + + test('caps expirySeconds at 7776000 (90 days)', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k3' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.createAuthKey({ expirySeconds: 99999999 }); + const sent = JSON.parse(fetchImpl.calls[0].opts.body); + expect(sent.expirySeconds).toBe(7776000); + }); + + test('ignores non-positive expirySeconds', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k4' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.createAuthKey({ expirySeconds: 0 }); + const sent = JSON.parse(fetchImpl.calls[0].opts.body); + expect(sent.expirySeconds).toBeUndefined(); + }); + + test('ignores non-array tags', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k5' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.createAuthKey({ tags: 'tag:foo' }); + const sent = JSON.parse(fetchImpl.calls[0].opts.body); + expect(sent.tags).toBeUndefined(); + }); +}); + +describe('tailscale-coord: listAuthKeys()', () => { + test('returns keys array and caches for TTL_LIST_MS', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/keys': { + status: 200, + body: { keys: [{ id: 'k1' }, { id: 'k2' }] }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const k1 = await c.listAuthKeys(); + const k2 = await c.listAuthKeys(); + expect(k1).toHaveLength(2); + expect(k2).toBe(k1); // cached + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('missing keys field returns []', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/keys': { status: 200, body: {} }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const keys = await c.listAuthKeys(); + expect(keys).toEqual([]); + }); +}); + +describe('tailscale-coord: deleteAuthKey()', () => { + test('invalidates keys:list cache', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/keys': { status: 200, body: { keys: [{ id: 'k1' }] } }, + 'DELETE /api/v2/keys/k1': { status: 200, body: {} }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.listAuthKeys(); + await c.deleteAuthKey('k1'); + await c.listAuthKeys(); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + test('throws bad_input when keyId missing', async () => { + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); + await expect(c.deleteAuthKey('')).rejects.toMatchObject({ code: 'bad_input' }); + }); +}); + +describe('tailscale-coord: listUsers()', () => { + test('returns users array and caches', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/users': { + status: 200, + body: { + users: [ + { id: 'u1', displayName: 'Sami', loginName: 'sami@github', role: 'admin' }, + { id: 'u2', displayName: 'Friend', loginName: 'friend@gmail.com', role: 'member' }, + ], + }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const u = await c.listUsers(); + expect(u).toHaveLength(2); + expect(u[0].role).toBe('admin'); + await c.listUsers(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); + +describe('tailscale-coord: ACL', () => { + const aclFixture = { + acls: [{ action: 'accept', src: ['autogroup:member'], dst: ['*:*'] }], + ssh: [{ action: 'accept', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['root', 'autogroup:nonroot'] }], + }; + + test('getAcl returns parsed body (not cached)', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/acl': { status: 200, body: aclFixture }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const a1 = await c.getAcl(); + const a2 = await c.getAcl(); + expect(a1.acls[0].action).toBe('accept'); + expect(fetchImpl).toHaveBeenCalledTimes(2); // explicitly not cached + }); + + test('updateAcl sends the object as JSON body', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl': { status: 200, body: {} }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.updateAcl(aclFixture); + const sent = JSON.parse(fetchImpl.calls[0].opts.body); + expect(sent.acls[0].src).toContain('autogroup:member'); + }); + + test('updateAcl throws bad_input on non-object', async () => { + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); + await expect(c.updateAcl(null)).rejects.toMatchObject({ code: 'bad_input' }); + await expect(c.updateAcl('a string')).rejects.toMatchObject({ code: 'bad_input' }); + await expect(c.updateAcl([])).rejects.toMatchObject({ code: 'bad_input' }); + }); +}); + +describe('tailscale-coord: HTTP shape', () => { + test('sends Authorization: Bearer header', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.ping(); + expect(fetchImpl.calls[0].opts.headers.Authorization).toBe('Bearer ' + VALID_TOKEN); + }); + + test('sends Content-Type: application/json on POST with body', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.createAuthKey({ tags: ['tag:x'] }); + expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBe('application/json'); + }); + + test('does not send Content-Type when no body', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.ping(); + expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBeUndefined(); + }); + + test('parses string JSON body correctly', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 200, + body: JSON.stringify({ devices: [{ id: '1', name: 'foo.ts.net' }] }), + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const result = await c.ping(); + expect(result.domain).toBe('foo.ts.net'); + expect(result.deviceCount).toBe(1); + }); + + test('non-JSON 200 body returned as string', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/acl': { status: 200, body: 'not-json', headers: { 'content-type': 'text/plain' } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + const result = await c.getAcl(); + expect(result).toBe('not-json'); + }); + + test('extracts retryAfter from response headers', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 429, + body: { message: 'slow down' }, + headers: { 'content-type': 'application/json', 'retry-after': '60' }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + try { + await c.listDevices(); + throw new Error('expected throw'); + } catch (e) { + expect(e).toBeInstanceOf(TailscaleCoordError); + expect(e.retryAfter).toBe('60'); + } + }); +}); + +describe('tailscale-coord: cache lifecycle', () => { + test('setApiToken clears all caches', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.ping(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + c.setApiToken('tskey-api-other'); + await c.ping(); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + test('expired cache entries re-fetch', async () => { + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/devices': { + status: 200, + body: { devices: [{ id: '1', name: 'a.ts.net' }] }, + }, + }); + const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); + await c.ping(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + // Manually expire the cache entry + c._cache.set('ping', { expiresAt: Date.now() - 1000, value: { stale: true } }); + const fresh = await c.ping(); + expect(fresh.domain).toBe('a.ts.net'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); + +describe('tailscale-coord: error class', () => { + test('TailscaleCoordError carries status, code, body, retryAfter', () => { + const e = new TailscaleCoordError('test', { status: 429, body: { x: 1 }, retryAfter: '60', code: 'rate_limited' }); + expect(e.message).toBe('test'); + expect(e.status).toBe(429); + expect(e.code).toBe('rate_limited'); + expect(e.body).toEqual({ x: 1 }); + expect(e.retryAfter).toBe('60'); + expect(e).toBeInstanceOf(Error); + expect(e).toBeInstanceOf(TailscaleCoordError); + }); + + test('default code derives from status', () => { + expect(new TailscaleCoordError('x', { status: 401 }).code).toBe('unauthorized'); + expect(new TailscaleCoordError('x', { status: 403 }).code).toBe('unauthorized'); + expect(new TailscaleCoordError('x', { status: 404 }).code).toBe('not_found'); + expect(new TailscaleCoordError('x', { status: 429 }).code).toBe('rate_limited'); + expect(new TailscaleCoordError('x', { status: 500 }).code).toBe('server_error'); + expect(new TailscaleCoordError('x', { status: 502 }).code).toBe('server_error'); + expect(new TailscaleCoordError('x', {}).code).toBe('unknown'); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/routes/tailscale-admin.js b/dashcaddy-api/routes/tailscale-admin.js new file mode 100644 index 0000000..ad363e6 --- /dev/null +++ b/dashcaddy-api/routes/tailscale-admin.js @@ -0,0 +1,257 @@ +/** + * Tailscale admin & settings routes + * + * Two distinct surfaces, both gated by DashCaddy's TOTP auth: + * + * GET /api/v1/tailscale/settings + * Returns { configured, tailnetName, deviceCount, keyValidatedAt } + * NEVER returns the raw API token. + * + * PUT /api/v1/tailscale/settings + * body: { apiToken: 'tskey-api-...' } + * Validates by pinging /api/v2/tailnet/-/preferences. On success, + * stores the token encrypted and writes tailscale-config.json metadata. + * Returns the same shape as GET (without the token). + * + * DELETE /api/v1/tailscale/settings + * Clears the stored token and metadata. + * + * POST /api/v1/tailscale/settings/test + * body: { apiToken?: 'tskey-api-...' } // optional; defaults to stored + * Pings Tailscale with the given token (or stored one) and returns + * { valid: bool, tailnetName?, error? }. Does NOT save anything. + * + * GET /api/v1/tailscale/admin/devices + * Lists all devices in the tailnet via the coord API. 503 if not configured. + * + * GET /api/v1/tailscale/admin/users + * Lists tailnet users. + * + * GET /api/v1/tailscale/admin/keys + * Lists pre-auth keys (metadata only, never the secret). + * + * POST /api/v1/tailscale/admin/keys + * body: { reusable?, ephemeral?, preauthorized?, tags?, description?, expirySeconds? } + * Creates a new pre-auth key. Returns { id, key } — the `key` is the + * ONLY time the secret is available, callers must show it to the user + * immediately and not store it. + * + * DELETE /api/v1/tailscale/admin/keys/:id + * Revokes a pre-auth key. + * + * DELETE /api/v1/tailscale/admin/devices/:id + * Revokes a device from the tailnet. + */ + +const express = require('express'); +const { ok, errorResponse } = require('../src/utils/responses'); +const { TailscaleCoordError } = require('../src/managers/tailscale-coord'); + +module.exports = function({ + tailscaleCoord, + asyncHandler, + log, + logError: _logError, +}) { + const router = express.Router(); + + // ---------- Settings ---------- + + router.get('/settings', asyncHandler( + // eslint-disable-next-line require-await + async (req, res) => { + const meta = tailscaleCoord.loadMetadata(); + if (!meta.configured) { + return ok(res, { configured: false }); + } + return ok(res, { + configured: true, + tailnetName: meta.tailnetName || null, + deviceCount: typeof meta.deviceCount === 'number' ? meta.deviceCount : null, + keyValidatedAt: meta.keyValidatedAt || null, + lastUsedAt: meta.lastUsedAt || null, + }); + })); + + router.put('/settings', asyncHandler(async (req, res) => { + const token = req.body && req.body.apiToken; + if (!token || typeof token !== 'string' || !token.startsWith('tskey-api-')) { + return errorResponse(res, 400, 'Invalid API token (must start with tskey-api-)'); + } + + // Validate before storing + const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token }); + let prefs; + try { + prefs = await client.ping(); + } catch (e) { + if (e instanceof TailscaleCoordError) { + if (e.code === 'unauthorized') { + return errorResponse(res, 401, 'Tailscale rejected this API token (401 unauthorized)'); + } + return errorResponse(res, 502, 'Tailscale API error: ' + e.message); + } + throw e; + } + + // Get device count for the metadata + let deviceCount = null; + try { + const devs = await client.listDevices(); + deviceCount = devs.length; + } catch (_e) { /* non-fatal */ } + + // Persist token (encrypted) + metadata (plaintext) + await tailscaleCoord.setApiToken(token); + tailscaleCoord.saveMetadata({ + configured: true, + tailnetName: prefs.domain || null, + deviceCount, + keyValidatedAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }); + + if (log && log.info) log.info('tailscale-coord', 'API token configured', { tailnetName: prefs.domain, deviceCount }); + + return ok(res, { + configured: true, + tailnetName: prefs.domain || null, + deviceCount, + keyValidatedAt: new Date().toISOString(), + }); + })); + + router.delete('/settings', asyncHandler(async (req, res) => { + await tailscaleCoord.setApiToken(null); + tailscaleCoord.saveMetadata({ configured: false }); + if (log && log.info) log.info('tailscale-coord', 'API token cleared'); + return ok(res, { configured: false }); + })); + + router.post('/settings/test', asyncHandler(async (req, res) => { + const token = (req.body && req.body.apiToken) || null; + const client = await tailscaleCoord.getClient(); + if (token) { + // Caller provided a fresh token to test — don't save it + client.setApiToken(token); + } + if (!client.isConfigured()) { + return ok(res, { valid: false, error: 'No Tailscale API token configured' }); + } + try { + const prefs = await client.ping({ skipCache: true }); + return ok(res, { valid: true, tailnetName: prefs.domain }); + } catch (e) { + if (e instanceof TailscaleCoordError && e.code === 'unauthorized') { + return ok(res, { valid: false, error: 'Tailscale rejected the token (unauthorized)' }); + } + return ok(res, { valid: false, error: e.message }); + } + })); + + // ---------- Admin: devices ---------- + + router.get('/admin/devices', asyncHandler(async (req, res) => { + const client = await tailscaleCoord.getClient(); + if (!client.isConfigured()) { + return errorResponse(res, 503, 'Tailscale API token not configured'); + } + try { + const devices = await client.listDevices(); + return ok(res, { devices, count: devices.length }); + } catch (e) { + if (e instanceof TailscaleCoordError && e.code === 'unauthorized') { + return errorResponse(res, 401, 'Tailscale rejected the configured token'); + } + throw e; + } + })); + + router.delete('/admin/devices/:id', asyncHandler(async (req, res) => { + const client = await tailscaleCoord.getClient(); + if (!client.isConfigured()) { + return errorResponse(res, 503, 'Tailscale API token not configured'); + } + const id = req.params.id; + try { + await client.deleteDevice(id); + if (log && log.info) log.info('tailscale-coord', 'Device deleted', { deviceId: id }); + return ok(res, { success: true, deviceId: id }); + } catch (e) { + if (e instanceof TailscaleCoordError) { + if (e.code === 'unauthorized') return errorResponse(res, 401, 'Unauthorized'); + if (e.code === 'not_found') return errorResponse(res, 404, 'Device not found'); + } + throw e; + } + })); + + // ---------- Admin: users ---------- + + router.get('/admin/users', asyncHandler(async (req, res) => { + const client = await tailscaleCoord.getClient(); + if (!client.isConfigured()) { + return errorResponse(res, 503, 'Tailscale API token not configured'); + } + const users = await client.listUsers(); + return ok(res, { users, count: users.length }); + })); + + // ---------- Admin: pre-auth keys ---------- + + router.get('/admin/keys', asyncHandler(async (req, res) => { + const client = await tailscaleCoord.getClient(); + if (!client.isConfigured()) { + return errorResponse(res, 503, 'Tailscale API token not configured'); + } + const keys = await client.listAuthKeys(); + return ok(res, { keys, count: keys.length }); + })); + + router.post('/admin/keys', asyncHandler(async (req, res) => { + const client = await tailscaleCoord.getClient(); + if (!client.isConfigured()) { + return errorResponse(res, 503, 'Tailscale API token not configured'); + } + const opts = req.body || {}; + // Reject obviously-bad input early + if (opts.tags && !Array.isArray(opts.tags)) { + return errorResponse(res, 400, 'tags must be an array of strings'); + } + if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) { + return errorResponse(res, 400, 'expirySeconds must be a positive integer'); + } + try { + const result = await client.createAuthKey(opts); + if (log && log.info) log.info('tailscale-coord', 'Auth key created', { id: result.id, description: opts.description, tags: opts.tags }); + return ok(res, result); + } catch (e) { + if (e instanceof TailscaleCoordError) { + if (e.code === 'unauthorized') return errorResponse(res, 401, 'Unauthorized'); + return errorResponse(res, 502, 'Tailscale API error: ' + e.message); + } + throw e; + } + })); + + router.delete('/admin/keys/:id', asyncHandler(async (req, res) => { + const client = await tailscaleCoord.getClient(); + if (!client.isConfigured()) { + return errorResponse(res, 503, 'Tailscale API token not configured'); + } + const id = req.params.id; + try { + await client.deleteAuthKey(id); + if (log && log.info) log.info('tailscale-coord', 'Auth key deleted', { keyId: id }); + return ok(res, { success: true, keyId: id }); + } catch (e) { + if (e instanceof TailscaleCoordError) { + if (e.code === 'unauthorized') return errorResponse(res, 401, 'Unauthorized'); + if (e.code === 'not_found') return errorResponse(res, 404, 'Key not found'); + } + throw e; + } + })); + + return router; +}; \ No newline at end of file diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 48b272e..552a64c 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -64,6 +64,7 @@ const notificationRoutes = require('../routes/notifications'); const containerRoutes = require('../routes/containers'); const serviceRoutes = require('../routes/services'); const tailscaleRoutes = require('../routes/tailscale'); +const tailscaleAdminRoutes = require('../routes/tailscale-admin'); const sitesRoutes = require('../routes/sites'); const credentialsRoutes = require('../routes/credentials'); const arrRoutes = require('../routes/arr'); @@ -537,6 +538,13 @@ async function createApp() { SERVICES_FILE: ctx.SERVICES_FILE, log: ctx.log })); + apiRouter.use('/tailscale', tailscaleAdminRoutes({ + tailscaleCoord: ctx.tailscaleCoord, + asyncHandler: ctx.asyncHandler, + ok: ctx.ok, + log: ctx.log, + logError: ctx.logError, + })); apiRouter.use(sitesRoutes({ asyncHandler: ctx.asyncHandler, ok: ctx.ok, diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js index d49b0ea..c522ed2 100644 --- a/dashcaddy-api/src/context/index.js +++ b/dashcaddy-api/src/context/index.js @@ -8,6 +8,8 @@ const { createDnsContext } = require('./dns'); const { createSessionContext } = require('./session'); const NotificationManager = require('../managers/notification-manager'); const tailscaleManager = require('../managers/tailscale-manager'); +const { TailscaleCoordClient } = require('../managers/tailscale-coord'); +const fs = require('fs'); /** * Assemble the full application context @@ -96,6 +98,34 @@ function assembleContext({ config: siteConfig }); + // --- Tailscale coordination API client -------------------------------------- + // Reads the API token from credentialManager on every call (not cached on + // the client) so that PUT /api/v1/tailscale/settings takes effect + // immediately without restarting the process. The metadata file + // tailscale-config.json stores non-secret state (tailnet name, last + // validation time, device count) so we don't have to hit the API just to + // answer "is this configured?" in the UI. + function loadTailscaleMetadata() { + try { + if (TAILSCALE_CONFIG_FILE && fs.existsSync(TAILSCALE_CONFIG_FILE)) { + return JSON.parse(fs.readFileSync(TAILSCALE_CONFIG_FILE, 'utf8')); + } + } catch (_e) { /* corrupt file → treat as unconfigured */ } + return { configured: false }; + } + function saveTailscaleMetadata(meta) { + if (!TAILSCALE_CONFIG_FILE) return; + try { + fs.writeFileSync(TAILSCALE_CONFIG_FILE, JSON.stringify(meta, null, 2), 'utf8'); + } catch (e) { + log.error('tailscale-coord', 'Failed to write tailscale-config.json', { error: e.message }); + } + } + async function getCoordClient() { + const tok = await credentialManager.retrieve('tailscale.coord.apiToken'); + return new TailscaleCoordClient({ apiToken: tok || null }); + } + // 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 @@ -122,6 +152,37 @@ function assembleContext({ stopSyncTimer: tailscaleManager.stopSyncTimer, syncAPI: tailscaleManager.syncAPI, }, + + // Tailscale coordination API client — talk to api.tailscale.com for + // device management, pre-auth key creation, ACL reads/writes, and user + // listing. Distinct from the local tailscaleManager above (which reads + // the local tailscaled daemon). The API token is stored encrypted via + // credentialManager and re-read on every call so settings changes take + // effect without process restart. + tailscaleCoord: { + // Returns a fresh client each call — cheap (just a Map + token lookup), + // and guarantees the latest token is used. + getClient: getCoordClient, + // Metadata helpers — read/write tailscale-config.json + loadMetadata: loadTailscaleMetadata, + saveMetadata: saveTailscaleMetadata, + // Storage helpers — wraps credentialManager so route code doesn't + // need to know the key naming convention. + setApiToken: async (token) => { + if (token) { + await credentialManager.store('tailscale.coord.apiToken', token, { + description: 'Tailscale coordination API token', + source: 'settings-ui', + }); + } else { + await credentialManager.delete('tailscale.coord.apiToken'); + } + }, + hasApiToken: async () => { + const tok = await credentialManager.retrieve('tailscale.coord.apiToken'); + return !!tok; + }, + }, // App and config app, diff --git a/dashcaddy-api/src/managers/tailscale-coord.js b/dashcaddy-api/src/managers/tailscale-coord.js new file mode 100644 index 0000000..877daab --- /dev/null +++ b/dashcaddy-api/src/managers/tailscale-coord.js @@ -0,0 +1,405 @@ +/** + * Tailscale Coordination API client + * + * Wraps the public Tailscale coordination server API at + * https://api.tailscale.com/api/v2/ + * used to manage the user's tailnet from DashCaddy (device list, invite + * keys, ACL edits). Distinct from src/managers/tailscale-manager.js, which + * queries the *local* tailscaled daemon via the `tailscale` CLI for + * status/read-side data. This module is the write-side: it talks to + * Tailscale's cloud, so it requires a personal API token (configured in + * DashCaddy settings, encrypted via credentialManager). + * + * Auth model: every request carries + * Authorization: Bearer + * The token is opaque to this module once retrieved; the route layer is + * responsible for showing it to the user exactly once on create, never + * echoing it in GET responses. + * + * Endpoints used (current as of Tailscale API v2, July 2026): + * GET /api/v2/tailnet/{tailnet}/devices list all devices + * GET /api/v2/device/{deviceId} one device + * DELETE /api/v2/device/{deviceId} remove device from tailnet + * POST /api/v2/tailnet/{tailnet}/keys create pre-auth key + * GET /api/v2/tailnet/{tailnet}/keys list keys (metadata only) + * DELETE /api/v2/keys/{keyId} delete a key + * GET /api/v2/tailnet/{tailnet}/users list users + * GET /api/v2/tailnet/{tailnet}/acl read ACL (HuJSON) + * POST /api/v2/tailnet/{tailnet}/acl replace ACL (HuJSON) + * + * The `/api/v2/tailnet/-/preferences` endpoint that earlier versions of + * this client used for ping() was retired by Tailscale (verified 2026-07-07). + * ping() now hits /devices and derives the tailnet name from the + * `MagicDNSSuffix` field on the first device. + * + * Failure modes: + * - No token set → returns null from all methods; route layer decides UX + * - 401 / 403 → token invalid; surface as { error: 'unauthorized' } + * - 429 → rate limited; throw with retry-after info + * - 5xx → transient; throw, route layer can retry + * - Network error → throw; same as 5xx from the caller's POV + * + * Caching: device list is cached for 60 seconds (Tailnet state changes are + * user-driven and rare; avoid hammering the API on dashboard polls). ACL, + * users, keys, preferences are cached for 5 minutes. Writes invalidate + * their own caches. The token-validity ping is cached separately for 1 hour. + */ + +'use strict'; + +const https = require('https'); + +const API_BASE = 'api.tailscale.com'; +const API_PREFIX = '/api/v2'; +const DEFAULT_TIMEOUT_MS = 10000; + +// Default TTLs (ms). Individual methods may override. +const TTL_DEVICES_MS = 60 * 1000; +const TTL_LIST_MS = 5 * 60 * 1000; +const TTL_PING_MS = 60 * 60 * 1000; + +class TailscaleCoordError extends Error { + constructor(message, { status, body, retryAfter, code } = {}) { + super(message); + this.name = 'TailscaleCoordError'; + this.status = status; + this.body = body; + this.retryAfter = retryAfter; + this.code = code || _codeFromStatus(status); + } +} + +function _codeFromStatus(status) { + if (status === 401 || status === 403) return 'unauthorized'; + if (status === 404) return 'not_found'; + if (status === 429) return 'rate_limited'; + if (status && status >= 500) return 'server_error'; + return 'unknown'; +} + +class TailscaleCoordClient { + constructor({ apiToken, fetchImpl } = {}) { + this.apiToken = apiToken || null; + // Allow injection of a fetch-like function for tests. We only use the + // subset of undici/fetch that maps cleanly to https.request — i.e. + // a function returning { status, body, headers }. + this.fetchImpl = fetchImpl || null; + // cache: Map + this._cache = new Map(); + this._negativeCache = new Map(); + } + + // ---------- public config / introspection ---------- + + isConfigured() { + return !!this.apiToken; + } + + /** + * Set or clear the API token. Clears all caches because validity of + * cached data depends on which token was used to fetch it. + */ + setApiToken(token) { + this.apiToken = token || null; + this._cache.clear(); + this._negativeCache.clear(); + } + + // ---------- cache helpers ---------- + + _cacheGet(key) { + const entry = this._cache.get(key); + if (!entry) return undefined; + if (Date.now() >= entry.expiresAt) { + this._cache.delete(key); + return undefined; + } + return entry.value; + } + _cacheSet(key, value, ttlMs) { + this._cache.set(key, { expiresAt: Date.now() + ttlMs, value }); + this._negativeCache.delete(key); + } + _cacheInvalidate(prefix) { + for (const k of [...this._cache.keys()]) { + if (k.startsWith(prefix)) this._cache.delete(k); + } + } + + // ---------- low-level HTTP ---------- + + async _request(method, path, { body, query } = {}) { + if (!this.apiToken) { + throw new TailscaleCoordError('Tailscale API token not configured', { code: 'not_configured' }); + } + const qs = query + ? '?' + Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)) + .join('&') + : ''; + const urlPath = API_PREFIX + path + qs; + + if (this.fetchImpl) { + // Test path: callers pass a fetch-like impl that returns + // { status, body, headers }. Body may be string (already serialized) + // or undefined. + const headers = { + 'Authorization': 'Bearer ' + this.apiToken, + 'Accept': 'application/json', + }; + const hasBody = body !== undefined; + if (hasBody) headers['Content-Type'] = 'application/json'; + const res = await this.fetchImpl(method, urlPath, { + headers, + body: hasBody ? JSON.stringify(body) : undefined, + }); + return _parseResponse(res); + } + + // Production path: native https. + return await new Promise((resolve, reject) => { + const opts = { + hostname: API_BASE, + port: 443, + path: urlPath, + method, + headers: { + 'Authorization': 'Bearer ' + this.apiToken, + 'Accept': 'application/json', + 'User-Agent': 'DashCaddy/1.14.9 (+tailscale-coord)', + }, + timeout: DEFAULT_TIMEOUT_MS, + }; + let payload = null; + if (body !== undefined) { + payload = Buffer.from(JSON.stringify(body), 'utf8'); + opts.headers['Content-Type'] = 'application/json'; + opts.headers['Content-Length'] = payload.length; + } + const req = https.request(opts, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve({ + status: res.statusCode, + headers: res.headers, + body: raw, + }); + }); + }); + req.on('timeout', () => { + req.destroy(new Error('timeout')); + }); + req.on('error', (e) => { + reject(new TailscaleCoordError('Network error: ' + e.message, { code: 'network_error' })); + }); + if (payload) req.write(payload); + req.end(); + }).then(_parseResponse); + } + + // ---------- public methods ---------- + + /** + * Cheap liveness + token validity check. Hits + * GET /api/v2/tailnet/-/devices + * and derives the tailnet name from the `MagicDNSSuffix` field on the + * first device. Returns an object with { domain, deviceCount }. + * Throws TailscaleCoordError with code=unauthorized on bad token. + * + * (Earlier versions hit /preferences — that endpoint was retired by + * Tailscale in 2026. /devices is the next-lightest read endpoint that + * still exists.) + */ + async ping({ skipCache = false } = {}) { + const cacheKey = 'ping'; + if (!skipCache) { + const cached = this._cacheGet(cacheKey); + if (cached !== undefined) return cached; + } + // When ping cache was stale but listDevices cache might still be fresh, + // we still need a fresh device list to rebuild the ping response — so + // always force-skip the listDevices cache here. + const devs = await this.listDevices({ skipCache: true }); + // The Tailscale API puts the magic-DNS suffix on the `name` field, e.g. + // `dns2-sami.tail3e209.ts.net`. Pull the last 3 components to extract + // `tail3e209.ts.net`. Falls back to magicDNSSuffix if a future API + // version exposes it explicitly. + const firstDev = devs && devs[0]; + let domain = null; + if (firstDev) { + const name = firstDev.name || ''; + // Find the `ts.net` suffix and grab it + the segment before it. + // Real tailnet suffixes are `tailXXXXX.ts.net` (3 components) or the + // user's custom domain (could be 2+). Use a regex that captures + // ".ts.net" or the last 2+ dot-separated parts of name. + const m = name.match(/([a-z0-9-]+\.ts\.net)$/i); + if (m) domain = m[1]; + else if (firstDev.magicDNSSuffix) domain = firstDev.magicDNSSuffix; + } + const result = { domain, deviceCount: devs.length }; + this._cacheSet(cacheKey, result, TTL_PING_MS); + return result; + } + + /** + * List all devices in the tailnet. Returns the `devices` array from + * GET /api/v2/tailnet/-/devices + * Cached for TTL_DEVICES_MS. + */ + async listDevices({ skipCache = false } = {}) { + const cacheKey = 'devices:list'; + if (!skipCache) { + const cached = this._cacheGet(cacheKey); + if (cached !== undefined) return cached; + } + const data = await this._request('GET', '/tailnet/-/devices'); + const devices = data.devices || []; + this._cacheSet(cacheKey, devices, TTL_DEVICES_MS); + return devices; + } + + /** + * Get one device by ID. NOT cached — call sites already have the device + * list locally and want fresh data. + */ + async getDevice(deviceId) { + if (!deviceId) throw new TailscaleCoordError('deviceId required', { code: 'bad_input' }); + const data = await this._request('GET', '/device/' + encodeURIComponent(deviceId)); + return data; + } + + /** + * Delete a device from the tailnet. Invalidates device caches. + * DELETE /api/v2/device/{deviceId} + * Returns { success: true } on 200. + */ + async deleteDevice(deviceId) { + if (!deviceId) throw new TailscaleCoordError('deviceId required', { code: 'bad_input' }); + await this._request('DELETE', '/device/' + encodeURIComponent(deviceId)); + this._cacheInvalidate('devices:'); + return { success: true }; + } + + /** + * Create a pre-auth key. Used by the share-invite flow (Phase 2). + * POST /api/v2/tailnet/-/keys + * Body fields accepted by Tailscale (only the ones we use): + * - reusable: bool, default false + * - ephemeral: bool, default false + * - preauthorized: bool, default true (device joins without admin approval) + * - tags: string[], e.g. ['tag:guest-plex'] + * - expirySeconds: int, max 7776000 (90 days) + * - description: string, free-form + * Returns the full response: { id, key, created, expires, ... }. + * The `key` field is shown to the user EXACTLY ONCE. + * Invalidates keys:list cache. + */ + async createAuthKey(opts = {}) { + const body = { + reusable: opts.reusable !== undefined ? !!opts.reusable : false, + ephemeral: opts.ephemeral !== undefined ? !!opts.ephemeral : false, + preauthorized: opts.preauthorized !== undefined ? !!opts.preauthorized : true, + }; + if (Array.isArray(opts.tags) && opts.tags.length > 0) body.tags = opts.tags; + if (typeof opts.description === 'string') body.description = opts.description; + if (Number.isInteger(opts.expirySeconds) && opts.expirySeconds > 0) { + body.expirySeconds = Math.min(opts.expirySeconds, 7776000); + } + const data = await this._request('POST', '/tailnet/-/keys', { body }); + this._cacheInvalidate('keys:'); + return data; + } + + /** + * List pre-auth keys. Note: the response includes metadata (id, created, + * expires, description, capabilities) but never the secret value. + * GET /api/v2/tailnet/-/keys + */ + async listAuthKeys({ skipCache = false } = {}) { + const cacheKey = 'keys:list'; + if (!skipCache) { + const cached = this._cacheGet(cacheKey); + if (cached !== undefined) return cached; + } + const data = await this._request('GET', '/tailnet/-/keys'); + const keys = data.keys || []; + this._cacheSet(cacheKey, keys, TTL_LIST_MS); + return keys; + } + + /** + * Delete a pre-auth key. Invalidates keys:list cache. + * DELETE /api/v2/keys/{keyId} + */ + async deleteAuthKey(keyId) { + if (!keyId) throw new TailscaleCoordError('keyId required', { code: 'bad_input' }); + await this._request('DELETE', '/keys/' + encodeURIComponent(keyId)); + this._cacheInvalidate('keys:'); + return { success: true }; + } + + /** + * List tailnet users (the human accounts). + * GET /api/v2/tailnet/-/users + */ + async listUsers({ skipCache = false } = {}) { + const cacheKey = 'users:list'; + if (!skipCache) { + const cached = this._cacheGet(cacheKey); + if (cached !== undefined) return cached; + } + const data = await this._request('GET', '/tailnet/-/users'); + const users = data.users || []; + this._cacheSet(cacheKey, users, TTL_LIST_MS); + return users; + } + + /** + * Read the current ACL as a HuJSON string. NOT cached — admins editing + * ACLs want fresh data on every click. + */ + async getAcl() { + return await this._request('GET', '/tailnet/-/acl'); + } + + /** + * Replace the ACL entirely. Caller is responsible for merging/validating + * the HuJSON. Body must be the raw ACL object (not stringified). + */ + async updateAcl(aclObject) { + if (!aclObject || typeof aclObject !== 'object' || Array.isArray(aclObject)) { + throw new TailscaleCoordError('ACL must be a non-array object', { code: 'bad_input' }); + } + return await this._request('POST', '/tailnet/-/acl', { body: aclObject }); + } +} + +function _parseResponse(res) { + const { status, body, headers } = res; + let parsed = body; + const ct = (headers && headers['content-type']) || ''; + if (body && (ct.includes('application/json') || body.startsWith('{') || body.startsWith('['))) { + try { parsed = JSON.parse(body); } catch (_e) { /* leave as string */ } + } + if (status >= 200 && status < 300) return parsed; + // Extract retry-after if present + const retryAfter = headers && (headers['retry-after'] || headers['Retry-After']); + const message = (parsed && parsed.message) || (typeof parsed === 'string' ? parsed : 'HTTP ' + status); + throw new TailscaleCoordError('Tailscale API ' + status + ': ' + message, { + status, + body: parsed, + retryAfter, + }); +} + +module.exports = { + TailscaleCoordClient, + TailscaleCoordError, + // For tests: a factory that builds a new instance. Most call sites use the + // singleton via context, but tests + scripts that want isolation can use + // this directly. + create: (opts) => new TailscaleCoordClient(opts), +}; \ No newline at end of file