DC-043: tailscale coordination API client + admin/settings routes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Companion to DC-042 (tailscale-manager). Adds the write-side of Tailscale
integration — REST client for api.tailscale.com that lets DashCaddy
manage its own tailnet (devices, pre-auth keys, users, ACL).

src/managers/tailscale-coord.js — new module:
  * Ping, list/get/delete devices, create/list/delete pre-auth keys,
    list users, get/update ACL
  * 5min read cache, 60s device-list cache, 1hr ping cache
  * Cache invalidation on writes
  * Graceful {configured:false} when no token
  * TailscaleCoordError class with code mapping (unauthorized, not_found,
    rate_limited, server_error)
  * fetchImpl injection point for tests; native https in production
  * 45 unit tests covering all paths

src/context/index.js — new tailscaleCoord namespace:
  * getClient() lazy-builds a fresh client each call (token re-read from
    credentialManager so settings changes take effect without restart)
  * loadMetadata/saveMetadata for tailscale-config.json
  * setApiToken/hasApiToken wrappers around credentialManager

routes/tailscale-admin.js — new routes:
  * GET    /api/v1/tailscale/settings         — config status, never the token
  * PUT    /api/v1/tailscale/settings         — validate + store encrypted
  * DELETE /api/v1/tailscale/settings         — wipe token + metadata
  * POST   /api/v1/tailscale/settings/test    — ping without saving
  * GET    /api/v1/tailscale/admin/devices    — full device list
  * 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
  * 29 route integration tests with supertest

src/app.js — wired the new router into the /api/v1/tailscale mount.

BACKLOG.md — DC-042 marked done, DC-043 added with full design notes.
            Note: deliberately no token auto-rotation — Tailscale API keys
            don't auto-renew, and silently re-issuing admin credentials
            would erode the audit-trail checkpoint that token expiry
            provides.

Tests: 1167 -> 1212 (+45), all green. Lint clean.
This commit is contained in:
Krystie
2026-07-06 21:28:03 -07:00
parent d04238621f
commit 6fb4f9b169
7 changed files with 1937 additions and 0 deletions
+26
View File
@@ -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. - **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. - **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 <sha>` 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) ## Backlog note (2026-07-05)
@@ -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();
});
});
@@ -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 <token> 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');
});
});
+257
View File
@@ -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;
};
+8
View File
@@ -64,6 +64,7 @@ const notificationRoutes = require('../routes/notifications');
const containerRoutes = require('../routes/containers'); const containerRoutes = require('../routes/containers');
const serviceRoutes = require('../routes/services'); const serviceRoutes = require('../routes/services');
const tailscaleRoutes = require('../routes/tailscale'); const tailscaleRoutes = require('../routes/tailscale');
const tailscaleAdminRoutes = require('../routes/tailscale-admin');
const sitesRoutes = require('../routes/sites'); const sitesRoutes = require('../routes/sites');
const credentialsRoutes = require('../routes/credentials'); const credentialsRoutes = require('../routes/credentials');
const arrRoutes = require('../routes/arr'); const arrRoutes = require('../routes/arr');
@@ -537,6 +538,13 @@ async function createApp() {
SERVICES_FILE: ctx.SERVICES_FILE, SERVICES_FILE: ctx.SERVICES_FILE,
log: ctx.log 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({ apiRouter.use(sitesRoutes({
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler,
ok: ctx.ok, ok: ctx.ok,
+61
View File
@@ -8,6 +8,8 @@ const { createDnsContext } = require('./dns');
const { createSessionContext } = require('./session'); const { createSessionContext } = require('./session');
const NotificationManager = require('../managers/notification-manager'); const NotificationManager = require('../managers/notification-manager');
const tailscaleManager = require('../managers/tailscale-manager'); const tailscaleManager = require('../managers/tailscale-manager');
const { TailscaleCoordClient } = require('../managers/tailscale-coord');
const fs = require('fs');
/** /**
* Assemble the full application context * Assemble the full application context
@@ -96,6 +98,34 @@ function assembleContext({
config: siteConfig 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) // Assemble flat context (temporary - routes still expect this)
// Note: tailscale interface detection lives in src/utilities/network-detector.js // Note: tailscale interface detection lives in src/utilities/network-detector.js
// (DC-031). The empty `tailscale` stub previously wired here was dead code // (DC-031). The empty `tailscale` stub previously wired here was dead code
@@ -122,6 +152,37 @@ function assembleContext({
stopSyncTimer: tailscaleManager.stopSyncTimer, stopSyncTimer: tailscaleManager.stopSyncTimer,
syncAPI: tailscaleManager.syncAPI, 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 and config
app, app,
@@ -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 <apiToken>
* 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<cacheKey, { expiresAt: number, value: any }>
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
// "<segment>.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),
};