From 39acd3574a0fd81f70445aec6827fda623a3f191 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Fri, 8 May 2026 15:47:03 +0800 Subject: [PATCH] fix wsl default listen host (#542) --- docs/docker.md | 2 +- packages/server/src/config.ts | 6 +-- packages/server/src/index.ts | 65 +++------------------------------ packages/website/src/i18n/en.ts | 1 + packages/website/src/i18n/zh.ts | 1 + tests/server/config.test.ts | 6 +-- 6 files changed, 14 insertions(+), 67 deletions(-) diff --git a/docs/docker.md b/docs/docker.md index fef3cda..a6b149a 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -34,7 +34,7 @@ All key runtime settings are configured from compose variables. | Variable | Default | Description | |---|---|---| | `PORT` | `6060` | Web UI listen port | -| `BIND_HOST` | Node default | Optional Web UI bind host. Leave unset for IPv6 dual-stack when available, or set `0.0.0.0` / `::` explicitly. | +| `BIND_HOST` | `0.0.0.0` | Optional Web UI bind host. Defaults to IPv4 for stable WSL/Windows access. Set `::` explicitly if you want IPv6 listening. | | `UPSTREAM` | `http://hermes-agent:8642` | Hermes gateway URL (container internal) | | `HERMES_BIN` | `/opt/hermes/.venv/bin/hermes` | Path to Hermes CLI binary | | `HERMES_AGENT_IMAGE` | `nousresearch/hermes-agent:latest` | Hermes Agent base image | diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index a39c52e..a14fcf6 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -1,14 +1,14 @@ import { resolve } from 'path' import { homedir } from 'os' -export function getListenHost(env: Record = process.env): string | undefined { +export function getListenHost(env: Record = process.env): string { const host = env.BIND_HOST?.trim() - return host || undefined + return host || '0.0.0.0' } export const config = { port: parseInt(process.env.PORT || '8648', 10), - // Default undefined: listenWithFallback tries :: first, falls back to 0.0.0.0 + // Default to IPv4 for stable WSL/Windows browser access. Use BIND_HOST=:: explicitly for IPv6. host: getListenHost(), upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642', uploadDir: process.env.UPLOAD_DIR || resolve(homedir(), '.hermes-web-ui', 'upload'), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3a2164d..a5d2d2d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -53,66 +53,11 @@ function listen(app: Koa, port: number, host: string): Promise { }) } -function probeIPv4(port: number): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const req = require('http').get(`http://127.0.0.1:${port}/health`, (res: any) => { - res.resume() - resolve(true) - }) - req.once('error', () => resolve(false)) - req.setTimeout(1000, () => { - req.destroy() - resolve(false) - }) - }) -} - -/** - * Try listening on IPv6 dual-stack (::) first. If IPv4 is not reachable through - * that socket, keep IPv6 and add a separate IPv4 listener. Fall back to IPv4 - * only when IPv6 is unavailable. Skips fallback when BIND_HOST is explicitly set. - * - * On some systems (e.g. WSL2), binding to :: succeeds but the dual-stack - * doesn't actually accept IPv4 connections. We detect this by probing - * 127.0.0.1 after binding. - */ async function listenWithFallback(app: Koa, port: number, host?: string): Promise { - // Explicit host: use it directly. - if (host) { - console.log(`[bootstrap] listening on ${host}:${port}`) - const explicit = await listen(app, port, host) - return { primary: explicit, servers: [explicit] } - } - - console.log(`[bootstrap] trying IPv6 dual-stack on ::${port}`) - try { - const s6 = await listen(app, port, '::') - if (await probeIPv4(port)) { - console.log(`[bootstrap] IPv6 dual-stack verified (IPv4 probe ok) on ::${port}`) - return { primary: s6, servers: [s6] } - } - - console.log('[bootstrap] IPv6 listener is IPv6-only, adding IPv4 listener on 0.0.0.0') - try { - const s4 = await listen(app, port, '0.0.0.0') - console.log(`[bootstrap] listening on ::${port} and 0.0.0.0:${port}`) - return { primary: s6, servers: [s6, s4] } - } catch (err) { - console.log('[bootstrap] IPv4 listener failed; keeping IPv6 listener') - logger.warn({ err }, 'Could not add IPv4 listener after IPv6-only bind') - return { primary: s6, servers: [s6] } - } - } catch (err: any) { - if (err.code !== 'EADDRNOTAVAIL' && err.code !== 'EAFNOSUPPORT' && err.code !== 'EPROTONOSUPPORT') { - throw err - } - - console.log(`[bootstrap] IPv6 not available (${err.code}), falling back to 0.0.0.0`) - const s4 = await listen(app, port, '0.0.0.0') - console.log(`[bootstrap] listening on 0.0.0.0:${port}`) - return { primary: s4, servers: [s4] } - } + const bindHost = host || '0.0.0.0' + console.log(`[bootstrap] listening on ${bindHost}:${port}`) + const primary = await listen(app, port, bindHost) + return { primary, servers: [primary] } } /** @@ -177,7 +122,7 @@ export async function bootstrap() { }) console.log('[bootstrap] SPA fallback registered') - // Start server — try IPv6 dual-stack first, fall back to IPv4 + // Start server using the configured bind host. Default is IPv4 for WSL stability. const listenResult = await listenWithFallback(app, config.port, config.host) server = listenResult.primary servers = listenResult.servers diff --git a/packages/website/src/i18n/en.ts b/packages/website/src/i18n/en.ts index f698692..95dd5df 100644 --- a/packages/website/src/i18n/en.ts +++ b/packages/website/src/i18n/en.ts @@ -136,6 +136,7 @@ export default { ['AUTH_DISABLED', 'Set to "1" to disable authentication'], ['AUTH_TOKEN', 'Custom auth token (overrides auto-generated)'], ['PORT', 'Server listen port (default: 8648)'], + ['BIND_HOST', 'Server bind host (default: 0.0.0.0). Set :: explicitly to enable IPv6 listening.'], ['UPSTREAM', 'Hermes gateway URL (default: http://127.0.0.1:8642)'], ['UPLOAD_DIR', 'Custom upload directory path'], ['CORS_ORIGINS', 'CORS origin config (default: *)'], diff --git a/packages/website/src/i18n/zh.ts b/packages/website/src/i18n/zh.ts index 166267a..3489fb6 100644 --- a/packages/website/src/i18n/zh.ts +++ b/packages/website/src/i18n/zh.ts @@ -136,6 +136,7 @@ export default { ['AUTH_DISABLED', '设为 "1" 禁用认证'], ['AUTH_TOKEN', '自定义认证令牌(覆盖自动生成的令牌)'], ['PORT', '服务器监听端口(默认:8648)'], + ['BIND_HOST', '服务器绑定地址(默认:0.0.0.0)。如需 IPv6,请显式设置为 ::。'], ['UPSTREAM', 'Hermes 网关 URL(默认:http://127.0.0.1:8642)'], ['UPLOAD_DIR', '自定义上传目录路径'], ['CORS_ORIGINS', 'CORS 来源配置(默认:*)'], diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 63d60ab..a810302 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { getListenHost } from '../../packages/server/src/config' describe('server config', () => { - it('does not force an IPv4 bind host by default', () => { - expect(getListenHost({})).toBeUndefined() + it('defaults to an IPv4 bind host', () => { + expect(getListenHost({})).toBe('0.0.0.0') }) it('uses BIND_HOST when provided', () => { @@ -11,6 +11,6 @@ describe('server config', () => { }) it('ignores blank BIND_HOST values', () => { - expect(getListenHost({ BIND_HOST: ' ' })).toBeUndefined() + expect(getListenHost({ BIND_HOST: ' ' })).toBe('0.0.0.0') }) })