feat: add gateway lock, shell completion, and tailscale serve (Tier 4 features 1-3)
This commit is contained in:
@@ -444,7 +444,7 @@ describe('config handlers', () => {
|
||||
function makeConfig() {
|
||||
return {
|
||||
telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] },
|
||||
server: { tailscale_only: true, localhost: true, port: 18800 },
|
||||
server: { tailscale: {}, localhost: true, port: 18800 },
|
||||
models: {
|
||||
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
|
||||
fallback_chain: ['anthropic'],
|
||||
@@ -551,7 +551,7 @@ describe('redactConfig – comprehensive credential redaction', () => {
|
||||
telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true },
|
||||
discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true },
|
||||
slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false },
|
||||
server: { tailscale_only: true, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true },
|
||||
server: { tailscale: {}, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true },
|
||||
models: {
|
||||
default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def',
|
||||
fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' },
|
||||
@@ -704,7 +704,7 @@ describe('redactConfig – comprehensive credential redaction', () => {
|
||||
expect((result.slack as any).allowed_channel_ids).toEqual([]);
|
||||
// server
|
||||
expect((result.server as any).port).toBe(18800);
|
||||
expect((result.server as any).tailscale_only).toBe(true);
|
||||
expect((result.server as any).tailscale).toBeDefined();
|
||||
// models
|
||||
expect((result.models as any).default.provider).toBe('anthropic');
|
||||
expect((result.models as any).default.model).toBe('claude');
|
||||
|
||||
@@ -8,6 +8,8 @@ export { LaneQueue } from './lane-queue.js';
|
||||
export { authenticateRequest } from './auth.js';
|
||||
export type { AuthConfig, AuthResult } from './auth.js';
|
||||
export { serveStatic } from './static.js';
|
||||
export { isTailscaleAvailable, startTailscaleServe, stopTailscaleServe } from './tailscale.js';
|
||||
export type { TailscaleServeConfig } from './tailscale.js';
|
||||
export {
|
||||
ErrorCode,
|
||||
isValidRequest,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { AuthConfig } from './auth.js';
|
||||
import {
|
||||
parseMessage,
|
||||
makeError,
|
||||
makeResponse,
|
||||
ErrorCode,
|
||||
type OutboundMessage,
|
||||
} from './protocol.js';
|
||||
@@ -39,6 +40,8 @@ export interface GatewayServerConfig {
|
||||
toolExecutor: ToolExecutor;
|
||||
version?: string;
|
||||
auth?: AuthConfig;
|
||||
/** When true, only one WebSocket client can be connected at a time. */
|
||||
lock?: boolean;
|
||||
/** Whether to apply token auth to HTTP requests too (default: true when token is set). */
|
||||
authHttp?: boolean;
|
||||
uiDir?: string;
|
||||
@@ -159,6 +162,15 @@ export class GatewayServer {
|
||||
this.handleConnection(ws, authResult.identity);
|
||||
});
|
||||
|
||||
// Register system.lock handler (needs access to connectionMap)
|
||||
this.router.register('system.lock', async (request) => {
|
||||
return makeResponse(request.id, {
|
||||
locked: this.config.lock ?? false,
|
||||
activeClients: this.connectionMap.size,
|
||||
maxClients: this.config.lock ? 1 : null,
|
||||
});
|
||||
});
|
||||
|
||||
this.httpServer.listen(port, host, () => {
|
||||
console.log(`Gateway server listening on ${host}:${port}`);
|
||||
resolve();
|
||||
@@ -199,6 +211,12 @@ export class GatewayServer {
|
||||
}
|
||||
|
||||
private handleConnection(ws: WebSocket, identity?: string): void {
|
||||
// Gateway lock — reject if another client is already connected
|
||||
if (this.config.lock && this.connectionMap.size > 0) {
|
||||
ws.close(4003, 'Gateway locked — another client is already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = randomUUID();
|
||||
this.sessionBridge.connect(connectionId);
|
||||
this.connectionMap.set(ws, connectionId);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
// Mock child_process before importing module
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecFile = vi.mocked(execFile);
|
||||
|
||||
describe('tailscale', () => {
|
||||
// Import after mocking
|
||||
let isTailscaleAvailable: typeof import('./tailscale.js').isTailscaleAvailable;
|
||||
let startTailscaleServe: typeof import('./tailscale.js').startTailscaleServe;
|
||||
let stopTailscaleServe: typeof import('./tailscale.js').stopTailscaleServe;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('./tailscale.js');
|
||||
isTailscaleAvailable = mod.isTailscaleAvailable;
|
||||
startTailscaleServe = mod.startTailscaleServe;
|
||||
stopTailscaleServe = mod.stopTailscaleServe;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('isTailscaleAvailable', () => {
|
||||
it('returns available when tailscale CLI works', async () => {
|
||||
mockExecFile
|
||||
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(null, '1.62.0', '');
|
||||
return {} as any;
|
||||
})
|
||||
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(null, '{}', '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
const result = await isTailscaleAvailable();
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe('1.62.0');
|
||||
});
|
||||
|
||||
it('returns unavailable when tailscale CLI fails', async () => {
|
||||
mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(new Error('command not found'), '', 'command not found');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
const result = await isTailscaleAvailable();
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startTailscaleServe', () => {
|
||||
it('calls tailscale serve with correct args', async () => {
|
||||
mockExecFile
|
||||
// serve command
|
||||
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(null, '', '');
|
||||
return {} as any;
|
||||
})
|
||||
// status for hostname
|
||||
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(null, JSON.stringify({ Self: { DNSName: 'myhost.tailnet.ts.net.' } }), '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
const url = await startTailscaleServe({ localPort: 18800 });
|
||||
expect(url).toBe('https://myhost.tailnet.ts.net');
|
||||
|
||||
const serveCall = mockExecFile.mock.calls[0];
|
||||
expect(serveCall[0]).toBe('tailscale');
|
||||
expect(serveCall[1]).toEqual(['serve', '--bg', '--https=443', 'http://127.0.0.1:18800']);
|
||||
});
|
||||
|
||||
it('uses custom serve port', async () => {
|
||||
mockExecFile
|
||||
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(null, '', '');
|
||||
return {} as any;
|
||||
})
|
||||
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(null, JSON.stringify({ Self: { DNSName: 'myhost.tailnet.ts.net.' } }), '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
const url = await startTailscaleServe({ localPort: 18800, servePort: 8443 });
|
||||
expect(url).toBe('https://myhost.tailnet.ts.net:8443');
|
||||
|
||||
const serveCall = mockExecFile.mock.calls[0];
|
||||
expect(serveCall[1]).toEqual(['serve', '--bg', '--https=8443', 'http://127.0.0.1:18800']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopTailscaleServe', () => {
|
||||
it('calls tailscale serve off', async () => {
|
||||
mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(null, '', '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
await stopTailscaleServe({ localPort: 18800 });
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledOnce();
|
||||
const call = mockExecFile.mock.calls[0];
|
||||
expect(call[0]).toBe('tailscale');
|
||||
expect(call[1]).toEqual(['serve', '--https=443', 'off']);
|
||||
});
|
||||
|
||||
it('does not throw on failure', async () => {
|
||||
mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
|
||||
callback(new Error('failed'), '', 'failed');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await stopTailscaleServe({ localPort: 18800 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
export interface TailscaleServeConfig {
|
||||
/** The gateway's local port (e.g. 18800). */
|
||||
localPort: number;
|
||||
/** Tailscale Serve HTTPS port (default 443). */
|
||||
servePort?: number;
|
||||
/** Custom hostname (default: machine hostname). */
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
/** Execute a command with a timeout, returning stdout. */
|
||||
function exec(cmd: string, args: string[], timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`${cmd} ${args.join(' ')} failed: ${stderr || error.message}`));
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Tailscale CLI is available and the daemon is running.
|
||||
*/
|
||||
export async function isTailscaleAvailable(): Promise<{ available: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
const version = await exec('tailscale', ['version', '--short']);
|
||||
// Check if daemon is running by getting status
|
||||
await exec('tailscale', ['status', '--json']);
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return { available: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MagicDNS hostname from Tailscale status.
|
||||
*/
|
||||
async function getTailscaleHostname(): Promise<string | undefined> {
|
||||
try {
|
||||
const statusJson = await exec('tailscale', ['status', '--json']);
|
||||
const status = JSON.parse(statusJson) as { Self?: { DNSName?: string } };
|
||||
const dnsName = status.Self?.DNSName;
|
||||
// DNSName ends with a trailing dot — strip it
|
||||
return dnsName?.replace(/\.$/, '');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Tailscale Serve to expose the local gateway port on the tailnet.
|
||||
*
|
||||
* Runs: `tailscale serve --bg --https=<servePort> http://127.0.0.1:<localPort>`
|
||||
*/
|
||||
export async function startTailscaleServe(config: TailscaleServeConfig): Promise<string | undefined> {
|
||||
const servePort = config.servePort ?? 443;
|
||||
const target = `http://127.0.0.1:${config.localPort}`;
|
||||
|
||||
try {
|
||||
await exec('tailscale', ['serve', '--bg', `--https=${servePort}`, target]);
|
||||
const hostname = config.hostname ?? await getTailscaleHostname();
|
||||
if (hostname) {
|
||||
const url = servePort === 443 ? `https://${hostname}` : `https://${hostname}:${servePort}`;
|
||||
console.log(`Tailscale Serve: Flynn available at ${url}`);
|
||||
return url;
|
||||
}
|
||||
console.log('Tailscale Serve: started (could not determine hostname)');
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
console.error('Failed to start Tailscale Serve:', err instanceof Error ? err.message : err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Tailscale Serve for the configured port.
|
||||
*
|
||||
* Runs: `tailscale serve --https=<servePort> off`
|
||||
*/
|
||||
export async function stopTailscaleServe(config: TailscaleServeConfig): Promise<void> {
|
||||
const servePort = config.servePort ?? 443;
|
||||
|
||||
try {
|
||||
await exec('tailscale', ['serve', `--https=${servePort}`, 'off']);
|
||||
console.log('Tailscale Serve: stopped');
|
||||
} catch (err) {
|
||||
// Best effort — don't fail shutdown
|
||||
console.error('Failed to stop Tailscale Serve:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,14 @@ export class FlynnClient {
|
||||
this._handleMessage(event.data);
|
||||
};
|
||||
|
||||
this._ws.onclose = () => {
|
||||
this._ws.onclose = (event) => {
|
||||
this._ws = null;
|
||||
// Gateway lock — show specific message and don't auto-reconnect
|
||||
if (event.code === 4003) {
|
||||
this._setStatus('locked');
|
||||
this._autoReconnect = false;
|
||||
return;
|
||||
}
|
||||
this._setStatus('disconnected');
|
||||
// Reject all pending requests
|
||||
for (const [id, pending] of this._pending) {
|
||||
|
||||
Reference in New Issue
Block a user