diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index f2066b63..4c513d85 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + 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() + 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() + 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() diff --git a/desktop/src/api/h5Access.ts b/desktop/src/api/h5Access.ts index f2dd7f47..6e1a5422 100644 --- a/desktop/src/api/h5Access.ts +++ b/desktop/src/api/h5Access.ts @@ -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 = { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 34732afa..2699ed09 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index c337bef1..5f612f24 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -903,6 +903,11 @@ export const zh: Record = { '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': '系统代理', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 5ce407e1..a5ac0cb0 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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() { + {h5AccessDiagnostics?.storedHostStaleness === 'unreachable' && h5AccessDiagnostics.storedPublicBaseUrl ? ( +
+
+ {t('settings.general.h5AccessStaleHostTitle')} +
+
+ {h5AccessDiagnostics.suggestedHost + ? t('settings.general.h5AccessStaleHostBody', { + storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl, + }) + : t('settings.general.h5AccessStaleHostNoSuggestion', { + storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl, + })} +
+ {h5AccessDiagnostics.suggestedHost && ( +
+ +
+ )} +
+ ) : null} + + {h5AccessDiagnostics?.storedHostStaleness === 'proxy' ? ( +
+ {t('settings.general.h5AccessProxyNote')} +
+ ) : null} +
((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((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((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((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((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((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((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) => void, +): Promise { + // 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.'), } } diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 6624e74b..33b9175d 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -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' diff --git a/src/server/__tests__/h5-access-api.test.ts b/src/server/__tests__/h5-access-api.test.ts index 681255fb..2a434575 100644 --- a/src/server/__tests__/h5-access-api.test.ts +++ b/src/server/__tests__/h5-access-api.test.ts @@ -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 () => { diff --git a/src/server/__tests__/h5-access-service.test.ts b/src/server/__tests__/h5-access-service.test.ts index 99ddf5c9..10f34dfc 100644 --- a/src/server/__tests__/h5-access-service.test.ts +++ b/src/server/__tests__/h5-access-service.test.ts @@ -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() diff --git a/src/server/api/h5-access.ts b/src/server/api/h5-access.ts index 8311b916..f011dd45 100644 --- a/src/server/api/h5-access.ts +++ b/src/server/api/h5-access.ts @@ -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) diff --git a/src/server/services/h5AccessService.ts b/src/server/services/h5AccessService.ts index 30b3afba..eb0b89b3 100644 --- a/src/server/services/h5AccessService.ts +++ b/src/server/services/h5AccessService.ts @@ -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://:` 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 +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 { 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 { + const { h5Access } = await this.readStoredSettings() + return describeH5AccessDiagnostics(h5Access) + } + async validateToken(token: string | null | undefined): Promise { if (!token) { return false