feat: add gateway lock, shell completion, and tailscale serve (Tier 4 features 1-3)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateBashCompletion, generateZshCompletion, generateFishCompletion } from './completion.js';
|
||||
|
||||
const EXPECTED_SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion'];
|
||||
|
||||
describe('generateBashCompletion', () => {
|
||||
it('generates valid bash completion', () => {
|
||||
const script = generateBashCompletion();
|
||||
expect(script).toContain('_flynn_completions');
|
||||
expect(script).toContain('complete -F _flynn_completions flynn');
|
||||
});
|
||||
|
||||
it('contains all subcommands', () => {
|
||||
const script = generateBashCompletion();
|
||||
for (const cmd of EXPECTED_SUBCOMMANDS) {
|
||||
expect(script).toContain(cmd);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains global options', () => {
|
||||
const script = generateBashCompletion();
|
||||
expect(script).toContain('--help');
|
||||
expect(script).toContain('--version');
|
||||
});
|
||||
|
||||
it('contains subcommand-specific options', () => {
|
||||
const script = generateBashCompletion();
|
||||
expect(script).toContain('--config');
|
||||
expect(script).toContain('--fullscreen');
|
||||
expect(script).toContain('--no-tools');
|
||||
expect(script).toContain('--raw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateZshCompletion', () => {
|
||||
it('generates valid zsh completion', () => {
|
||||
const script = generateZshCompletion();
|
||||
expect(script).toContain('#compdef flynn');
|
||||
expect(script).toContain('compdef _flynn flynn');
|
||||
});
|
||||
|
||||
it('contains all subcommands', () => {
|
||||
const script = generateZshCompletion();
|
||||
for (const cmd of EXPECTED_SUBCOMMANDS) {
|
||||
expect(script).toContain(cmd);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains subcommand-specific options', () => {
|
||||
const script = generateZshCompletion();
|
||||
expect(script).toContain('--config');
|
||||
expect(script).toContain('--fullscreen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFishCompletion', () => {
|
||||
it('generates valid fish completion', () => {
|
||||
const script = generateFishCompletion();
|
||||
expect(script).toContain('complete -c flynn');
|
||||
expect(script).toContain('__fish_use_subcommand');
|
||||
});
|
||||
|
||||
it('contains all subcommands', () => {
|
||||
const script = generateFishCompletion();
|
||||
for (const cmd of EXPECTED_SUBCOMMANDS) {
|
||||
expect(script).toContain(cmd);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains subcommand-specific options', () => {
|
||||
const script = generateFishCompletion();
|
||||
expect(script).toContain('__fish_seen_subcommand_from start');
|
||||
expect(script).toContain('__fish_seen_subcommand_from tui');
|
||||
});
|
||||
|
||||
it('contains shell type completions for completion subcommand', () => {
|
||||
const script = generateFishCompletion();
|
||||
expect(script).toContain("'bash zsh fish'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Command } from 'commander';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion'];
|
||||
|
||||
const SUBCOMMAND_OPTIONS: Record<string, string[]> = {
|
||||
start: ['-c', '--config'],
|
||||
tui: ['-c', '--config', '-f', '--fullscreen'],
|
||||
send: ['-c', '--config', '--no-tools'],
|
||||
config: ['-c', '--config', '--raw'],
|
||||
completion: ['--install'],
|
||||
};
|
||||
|
||||
export function generateBashCompletion(): string {
|
||||
const subcmds = SUBCOMMANDS.join(' ');
|
||||
const cases = Object.entries(SUBCOMMAND_OPTIONS)
|
||||
.map(([cmd, opts]) => ` ${cmd}) COMPREPLY=( $(compgen -W "${opts.join(' ')}" -- "$cur") ) ;;`)
|
||||
.join('\n');
|
||||
|
||||
return `# Flynn bash completion — generated by 'flynn completion bash'
|
||||
_flynn_completions() {
|
||||
local cur prev subcmd
|
||||
cur="\${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
||||
COMPREPLY=( $(compgen -W "${subcmds} --help --version" -- "$cur") )
|
||||
return
|
||||
fi
|
||||
|
||||
subcmd="\${COMP_WORDS[1]}"
|
||||
case "$subcmd" in
|
||||
${cases}
|
||||
completion) COMPREPLY=( $(compgen -W "bash zsh fish --install" -- "$cur") ) ;;
|
||||
*) COMPREPLY=() ;;
|
||||
esac
|
||||
}
|
||||
complete -F _flynn_completions flynn
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateZshCompletion(): string {
|
||||
const subcmds = SUBCOMMANDS.map(c => `'${c}:${c} subcommand'`).join(' ');
|
||||
const cases = Object.entries(SUBCOMMAND_OPTIONS)
|
||||
.map(([cmd, opts]) => {
|
||||
const flags = opts.filter(o => o.startsWith('--')).map(o => `'${o}'`).join(' ');
|
||||
return ` ${cmd}) compadd ${flags} ;;`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `#compdef flynn
|
||||
# Flynn zsh completion — generated by 'flynn completion zsh'
|
||||
_flynn() {
|
||||
local -a subcmds
|
||||
subcmds=(${subcmds})
|
||||
|
||||
if (( CURRENT == 2 )); then
|
||||
_describe 'command' subcmds
|
||||
return
|
||||
fi
|
||||
|
||||
case "\${words[2]}" in
|
||||
${cases}
|
||||
completion) compadd bash zsh fish '--install' ;;
|
||||
*) ;;
|
||||
esac
|
||||
}
|
||||
compdef _flynn flynn
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateFishCompletion(): string {
|
||||
const lines = [
|
||||
`# Flynn fish completion — generated by 'flynn completion fish'`,
|
||||
`# Disable file completions by default`,
|
||||
`complete -c flynn -f`,
|
||||
``,
|
||||
`# Subcommands`,
|
||||
];
|
||||
|
||||
for (const cmd of SUBCOMMANDS) {
|
||||
lines.push(`complete -c flynn -n '__fish_use_subcommand' -a '${cmd}' -d '${cmd} subcommand'`);
|
||||
}
|
||||
lines.push(`complete -c flynn -n '__fish_use_subcommand' -l help -d 'Show help'`);
|
||||
lines.push(`complete -c flynn -n '__fish_use_subcommand' -l version -d 'Show version'`);
|
||||
lines.push('');
|
||||
lines.push('# Subcommand options');
|
||||
|
||||
for (const [cmd, opts] of Object.entries(SUBCOMMAND_OPTIONS)) {
|
||||
for (const opt of opts) {
|
||||
const flag = opt.replace(/^-+/, '');
|
||||
const short = opt.startsWith('--') ? '' : ` -s ${flag}`;
|
||||
const long = opt.startsWith('--') ? ` -l ${flag}` : '';
|
||||
lines.push(`complete -c flynn -n '__fish_seen_subcommand_from ${cmd}'${short}${long}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`complete -c flynn -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' -d 'Shell type'`);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getInstallPath(shell: string): string {
|
||||
const home = homedir();
|
||||
switch (shell) {
|
||||
case 'bash': return resolve(home, '.local/share/bash-completion/completions/flynn');
|
||||
case 'zsh': return resolve(home, '.zfunc/_flynn');
|
||||
case 'fish': return resolve(home, '.config/fish/completions/flynn.fish');
|
||||
default: throw new Error(`Unknown shell: ${shell}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCompletionCommand(program: Command): void {
|
||||
program
|
||||
.command('completion')
|
||||
.description('Generate shell completion script')
|
||||
.argument('<shell>', 'Shell type: bash, zsh, or fish')
|
||||
.option('--install', 'Install to standard location')
|
||||
.action((shell: string, opts: { install?: boolean }) => {
|
||||
const generators: Record<string, () => string> = {
|
||||
bash: generateBashCompletion,
|
||||
zsh: generateZshCompletion,
|
||||
fish: generateFishCompletion,
|
||||
};
|
||||
|
||||
const generator = generators[shell];
|
||||
if (!generator) {
|
||||
console.error(`Unknown shell: ${shell}. Supported: bash, zsh, fish`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const script = generator();
|
||||
|
||||
if (opts.install) {
|
||||
const installPath = getInstallPath(shell);
|
||||
mkdirSync(resolve(installPath, '..'), { recursive: true });
|
||||
writeFileSync(installPath, script, 'utf-8');
|
||||
console.log(`Completion script installed to: ${installPath}`);
|
||||
if (shell === 'zsh') {
|
||||
console.log(`Ensure ~/.zfunc is in your fpath: fpath=(~/.zfunc $fpath)`);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(script);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -185,6 +185,22 @@ const checkSkills: Check = async (ctx) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkTailscale: Check = async (ctx) => {
|
||||
if (!ctx.config?.server?.tailscale?.serve) {
|
||||
return { status: 'skip', label: 'Tailscale Serve', detail: '(not enabled)' };
|
||||
}
|
||||
try {
|
||||
const { isTailscaleAvailable } = await import('../gateway/tailscale.js');
|
||||
const result = await isTailscaleAvailable();
|
||||
if (result.available) {
|
||||
return { status: 'pass', label: 'Tailscale Serve', detail: `(v${result.version})` };
|
||||
}
|
||||
return { status: 'fail', label: 'Tailscale Serve', detail: result.error ?? 'Tailscale not available' };
|
||||
} catch (err) {
|
||||
return { status: 'fail', label: 'Tailscale Serve', detail: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
const allChecks: Check[] = [
|
||||
checkConfigExists,
|
||||
checkConfigParses,
|
||||
@@ -196,6 +212,7 @@ const allChecks: Check[] = [
|
||||
checkTelegram,
|
||||
checkMcpServers,
|
||||
checkSkills,
|
||||
checkTailscale,
|
||||
];
|
||||
|
||||
/** Run all doctor checks in order. Exported for testing. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerSessionsCommand } from './sessions.js';
|
||||
import { registerDoctorCommand } from './doctor.js';
|
||||
import { registerConfigCommand } from './config-cmd.js';
|
||||
import { registerTuiCommand } from './tui.js';
|
||||
import { registerCompletionCommand } from './completion.js';
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command();
|
||||
@@ -21,6 +22,7 @@ export function createProgram(): Command {
|
||||
registerSessionsCommand(program);
|
||||
registerDoctorCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerCompletionCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
+21
-1
@@ -6,8 +6,26 @@ const telegramSchema = z.object({
|
||||
require_mention: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const tailscaleSchema = z.object({
|
||||
/** Enable Tailscale Serve to expose gateway on tailnet. */
|
||||
serve: z.boolean().default(false),
|
||||
/** Custom hostname for Tailscale Serve. Defaults to machine hostname. */
|
||||
hostname: z.string().optional(),
|
||||
/** Tailscale Serve HTTPS port. */
|
||||
port: z.number().default(443),
|
||||
}).default({});
|
||||
|
||||
const pairingSchema = z.object({
|
||||
/** Enable DM pairing codes for unknown senders. */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Pairing code time-to-live duration (e.g. '5m', '1h'). */
|
||||
code_ttl: z.string().default('5m'),
|
||||
/** Length of generated pairing codes. */
|
||||
code_length: z.number().default(6),
|
||||
}).default({});
|
||||
|
||||
const serverSchema = z.object({
|
||||
tailscale_only: z.boolean().default(true),
|
||||
tailscale: tailscaleSchema,
|
||||
localhost: z.boolean().default(true),
|
||||
port: z.number().default(18800),
|
||||
/** Static bearer token for gateway auth. If set, all connections must provide it. */
|
||||
@@ -16,6 +34,8 @@ const serverSchema = z.object({
|
||||
tailscale_identity: z.boolean().default(false),
|
||||
/** Apply token auth to HTTP requests too (not just WebSocket). Default: true when token is set. */
|
||||
auth_http: z.boolean().default(true),
|
||||
/** Single-client gateway lock. When true, only one WebSocket client can be connected at a time. */
|
||||
lock: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const modelConfigBaseSchema = z.object({
|
||||
|
||||
@@ -811,6 +811,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
tailscaleIdentity: config.server.tailscale_identity,
|
||||
},
|
||||
authHttp: config.server.auth_http,
|
||||
lock: config.server.lock,
|
||||
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
|
||||
config,
|
||||
channelRegistry,
|
||||
@@ -1002,6 +1003,24 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
|
||||
await gateway.start();
|
||||
|
||||
// ── Tailscale Serve ────────────────────────────────────────────
|
||||
if (config.server.tailscale?.serve) {
|
||||
const { startTailscaleServe, stopTailscaleServe } = await import('../gateway/tailscale.js');
|
||||
const tsConfig = {
|
||||
localPort: config.server.port,
|
||||
servePort: config.server.tailscale.port,
|
||||
hostname: config.server.tailscale.hostname,
|
||||
};
|
||||
try {
|
||||
await startTailscaleServe(tsConfig);
|
||||
lifecycle.onShutdown(async () => {
|
||||
await stopTailscaleServe(tsConfig);
|
||||
});
|
||||
} catch {
|
||||
console.warn('Tailscale Serve failed to start — gateway still accessible on local port');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat Monitor ──────────────────────────────────────────
|
||||
const heartbeatMonitor = new HeartbeatMonitor({
|
||||
config: config.automation.heartbeat,
|
||||
|
||||
@@ -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