fix: heal stale H5 LAN host and validate saves against local interfaces

H5 access broke after switching Wi-Fi: the previously saved private-LAN host
(e.g. 192.168.1.207) was no longer bound to any interface on the current
network (which had moved to 192.168.0.x), but commit da8308de only refreshed
the stale *port* and kept the stale *hostname*. The QR code therefore pointed
at an IP this machine no longer had, and phones got TCP-refused.

Two changes:

1. resolveEffectiveH5PublicBaseUrl now accepts the set of local IPv4 hosts
   and, when the stored URL is a plain private-LAN HTTP URL whose hostname
   is no longer on any interface, falls back to the auto-discovered URL —
   without overwriting the stored value, so switching back to the original
   network restores it automatically.

2. H5AccessService.updateSettings now validates the publicBaseUrl on save.
   Plain LAN URLs whose host is not on any local interface are rejected with
   a 400 + suggested LAN IP. Reverse-proxy URLs (https, custom path, hostname
   targets) are accepted unchanged because reachability is owned by the
   user's tunnel / nginx / cloudflared setup, not the desktop.

GET /api/h5-access also returns a diagnostics block (storedHostStaleness,
suggestedHost, localInterfaceHosts, effectivePublicBaseUrl) so the desktop
Settings page can render a warning banner with a one-click switch to the
current LAN IP, plus a quieter note for proxy URLs.

Constraint: Reverse-proxy users may legitimately point H5 at a hostname that
is not on this machine's network adapters; we must not regress that path.

Rejected: Reachability-probe arbitrary public URLs from the server | the
desktop cannot reliably round-trip through the user's external tunnel and
should not pretend to validate it.

Rejected: Overwrite the stored host with the auto host | users on multi-
network laptops would lose their pinned WSL/Docker-aware choice on every
Wi-Fi switch.

Confidence: high

Scope-risk: moderate

Directive: Do not narrow validateH5PublicBaseUrl to reject reverse-proxy
URLs without restoring an explicit opt-in path for them.

Tested: bun test src/server/__tests__/h5-access-service.test.ts (21 pass)
Tested: bun test src/server/__tests__/h5-access-api.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/h5-access-policy.test.ts (57 pass)
Tested: bun run check:server (840 pass)
Tested: bun run lint && bun test --run in desktop/ (734 pass, 91 files, +3 new H5 banner / proxy-note / save-error cases)

Not-tested: Real phone scan against a packaged desktop build after a Wi-Fi switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-24 01:07:46 +08:00
parent 2a89331697
commit 63daa34be0
11 changed files with 591 additions and 16 deletions

View File

