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[] = [
|
const allChecks: Check[] = [
|
||||||
checkConfigExists,
|
checkConfigExists,
|
||||||
checkConfigParses,
|
checkConfigParses,
|
||||||
@@ -196,6 +212,7 @@ const allChecks: Check[] = [
|
|||||||
checkTelegram,
|
checkTelegram,
|
||||||
checkMcpServers,
|
checkMcpServers,
|
||||||
checkSkills,
|
checkSkills,
|
||||||
|
checkTailscale,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Run all doctor checks in order. Exported for testing. */
|
/** Run all doctor checks in order. Exported for testing. */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { registerSessionsCommand } from './sessions.js';
|
|||||||
import { registerDoctorCommand } from './doctor.js';
|
import { registerDoctorCommand } from './doctor.js';
|
||||||
import { registerConfigCommand } from './config-cmd.js';
|
import { registerConfigCommand } from './config-cmd.js';
|
||||||
import { registerTuiCommand } from './tui.js';
|
import { registerTuiCommand } from './tui.js';
|
||||||
|
import { registerCompletionCommand } from './completion.js';
|
||||||
|
|
||||||
export function createProgram(): Command {
|
export function createProgram(): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -21,6 +22,7 @@ export function createProgram(): Command {
|
|||||||
registerSessionsCommand(program);
|
registerSessionsCommand(program);
|
||||||
registerDoctorCommand(program);
|
registerDoctorCommand(program);
|
||||||
registerConfigCommand(program);
|
registerConfigCommand(program);
|
||||||
|
registerCompletionCommand(program);
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-1
@@ -6,8 +6,26 @@ const telegramSchema = z.object({
|
|||||||
require_mention: z.boolean().default(true),
|
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({
|
const serverSchema = z.object({
|
||||||
tailscale_only: z.boolean().default(true),
|
tailscale: tailscaleSchema,
|
||||||
localhost: z.boolean().default(true),
|
localhost: z.boolean().default(true),
|
||||||
port: z.number().default(18800),
|
port: z.number().default(18800),
|
||||||
/** Static bearer token for gateway auth. If set, all connections must provide it. */
|
/** 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),
|
tailscale_identity: z.boolean().default(false),
|
||||||
/** Apply token auth to HTTP requests too (not just WebSocket). Default: true when token is set. */
|
/** Apply token auth to HTTP requests too (not just WebSocket). Default: true when token is set. */
|
||||||
auth_http: z.boolean().default(true),
|
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({
|
const modelConfigBaseSchema = z.object({
|
||||||
|
|||||||
@@ -811,6 +811,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
tailscaleIdentity: config.server.tailscale_identity,
|
tailscaleIdentity: config.server.tailscale_identity,
|
||||||
},
|
},
|
||||||
authHttp: config.server.auth_http,
|
authHttp: config.server.auth_http,
|
||||||
|
lock: config.server.lock,
|
||||||
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
|
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
|
||||||
config,
|
config,
|
||||||
channelRegistry,
|
channelRegistry,
|
||||||
@@ -1002,6 +1003,24 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
|
|
||||||
await gateway.start();
|
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 ──────────────────────────────────────────
|
// ── Heartbeat Monitor ──────────────────────────────────────────
|
||||||
const heartbeatMonitor = new HeartbeatMonitor({
|
const heartbeatMonitor = new HeartbeatMonitor({
|
||||||
config: config.automation.heartbeat,
|
config: config.automation.heartbeat,
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ describe('config handlers', () => {
|
|||||||
function makeConfig() {
|
function makeConfig() {
|
||||||
return {
|
return {
|
||||||
telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] },
|
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: {
|
models: {
|
||||||
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
|
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
|
||||||
fallback_chain: ['anthropic'],
|
fallback_chain: ['anthropic'],
|
||||||
@@ -551,7 +551,7 @@ describe('redactConfig – comprehensive credential redaction', () => {
|
|||||||
telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true },
|
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 },
|
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 },
|
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: {
|
models: {
|
||||||
default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def',
|
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' },
|
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([]);
|
expect((result.slack as any).allowed_channel_ids).toEqual([]);
|
||||||
// server
|
// server
|
||||||
expect((result.server as any).port).toBe(18800);
|
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
|
// models
|
||||||
expect((result.models as any).default.provider).toBe('anthropic');
|
expect((result.models as any).default.provider).toBe('anthropic');
|
||||||
expect((result.models as any).default.model).toBe('claude');
|
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 { authenticateRequest } from './auth.js';
|
||||||
export type { AuthConfig, AuthResult } from './auth.js';
|
export type { AuthConfig, AuthResult } from './auth.js';
|
||||||
export { serveStatic } from './static.js';
|
export { serveStatic } from './static.js';
|
||||||
|
export { isTailscaleAvailable, startTailscaleServe, stopTailscaleServe } from './tailscale.js';
|
||||||
|
export type { TailscaleServeConfig } from './tailscale.js';
|
||||||
export {
|
export {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
isValidRequest,
|
isValidRequest,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { AuthConfig } from './auth.js';
|
|||||||
import {
|
import {
|
||||||
parseMessage,
|
parseMessage,
|
||||||
makeError,
|
makeError,
|
||||||
|
makeResponse,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type OutboundMessage,
|
type OutboundMessage,
|
||||||
} from './protocol.js';
|
} from './protocol.js';
|
||||||
@@ -39,6 +40,8 @@ export interface GatewayServerConfig {
|
|||||||
toolExecutor: ToolExecutor;
|
toolExecutor: ToolExecutor;
|
||||||
version?: string;
|
version?: string;
|
||||||
auth?: AuthConfig;
|
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). */
|
/** Whether to apply token auth to HTTP requests too (default: true when token is set). */
|
||||||
authHttp?: boolean;
|
authHttp?: boolean;
|
||||||
uiDir?: string;
|
uiDir?: string;
|
||||||
@@ -159,6 +162,15 @@ export class GatewayServer {
|
|||||||
this.handleConnection(ws, authResult.identity);
|
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, () => {
|
this.httpServer.listen(port, host, () => {
|
||||||
console.log(`Gateway server listening on ${host}:${port}`);
|
console.log(`Gateway server listening on ${host}:${port}`);
|
||||||
resolve();
|
resolve();
|
||||||
@@ -199,6 +211,12 @@ export class GatewayServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleConnection(ws: WebSocket, identity?: string): void {
|
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();
|
const connectionId = randomUUID();
|
||||||
this.sessionBridge.connect(connectionId);
|
this.sessionBridge.connect(connectionId);
|
||||||
this.connectionMap.set(ws, 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._handleMessage(event.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
this._ws.onclose = () => {
|
this._ws.onclose = (event) => {
|
||||||
this._ws = null;
|
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');
|
this._setStatus('disconnected');
|
||||||
// Reject all pending requests
|
// Reject all pending requests
|
||||||
for (const [id, pending] of this._pending) {
|
for (const [id, pending] of this._pending) {
|
||||||
|
|||||||
Reference in New Issue
Block a user