mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: preserve manual H5 LAN addresses
Manual H5 LAN hosts should survive auto LAN discovery, because the detected adapter can be a WSL or Docker virtual interface instead of the physical network reachable by a phone. The settings UI now asks for the host or IP separately and reuses the current service port for the normal LAN workflow, while still accepting full URLs for reverse proxy setups. Constraint: H5 auto discovery may see multiple private IPv4 adapters on Windows hosts. Rejected: Treat all private URLs as stale auto-discovery output | this overwrites intentional LAN choices. Rejected: Let users manually type the port for the normal LAN path | the server already owns the bound port and the UI can reuse it. Confidence: high Scope-risk: moderate Tested: bun test src/server/__tests__/h5-access-service.test.ts 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 Tested: bun run check:server Tested: bun run check:desktop Not-tested: Real Windows plus WSL/Docker plus phone LAN smoke.
This commit is contained in:
parent
6b62b6c633
commit
da8308debf
@ -857,6 +857,7 @@ describe('Settings > General tab', () => {
|
||||
fireEvent.click(screen.getByText('H5 Access'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
|
||||
expect(within(section).getByLabelText('Access host / IP')).toHaveValue('https://phone.example/app')
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Copy H5 URL' }))
|
||||
})
|
||||
@ -878,7 +879,35 @@ describe('Settings > General tab', () => {
|
||||
expect(within(section).getByText('H5 unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates H5 public URL from General settings', async () => {
|
||||
it('updates H5 host by reusing the current service port', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5a1b2c3',
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: 'http://172.20.16.1:54064',
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('H5 Access'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
expect(within(section).getByLabelText('Current port')).toHaveValue('54064')
|
||||
fireEvent.change(within(section).getByLabelText('Access host / IP'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' }))
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
|
||||
publicBaseUrl: 'http://192.168.1.100:54064',
|
||||
})
|
||||
})
|
||||
|
||||
it('still accepts a full H5 public URL for reverse proxy setups', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
@ -892,7 +921,7 @@ describe('Settings > General tab', () => {
|
||||
fireEvent.click(screen.getByText('H5 Access'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
fireEvent.change(within(section).getByLabelText('Public URL'), {
|
||||
fireEvent.change(within(section).getByLabelText('Access host / IP'), {
|
||||
target: { value: 'https://phone.example/app' },
|
||||
})
|
||||
|
||||
|
||||
@ -879,12 +879,16 @@ export const en = {
|
||||
'settings.general.h5AccessLaunchUrlCopied': 'QR link copied.',
|
||||
'settings.general.h5AccessHideToken': 'Hide token',
|
||||
'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new token.',
|
||||
'settings.general.h5AccessPublicHost': 'Access host / IP',
|
||||
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
|
||||
'settings.general.h5AccessCurrentPort': 'Current port',
|
||||
'settings.general.h5AccessCurrentPortUnknown': 'Auto',
|
||||
'settings.general.h5AccessPublicUrl': 'Public URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': 'Allowed origins',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.',
|
||||
'settings.general.h5AccessOpenHint': 'Leave blank to use the detected LAN URL. Set this only when you expose H5 through a stable address.',
|
||||
'settings.general.h5AccessOpenHint': 'For LAN access, change only the host / IP; the current service port is reused. Enter a full URL for reverse proxies.',
|
||||
'settings.general.h5AccessSave': 'Save H5 settings',
|
||||
'settings.general.h5AccessUrl': 'H5 URL',
|
||||
'settings.general.h5AccessQrTitle': 'Phone connection',
|
||||
|
||||
@ -881,12 +881,16 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.h5AccessLaunchUrlCopied': '扫码链接已复制。',
|
||||
'settings.general.h5AccessHideToken': '隐藏令牌',
|
||||
'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。',
|
||||
'settings.general.h5AccessPublicHost': '访问主机 / IP',
|
||||
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
|
||||
'settings.general.h5AccessCurrentPort': '当前端口',
|
||||
'settings.general.h5AccessCurrentPortUnknown': '自动',
|
||||
'settings.general.h5AccessPublicUrl': '公开访问 URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': '允许的来源',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。',
|
||||
'settings.general.h5AccessOpenHint': '留空时使用自动探测的局域网地址。只有通过固定地址暴露 H5 时才需要填写。',
|
||||
'settings.general.h5AccessOpenHint': '普通局域网访问只改主机 / IP,端口使用当前服务端口。反向代理可直接填完整 URL。',
|
||||
'settings.general.h5AccessSave': '保存 H5 设置',
|
||||
'settings.general.h5AccessUrl': 'H5 链接',
|
||||
'settings.general.h5AccessQrTitle': '手机连接',
|
||||
|
||||
@ -73,6 +73,58 @@ function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string
|
||||
}
|
||||
}
|
||||
|
||||
function isLanH5BaseUrl(url: URL): boolean {
|
||||
return url.protocol === 'http:' &&
|
||||
!!url.port &&
|
||||
(
|
||||
url.hostname === 'localhost' ||
|
||||
url.hostname === '127.0.0.1' ||
|
||||
url.hostname.startsWith('10.') ||
|
||||
url.hostname.startsWith('192.168.') ||
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(url.hostname) ||
|
||||
url.hostname.startsWith('169.254.')
|
||||
)
|
||||
}
|
||||
|
||||
function extractH5AccessAddressDraft(baseUrl: string | null): string {
|
||||
if (!baseUrl) return ''
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
return isLanH5BaseUrl(url) ? url.hostname : baseUrl
|
||||
} catch {
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
function extractH5AccessPort(baseUrl: string | null): string | null {
|
||||
if (!baseUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
return url.port || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string | null): string | null {
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) return null
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
|
||||
|
||||
try {
|
||||
const current = currentBaseUrl ? new URL(currentBaseUrl) : null
|
||||
if (!current) return trimmed
|
||||
|
||||
const port = current.port ? `:${current.port}` : ''
|
||||
const path = current.pathname === '/' ? '' : current.pathname.replace(/\/+$/, '')
|
||||
return `${current.protocol}//${trimmed}${port}${path}`
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
|
||||
@ -2465,7 +2517,7 @@ function H5AccessSettings() {
|
||||
} = useSettingsStore()
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '')
|
||||
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
|
||||
const [h5GeneratedToken, setH5GeneratedToken] = useState<string | null>(null)
|
||||
const [h5TokenVisible, setH5TokenVisible] = useState(false)
|
||||
const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false)
|
||||
@ -2476,10 +2528,12 @@ function H5AccessSettings() {
|
||||
() => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken),
|
||||
[h5AccessUrl, h5GeneratedToken],
|
||||
)
|
||||
const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '')
|
||||
const h5AccessPort = extractH5AccessPort(h5AccessUrl)
|
||||
const h5NextPublicBaseUrl = buildH5PublicBaseUrlFromHostDraft(h5PublicBaseUrlDraft, h5Access.publicBaseUrl)
|
||||
const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '')
|
||||
setH5PublicBaseUrlDraft(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
|
||||
}, [h5Access])
|
||||
|
||||
useEffect(() => {
|
||||
@ -2518,7 +2572,7 @@ function H5AccessSettings() {
|
||||
const handleH5SettingsSave = async () => {
|
||||
await runH5Action(async () => {
|
||||
await updateH5AccessSettings({
|
||||
publicBaseUrl: h5PublicBaseUrlDraft.trim() || null,
|
||||
publicBaseUrl: h5NextPublicBaseUrl,
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -2624,13 +2678,22 @@ function H5AccessSettings() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||
<Input
|
||||
id="h5-access-public-url"
|
||||
label={t('settings.general.h5AccessPublicUrl')}
|
||||
value={h5PublicBaseUrlDraft}
|
||||
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
|
||||
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<Input
|
||||
id="h5-access-public-url"
|
||||
label={t('settings.general.h5AccessPublicHost')}
|
||||
value={h5PublicBaseUrlDraft}
|
||||
placeholder={t('settings.general.h5AccessPublicHostPlaceholder')}
|
||||
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="h5-access-current-port"
|
||||
label={t('settings.general.h5AccessCurrentPort')}
|
||||
value={h5AccessPort ?? t('settings.general.h5AccessCurrentPortUnknown')}
|
||||
readOnly
|
||||
className="text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessOpenHint')}
|
||||
|
||||
@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import {
|
||||
findPrivateLanAddress,
|
||||
H5AccessService,
|
||||
resolveEffectiveH5PublicBaseUrl,
|
||||
} from '../services/h5AccessService.js'
|
||||
@ -100,10 +101,10 @@ describe('H5AccessService', () => {
|
||||
expect(result.settings.publicBaseUrl).toBe('https://chat.example.com/app')
|
||||
})
|
||||
|
||||
test('auto LAN mode replaces stale local public URLs but keeps public reverse proxies', () => {
|
||||
test('auto LAN mode fills blank or loopback URLs but preserves manual LAN URLs', () => {
|
||||
expect(resolveEffectiveH5PublicBaseUrl({
|
||||
enabled: true,
|
||||
storedPublicBaseUrl: 'http://192.168.0.102:5179',
|
||||
storedPublicBaseUrl: null,
|
||||
configuredPublicBaseUrl: null,
|
||||
autoPublicBaseUrl: 'http://192.168.0.102:39876',
|
||||
})).toBe('http://192.168.0.102:39876')
|
||||
@ -115,6 +116,13 @@ describe('H5AccessService', () => {
|
||||
autoPublicBaseUrl: 'http://192.168.0.102:39876',
|
||||
})).toBe('http://192.168.0.102:39876')
|
||||
|
||||
expect(resolveEffectiveH5PublicBaseUrl({
|
||||
enabled: true,
|
||||
storedPublicBaseUrl: 'http://192.168.1.100:54064',
|
||||
configuredPublicBaseUrl: null,
|
||||
autoPublicBaseUrl: 'http://172.20.16.1:39876',
|
||||
})).toBe('http://192.168.1.100:54064')
|
||||
|
||||
expect(resolveEffectiveH5PublicBaseUrl({
|
||||
enabled: true,
|
||||
storedPublicBaseUrl: 'https://chat.example.com/app',
|
||||
@ -123,6 +131,35 @@ describe('H5AccessService', () => {
|
||||
})).toBe('https://chat.example.com/app')
|
||||
})
|
||||
|
||||
test('auto LAN detection prefers physical adapters over WSL and Docker virtual adapters', () => {
|
||||
expect(findPrivateLanAddress({
|
||||
'vEthernet (WSL)': [{
|
||||
address: '172.20.16.1',
|
||||
netmask: '255.255.240.0',
|
||||
family: 'IPv4',
|
||||
mac: '00:15:5d:00:00:01',
|
||||
internal: false,
|
||||
cidr: '172.20.16.1/20',
|
||||
}],
|
||||
'Docker Desktop': [{
|
||||
address: '172.17.0.1',
|
||||
netmask: '255.255.0.0',
|
||||
family: 'IPv4',
|
||||
mac: '02:42:ac:11:00:01',
|
||||
internal: false,
|
||||
cidr: '172.17.0.1/16',
|
||||
}],
|
||||
'Wi-Fi': [{
|
||||
address: '192.168.1.100',
|
||||
netmask: '255.255.255.0',
|
||||
family: 'IPv4',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
internal: false,
|
||||
cidr: '192.168.1.100/24',
|
||||
}],
|
||||
})).toBe('192.168.1.100')
|
||||
})
|
||||
|
||||
test('regenerateToken invalidates the previous token', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
|
||||
@ -175,47 +175,89 @@ export function resolveEffectiveH5PublicBaseUrl({
|
||||
return storedPublicBaseUrl
|
||||
}
|
||||
|
||||
if (!storedPublicBaseUrl || isLocalOrPrivatePublicBaseUrl(storedPublicBaseUrl)) {
|
||||
if (!storedPublicBaseUrl || isLocalPublicBaseUrl(storedPublicBaseUrl)) {
|
||||
return autoPublicBaseUrl
|
||||
}
|
||||
|
||||
return storedPublicBaseUrl
|
||||
}
|
||||
|
||||
function isLocalOrPrivatePublicBaseUrl(value: string): boolean {
|
||||
function isLocalPublicBaseUrl(value: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(value).hostname
|
||||
.trim()
|
||||
.replace(/^\[/, '')
|
||||
.replace(/\]$/, '')
|
||||
.toLowerCase()
|
||||
return isLocalOrPrivateHost(hostname)
|
||||
return isLocalHost(hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalOrPrivateHost(hostname: string): boolean {
|
||||
function isLocalHost(hostname: string): boolean {
|
||||
return hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
isPrivateIPv4(hostname) ||
|
||||
hostname.startsWith('fc') ||
|
||||
hostname.startsWith('fd') ||
|
||||
hostname.startsWith('fe80:')
|
||||
hostname === '::'
|
||||
}
|
||||
|
||||
function findPrivateLanAddress(): string | null {
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
type NetworkInterfaces = ReturnType<typeof os.networkInterfaces>
|
||||
|
||||
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
|
||||
|
||||
export function findPrivateLanAddress(networkInterfaces: NetworkInterfaces = os.networkInterfaces()): string | null {
|
||||
const candidates: Array<{
|
||||
address: string
|
||||
interfaceName: string
|
||||
index: number
|
||||
score: number
|
||||
}> = []
|
||||
|
||||
let index = 0
|
||||
for (const [interfaceName, entries] of Object.entries(networkInterfaces)) {
|
||||
for (const entry of entries ?? []) {
|
||||
if (entry.family !== 'IPv4' || entry.internal || !isPrivateIPv4(entry.address)) {
|
||||
continue
|
||||
}
|
||||
return entry.address
|
||||
|
||||
candidates.push({
|
||||
address: entry.address,
|
||||
interfaceName,
|
||||
index,
|
||||
score: scoreLanAddressCandidate(interfaceName, entry.address),
|
||||
})
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
return candidates[0]?.address ?? null
|
||||
}
|
||||
|
||||
function scoreLanAddressCandidate(interfaceName: string, address: string): number {
|
||||
let score = 0
|
||||
|
||||
if (PHYSICAL_INTERFACE_RE.test(interfaceName)) {
|
||||
score += 100
|
||||
}
|
||||
if (VIRTUAL_INTERFACE_RE.test(interfaceName)) {
|
||||
score -= 200
|
||||
}
|
||||
|
||||
if (address.startsWith('192.168.')) {
|
||||
score += 30
|
||||
} else if (address.startsWith('10.')) {
|
||||
score += 20
|
||||
} else if (is172PrivateIPv4(address)) {
|
||||
score += 10
|
||||
} else if (address.startsWith('169.254.')) {
|
||||
score -= 100
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string): boolean {
|
||||
@ -227,12 +269,22 @@ function isPrivateIPv4(address: string): boolean {
|
||||
const [a = -1, b = -1] = parts.map((part) => Number(part))
|
||||
return (
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
is172PrivateIPv4(address) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
)
|
||||
}
|
||||
|
||||
function is172PrivateIPv4(address: string): boolean {
|
||||
const parts = address.split('.')
|
||||
if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [a = -1, b = -1] = parts.map((part) => Number(part))
|
||||
return a === 172 && b >= 16 && b <= 31
|
||||
}
|
||||
|
||||
function normalizeStoredSettings(value: unknown): StoredH5AccessSettings {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_STORED_SETTINGS }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user