Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added artifacts/feature-previews/diagnostics-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ import {
createTmuxOutputActivityMonitor,
getCurrentTmuxPaneIdentity,
getCurrentTmuxPaneIdentityState,
getGjcWatcherHealth,
initializeSessionsWatcher,
onTranscriptChanged,
readTmuxPaneIdentity,
runTmux,
} from '@/modules/providers/index.js';
import { createWebSocketServer } from '@/modules/websocket/index.js';
import { createDiagnosticsRouter, createDiagnosticsService } from '@/modules/diagnostics/index.js';
import { createFleetHubLifecycle, createLocalFleetHubRuntime } from '@/modules/fleet/hub/connection/index.js';
import { createFleetPeerLifecycle, createLocalFleetPeerRuntime } from '@/modules/fleet/peer/index.js';
import { fleetRuntimeEnabled, stopFleetRuntimeServices } from '@/modules/fleet/runtime-lifecycle.js';
Expand Down Expand Up @@ -356,6 +358,17 @@ app.get('/health', (req, res) => {
// Authentication routes (public)
app.use('/api/auth', authRoutes);

// Diagnostics owns authentication so even rejected reads receive no-store.
const ownerDiagnostics = createDiagnosticsService({
collector: () => discoveryCollector,
watcher: getGjcWatcherHealth,
});
app.use('/api/settings/diagnostics', createDiagnosticsRouter({
authMode: AUTH_MODE,
authenticate: authenticateToken,
read: ownerDiagnostics.read,
}));

// Machine pairing carries signed installation identity instead of browser auth.
// Every other fleet-management route must first resolve the browser owner.
app.use('/api/fleet', (req, res, next) => (
Expand Down
55 changes: 55 additions & 0 deletions server/modules/diagnostics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Owner diagnostics API

`GET /api/settings/diagnostics` is a local, display-only settings endpoint.
It runs the existing browser authentication middleware and fleet owner check:
password-authenticated owners, Tailscale `owner`/`local`, or an implicit owner
on a loopback connection in `none` mode. An absent principal returns 401;
an authenticated non-owner returns 403. No client-supplied role, address,
refresh flag, session identity, or revision grants access. Every response,
including authentication rejection and failures, has `Cache-Control: no-store`.
The route is mounted before generic `/api` middleware so rejection responses
also receive that header. `/health` is unchanged.

The schema is defined in `shared/diagnostics.ts`. One service instance caches
the projected summary for 2 seconds, across callers. Reading or refreshing it
never calls discovery `tick`, `ensureFresh`, `forceRefresh`, filesystem APIs,
tmux, provider processes, watcher lifecycle methods, or action verifiers. It
does not activate idle discovery. It owns no timers or listeners, and performs
no persistence or logging. Provider failures are converted to fixed unavailable
states; an unexpected route-level failure returns 503 `diagnostics_unavailable`.

Fields are explicitly constructed from existing cached metadata:

- Collector timer/active/in-flight/disposed state; cached observation age;
age of the last full scan where both lanes succeeded; lane result and
consecutive failure counts. Observation freshness uses 30 seconds; bootstrap
with no observation is `waiting`, never healthy. An observation can be recent
while a lane is failing. Cheap host observations do not reset full-scan age.
- First 1,000 retained discovery rows, counted by lane and stale presence, plus
counts of the seven allowlisted `ProviderConnectionIssue` codes. Truncation is
explicit. These counts describe retained rows, not lifetime failure events.
- Existing GJC watcher failure/degraded/watch-limit signals. The accessor does
not establish watcher liveness, so zero failures is `no_failures_reported`.
- Node's platform `performance.eventLoopUtilization()` cumulative active share
since process start, rounded to four decimals. This is not CPU load, event-loop
delay, or a current latency measure. Sampling creates no histogram or timer.

Ages are capped at seven days; counters at 1,000,000. Invalid/future observation
times yield unknown age, not fresh health. Summaries include their capture time
and cache TTL. The browser loads once per tab mount and offers a manual refresh;
it does not poll, persist results, or send scans/restarts/agent inputs. Requests
are aborted on unmount or after 10 seconds. English and Korean strings are
provided; other locales use the existing English fallback.

Never add object spreads of provider objects, raw errors, paths, command argv,
transcripts, socket/pane/process/session identities, labels, credentials, or
tokens to this payload. These diagnostics cannot authorize or identify an action
target. Recovery guidance preserves exact-identity checks and points users to
verified terminal attach or their existing terminal when binding is uncertain.

Focused checks use the existing test runner's `runTests` with
`server/tsconfig.json` for this module's tests and
`providers/tests/discovery-collector.service.test.ts`, and `tsconfig.json` for
`DiagnosticsSettingsTab.test.tsx` and `DiagnosticsSettingsTab.mounted.test.tsx`.
Use `npm run verify` and the repository CUA harness for full repository and
desktop/mobile browser regression checks.
112 changes: 112 additions & 0 deletions server/modules/diagnostics/diagnostics.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import test from 'node:test';

import express from 'express';
import type { RequestHandler } from 'express';

import { createDiagnosticsRouter } from './diagnostics.routes.js';
import { createDiagnosticsService } from './diagnostics.service.js';

async function fixture(options: {
authMode?: 'none' | 'password' | 'tailscale';
remoteAddress?: string;
fail?: boolean;
} = {}) {
let reads = 0;
const app = express();
const authenticate: RequestHandler = (request, response, next) => {
if (request.headers['x-test-auth'] === 'rejected') {
response.status(401).json({ error: 'authentication_required' });
return;
}
if (request.headers['x-test-auth']) {
Object.defineProperty(request, 'user', { value: {
id: 1, tailscaleRole: request.headers['x-test-auth'],
} });
}
if (options.remoteAddress) Object.defineProperty(request.socket, 'remoteAddress', { value: options.remoteAddress });
next();
};
const service = createDiagnosticsService({
collector: () => null, watcher: () => null, eventLoopUtilization: () => 0.2,
});
app.use('/api/settings/diagnostics', createDiagnosticsRouter({
authMode: options.authMode ?? 'tailscale', authenticate,
read: () => {
reads++;
if (options.fail) throw new Error('PRIVATE_ERROR /home/private/token');
return service.read();
},
}));
const server = createServer(app);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
assert.ok(address && typeof address !== 'string');
return {
url: `http://127.0.0.1:${address.port}/api/settings/diagnostics`,
reads: () => reads,
close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}

test('unauthenticated and non-owner reads fail closed with no-store before collecting data', async (context) => {
const subject = await fixture();
context.after(subject.close);
for (const [role, status] of [['', 401], ['rejected', 401], ['user', 403], ['member', 403]] as const) {
const response = await fetch(`${subject.url}?owner=true&refresh=true`, {
headers: { 'x-test-auth': role, 'x-forwarded-for': '127.0.0.1' },
});
assert.equal(response.status, status);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.deepEqual(await response.json(), { error: status === 401 ? 'authentication_required' : 'owner_required' });
}
assert.equal(subject.reads(), 0);
});

test('Tailscale owner/local and password principals may read a bounded summary', async (context) => {
for (const authMode of ['tailscale', 'password'] as const) {
const subject = await fixture({ authMode });
context.after(subject.close);
for (const role of ['owner', 'local']) {
const response = await fetch(subject.url, { headers: { 'x-test-auth': role } });
assert.equal(response.status, 200);
assert.equal(response.headers.get('cache-control'), 'no-store');
const body = await response.text();
assert.equal(JSON.parse(body).schemaVersion, 1);
assert.doesNotMatch(body, /PRIVATE|socketPath|transcriptPaths|providerSessionId|password|token/);
}
}
});

test('implicit ownership requires actual loopback and ignores forwarded address claims', async (context) => {
for (const [remoteAddress, status] of [['127.0.0.1', 200], ['::1', 200], ['100.64.0.9', 403]] as const) {
const subject = await fixture({ authMode: 'none', remoteAddress });
context.after(subject.close);
const response = await fetch(subject.url, { headers: { 'x-test-auth': 'local', 'x-forwarded-for': '127.0.0.1' } });
assert.equal(response.status, status);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.equal(subject.reads(), status === 200 ? 1 : 0);
await response.body?.cancel();
}
});

test('unexpected summary errors return generic 503 without private diagnostics', async (context) => {
const subject = await fixture({ fail: true });
context.after(subject.close);
const response = await fetch(subject.url, { headers: { 'x-test-auth': 'owner' } });
assert.equal(response.status, 503);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.deepEqual(await response.json(), { error: 'diagnostics_unavailable' });
});

test('there is no mutation or restart API', async (context) => {
const subject = await fixture();
context.after(subject.close);
for (const method of ['POST', 'PUT', 'DELETE']) {
const response = await fetch(subject.url, { method, headers: { 'x-test-auth': 'owner' } });
assert.equal(response.status, 404);
await response.body?.cancel();
}
assert.equal(subject.reads(), 0);
});
36 changes: 36 additions & 0 deletions server/modules/diagnostics/diagnostics.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import express from 'express';
import type { RequestHandler } from 'express';

import { authorizeFleetBrowserRequest } from '@/modules/fleet/index.js';

import type { OwnerDiagnostics } from '../../../shared/diagnostics.js';

export function createDiagnosticsRouter(dependencies: {
authMode: 'none' | 'password' | 'tailscale';
authenticate: RequestHandler;
read: () => OwnerDiagnostics;
}): express.Router {
const router = express.Router();
router.use((_request, response, next) => {
response.set('Cache-Control', 'no-store');
next();
});
router.use(dependencies.authenticate);
router.get('/', (request, response) => {
const owner = authorizeFleetBrowserRequest(request, dependencies.authMode);
if (!('user' in request) || !request.user) {
response.status(401).json({ error: 'authentication_required' });
return;
}
if (!owner) {
response.status(403).json({ error: 'owner_required' });
return;
}
try {
response.json(dependencies.read());
} catch {
response.status(503).json({ error: 'diagnostics_unavailable' });
}
});
return router;
}
Loading
Loading