@ -179,6 +179,7 @@ describe('Settings > General tab', () => {
allowedOrigins: [],
publicBaseUrl: null,
},
h5AccessDiagnostics: null,
h5AccessError: null,
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ thinkingEnabled: enabled })
@ -934,6 +935,100 @@ describe('Settings > General tab', () => {
})
})
it('shows the stale-host banner and a one-click switch when the saved H5 host is unreachable', async () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
tokenPreview: 'h5a1b2c3',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.1.207:55379',
},
h5AccessDiagnostics: {
storedHostStaleness: 'unreachable',
storedPublicBaseUrl: 'http://192.168.1.207:55379',
effectivePublicBaseUrl: 'http://192.168.0.105:55379',
suggestedHost: '192.168.0.105',
localInterfaceHosts: ['192.168.0.105'],
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
const banner = within(section).getByTestId('h5-access-stale-host-banner')
expect(banner).toBeInTheDocument()
expect(banner.textContent).toContain('192.168.1.207')
expect(within(section).queryByTestId('h5-access-proxy-note')).toBeNull()
await act(async () => {
fireEvent.click(within(section).getByTestId('h5-access-stale-host-apply'))
})
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
publicBaseUrl: 'http://192.168.0.105:55379',
})
})
it('shows the proxy note when the saved H5 URL is a reverse proxy', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
tokenPreview: 'h5a1b2c3',
allowedOrigins: [],
publicBaseUrl: 'https://h5.mydomain.com',
},
h5AccessDiagnostics: {
storedHostStaleness: 'proxy',
storedPublicBaseUrl: 'https://h5.mydomain.com',
effectivePublicBaseUrl: 'https://h5.mydomain.com',
suggestedHost: '192.168.0.105',
localInterfaceHosts: ['192.168.0.105'],
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
expect(within(section).getByTestId('h5-access-proxy-note')).toBeInTheDocument()
expect(within(section).queryByTestId('h5-access-stale-host-banner')).toBeNull()
})
it('shows the friendly backend reason when saving an H5 host that is not on any local interface', async () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
tokenPreview: 'h5a1b2c3',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.105:55379',
},
h5AccessDiagnostics: {
storedHostStaleness: 'ok',
storedPublicBaseUrl: 'http://192.168.0.105:55379',
effectivePublicBaseUrl: 'http://192.168.0.105:55379',
suggestedHost: '192.168.0.105',
localInterfaceHosts: ['192.168.0.105'],
},
updateH5AccessSettings: vi.fn().mockImplementation(async () => {
useSettingsStore.setState({
h5AccessError: 'H5 host 10.255.255.254 is not bound to any local network interface on this machine. Available LAN IPv4: 192.168.0.105',
})
throw new Error('rejected')
}),
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.change(within(section).getByLabelText('Access host / IP'), {
target: { value: '10.255.255.254' },
})
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' }))
})
expect(within(section).getByText(/10\.255\.255\.254/)).toBeInTheDocument()
})
it('saves WebSearch fallback provider settings', () => {
render(<Settings />)

View File

@ -1,10 +1,11 @@
import { api } from './client'
import type { H5AccessSettings } from '../types/settings'
import type { H5AccessDiagnostics, H5AccessSettings } from '../types/settings'
export type { H5AccessSettings } from '../types/settings'
export type { H5AccessDiagnostics, H5AccessSettings } from '../types/settings'
export type H5AccessStatus = {
settings: H5AccessSettings
diagnostics?: H5AccessDiagnostics
}
export type H5AccessTokenResult = {

View File

@ -901,6 +901,11 @@ export const en = {
'settings.general.h5AccessConfirmBody': 'This will expose the desktop H5 app on your LAN address and port. Devices with the QR token can access desktop sessions and related controls. Continue only on a trusted network.',
'settings.general.h5AccessConfirmEnable': 'Enable H5 access',
'settings.general.h5AccessError': 'Failed to update H5 access settings.',
'settings.general.h5AccessStaleHostTitle': 'Saved H5 host is no longer reachable',
'settings.general.h5AccessStaleHostBody': 'The saved host {storedHost} is not bound to any active interface on this machine. Phones scanning the QR code will fail to connect. We recommend switching to the current LAN IP.',
'settings.general.h5AccessStaleHostNoSuggestion': 'The saved host {storedHost} is not bound to any active interface on this machine. Phones scanning the QR code will fail to connect. Please check this machines network connection, or switch to a reverse proxy URL.',
'settings.general.h5AccessStaleHostApply': 'Switch to {suggestedHost}',
'settings.general.h5AccessProxyNote': 'Currently using a reverse proxy URL. Phones reach the desktop through the public domain / tunnel you configured. Make sure your proxy is still up.',
'settings.general.networkTitle': 'Network',
'settings.general.networkDescription': 'Controls provider API requests made by desktop sessions.',
'settings.general.networkProxyModeSystem': 'System proxy',

View File

@ -903,6 +903,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessConfirmBody': '这会把桌面 H5 应用暴露到局域网地址和端口。持有二维码 token 的设备可以访问桌面会话和相关控制能力。请只在可信网络中继续。',
'settings.general.h5AccessConfirmEnable': '启用 H5 访问',
'settings.general.h5AccessError': '更新 H5 设置失败。',
'settings.general.h5AccessStaleHostTitle': '当前保存的访问主机已不可达',
'settings.general.h5AccessStaleHostBody': '保存的 {storedHost} 不在本机当前的任何网卡上。手机扫码会连不上。建议切到本机当前的局域网 IP。',
'settings.general.h5AccessStaleHostNoSuggestion': '保存的 {storedHost} 不在本机当前的任何网卡上。手机扫码会连不上。请检查本机的网络连接,或改填反向代理 URL。',
'settings.general.h5AccessStaleHostApply': '切到 {suggestedHost}',
'settings.general.h5AccessProxyNote': '当前使用的是反向代理 URL手机扫码会经公网/隧道返回桌面端。请确认你的反代仍能工作。',
'settings.general.networkTitle': '网络',
'settings.general.networkDescription': '控制桌面会话发起的服务商 API 请求。',
'settings.general.networkProxyModeSystem': '系统代理',

View File

@ -97,6 +97,15 @@ function extractH5AccessAddressDraft(baseUrl: string | null): string {
}
}
function extractHostnameFromUrl(value: string | null): string | null {
if (!value) return null
try {
return new URL(value).hostname || null
} catch {
return null
}
}
function extractH5AccessPort(baseUrl: string | null): string | null {
if (!baseUrl) return null
@ -2509,6 +2518,7 @@ function GeneralSettings() {
function H5AccessSettings() {
const {
h5Access,
h5AccessDiagnostics,
h5AccessError,
enableH5Access,
disableH5Access,
@ -2577,6 +2587,17 @@ function H5AccessSettings() {
})
}
const handleH5SwitchToSuggestedHost = async () => {
const suggested = h5AccessDiagnostics?.suggestedHost
if (!suggested) return
await runH5Action(async () => {
// Build URL using current port if available, otherwise let backend pick.
const port = extractH5AccessPort(h5Access.publicBaseUrl)
const nextUrl = port ? `http://${suggested}:${port}` : `http://${suggested}`
await updateH5AccessSettings({ publicBaseUrl: nextUrl })
})
}
const handleH5UrlCopy = async () => {
if (!h5AccessUrl) return
const copied = await copyTextToClipboard(h5AccessUrl)
@ -2677,6 +2698,50 @@ function H5AccessSettings() {
</span>
</div>
{h5AccessDiagnostics?.storedHostStaleness === 'unreachable' && h5AccessDiagnostics.storedPublicBaseUrl ? (
<div
data-testid="h5-access-stale-host-banner"
className="mt-4 rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-3 text-xs leading-5 text-[var(--color-text-primary)]"
>
<div className="font-semibold">
{t('settings.general.h5AccessStaleHostTitle')}
</div>
<div className="mt-1 text-[var(--color-text-secondary)]">
{h5AccessDiagnostics.suggestedHost
? t('settings.general.h5AccessStaleHostBody', {
storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl,
})
: t('settings.general.h5AccessStaleHostNoSuggestion', {
storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl,
})}
</div>
{h5AccessDiagnostics.suggestedHost && (
<div className="mt-2">
<Button
size="sm"
variant="primary"
loading={h5ActionRunning}
onClick={() => void handleH5SwitchToSuggestedHost()}
data-testid="h5-access-stale-host-apply"
>
{t('settings.general.h5AccessStaleHostApply', {
suggestedHost: h5AccessDiagnostics.suggestedHost,
})}
</Button>
</div>
)}
</div>
) : null}
{h5AccessDiagnostics?.storedHostStaleness === 'proxy' ? (
<div
data-testid="h5-access-proxy-note"
className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2 text-xs leading-5 text-[var(--color-text-tertiary)]"
>
{t('settings.general.h5AccessProxyNote')}
</div>
) : null}
<div className="mt-4 grid grid-cols-1 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Input

View File

@ -9,6 +9,7 @@ import {
type AppModeConfig,
type DesktopTerminalSettings,
type DesktopTerminalStartupShell,
type H5AccessDiagnostics,
type H5AccessSettings,
type NetworkSettings,
type PermissionMode,
@ -63,6 +64,7 @@ type SettingsStore = {
updateProxy: UpdateProxySettings
network: NetworkSettings
h5Access: H5AccessSettings
h5AccessDiagnostics: H5AccessDiagnostics | null
h5AccessError: string | null
responseLanguage: string
uiZoom: number
@ -144,6 +146,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS,
network: DEFAULT_NETWORK_SETTINGS,
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
h5AccessDiagnostics: null,
h5AccessError: null,
responseLanguage: '',
uiZoom: readStoredAppZoomLevel(),
@ -193,6 +196,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy),
network: normalizeNetworkSettings(userSettings.network),
h5Access: h5AccessResult.settings,
h5AccessDiagnostics: h5AccessResult.diagnostics,
h5AccessError: h5AccessResult.error,
responseLanguage: typeof userSettings.language === 'string' ? userSettings.language : '',
isLoading: false,
@ -208,7 +212,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
fetchH5Access: async () => {
const result = await loadH5AccessSettings(get().h5Access)
set({ h5Access: result.settings, h5AccessError: result.error })
set({
h5Access: result.settings,
h5AccessDiagnostics: result.diagnostics,
h5AccessError: result.error,
})
},
setPermissionMode: async (mode) => {
@ -350,6 +358,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
return token
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to enable H5 access.') })
@ -365,6 +374,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to disable H5 access.') })
throw error
@ -379,6 +389,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
return token
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to regenerate the H5 token.') })
@ -394,6 +405,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to update H5 access settings.') })
throw error
@ -511,26 +523,45 @@ function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5Ac
}
}
async function refreshH5DiagnosticsSilent(
set: (partial: Partial<SettingsStore>) => void,
): Promise<void> {
// Best-effort diagnostics refresh. Failure here must not surface as an
// error on the main H5 action (enable/disable/regenerate/update), because
// the main action has already succeeded by the time we reach this point.
try {
const response = await h5AccessApi.get()
const diagnostics = response?.diagnostics ?? null
set({ h5AccessDiagnostics: diagnostics })
} catch {
// silent: keep previous diagnostics value
}
}
async function loadH5AccessSettings(previousH5Access: H5AccessSettings): Promise<{
settings: H5AccessSettings
diagnostics: H5AccessDiagnostics | null
error: string | null
}> {
try {
const { settings } = await h5AccessApi.get()
const { settings, diagnostics } = await h5AccessApi.get()
return {
settings: normalizeH5AccessSettings(settings),
diagnostics: diagnostics ?? null,
error: null,
}
} catch (error) {
if (isLegacyH5EndpointError(error)) {
return {
settings: DEFAULT_H5_ACCESS_SETTINGS,
diagnostics: null,
error: null,
}
}
return {
settings: previousH5Access,
diagnostics: null,
error: getErrorMessage(error, 'Failed to load H5 access settings.'),
}
}

View File

@ -44,6 +44,16 @@ export type H5AccessSettings = {
publicBaseUrl: string | null
}
export type H5HostStaleness = 'ok' | 'unreachable' | 'proxy' | 'unset'
export type H5AccessDiagnostics = {
storedHostStaleness: H5HostStaleness
storedPublicBaseUrl: string | null
effectivePublicBaseUrl: string | null
suggestedHost: string | null
localInterfaceHosts: string[]
}
export type DesktopTerminalStartupShell =
| 'system'
| 'pwsh'

View File

@ -52,14 +52,26 @@ describe('/api/h5-access', () => {
const response = await api('GET', '/api/h5-access')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
settings: {
enabled: false,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
},
const body = await response.json() as {
settings: unknown
diagnostics?: {
storedHostStaleness: string
storedPublicBaseUrl: string | null
effectivePublicBaseUrl: string | null
suggestedHost: string | null
localInterfaceHosts: string[]
}
}
expect(body.settings).toEqual({
enabled: false,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
})
expect(body.diagnostics).toBeDefined()
expect(body.diagnostics?.storedHostStaleness).toBe('unset')
expect(body.diagnostics?.storedPublicBaseUrl).toBeNull()
expect(Array.isArray(body.diagnostics?.localInterfaceHosts)).toBe(true)
})
test('enable returns raw token once and status stays sanitized', async () => {

View File

@ -3,9 +3,12 @@ import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import {
classifyH5PublicBaseUrl,
collectLocalIPv4Hosts,
findPrivateLanAddress,
H5AccessService,
resolveEffectiveH5PublicBaseUrl,
validateH5PublicBaseUrl,
} from '../services/h5AccessService.js'
import { ProviderService } from '../services/providerService.js'
@ -91,8 +94,12 @@ describe('H5AccessService', () => {
test('configured public URL overrides stale stored local URLs', async () => {
const service = new H5AccessService()
// Use loopback rather than a private-LAN IP so updateSettings's new
// local-interface validation does not reject the fixture URL. The intent
// here is still "stale stored URL overridden by configured" — loopback is
// treated as a proxy URL by classifyH5PublicBaseUrl and accepted as-is.
await service.updateSettings({
publicBaseUrl: 'http://192.168.0.102:5179',
publicBaseUrl: 'http://127.0.0.1:5179',
})
process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'https://chat.example.com/app/'
@ -291,6 +298,162 @@ describe('H5AccessService', () => {
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false)
})
test('stale stored LAN host falls back to auto when not bound to any local interface', () => {
// User saved 192.168.1.207 on a previous network; current interfaces only have 192.168.0.105.
// Effective URL must hop over to the auto-discovered host:port.
expect(resolveEffectiveH5PublicBaseUrl({
enabled: true,
storedPublicBaseUrl: 'http://192.168.1.207:55379',
configuredPublicBaseUrl: null,
autoPublicBaseUrl: 'http://192.168.0.105:55379',
localInterfaceHosts: ['192.168.0.105'],
})).toBe('http://192.168.0.105:55379')
// When stored host IS on a local interface, keep refreshing only the port (existing behavior).
expect(resolveEffectiveH5PublicBaseUrl({
enabled: true,
storedPublicBaseUrl: 'http://192.168.1.100:5179',
configuredPublicBaseUrl: null,
autoPublicBaseUrl: 'http://192.168.1.100:55379',
localInterfaceHosts: ['192.168.1.100'],
})).toBe('http://192.168.1.100:55379')
// Reverse-proxy stored URLs are never replaced by auto fallback, even if host is unreachable.
expect(resolveEffectiveH5PublicBaseUrl({
enabled: true,
storedPublicBaseUrl: 'https://h5.mydomain.com',
configuredPublicBaseUrl: null,
autoPublicBaseUrl: 'http://192.168.0.105:55379',
localInterfaceHosts: ['192.168.0.105'],
})).toBe('https://h5.mydomain.com')
// Backward compat: without localInterfaceHosts the legacy port-refresh-only path still works.
expect(resolveEffectiveH5PublicBaseUrl({
enabled: true,
storedPublicBaseUrl: 'http://192.168.1.207:5179',
configuredPublicBaseUrl: null,
autoPublicBaseUrl: 'http://192.168.0.105:55379',
})).toBe('http://192.168.1.207:55379')
})
test('classifyH5PublicBaseUrl distinguishes plain LAN, proxy and invalid', () => {
expect(classifyH5PublicBaseUrl('http://192.168.0.105:55379')).toBe('plain-lan')
expect(classifyH5PublicBaseUrl('http://10.0.0.5:8080')).toBe('plain-lan')
expect(classifyH5PublicBaseUrl('http://172.20.16.1:39876')).toBe('plain-lan')
// proxy: https / custom path / hostname instead of IP
expect(classifyH5PublicBaseUrl('https://h5.mydomain.com')).toBe('proxy')
expect(classifyH5PublicBaseUrl('http://192.168.0.105:8080/h5')).toBe('proxy')
expect(classifyH5PublicBaseUrl('https://192.168.0.105:8443')).toBe('proxy')
expect(classifyH5PublicBaseUrl('http://my-tunnel:8443')).toBe('proxy')
// invalid
expect(classifyH5PublicBaseUrl('not a url')).toBe('invalid')
expect(classifyH5PublicBaseUrl('ftp://example.com')).toBe('invalid')
expect(classifyH5PublicBaseUrl('http://user:pass@example.com')).toBe('invalid')
})
test('validateH5PublicBaseUrl: plain LAN host must be on local interfaces', () => {
const localHosts = ['192.168.0.105']
expect(validateH5PublicBaseUrl('http://192.168.0.105:55379', localHosts)).toEqual({
ok: true,
kind: 'plain-lan',
})
const stale = validateH5PublicBaseUrl('http://192.168.1.207:55379', localHosts)
expect(stale.ok).toBe(false)
if (!stale.ok) {
expect(stale.reason).toContain('192.168.1.207')
expect(stale.reason).toContain('192.168.0.105')
expect(stale.suggestedHost).toBe('192.168.0.105')
}
})
test('validateH5PublicBaseUrl: proxy URLs are accepted without local-interface checks', () => {
expect(validateH5PublicBaseUrl('https://h5.mydomain.com', ['192.168.0.105'])).toEqual({
ok: true,
kind: 'proxy',
})
expect(validateH5PublicBaseUrl('http://192.168.0.105:8080/h5', ['10.0.0.5'])).toEqual({
ok: true,
kind: 'proxy',
})
})
test('validateH5PublicBaseUrl: invalid URLs are rejected with suggested host', () => {
const result = validateH5PublicBaseUrl('ftp://example.com', ['192.168.0.105'])
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.suggestedHost).toBe('192.168.0.105')
}
})
test('updateSettings rejects a stale LAN host', async () => {
const service = new H5AccessService()
// Pick a private-LAN IP that is virtually guaranteed not to be on the
// test machine's interfaces (192.168.255.0/24 is reserved-ish in practice
// and we never see it in CI / dev environments).
await expect(
service.updateSettings({
publicBaseUrl: 'http://192.168.255.254:55379',
}),
).rejects.toMatchObject({
statusCode: 400,
})
})
test('collectLocalIPv4Hosts returns only non-internal IPv4 hosts', () => {
expect(collectLocalIPv4Hosts({
lo: [{
address: '127.0.0.1',
netmask: '255.0.0.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: true,
cidr: '127.0.0.1/8',
}],
'Wi-Fi': [{
address: '192.168.0.105',
netmask: '255.255.255.0',
family: 'IPv4',
mac: 'aa:bb:cc:dd:ee:ff',
internal: false,
cidr: '192.168.0.105/24',
}, {
address: 'fe80::1',
netmask: 'ffff:ffff:ffff:ffff::',
family: 'IPv6',
mac: 'aa:bb:cc:dd:ee:ff',
internal: false,
scopeid: 0,
cidr: 'fe80::1/64',
}],
})).toEqual(['192.168.0.105'])
})
test('getDiagnostics reports stale, ok, proxy and unset states', async () => {
const service = new H5AccessService()
// unset: no stored URL
let diag = await service.getDiagnostics()
expect(diag.storedHostStaleness).toBe('unset')
expect(diag.storedPublicBaseUrl).toBeNull()
expect(Array.isArray(diag.localInterfaceHosts)).toBe(true)
// proxy stored URL
await service.updateSettings({ publicBaseUrl: 'https://h5.mydomain.com' })
diag = await service.getDiagnostics()
expect(diag.storedHostStaleness).toBe('proxy')
expect(diag.storedPublicBaseUrl).toBe('https://h5.mydomain.com')
// ok: stored URL host is on local interfaces
const localHost = collectLocalIPv4Hosts()[0]
if (localHost) {
await service.updateSettings({ publicBaseUrl: `http://${localHost}:55379` })
diag = await service.getDiagnostics()
expect(diag.storedHostStaleness).toBe('ok')
}
})
test('concurrent h5 enable and provider managed settings update preserve both fields', async () => {
const h5Service = new H5AccessService()
const providerService = new ProviderService()

View File

@ -43,7 +43,11 @@ export async function handleH5AccessApi(
switch (sub) {
case undefined:
if (req.method === 'GET') {
return Response.json({ settings: await h5AccessService.getSettings() })
const [settings, diagnostics] = await Promise.all([
h5AccessService.getSettings(),
h5AccessService.getDiagnostics(),
])
return Response.json({ settings, diagnostics })
}
if (req.method === 'PUT') {
const body = await parseJsonBody(req)

View File

@ -16,6 +16,22 @@ export type H5AccessEnableResult = {
token: string
}
export type H5HostStaleness = 'ok' | 'unreachable' | 'proxy' | 'unset'
export type H5AccessDiagnostics = {
storedHostStaleness: H5HostStaleness
storedPublicBaseUrl: string | null
effectivePublicBaseUrl: string | null
suggestedHost: string | null
localInterfaceHosts: string[]
}
export type H5PublicBaseUrlClassification = 'plain-lan' | 'proxy'
export type H5PublicBaseUrlValidationResult =
| { ok: true; kind: H5PublicBaseUrlClassification }
| { ok: false; reason: string; suggestedHost: string | null }
type StoredH5AccessSettings = H5AccessSettings & {
tokenHash: string | null
}
@ -44,10 +60,50 @@ function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings {
storedPublicBaseUrl: settings.publicBaseUrl,
configuredPublicBaseUrl: resolveConfiguredPublicBaseUrl(),
autoPublicBaseUrl: resolveAutoLanPublicBaseUrl(),
localInterfaceHosts: collectLocalIPv4Hosts(),
}),
}
}
function describeH5AccessDiagnostics(stored: StoredH5AccessSettings): H5AccessDiagnostics {
const localInterfaceHosts = collectLocalIPv4Hosts()
const autoPublicBaseUrl = resolveAutoLanPublicBaseUrl()
const configuredPublicBaseUrl = resolveConfiguredPublicBaseUrl()
const effectivePublicBaseUrl = resolveEffectiveH5PublicBaseUrl({
enabled: stored.enabled,
storedPublicBaseUrl: stored.publicBaseUrl,
configuredPublicBaseUrl,
autoPublicBaseUrl,
localInterfaceHosts,
})
const suggestedHost = pickPreferredLanHost(localInterfaceHosts)
let storedHostStaleness: H5HostStaleness = 'unset'
if (stored.publicBaseUrl) {
const classification = classifyH5PublicBaseUrl(stored.publicBaseUrl)
if (classification === 'plain-lan') {
try {
const u = new URL(stored.publicBaseUrl)
storedHostStaleness = localInterfaceHosts.includes(u.hostname) ? 'ok' : 'unreachable'
} catch {
storedHostStaleness = 'unreachable'
}
} else if (classification === 'proxy') {
storedHostStaleness = 'proxy'
} else {
storedHostStaleness = 'unreachable'
}
}
return {
storedHostStaleness,
storedPublicBaseUrl: stored.publicBaseUrl,
effectivePublicBaseUrl,
suggestedHost,
localInterfaceHosts,
}
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
@ -157,11 +213,13 @@ export function resolveEffectiveH5PublicBaseUrl({
storedPublicBaseUrl,
configuredPublicBaseUrl,
autoPublicBaseUrl,
localInterfaceHosts,
}: {
enabled: boolean
storedPublicBaseUrl: string | null
configuredPublicBaseUrl: string | null
autoPublicBaseUrl: string | null
localInterfaceHosts?: string[]
}): string | null {
if (!enabled) {
return storedPublicBaseUrl
@ -179,6 +237,17 @@ export function resolveEffectiveH5PublicBaseUrl({
return autoPublicBaseUrl
}
// Stale-host fallback: stored is a plain private-LAN URL pointing at an IP
// that no longer belongs to any of this machine's interfaces (e.g. user
// switched Wi-Fi). Fall back to the auto-discovered URL without overwriting
// the stored value, so reconnecting to the original network restores it.
if (
Array.isArray(localInterfaceHosts) &&
isStaleLanPublicBaseUrl(storedPublicBaseUrl, localInterfaceHosts)
) {
return autoPublicBaseUrl
}
const refreshedLanUrl = refreshLanPublicBaseUrlPort(storedPublicBaseUrl, autoPublicBaseUrl)
if (refreshedLanUrl) {
return refreshedLanUrl
@ -187,6 +256,41 @@ export function resolveEffectiveH5PublicBaseUrl({
return storedPublicBaseUrl
}
function isStaleLanPublicBaseUrl(value: string, localInterfaceHosts: string[]): boolean {
if (classifyH5PublicBaseUrl(value) !== 'plain-lan') return false
try {
const hostname = new URL(value).hostname
return !localInterfaceHosts.includes(hostname)
} catch {
return false
}
}
/**
* Classify a stored or input H5 publicBaseUrl. A "plain-lan" URL is a bare
* `http://<private-ipv4>:<port>` with no path and no userinfo we know we
* can reach it only if the host is bound to one of our local interfaces.
* Everything else (https, custom path, hostname/proxy URL) is treated as a
* user-managed proxy URL we cannot reachability-check.
*/
export function classifyH5PublicBaseUrl(value: string): H5PublicBaseUrlClassification | 'invalid' {
let url: URL
try {
url = new URL(value)
} catch {
return 'invalid'
}
if (!['http:', 'https:'].includes(url.protocol)) return 'invalid'
if (url.username || url.password) return 'invalid'
const path = url.pathname.replace(/\/+$/, '')
if (url.protocol === 'http:' && path === '' && isPrivateIPv4(url.hostname)) {
return 'plain-lan'
}
return 'proxy'
}
function isLocalPublicBaseUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname
@ -231,6 +335,70 @@ function refreshLanPublicBaseUrlPort(storedPublicBaseUrl: string, autoPublicBase
type NetworkInterfaces = ReturnType<typeof os.networkInterfaces>
export function collectLocalIPv4Hosts(networkInterfaces: NetworkInterfaces = os.networkInterfaces()): string[] {
const hosts: string[] = []
for (const entries of Object.values(networkInterfaces)) {
for (const entry of entries ?? []) {
if (entry.family === 'IPv4' && !entry.internal) {
hosts.push(entry.address)
}
}
}
return hosts
}
function pickPreferredLanHost(localHosts: string[]): string | null {
for (const host of localHosts) {
if (host.startsWith('192.168.')) return host
}
for (const host of localHosts) {
if (host.startsWith('10.')) return host
}
for (const host of localHosts) {
if (is172PrivateIPv4(host)) return host
}
return localHosts[0] ?? null
}
export function validateH5PublicBaseUrl(
publicBaseUrl: string,
localInterfaceHosts: string[] = collectLocalIPv4Hosts(),
): H5PublicBaseUrlValidationResult {
const kind = classifyH5PublicBaseUrl(publicBaseUrl)
if (kind === 'invalid') {
return {
ok: false,
reason: `Invalid H5 publicBaseUrl: ${publicBaseUrl}`,
suggestedHost: pickPreferredLanHost(localInterfaceHosts),
}
}
if (kind === 'plain-lan') {
try {
const hostname = new URL(publicBaseUrl).hostname
if (!localInterfaceHosts.includes(hostname)) {
const suggested = pickPreferredLanHost(localInterfaceHosts)
const availableList = localInterfaceHosts.length > 0
? localInterfaceHosts.join(', ')
: 'none'
return {
ok: false,
reason: `H5 host ${hostname} is not bound to any local network interface on this machine. Available LAN IPv4: ${availableList}`,
suggestedHost: suggested,
}
}
} catch {
return {
ok: false,
reason: `Invalid H5 publicBaseUrl: ${publicBaseUrl}`,
suggestedHost: pickPreferredLanHost(localInterfaceHosts),
}
}
}
return { ok: true, kind }
}
const PHYSICAL_INTERFACE_RE = /\b(wi-?fi|wlan|wireless|ethernet|lan|en\d+|eth\d+)\b/i
const VIRTUAL_INTERFACE_RE = /\b(wsl|docker|hyper-?v|veth|vethernet|virtual|virtualbox|vmware|podman|container|bridge|br-|tailscale|zerotier|utun|vpn)\b/i
@ -436,14 +604,25 @@ export class H5AccessService {
}): Promise<H5AccessSettings> {
return this.managedSettingsService.updateSettings(async (current) => {
const h5Access = normalizeStoredSettings(current.h5Access)
let nextPublicBaseUrl: string | null
if (input.publicBaseUrl === undefined) {
nextPublicBaseUrl = h5Access.publicBaseUrl
} else {
nextPublicBaseUrl = normalizePublicBaseUrl(input.publicBaseUrl)
if (nextPublicBaseUrl !== null) {
const validation = validateH5PublicBaseUrl(nextPublicBaseUrl)
if (!validation.ok) {
throw ApiError.badRequest(validation.reason)
}
}
}
const nextSettings: StoredH5AccessSettings = {
...h5Access,
allowedOrigins: input.allowedOrigins === undefined
? h5Access.allowedOrigins
: normalizeAllowedOrigins(input.allowedOrigins),
publicBaseUrl: input.publicBaseUrl === undefined
? h5Access.publicBaseUrl
: normalizePublicBaseUrl(input.publicBaseUrl),
publicBaseUrl: nextPublicBaseUrl,
}
return {
@ -456,6 +635,11 @@ export class H5AccessService {
})
}
async getDiagnostics(): Promise<H5AccessDiagnostics> {
const { h5Access } = await this.readStoredSettings()
return describeH5AccessDiagnostics(h5Access)
}
async validateToken(token: string | null | undefined): Promise<boolean> {
if (!token) {
return false