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
12 changes: 9 additions & 3 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { config } from './config.js';
import { listenWithProbe } from './utils/listen-with-probe.js';
import {
generateToken, parseCookie, buildSetCookie, verifyHmac, cliAuthBind, decideDashboardAuth,
loadPersistedToken, persistToken, loadDashboardSecret, loadOrCreateDashboardSecret,
loadPersistedToken, loadOrCreatePersistedToken, persistToken,
loadDashboardSecret, loadOrCreateDashboardSecret,
} from './dashboard/auth.js';
import { DaemonRegistry, botsRosterSignature } from './dashboard/registry.js';
import { Aggregator, subscribeDaemon } from './dashboard/aggregator.js';
Expand Down Expand Up @@ -232,8 +233,9 @@ function loadOrCreateSecret(): string {
}

// The active dashboard token is persisted to disk so a previously-issued
// dashboard URL survives `botmux restart`; only `botmux dashboard` (the
// /__cli/rotate endpoint) rotates it and thereby invalidates the old link.
// dashboard URL survives `botmux restart`. A platform-bound dashboard creates
// the first token on startup; only `botmux dashboard` (the /__cli/rotate
// endpoint) replaces it and thereby invalidates the old link.
// The start/restart hint reads it via the non-rotating /__cli/current endpoint
// so it can show the live link without invalidating it.
let activeToken: string | null = loadPersistedToken(TOKEN_PATH);
Expand Down Expand Up @@ -5189,6 +5191,10 @@ function startPlatformTunnelIfBound(): void {
try {
const binding = readPlatformBinding();
if (!binding) return;
if (!activeToken) {
activeToken = loadOrCreatePersistedToken(TOKEN_PATH);
logger.info('[platform-tunnel] 已初始化 dashboard token');
}
const version = readBotmuxVersion();
platformTunnel = startPlatformTunnelClient({
binding,
Expand Down
9 changes: 9 additions & 0 deletions src/dashboard/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ export function persistToken(tokenPath: string, token: string): void {
chmodSync(tokenPath, 0o600);
}

/** Load the active token, creating and persisting the first one when absent. */
export function loadOrCreatePersistedToken(tokenPath: string): string {
const existing = loadPersistedToken(tokenPath);
if (existing) return existing;
const token = generateToken();
persistToken(tokenPath, token);
return token;
}

/** Extract `botmux_dashboard_token` value from a Cookie header. */
export function parseCookie(header: string | undefined): string | undefined {
if (!header) return undefined;
Expand Down
16 changes: 15 additions & 1 deletion test/dashboard-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
verifyHmac, generateToken, parseCookie, decideDashboardAuth,
loadPersistedToken, persistToken, loadDashboardSecret, loadOrCreateDashboardSecret,
loadPersistedToken, loadOrCreatePersistedToken, persistToken,
loadDashboardSecret, loadOrCreateDashboardSecret,
} from '../src/dashboard/auth.js';

const SECRET = 'a'.repeat(43); // base64url 32 bytes
Expand Down Expand Up @@ -83,6 +84,19 @@ describe('token persistence (survives restart, rotates only on `botmux dashboard
expect(loadPersistedToken(tokenPath)).toBe(tok);
});

it('loadOrCreatePersistedToken creates and persists a missing token', () => {
const tok = loadOrCreatePersistedToken(tokenPath);
expect(tok).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(loadPersistedToken(tokenPath)).toBe(tok);
expect(statSync(tokenPath).mode & 0o777).toBe(0o600);
});

it('loadOrCreatePersistedToken reuses an existing token without rotating it', () => {
persistToken(tokenPath, 'existing-token');
expect(loadOrCreatePersistedToken(tokenPath)).toBe('existing-token');
expect(loadPersistedToken(tokenPath)).toBe('existing-token');
});

it('persisted token survives a simulated restart (same file, new process)', () => {
const tok = generateToken();
persistToken(tokenPath, tok);
Expand Down