fix wsl default listen host (#542)
This commit is contained in:
+1
-1
@@ -34,7 +34,7 @@ All key runtime settings are configured from compose variables.
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `6060` | Web UI listen port |
|
| `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) |
|
| `UPSTREAM` | `http://hermes-agent:8642` | Hermes gateway URL (container internal) |
|
||||||
| `HERMES_BIN` | `/opt/hermes/.venv/bin/hermes` | Path to Hermes CLI binary |
|
| `HERMES_BIN` | `/opt/hermes/.venv/bin/hermes` | Path to Hermes CLI binary |
|
||||||
| `HERMES_AGENT_IMAGE` | `nousresearch/hermes-agent:latest` | Hermes Agent base image |
|
| `HERMES_AGENT_IMAGE` | `nousresearch/hermes-agent:latest` | Hermes Agent base image |
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
|
||||||
export function getListenHost(env: Record<string, string | undefined> = process.env): string | undefined {
|
export function getListenHost(env: Record<string, string | undefined> = process.env): string {
|
||||||
const host = env.BIND_HOST?.trim()
|
const host = env.BIND_HOST?.trim()
|
||||||
return host || undefined
|
return host || '0.0.0.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
port: parseInt(process.env.PORT || '8648', 10),
|
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(),
|
host: getListenHost(),
|
||||||
upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642',
|
upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642',
|
||||||
uploadDir: process.env.UPLOAD_DIR || resolve(homedir(), '.hermes-web-ui', 'upload'),
|
uploadDir: process.env.UPLOAD_DIR || resolve(homedir(), '.hermes-web-ui', 'upload'),
|
||||||
|
|||||||
@@ -53,66 +53,11 @@ function listen(app: Koa, port: number, host: string): Promise<any> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeIPv4(port: number): Promise<boolean> {
|
|
||||||
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<ListenResult> {
|
async function listenWithFallback(app: Koa, port: number, host?: string): Promise<ListenResult> {
|
||||||
// Explicit host: use it directly.
|
const bindHost = host || '0.0.0.0'
|
||||||
if (host) {
|
console.log(`[bootstrap] listening on ${bindHost}:${port}`)
|
||||||
console.log(`[bootstrap] listening on ${host}:${port}`)
|
const primary = await listen(app, port, bindHost)
|
||||||
const explicit = await listen(app, port, host)
|
return { primary, servers: [primary] }
|
||||||
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] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,7 +122,7 @@ export async function bootstrap() {
|
|||||||
})
|
})
|
||||||
console.log('[bootstrap] SPA fallback registered')
|
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)
|
const listenResult = await listenWithFallback(app, config.port, config.host)
|
||||||
server = listenResult.primary
|
server = listenResult.primary
|
||||||
servers = listenResult.servers
|
servers = listenResult.servers
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export default {
|
|||||||
['AUTH_DISABLED', 'Set to "1" to disable authentication'],
|
['AUTH_DISABLED', 'Set to "1" to disable authentication'],
|
||||||
['AUTH_TOKEN', 'Custom auth token (overrides auto-generated)'],
|
['AUTH_TOKEN', 'Custom auth token (overrides auto-generated)'],
|
||||||
['PORT', 'Server listen port (default: 8648)'],
|
['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)'],
|
['UPSTREAM', 'Hermes gateway URL (default: http://127.0.0.1:8642)'],
|
||||||
['UPLOAD_DIR', 'Custom upload directory path'],
|
['UPLOAD_DIR', 'Custom upload directory path'],
|
||||||
['CORS_ORIGINS', 'CORS origin config (default: *)'],
|
['CORS_ORIGINS', 'CORS origin config (default: *)'],
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export default {
|
|||||||
['AUTH_DISABLED', '设为 "1" 禁用认证'],
|
['AUTH_DISABLED', '设为 "1" 禁用认证'],
|
||||||
['AUTH_TOKEN', '自定义认证令牌(覆盖自动生成的令牌)'],
|
['AUTH_TOKEN', '自定义认证令牌(覆盖自动生成的令牌)'],
|
||||||
['PORT', '服务器监听端口(默认:8648)'],
|
['PORT', '服务器监听端口(默认:8648)'],
|
||||||
|
['BIND_HOST', '服务器绑定地址(默认:0.0.0.0)。如需 IPv6,请显式设置为 ::。'],
|
||||||
['UPSTREAM', 'Hermes 网关 URL(默认:http://127.0.0.1:8642)'],
|
['UPSTREAM', 'Hermes 网关 URL(默认:http://127.0.0.1:8642)'],
|
||||||
['UPLOAD_DIR', '自定义上传目录路径'],
|
['UPLOAD_DIR', '自定义上传目录路径'],
|
||||||
['CORS_ORIGINS', 'CORS 来源配置(默认:*)'],
|
['CORS_ORIGINS', 'CORS 来源配置(默认:*)'],
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { getListenHost } from '../../packages/server/src/config'
|
import { getListenHost } from '../../packages/server/src/config'
|
||||||
|
|
||||||
describe('server config', () => {
|
describe('server config', () => {
|
||||||
it('does not force an IPv4 bind host by default', () => {
|
it('defaults to an IPv4 bind host', () => {
|
||||||
expect(getListenHost({})).toBeUndefined()
|
expect(getListenHost({})).toBe('0.0.0.0')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses BIND_HOST when provided', () => {
|
it('uses BIND_HOST when provided', () => {
|
||||||
@@ -11,6 +11,6 @@ describe('server config', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ignores blank BIND_HOST values', () => {
|
it('ignores blank BIND_HOST values', () => {
|
||||||
expect(getListenHost({ BIND_HOST: ' ' })).toBeUndefined()
|
expect(getListenHost({ BIND_HOST: ' ' })).toBe('0.0.0.0')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user