mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
2a89331697
commit
63daa34be0
@ -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 />)
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 machine’s 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',
|
||||
|
||||
@ -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': '系统代理',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.'),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user