DC-043: tailscale coordination API client + admin/settings routes
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:
+26
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user