From 323bef05267ad450099751bf12c089a3f2e7a5e3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 01:48:59 -0800 Subject: [PATCH] feat(gateway): add optional bonjour/mdns discovery --- README.md | 21 ++++++ config/default.yaml | 7 ++ src/config/schema.test.ts | 28 +++++++ src/config/schema.ts | 13 ++++ src/daemon/services.ts | 6 ++ src/gateway/discovery.test.ts | 102 +++++++++++++++++++++++++ src/gateway/discovery.ts | 107 +++++++++++++++++++++++++++ src/gateway/server.discovery.test.ts | 76 +++++++++++++++++++ src/gateway/server.ts | 51 ++++++++++++- 9 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 src/gateway/discovery.test.ts create mode 100644 src/gateway/discovery.ts create mode 100644 src/gateway/server.discovery.test.ts diff --git a/README.md b/README.md index 8f6ec6a..9e00d91 100644 --- a/README.md +++ b/README.md @@ -757,6 +757,27 @@ server: When enabled, Flynn runs `tailscale serve` on startup to expose the gateway port over your tailnet, and cleans up on shutdown. The `flynn doctor` command includes a Tailscale availability check. +## Bonjour / mDNS Discovery + +Optionally advertise the gateway on your local network so LAN clients can discover Flynn without manual host entry. + +```yaml +server: + localhost: false + discovery: + enabled: true + service_name: flynn-gateway + service_type: _flynn._tcp + txt: + env: home-lab +``` + +Notes: +- Discovery is disabled by default. +- `server.localhost` must be `false` for LAN clients to connect. +- Flynn advertises non-secret metadata only (instance/version + optional `txt` keys you provide). +- Runtime uses host tools (`avahi-publish-service` on Linux, `dns-sd` on macOS) when available. + ## DM Pairing Codes Allow unknown senders to authenticate with the bot via time-limited pairing codes. Works across all channel adapters (Telegram, Discord, Slack, WhatsApp). diff --git a/config/default.yaml b/config/default.yaml index c657de9..2df5620 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -24,6 +24,13 @@ server: refill_per_sec: 15 max_violations: 8 violation_window_ms: 10000 + # Local-network service discovery (mDNS/Bonjour). Keep disabled by default. + # Requires server.localhost: false so LAN clients can actually connect. + discovery: + enabled: false + service_name: flynn-gateway + service_type: _flynn._tcp + txt: {} models: # ── Model tiers ──────────────────────────────────────────────────── diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index eacf1d4..798956f 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -74,6 +74,34 @@ describe('configSchema — server', () => { expect(result.server.ws_rate_limit.max_violations).toBe(3); expect(result.server.ws_rate_limit.violation_window_ms).toBe(2000); }); + + it('defaults discovery settings', () => { + const result = configSchema.parse(minimalConfig); + expect(result.server.discovery.enabled).toBe(false); + expect(result.server.discovery.service_name).toBe('flynn-gateway'); + expect(result.server.discovery.service_type).toBe('_flynn._tcp'); + expect(result.server.discovery.txt).toEqual({}); + }); + + it('accepts custom discovery settings', () => { + const result = configSchema.parse({ + ...minimalConfig, + server: { + discovery: { + enabled: true, + service_name: 'flynn-dev', + service_type: '_custom._tcp', + txt: { + env: 'dev', + }, + }, + }, + }); + expect(result.server.discovery.enabled).toBe(true); + expect(result.server.discovery.service_name).toBe('flynn-dev'); + expect(result.server.discovery.service_type).toBe('_custom._tcp'); + expect(result.server.discovery.txt).toEqual({ env: 'dev' }); + }); }); describe('configSchema — agent_configs', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 0a91357..b4a17f4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -32,6 +32,17 @@ const wsRateLimitSchema = z.object({ violation_window_ms: z.number().min(1000).max(60000).default(10000), }).default({}); +const serverDiscoverySchema = z.object({ + /** Enable local-network service discovery (mDNS/Bonjour advertisement). */ + enabled: z.boolean().default(false), + /** Service instance name advertised on LAN. */ + service_name: z.string().min(1).default('flynn-gateway'), + /** mDNS service type. */ + service_type: z.string().min(1).default('_flynn._tcp'), + /** Additional TXT metadata advertised with the service record. */ + txt: z.record(z.string(), z.string()).default({}), +}).default({}); + const serverSchema = z.object({ tailscale: tailscaleSchema, localhost: z.boolean().default(true), @@ -48,6 +59,8 @@ const serverSchema = z.object({ max_request_body_bytes: z.number().min(1024).max(10 * 1024 * 1024).default(1_048_576), /** Per-connection WebSocket ingress rate limit settings. */ ws_rate_limit: wsRateLimitSchema, + /** Optional Bonjour/mDNS advertisement settings. */ + discovery: serverDiscoverySchema, }); /** All supported model provider identifiers. Used by the config schema and TUI autocompletion. */ diff --git a/src/daemon/services.ts b/src/daemon/services.ts index dff3fa3..72ca4f9 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -321,6 +321,12 @@ export function createGateway(deps: GatewayDeps): GatewayServer { maxViolations: config.server.ws_rate_limit.max_violations, violationWindowMs: config.server.ws_rate_limit.violation_window_ms, }, + discovery: { + enabled: config.server.discovery.enabled, + serviceName: config.server.discovery.service_name, + serviceType: config.server.discovery.service_type, + txtRecord: config.server.discovery.txt, + }, commandRegistry: deps.commandRegistry, intentRegistry: deps.intentRegistry, routingPolicy: deps.routingPolicy, diff --git a/src/gateway/discovery.test.ts b/src/gateway/discovery.test.ts new file mode 100644 index 0000000..f62df02 --- /dev/null +++ b/src/gateway/discovery.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; + +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); + +class MockChildProcess extends EventEmitter { + exitCode: number | null = null; + killed = false; + unref = vi.fn(); + kill = vi.fn((signal?: NodeJS.Signals) => { + this.killed = true; + this.emit('exit', signal === 'SIGKILL' ? 137 : 0, signal ?? null); + return true; + }); +} + +const mockSpawn = vi.mocked(spawn); + +describe('gateway discovery', () => { + let startGatewayDiscovery: typeof import('./discovery.js').startGatewayDiscovery; + + beforeAll(async () => { + const mod = await import('./discovery.js'); + startGatewayDiscovery = mod.startGatewayDiscovery; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('starts avahi publisher with txt records', async () => { + const child = new MockChildProcess(); + mockSpawn.mockReturnValueOnce(child as unknown as ChildProcess); + setTimeout(() => child.emit('spawn'), 0); + + const handle = await startGatewayDiscovery({ + serviceName: 'flynn-gateway', + serviceType: '_flynn._tcp', + port: 18800, + txtRecord: { instance: 'pid-123', version: '0.1.0' }, + }); + + expect(mockSpawn).toHaveBeenCalledWith('avahi-publish-service', [ + 'flynn-gateway', + '_flynn._tcp', + '18800', + 'instance=pid-123', + 'version=0.1.0', + ], { stdio: 'ignore' }); + expect(child.unref).toHaveBeenCalledOnce(); + + await handle.stop(); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('falls back to dns-sd when avahi is unavailable', async () => { + const avahiChild = new MockChildProcess(); + const dnsChild = new MockChildProcess(); + mockSpawn.mockReturnValueOnce(avahiChild as unknown as ChildProcess); + mockSpawn.mockReturnValueOnce(dnsChild as unknown as ChildProcess); + + setTimeout(() => avahiChild.emit('error', new Error('ENOENT')), 0); + setTimeout(() => dnsChild.emit('spawn'), 0); + + const handle = await startGatewayDiscovery({ + serviceName: 'flynn-gateway', + serviceType: '_flynn._tcp', + port: 18800, + }); + + expect(mockSpawn).toHaveBeenNthCalledWith(2, 'dns-sd', [ + '-R', + 'flynn-gateway', + '_flynn._tcp', + 'local', + '18800', + ], { stdio: 'ignore' }); + + await handle.stop(); + expect(dnsChild.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('throws when no supported advertiser command is available', async () => { + const avahiChild = new MockChildProcess(); + const dnsChild = new MockChildProcess(); + mockSpawn.mockReturnValueOnce(avahiChild as unknown as ChildProcess); + mockSpawn.mockReturnValueOnce(dnsChild as unknown as ChildProcess); + + setTimeout(() => avahiChild.emit('error', new Error('ENOENT')), 0); + setTimeout(() => dnsChild.emit('error', new Error('ENOENT')), 0); + + await expect(startGatewayDiscovery({ + serviceName: 'flynn-gateway', + serviceType: '_flynn._tcp', + port: 18800, + })).rejects.toThrow(/Failed to start mDNS advertiser/); + }); +}); diff --git a/src/gateway/discovery.ts b/src/gateway/discovery.ts new file mode 100644 index 0000000..0f2b718 --- /dev/null +++ b/src/gateway/discovery.ts @@ -0,0 +1,107 @@ +import { spawn, type ChildProcess } from 'child_process'; + +export interface GatewayDiscoveryConfig { + serviceName: string; + serviceType: string; + port: number; + txtRecord?: Record; +} + +export interface GatewayDiscoveryHandle { + stop(): Promise; +} + +function toTxtArgs(txtRecord?: Record): string[] { + if (!txtRecord) { + return []; + } + return Object.entries(txtRecord).map(([key, value]) => `${key}=${value}`); +} + +async function spawnAdvertiser(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: 'ignore' }); + + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + cleanup(); + reject(new Error(`${command} exited early (code=${code ?? 'null'}, signal=${signal ?? 'null'})`)); + }; + const onSpawn = (): void => { + setTimeout(() => { + cleanup(); + resolve(child); + }, 100); + }; + const cleanup = (): void => { + child.off('error', onError); + child.off('exit', onExit); + child.off('spawn', onSpawn); + }; + + child.once('error', onError); + child.once('exit', onExit); + child.once('spawn', onSpawn); + }); +} + +async function stopChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.killed) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 1000); + + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + + child.kill('SIGTERM'); + }); +} + +/** + * Starts LAN discovery using best-effort host tools. + * Priority: avahi-publish-service (Linux) -> dns-sd (macOS). + */ +export async function startGatewayDiscovery(config: GatewayDiscoveryConfig): Promise { + const txtArgs = toTxtArgs(config.txtRecord); + const attempts: Array<{ command: string; args: string[] }> = [ + { + command: 'avahi-publish-service', + args: [config.serviceName, config.serviceType, String(config.port), ...txtArgs], + }, + { + command: 'dns-sd', + args: ['-R', config.serviceName, config.serviceType, 'local', String(config.port), ...txtArgs], + }, + ]; + + let lastError: Error | null = null; + + for (const attempt of attempts) { + try { + const child = await spawnAdvertiser(attempt.command, attempt.args); + child.unref(); + return { + stop: async () => { + await stopChild(child); + }, + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + throw new Error( + `Failed to start mDNS advertiser (tried avahi-publish-service and dns-sd): ${lastError?.message ?? 'unknown error'}`, + ); +} diff --git a/src/gateway/server.discovery.test.ts b/src/gateway/server.discovery.test.ts new file mode 100644 index 0000000..c508bc1 --- /dev/null +++ b/src/gateway/server.discovery.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; + +const startDiscoveryMock = vi.fn(); +const stopDiscoveryMock = vi.fn(); + +vi.mock('./discovery.js', () => ({ + startGatewayDiscovery: startDiscoveryMock, +})); + +describe('GatewayServer discovery lifecycle', () => { + let GatewayServer: typeof import('./server.js').GatewayServer; + let server: import('./server.js').GatewayServer; + + const baseConfig = { + port: 18910, + host: '0.0.0.0', + sessionManager: { + getSession: () => ({ id: 's1', addMessage: () => {}, getHistory: () => [], clear: () => {}, setHistory: () => {}, replaceHistory: () => {} }), + listSessions: () => [], + } as unknown as import('./server.js').GatewayServerConfig['sessionManager'], + modelClient: { chat: async () => ({ content: 'ok', usage: { inputTokens: 0, outputTokens: 0 }, stopReason: 'end_turn' }) }, + systemPrompt: 'test', + toolRegistry: { + list: () => [], + get: () => undefined, + } as unknown as import('./server.js').GatewayServerConfig['toolRegistry'], + toolExecutor: { execute: async () => ({ success: true, output: '' }) } as unknown as import('./server.js').GatewayServerConfig['toolExecutor'], + version: '0.1.0-test', + } as const; + + beforeAll(async () => { + ({ GatewayServer } = await import('./server.js')); + }); + + beforeEach(() => { + vi.clearAllMocks(); + startDiscoveryMock.mockResolvedValue({ + stop: stopDiscoveryMock, + }); + server = new GatewayServer({ + ...baseConfig, + discovery: { + enabled: true, + serviceName: 'flynn-test', + serviceType: '_flynn._tcp', + txtRecord: { env: 'test' }, + }, + }); + }); + + it('starts and stops discovery when enabled', async () => { + const typedServer = server as unknown as { startDiscovery: (host: string, port: number) => Promise }; + await typedServer.startDiscovery('0.0.0.0', 18910); + await server.stop(); + + expect(startDiscoveryMock).toHaveBeenCalledOnce(); + const [callConfig] = startDiscoveryMock.mock.calls[0] as [Record]; + expect(callConfig.serviceName).toBe('flynn-test'); + expect(callConfig.serviceType).toBe('_flynn._tcp'); + expect(callConfig.port).toBe(18910); + expect(callConfig.txtRecord).toMatchObject({ + env: 'test', + version: '0.1.0-test', + }); + expect(stopDiscoveryMock).toHaveBeenCalledOnce(); + }); + + it('does not advertise when host is loopback', async () => { + const typedServer = server as unknown as { startDiscovery: (host: string, port: number) => Promise }; + await typedServer.startDiscovery('127.0.0.1', 18911); + await server.stop(); + + expect(startDiscoveryMock).not.toHaveBeenCalled(); + expect(stopDiscoveryMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index cb6d110..c56556e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -9,6 +9,7 @@ import { LaneQueue } from './lane-queue.js'; import { MetricsCollector } from './metrics.js'; import { authenticateRequest } from './auth.js'; import type { AuthConfig } from './auth.js'; +import { startGatewayDiscovery, type GatewayDiscoveryHandle } from './discovery.js'; import { parseMessage, makeError, @@ -86,6 +87,12 @@ export interface GatewayServerConfig { commandRegistry?: CommandRegistry; intentRegistry?: ComponentRegistry; routingPolicy?: RoutingPolicy; + discovery?: { + enabled: boolean; + serviceName: string; + serviceType: string; + txtRecord?: Record; + }; } export class GatewayServer { @@ -103,6 +110,7 @@ export class GatewayServer { private sessionBridge: SessionBridge; private laneQueue: LaneQueue; private metrics: MetricsCollector; + private discoveryHandle: GatewayDiscoveryHandle | null = null; private connectionMap: Map = new Map(); private connectionRateMap: Map { console.log(`Gateway server listening on ${host}:${port}`); - resolve(); + void this.startDiscovery(host, port).finally(() => { + resolve(); + }); }); }); } async stop(): Promise { + if (this.discoveryHandle) { + try { + await this.discoveryHandle.stop(); + } catch (err) { + console.error('Failed to stop mDNS discovery:', err instanceof Error ? err.message : err); + } finally { + this.discoveryHandle = null; + } + } + // Close all WebSocket connections first for (const [ws, connectionId] of this.connectionMap) { this.sessionBridge.disconnect(connectionId); @@ -543,6 +563,35 @@ export class GatewayServer { this.config.gmailHandler = handler; } + private async startDiscovery(host: string, port: number): Promise { + const discovery = this.config.discovery; + if (!discovery?.enabled) { + return; + } + + if (host === '127.0.0.1' || host === '::1') { + console.warn('mDNS discovery is enabled, but server.localhost=true restricts gateway to loopback; skipping advertisement'); + return; + } + + try { + const txtRecord: Record = { + instance: `pid-${process.pid}`, + version: this.config.version ?? '0.1.0', + ...(discovery.txtRecord ?? {}), + }; + this.discoveryHandle = await startGatewayDiscovery({ + serviceName: discovery.serviceName, + serviceType: discovery.serviceType, + port, + txtRecord, + }); + console.log(`mDNS discovery enabled: ${discovery.serviceName}.${discovery.serviceType}.local:${port}`); + } catch (err) { + console.warn(`mDNS discovery failed to start: ${err instanceof Error ? err.message : String(err)}`); + } + } + /** Read the full request body as a string. */ private readRequestBody(req: IncomingMessage): Promise { const maxBytes = this.config.maxRequestBodyBytes ?? GatewayServer.DEFAULT_MAX_REQUEST_BODY_BYTES;