diff --git a/README.md b/README.md index 2a26743..5ef863c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ npm start - `auth.mode`:`none` 表示无密码;`password` 表示由本地聚合服务代登录。 - `auth.password`:可选。本地保存后可自动刷新会话;不要提交真实密码。 +### 为什么只接受 IP 地址? + +聚合服务会代理设备管理 API。为避免 DNS 重绑定将已校验主机名切换到本机或元数据地址,设备 URL 必须使用明确的 IPv4/IPv6 字面地址;局域网 IP(如 `192.168.x.x`)可正常使用。 + ## 常用命令 ```bash @@ -89,7 +93,7 @@ npm audit --omit=dev --audit-level=high git diff --check ``` -第一阶段只提供架构端口和基础安全不变量。控制面完整鉴权、CSRF、完整 SSRF(DNS 解析/重绑定与私网 CIDR policy)、审计/权限,以及前端 CRUD 可达性和整体 UI 重构留到第二阶段。默认监听 `127.0.0.1`;显式配置 `0.0.0.0` 会保持,但这会暴露当前尚未鉴权的控制面。 +第二阶段安全加固后,服务仅允许监听 loopback;显式配置 `0.0.0.0`、LAN 地址或非 loopback 主机将拒绝启动。实例目标必须为允许的 IP 字面地址,拒绝凭据、loopback、unspecified、link-local、metadata、multicast 及主机名,以避免 DNS 重绑定。生产代理使用精确路径/方法白名单,写操作要求短期一次性确认令牌。 ## 注意 diff --git a/public/app.js b/public/app.js index c8d6a5b..5e56334 100644 --- a/public/app.js +++ b/public/app.js @@ -1,577 +1,110 @@ import { escapeHtml, value, formatBytes } from './domain/formatters.js' import { createApiClient, normalizeTargetUrl } from './infrastructure/api-client.js' import { RequestCoordinator } from './state/request-coordinator.js' +import { filterAndSortFleet, fleetStatusKind, emptyFleetReason } from './state/fleet-view-model.js' +import { instanceDraftPayload, requestDraft, isWriteMethod } from './state/operation-draft.js' import { createViewRouter, resolveView } from './router/view-router.js' -let instances = [] -let statuses = new Map() -let catalog = [] -let activeId = null -let activeFilter = 'all' -let configPath = 'config.json' -let autoRefreshTimer = null - -const $ = (id) => document.getElementById(id) -const $$ = (selector, root = document) => [...root.querySelectorAll(selector)] - -const modules = [ - { id: 'overview', title: '设备总览', desc: '设备、SIM、网络、连接状态与流量统计。适合日常巡检和资产核对。', tone: 'ok', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] }, - { id: 'sms', title: '短信中心', desc: '短信统计、列表、会话读取与短信发送。发送类接口请先在 API 工作台确认请求体。', tone: 'warn', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/conversation', '/api/sms/send'] }, - { id: 'network', title: '蜂窝网络', desc: '数据开关、漫游、飞行模式、无线制式、频段/小区锁定与 APN。', tone: 'danger', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] }, - { id: 'device-network', title: '设备网络', desc: 'WLAN、DDNS、连接地址与局域网访问能力。', tone: 'neutral', endpoints: ['/api/device-network/wlan/status', '/api/device-network/wlan/profiles', '/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/network/connection-addresses'] }, - { id: 'calls', title: '电话能力', desc: '通话列表、历史记录、IMS 与语音信箱状态。', tone: 'neutral', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] }, - { id: 'esim', title: 'eSIM / 工作模式', desc: 'eUICC、profile、LPAC 状态、工作模式与 eSIM 配置。', tone: 'warn', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] }, - { id: 'notify-auto-ota', title: '通知 / 自动化 / OTA', desc: '通知配置与日志、自动化任务日志、在线升级状态。', tone: 'neutral', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] }, -] - -const requests = new RequestCoordinator() -const api = createApiClient() +let instances = [], statuses = new Map(), catalog = [], activeId = null, activeFilter = 'all', activeSort = 'name' +let lastSuccessfulRefresh = null, currentView = 'home', outputText = '', outputPrettyText = '', pendingDeleteId = null, pendingApiRequest = null +let instanceDialogEpoch = 0, deleteDialogEpoch = 0, loginDialogEpoch = 0 +const $ = id => document.getElementById(id), $$ = (selector, root = document) => [...root.querySelectorAll(selector)] +const requests = new RequestCoordinator(), api = createApiClient() const routeView = createViewRouter({ controls: () => $$('[data-view]'), panels: () => $$('.view') }) - -function boolText(v) { - if (v === true) return '开启' - if (v === false) return '关闭' - return value(v) +const modules = [ + { name: '设备与网络', endpoints: ['/api/device','/api/sim','/api/network','/api/stats','/api/connectivity','/api/data','/api/roaming','/api/airplane-mode','/api/radio-mode','/api/band-lock','/api/cell-lock','/api/apn'] }, + { name: '短信与通话', endpoints: ['/api/sms/stats','/api/sms/list','/api/sms/conversation','/api/sms/send','/api/calls','/api/call/history'] }, + { name: '系统服务', endpoints: ['/api/work-mode','/api/esim/config','/api/notifications/config','/api/automation/config','/api/ota/status'] }, +] +const firstValue = (...items) => items.find(x => x !== undefined && x !== null && x !== '') ?? null +const activeInstance = () => instances.find(x => x.id === activeId) +const activeStatus = () => statuses.get(activeId) +const tagsFromInput = text => String(text || '').split(',').map(x => x.trim()).filter(Boolean) +const statusText = s => ({ online: `在线${s?.latencyMs != null ? ` · ${s.latencyMs}ms` : ''}`, auth: '需登录', offline: '离线', unknown: '未知' })[fleetStatusKind(s)] +const showToast = message => { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(showToast.timer); showToast.timer = setTimeout(() => { $('toast').hidden = true }, 3000) } +function persistentError(message = '') { $('persistentErrorText').textContent = message; $('persistentError').hidden = !message } +function setPending(button, pending, label) { button.disabled = pending; if (label) button.textContent = pending ? label : button.dataset.idleLabel } +function invalidateDialog(id) { + if (id === 'instanceDialog') { instanceDialogEpoch += 1; setPending($('saveInstanceBtn'), false, '保存中…') } + if (id === 'deleteDialog') { deleteDialogEpoch += 1; setPending($('confirmDeleteBtn'), false, '删除中…') } + if (id === 'loginDialog') { loginDialogEpoch += 1; requests.invalidate('login'); $('submitLogin').disabled = false; $('passwordInput').value = '' } + if (id === 'apiDeleteDialog') pendingApiRequest = null } -function activeInstance() { return instances.find(x => x.id === activeId) } -function activeStatus() { return statuses.get(activeId) } -function tagsFromInput(text) { return String(text || '').split(',').map(x => x.trim()).filter(Boolean) } -function instancePayloadFromForm() { - return { - id: $('instanceIdInput').value.trim(), - name: $('instanceNameInput').value.trim(), - url: $('instanceUrlInput').value.trim(), - description: $('instanceDescInput').value.trim(), - tags: tagsFromInput($('instanceTagsInput').value), - auth: { password: $('instancePasswordInput').value }, - } -} -function statusKind(s) { - if (!s) return 'unknown' - if (!s.reachable) return 'offline' - if (s.authenticated === false) return 'auth' - return 'online' -} -function statusText(s) { - const kind = statusKind(s) - if (kind === 'online') return `在线${s.latencyMs != null ? ` ${s.latencyMs}ms` : ''}` - if (kind === 'auth') return '需登录' - if (kind === 'offline') return '离线' - return '未知' -} -function pillClass(s) { - const kind = statusKind(s) - if (kind === 'online') return 'ok' - if (kind === 'auth') return 'warn' - if (kind === 'offline') return 'bad' - return '' -} -function showToast(message, timeout = 2600) { - const toast = $('toast') - toast.textContent = message - toast.hidden = false - clearTimeout(showToast._timer) - showToast._timer = setTimeout(() => { toast.hidden = true }, timeout) -} -function flattenEndpoints() { - return [...new Set([ - ...catalog.flatMap(g => g.endpoints || []), - ...modules.flatMap(m => m.endpoints || []), - ])].sort() -} -function inferMethod(endpoint) { - if (/send|config|data$|roaming$|airplane-mode$|radio-mode$|band-lock$|cell-lock$|work-mode|login|logout/i.test(endpoint)) return 'POST' - return 'GET' -} -function firstValue(...items) { - for (const item of items) if (item !== undefined && item !== null && item !== '') return item - return null +function closeDialog(id) { invalidateDialog(id); const dialog=$(id); if(dialog.open)dialog.close() } +function frameClear() { $('frame').hidden = true; $('frame').removeAttribute('src') } +function switchView(requested) { + const view = resolveView(requested, Boolean(activeId)); currentView = view + if (view !== 'rawpage') frameClear() + if (view === 'rawpage') { const item = activeInstance(); $('frame').src = item.url; $('frame').hidden = false } + routeView(view) + const item = activeInstance(), titles = { home: '设备首页', detail: item ? `${item.name || item.id} · 设备详情` : '设备详情', rawpage: item ? `${item.name || item.id} · 原始页面` : '原始页面', api: item ? `${item.name || item.id} · API 工作台` : 'API 工作台' } + $('pageHeading').textContent = titles[view]; $('pageSubtitle').textContent = view === 'home' ? '集中巡检你的 SimAdmin 设备' : item?.url || '' + document.title = `${titles[view]} · Multi SimAdmin Island` } function formatSpeed(speed) { if (!speed || typeof speed !== 'object') return '-' - if (Array.isArray(speed.interfaces)) { - return speed.interfaces.map(item => `${item.interface || 'net'} ↓ ${formatBytes(firstValue(item.rx_bytes_per_sec, item.rx, item.download))}/s · ↑ ${formatBytes(firstValue(item.tx_bytes_per_sec, item.tx, item.upload))}/s`).join(' | ') - } - const rx = firstValue(speed.rx, speed.download, speed.recv, speed.received, speed.rx_bytes_per_sec) - const tx = firstValue(speed.tx, speed.upload, speed.sent, speed.tx_bytes_per_sec) - return `↓ ${formatBytes(rx)}/s · ↑ ${formatBytes(tx)}/s` + if (Array.isArray(speed.interfaces)) return speed.interfaces.map(x => `${x.interface || 'net'} ↓ ${formatBytes(firstValue(x.rx_bytes_per_sec,x.rx))}/s · ↑ ${formatBytes(firstValue(x.tx_bytes_per_sec,x.tx))}/s`).join(' | ') + return `↓ ${formatBytes(firstValue(speed.rx,speed.download,speed.rx_bytes_per_sec))}/s · ↑ ${formatBytes(firstValue(speed.tx,speed.upload,speed.tx_bytes_per_sec))}/s` } -function formatTemperature(temp) { - if (temp === undefined || temp === null || temp === '') return '-' - if (typeof temp === 'number') return `${temp}℃` - if (typeof temp === 'string') return temp.includes('°') || temp.includes('℃') ? temp : `${temp}℃` - if (Array.isArray(temp)) { - return temp.map(item => `${item.type || item.zone || 'temp'}: ${value(item.temperature)}℃`).join(' | ') || '-' - } - if (typeof temp === 'object') { - return Object.entries(temp).map(([k, v]) => `${k}: ${typeof v === 'number' ? `${v}℃` : value(v)}`).join(' · ') || '-' - } - return value(temp) -} -function formatPercentPair(obj) { - if (!obj || typeof obj !== 'object') return '-' - if (Array.isArray(obj)) { - return obj.map(item => `${item.mount_point || item.name || 'disk'} ${formatPercentPair(item)}`).join(' | ') - } - const used = Number(firstValue(obj.used, obj.used_bytes)) - const total = Number(firstValue(obj.total, obj.total_bytes)) - const percent = Number(firstValue(obj.used_percent, obj.percent)) - if (Number.isFinite(used) && Number.isFinite(total) && total > 0) return `${formatBytes(used)} / ${formatBytes(total)} (${Math.round(Number.isFinite(percent) ? percent : used / total * 100)}%)` - const available = firstValue(obj.available, obj.available_bytes, obj.free, obj.free_bytes) - if (available != null && total) return `可用 ${formatBytes(available)} / 总 ${formatBytes(total)}` - return Object.entries(obj).map(([k, v]) => `${k}: ${typeof v === 'number' ? formatBytes(v) : value(v)}`).join(' · ') || '-' -} -function formatUptime(seconds) { - if (seconds && typeof seconds === 'object') return seconds.uptime_formatted || seconds.formatted || formatUptime(seconds.uptime_seconds) - const n = Number(seconds) - if (!Number.isFinite(n)) return value(seconds) - const d = Math.floor(n / 86400) - const h = Math.floor((n % 86400) / 3600) - const m = Math.floor((n % 3600) / 60) - return [d ? `${d}天` : '', h ? `${h}小时` : '', `${m}分`].filter(Boolean).join(' ') -} -function metricCard(label, val, tone = '') { - return `
${escapeHtml(label)}${escapeHtml(value(val))}
` -} -function detailCard(title, rows, tone = '') { - return `

${escapeHtml(title)}

${rows.map(([k, v]) => `
${escapeHtml(k)}
${escapeHtml(value(v))}
`).join('')}
` -} -function getMonitorData() { - const item = activeInstance() - const s = activeStatus() - const sum = s?.summary || {} - return { item, s, sum, device: sum.device || {}, sim: sum.sim || {}, network: sum.network || {}, data: sum.data || {}, sms: sum.sms || {}, ota: sum.ota || {}, system: sum.system || {} } -} - +function formatTemperature(temp) { if (temp == null || temp === '') return '-'; if (typeof temp === 'object') return Object.entries(temp).map(([k,v]) => `${k}: ${v}${typeof v === 'number' ? '℃' : ''}`).join(' · '); return `${temp}${typeof temp === 'number' ? '℃' : ''}` } function renderFleetStats() { - const total = instances.length - const counts = { online: 0, auth: 0, offline: 0, unknown: 0 } - for (const inst of instances) counts[statusKind(statuses.get(inst.id))] += 1 - $('fleetStats').innerHTML = [ - ['全部', total], ['在线', counts.online], ['需登录', counts.auth], ['离线', counts.offline], - ].map(([label, num]) => `
${num}${label}
`).join('') + const counts = { all: instances.length, online: 0, auth: 0, offline: 0, unknown: 0 }; for (const item of instances) counts[fleetStatusKind(statuses.get(item.id))]++ + $('fleetStats').innerHTML = Object.entries({ all:'全部', online:'在线', auth:'需登录', offline:'离线', unknown:'未知' }).map(([k,label]) => ``).join('') + $$('[data-stat-filter]').forEach(btn => btn.addEventListener('click', () => setFilter(btn.dataset.statFilter))) } - -function filteredInstances() { - const q = $('instanceSearch')?.value.trim().toLowerCase() || '' - return instances.filter(item => { - const s = statuses.get(item.id) - const kind = statusKind(s) - if (activeFilter !== 'all' && kind !== activeFilter) return false - if (!q) return true - const haystack = [item.id, item.name, item.url, item.description, JSON.stringify(s?.summary || {})].join(' ').toLowerCase() - return haystack.includes(q) - }) -} - -function deviceSummary(item) { - const s = statuses.get(item.id) - const sum = s?.summary || {} - return { item, s, sum, device: sum.device || {}, sim: sum.sim || {}, network: sum.network || {}, data: sum.data || {}, sms: sum.sms || {}, ota: sum.ota || {}, system: sum.system || {} } -} - +function setFilter(filter) { activeFilter = filter; $$('[data-filter]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter))); renderHomeCards() } +function deviceSummary(item) { const s = statuses.get(item.id), sum = s?.summary || {}; return { s, device:sum.device||{}, sim:sum.sim||{}, network:sum.network||{}, data:sum.data||{}, sms:sum.sms||{}, system:sum.system||{}, ota:sum.ota||{} } } function renderHomeCards() { - const root = $('homeDeviceGrid') - renderFleetStats() - if (!instances.length) { - root.innerHTML = '

未配置设备

点击“添加设备”新增 SimAdmin 地址后,这里会出现设备卡片。

' - return - } - const list = filteredInstances() - if (!list.length) { - root.innerHTML = '

没有匹配设备

调整搜索关键字或状态筛选。

' - return - } - root.innerHTML = list.map(item => { - const { s, device, sim, network, data, sms, system, ota } = deviceSummary(item) - const dataState = firstValue(data.active, data.connected, data.enabled) - return `
-
-
-

${escapeHtml(item.id)}

-

${escapeHtml(item.name || item.id)}

- ${escapeHtml(item.url)} -
- ${escapeHtml(statusText(s))} -
-
${escapeHtml(value([device.manufacturer, device.model].filter(Boolean).join(' ') || device.imei || '未知设备'))}
-
- SIM${escapeHtml(sim.present === false ? '未插卡' : value(sim.iccid))} - 信号${escapeHtml(value(firstValue(network.signal, sim.signal)))} - 温度${escapeHtml(formatTemperature(system.temperature))} - 流量${escapeHtml(formatSpeed(system.networkSpeed))} - 数据${escapeHtml(dataState === true ? '已连接' : dataState === false ? '未连接' : '-')} - 短信${escapeHtml(sms.total != null ? `总 ${sms.total}` : '-')} -
-
- ${escapeHtml(value(network.operator || sim.operator || network.registration))} - ${escapeHtml(value(ota.currentVersion || device.firmware || device.revision))} -
-
` - }).join('') - $$('[data-open-detail]', root).forEach(card => { - const open = () => openDeviceDetail(card.dataset.openDetail) - card.addEventListener('click', open) - card.addEventListener('keydown', (event) => { - if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open() } - }) - }) + renderFleetStats(); const query = $('instanceSearch').value, list = filterAndSortFleet(instances, statuses, { filter:activeFilter, query, sort:activeSort }), reason = emptyFleetReason({ total:instances.length, query, filter:activeFilter, visible:list.length }) + if (reason) { const copy = { config:['未配置设备','添加第一个 SimAdmin 地址,开始建设你的设备岛。'], search:['搜索无结果','没有设备匹配当前搜索词,请修改或清空搜索。'], filter:['筛选无结果','当前状态下没有设备,可切换到“全部”。'] }[reason]; $('homeDeviceGrid').innerHTML = `

${copy[0]}

${copy[1]}

`; return } + $('homeDeviceGrid').innerHTML = list.map(item => { const { s,device,sim,network,data,sms,system,ota } = deviceSummary(item); return `

${escapeHtml(item.id)}

${escapeHtml(item.name || item.id)}

${escapeHtml(statusText(s))}

${escapeHtml(item.url)}

${escapeHtml(item.description || '暂无描述')}

${(item.tags||[]).map(tag=>`${escapeHtml(tag)}`).join('')}
${escapeHtml([device.manufacturer,device.model].filter(Boolean).join(' ') || device.imei || '未知设备')}
ICCID / SIM
${escapeHtml(sim.present === false ? '未插卡' : value(sim.iccid))}
信号
${escapeHtml(value(firstValue(network.signal,sim.signal)))}
温度
${escapeHtml(formatTemperature(system.temperature))}
实时流量
${escapeHtml(formatSpeed(system.networkSpeed))}
短信
${escapeHtml(sms.total != null ? `总 ${sms.total}` : '-')}
版本 / Commit
${escapeHtml(value(ota.currentVersion || device.firmware || device.revision))}${ota.currentCommit ? ` · ${escapeHtml(ota.currentCommit)}` : ''}
` }).join('') + $$('[data-open-detail]').forEach(b => b.addEventListener('click', () => openDeviceDetail(b.dataset.openDetail))); $$('[data-edit-instance]').forEach(b => b.addEventListener('click', () => openInstanceDialog(b.dataset.editInstance))) } - -function renderInstances() { renderHomeCards() } - -function openDeviceDetail(id) { - const item = instances.find(x => x.id === id) - if (!item) return - if (activeId !== id) { requests.invalidate('api-console'); requests.invalidate('login') } - activeId = id - localStorage.setItem('multi-simadmin-active', id) - $('loginBtn').disabled = false - $('openExternal').href = item.url - $('openExternal').classList.remove('disabled') - $('emptyState').hidden = true - $('frame').hidden = false - $('frame').src = item.url - switchView('detail') - renderHomeCards() - renderOverview() - renderDeviceDetails() - renderDetailActions() +function detailCard(title, rows) { return `

${escapeHtml(title)}

${rows.map(([k,v])=>`
${escapeHtml(k)}
${escapeHtml(value(v))}
`).join('')}
` } +function renderDeviceDetails() { renderDetail() } +function renderDetail() { + const item=activeInstance(), s=activeStatus(); if (!item) return; const {device,sim,network,data,sms,system,ota}=deviceSummary(item) + $('activeKicker').textContent=`${item.id} · ${statusText(s)}`; $('activeName').textContent=item.name||item.id; $('activeMeta').textContent=`${item.url}${item.description ? ` · ${item.description}` : ''}` + const signal=[['状态',statusText(s)],['运营商',network.operator||sim.operator],['信号',firstValue(network.signal,sim.signal)],['温度',formatTemperature(system.temperature)],['流量',formatSpeed(system.networkSpeed)],['数据连接',value(firstValue(data.active,data.connected,data.enabled))]]; $('signalPanel').innerHTML=signal.map(([k,v])=>`
${k}${escapeHtml(value(v))}
`).join('') + $('overview').innerHTML=[['延迟',s?.latencyMs!=null?`${s.latencyMs}ms`:'-'],['SIM',sim.present===false?'未插卡':sim.iccid],['短信',sms.total],['版本',ota.currentVersion||device.firmware]].map(([k,v])=>`
${k}${escapeHtml(value(v))}
`).join('') + $('detailDeviceGrid').innerHTML=[detailCard('设备信息',[['名称',item.name],['ID',item.id],['地址',item.url],['型号',device.model],['厂商',device.manufacturer],['IMEI',device.imei],['版本',ota.currentVersion||device.firmware],['Commit',ota.currentCommit]]),detailCard('SIM 卡',[['ICCID',sim.iccid],['IMSI',sim.imsi],['号码',sim.phoneNumber],['短信中心',sim.smsCenter]]),detailCard('蜂窝网络',[['运营商',network.operator],['注册状态',network.registration],['网络制式',network.accessTechnology],['信号',network.signal]]),detailCard('系统资源',[['温度',formatTemperature(system.temperature)],['CPU',Array.isArray(system.cpuLoad)?system.cpuLoad.join(' / '):system.cpuLoad],['内存',JSON.stringify(system.memory||{})],['磁盘',JSON.stringify(system.disk||{})]])].join('') + const eps=['/api/device','/api/sim','/api/network','/api/stats','/api/sms/stats','/api/ota/status']; $('detailActions').innerHTML=`${eps.map(ep=>``).join('')}`; $$('[data-detail-ep]').forEach(b=>b.addEventListener('click',()=>{ switchView('api'); $('endpointInput').value=b.dataset.detailEp; $('methodSelect').value='GET'; updateWriteUI() })); $$('[data-view]',$('detailActions')).forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.view))) } - -function setActiveHome() { - requests.invalidate('api-console'); requests.invalidate('login') - activeId = null - localStorage.removeItem('multi-simadmin-active') - $('loginBtn').disabled = true - $('openExternal').href = '#' - $('openExternal').classList.add('disabled') - $('emptyState').hidden = false - $('frame').hidden = true - $('frame').removeAttribute('src') - switchView('home') - renderHomeCards() -} - -function renderHero() { - const { item, s, device, sim, network, system, data, ota } = getMonitorData() - if (!item) { - $('activeKicker').textContent = 'DEVICE DETAIL' - $('activeName').textContent = '选择一个 SimAdmin 设备' - $('activeMeta').textContent = '从首页卡片进入详情页后,可查看状态并使用功能交互。' - $('signalPanel').innerHTML = [ - ['状态', '等待选择'], ['设备', '-'], ['温度', '-'], ['流量速率', '-'], - ].map(([k, v]) => `
${k}${v}
`).join('') - return - } - $('activeKicker').textContent = `${item.id} · ${statusText(s)}` - $('activeName').textContent = `${item.name || item.id} 详情页` - $('activeMeta').textContent = `${device.manufacturer || ''} ${device.model || ''}`.trim() || item.url - $('signalPanel').innerHTML = [ - ['在线状态', statusText(s)], - ['SIM / 运营商', `${sim.present === false ? '未插卡' : '已插卡'} · ${value(network.operator || sim.operator)}`], - ['信号 / 制式', `${value(network.signal || sim.signal)} · ${value(network.accessTechnology || network.registration)}`], - ['温度', formatTemperature(system.temperature)], - ['流量速率', formatSpeed(system.networkSpeed)], - ['数据连接', firstValue(data.active, data.connected, data.enabled) === true ? '已连接' : firstValue(data.active, data.connected, data.enabled) === false ? '未连接' : '-'], - ['系统负载', Array.isArray(system.cpuLoad) ? system.cpuLoad.join(' / ') : value(system.cpuLoad)], - ['版本', value(ota.currentVersion || device.firmware || device.revision)], - ].map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(value(v))}
`).join('') -} - -function renderOverview() { - const { item, s, sim, network, data, sms, system } = getMonitorData() - if (!item) return - const dataState = firstValue(data.active, data.connected, data.enabled) - $('overview').innerHTML = [ - metricCard('状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : '离线') : '未知', pillClass(s)), - metricCard('延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'), - metricCard('信号', firstValue(network.signal, sim.signal), 'signal'), - metricCard('温度', formatTemperature(system.temperature), 'temperature'), - metricCard('流量速率', formatSpeed(system.networkSpeed), 'traffic'), - metricCard('数据连接', dataState === true ? '已连接' : dataState === false ? '未连接' : '-'), - metricCard('SIM', sim.present === false ? '未插卡' : value(sim.iccid)), - metricCard('短信', sms.total != null ? `总 ${sms.total} / 收 ${value(sms.incoming)} / 发 ${value(sms.outgoing)}` : '-'), - ].join('') - renderHero() -} - -function renderDeviceDetails() { - const { item, device, sim, network, data, sms, ota, system, s } = getMonitorData() - if (!item) return - $('detailDeviceGrid').innerHTML = [ - detailCard('设备信息', [ - ['名称', item.name || item.id], ['地址', item.url], ['型号', device.model], ['厂商', device.manufacturer], ['IMEI', device.imei], ['电源', boolText(device.powered)], ['在线', boolText(device.online)], ['固件/版本', ota.currentVersion || device.firmware || device.revision], ['Commit', ota.currentCommit], - ], 'device'), - detailCard('SIM 卡', [ - ['状态', sim.present === false ? '未插卡' : sim.present === true ? '已插卡' : '-'], ['ICCID', sim.iccid], ['IMSI', sim.imsi], ['号码', sim.phoneNumber], ['短信中心', sim.smsCenter], ['MCC/MNC', [network.mcc || sim.mcc, network.mnc || sim.mnc].filter(Boolean).join('/') || '-'], - ], 'sim'), - detailCard('蜂窝网络', [ - ['运营商', network.operator || sim.operator], ['注册状态', network.registration], ['网络制式', network.accessTechnology], ['信号强度', firstValue(network.signal, sim.signal)], ['数据连接', firstValue(data.active, data.connected, data.enabled) === true ? '已连接' : firstValue(data.active, data.connected, data.enabled) === false ? '未连接' : '-'], ['漫游', boolText(data.roaming)], - ], 'network'), - detailCard('温度 / 系统', [ - ['温度', formatTemperature(system.temperature)], ['CPU 负载', Array.isArray(system.cpuLoad) ? system.cpuLoad.join(' / ') : system.cpuLoad], ['内存', formatPercentPair(system.memory)], ['磁盘', formatPercentPair(system.disk)], ['运行时间', formatUptime(system.uptime)], ['系统', system.info ? Object.values(system.info).filter(Boolean).join(' · ') : '-'], - ], 'system'), - detailCard('流量 / 短信', [ - ['实时速率', formatSpeed(system.networkSpeed)], ['短信总数', sms.total], ['接收短信', sms.incoming], ['发送短信', sms.outgoing], ['推送成功', sms.pushed], ['通话记录', s?.summary?.calls?.calls?.length ?? '-'], - ], 'traffic'), - ].join('') -} - -function renderDetailActions() { - const disabled = activeId ? '' : ' disabled' - const primary = [ - ['设备状态', '/api/device'], ['SIM 信息', '/api/sim'], ['蜂窝网络', '/api/network'], ['系统资源', '/api/stats'], - ['短信统计', '/api/sms/stats'], ['通话记录', '/api/calls'], ['OTA 状态', '/api/ota/status'], ['数据连接', '/api/data'], - ] - $('detailActions').innerHTML = ` - - ${primary.map(([label, ep]) => ``).join('')} - ` - $$('[data-detail-ep]', $('detailActions')).forEach(btn => btn.addEventListener('click', () => { - switchView('api') - $('endpointInput').value = btn.dataset.detailEp - $('methodSelect').value = inferMethod(btn.dataset.detailEp) - runEndpoint() - })) - $$('[data-view]', $('detailActions')).forEach(btn => btn.addEventListener('click', () => switchView(btn.dataset.view))) -} - -function renderFeatures() { renderDeviceDetails(); renderDetailActions() } - -function renderEndpointList() { - const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints })) - $('endpointList').innerHTML = groups.map(group => `
-

${escapeHtml(group.name || group.id)}

- ${(group.endpoints || []).map(ep => ``).join('')} -
`).join('') - $$('.endpoint-row', $('endpointList')).forEach(row => row.addEventListener('click', () => { - $('endpointInput').value = row.dataset.ep - $('methodSelect').value = inferMethod(row.dataset.ep) - $$('.endpoint-row', $('endpointList')).forEach(x => x.classList.toggle('active', x === row)) - })) -} - -function switchView(view) { - view = resolveView(view, Boolean(activeId)) - routeView(view) -} - -function selectInstance(id) { openDeviceDetail(id) } - -function openInstanceDialog(id = '') { - const item = id ? instances.find(x => x.id === id) : null - $('instanceOriginalId').value = item?.id || '' - $('instanceFormRibbon').textContent = item ? '编辑设备地址' : '添加设备地址' - $('instanceIdInput').value = item?.id || '' - $('instanceNameInput').value = item?.name || '' - $('instanceUrlInput').value = item?.url || '' - $('instanceDescInput').value = item?.description || '' - $('instanceTagsInput').value = (item?.tags || []).join(', ') - $('instancePasswordInput').value = '' - $('instancePasswordInput').placeholder = item?.auth?.hasPassword ? '留空将清除已保存密码;输入新密码可替换' : '留空表示无密码' - $('deleteInstanceBtn').hidden = !item - $('instanceDialog').showModal() - setTimeout(() => (item ? $('instanceNameInput') : $('instanceIdInput')).focus(), 50) -} - -async function reloadConfigAndRefresh({ selectId = activeId, toast = '' } = {}) { - await loadConfig({ preserveSelection: selectId }) - await refreshStatus({ silent: true }) - if (selectId && instances.some(x => x.id === selectId)) selectInstance(selectId) - else setActiveHome() - if (toast) showToast(toast) -} - -async function saveInstanceFromForm() { - const originalId = $('instanceOriginalId').value - const payload = instancePayloadFromForm() - payload.url = normalizeTargetUrl(payload.url) - const data = originalId ? await api.updateInstance(originalId, payload) : await api.createInstance(payload) - $('instanceDialog').close() - await reloadConfigAndRefresh({ selectId: data.instance?.id || payload.id, toast: originalId ? '设备地址已更新' : '设备地址已添加' }) -} - -async function deleteInstance(id) { - const item = instances.find(x => x.id === id) - if (!item) return - if (!confirm(`确定删除设备“${item.name || item.id}”?\n配置会立即写入 config.json。`)) return - try { await api.deleteInstance(id) } catch (error) { showToast(error.message, 4200); return } - await reloadConfigAndRefresh({ selectId: null, toast: '设备地址已删除' }) -} - -async function deleteCurrentInstanceFromDialog() { - const id = $('instanceOriginalId').value - if (!id) return - $('instanceDialog').close() - await deleteInstance(id) -} - -async function loadConfig({ preserveSelection = activeId } = {}) { - const [data, catalogData] = await Promise.all([api.config(), api.catalog().catch(() => ({ groups: [] }))]) - instances = data.instances || [] - configPath = data.configPath || 'config.json' - $('configPath').textContent = configPath - catalog = catalogData.groups || [] - const saved = preserveSelection || localStorage.getItem('multi-simadmin-active') - activeId = saved && instances.some(x => x.id === saved) ? saved : null - renderEndpointList() - renderHomeCards() - if (activeId) openDeviceDetail(activeId) -} - -async function refreshStatus({ silent = false } = {}) { - $('refreshBtn').disabled = true - if (!silent) showToast('正在刷新全部实例…', 1200) +function openDeviceDetail(id) { if (!instances.some(x=>x.id===id)) return; if(activeId!==id){ requests.invalidate('api-console'); requests.invalidate('login'); loginDialogEpoch += 1; $('submitLogin').disabled=false; pendingApiRequest=null; $('runEndpoint').disabled=false; $('runEndpoint').textContent='发送请求'; frameClear(); clearApiOutput() } activeId=id; localStorage.setItem('multi-simadmin-active',id); const item=activeInstance(); $('openExternal').href=item.url; renderDetail(); switchView('detail') } +function setActiveHome() { requests.invalidate('api-console'); requests.invalidate('login'); loginDialogEpoch += 1; $('submitLogin').disabled=false; pendingApiRequest=null; $('runEndpoint').disabled=false; $('runEndpoint').textContent='发送请求'; activeId=null; localStorage.removeItem('multi-simadmin-active'); frameClear(); switchView('home'); renderHomeCards() } +function openInstanceDialog(id='') { instanceDialogEpoch += 1; const item=instances.find(x=>x.id===id); $('instanceDialogTitle').textContent=item?'编辑设备':'添加设备'; $('instanceOriginalId').value=item?.id||''; $('instanceIdInput').value=item?.id||''; $('instanceNameInput').value=item?.name||''; $('instanceUrlInput').value=item?.url||''; $('instanceDescInput').value=item?.description||''; $('instanceTagsInput').value=(item?.tags||[]).join(', '); $('instancePasswordInput').value=''; $('clearPasswordConfirm').checked=false; $('passwordPreserve').checked=Boolean(item); $('passwordClear').checked=!item; $('passwordPreserve').disabled=!item; $('deleteInstanceBtn').hidden=!item; $('instanceFormError').hidden=true; updatePasswordUI(); $('instanceDialog').showModal() } +function updatePasswordUI(){ const action=$('instanceForm').elements.passwordAction.value; $('newPasswordLabel').hidden=action!=='set'; $('instancePasswordInput').required=action==='set'; $('clearPasswordLabel').hidden=action!=='clear' || !$('instanceOriginalId').value } +async function saveInstanceFromForm(){ const epoch=instanceDialogEpoch, editing=Boolean($('instanceOriginalId').value), btn=$('saveInstanceBtn'); if(btn.disabled)return; btn.dataset.idleLabel='保存'; setPending(btn,true,'保存中…'); $('instanceFormError').hidden=true; try { const payload=instanceDraftPayload({ id:$('instanceIdInput').value,name:$('instanceNameInput').value,url:normalizeTargetUrl($('instanceUrlInput').value),description:$('instanceDescInput').value,tags:tagsFromInput($('instanceTagsInput').value),passwordAction:$('instanceForm').elements.passwordAction.value,password:$('instancePasswordInput').value,clearConfirmed:$('clearPasswordConfirm').checked },editing); const data=editing?await api.updateInstance($('instanceOriginalId').value,payload):await api.createInstance(payload); if(epoch!==instanceDialogEpoch)return; $('instanceDialog').close(); await reload({ selectId:data.instance?.id||payload.id }); showToast(editing?'设备已更新':'设备已添加') } catch(error){ if(epoch!==instanceDialogEpoch)return; $('instanceFormError').textContent=error.message; $('instanceFormError').hidden=false } finally { if(epoch===instanceDialogEpoch)setPending(btn,false,'保存中…') } } +function openDeleteDialog(){ const item=instances.find(x=>x.id===$('instanceOriginalId').value); deleteDialogEpoch += 1; if(!item)return; pendingDeleteId=item.id; $('deleteDetails').innerHTML=`
名称
${escapeHtml(item.name||item.id)}
ID
${escapeHtml(item.id)}
URL
${escapeHtml(item.url)}
`; $('deleteError').hidden=true; $('deleteDialog').showModal() } +async function confirmDelete(){ const epoch=deleteDialogEpoch, btn=$('confirmDeleteBtn'); if(btn.disabled)return; btn.dataset.idleLabel='确认删除'; setPending(btn,true,'删除中…'); try{ await api.deleteInstance(pendingDeleteId); if(epoch!==deleteDialogEpoch)return; $('deleteDialog').close(); $('instanceDialog').close(); await reload({selectId:null}); setActiveHome(); showToast('设备已删除') }catch(error){if(epoch!==deleteDialogEpoch)return;$('deleteError').textContent=error.message;$('deleteError').hidden=false}finally{if(epoch===deleteDialogEpoch)setPending(btn,false,'删除中…')} } +function renderEndpointList(){ const q=$('endpointSearch').value.trim().toLowerCase(), groups=catalog.length?catalog:modules; $('endpointList').innerHTML=groups.map(g=>{ const eps=(g.endpoints||[]).filter(ep=>ep.toLowerCase().includes(q)); return eps.length?`

${escapeHtml(g.name||g.id)}

${eps.map(ep=>``).join('')}
`:'' }).join('')||'

没有匹配接口

'; $$('.endpoint-row').forEach(b=>b.addEventListener('click',()=>{ $('endpointInput').value=b.dataset.ep; $('methodSelect').value='GET'; updateWriteUI(); $$('.endpoint-row').forEach(x=>x.setAttribute('aria-pressed',String(x===b))) })) } +function updateWriteUI(){ const write=isWriteMethod($('methodSelect').value); $('writeWarning').hidden=!write; $('requestBody').disabled=$('methodSelect').value==='GET' } +function clearApiOutput(){ outputText='';outputPrettyText='';$('rawOutput').textContent='设备已切换。选择接口并点击“发送请求”。' } +function parseBody(){ const raw=$('requestBody').value.trim(); return raw?JSON.parse(raw):undefined } +async function runEndpoint(confirmed=false){ + if(!activeId)return + let draft try { - const result = await requests.run('fleet-status', 'fleet', () => api.status()) - if (!result.accepted || result.error) { - if (result.accepted && result.error) showToast(result.error.message || '刷新失败', 4200) - return + if(confirmed){ draft=pendingApiRequest; pendingApiRequest=null; if(!draft||draft.owner!==activeId)return } + else { + const method=$('methodSelect').value, endpoint=$('endpointInput').value.trim() + if(!endpoint.startsWith('/')){persistentError('接口路径必须以 / 开头');return} + const body=method==='GET'?undefined:parseBody(); draft={...requestDraft(method,endpoint,body),owner:activeId,body} + if(isWriteMethod(method)){ + pendingApiRequest=draft + $('apiDeleteDetails').textContent=`${activeInstance().name||activeId} (${activeId}) · ${method} ${endpoint}` + $('apiDeleteBody').textContent=body===undefined?'(无请求体)':JSON.stringify(body,null,2) + $('apiDeleteDialog').showModal(); return + } } - const data = result.value - statuses = new Map((data.instances || []).map(item => [item.id, item])) - renderHomeCards() - if (activeId) { - renderOverview() - renderDeviceDetails() - renderDetailActions() - } - if (!silent) showToast(`已刷新 ${statuses.size} 个实例`) - } catch (error) { - showToast(error.message || '刷新失败', 4200) - } finally { - $('refreshBtn').disabled = false - } -} - -async function loginActive() { - if (!activeId) return - const item = activeInstance() - if (!item) return - // If config already has a saved password, try direct refresh first; otherwise ask for password. - if (item.auth?.hasPassword) { - const loginOwnerId = activeId - showToast('正在使用本地配置密码刷新会话…', 1600) - const result = await requests.run('login', loginOwnerId, () => api.login(loginOwnerId)) - if (!result.accepted || result.ownerId !== activeId) return - if (result.error) { showToast(result.error.message || '登录失败', 4200); return } - await refreshStatus({ silent: true }) - return - } - $('loginDialog').showModal() - $('loginDialog').dataset.instanceId = activeId - setTimeout(() => $('passwordInput').focus(), 50) -} - -function parseBody() { - const raw = $('requestBody').value.trim() - if (!raw) return undefined - return JSON.parse(raw) -} - -async function runEndpoint() { - if (!activeId) { - showToast('请先在首页选择设备卡片') - return - } - const requestOwnerId = activeId - const endpoint = $('endpointInput').value.trim() - const method = $('methodSelect').value - if (!endpoint.startsWith('/')) { - showToast('接口路径必须以 / 开头') - return - } - $('rawOutput').textContent = '调用中…' - try { - const init = { method } - if (method !== 'GET') { - init.headers = { 'content-type': 'application/json' } - const body = parseBody() - if (body !== undefined) init.body = JSON.stringify(body) - } - const started = performance.now() - const result = await requests.run('api-console', requestOwnerId, async () => { - const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}` - const res = await api.proxy(requestOwnerId, normalized, init) - return { res, text: await res.text() } - }) - if (!result.accepted || result.ownerId !== activeId) return - if (result.error) { - $('rawOutput').textContent = result.error.message || String(result.error) - showToast('接口调用失败', 4200) - return - } - const { res, text } = result.value - const elapsed = Math.round(performance.now() - started) - let formatted = text - try { formatted = JSON.stringify(JSON.parse(text), null, 2) } catch {} - $('rawOutput').textContent = `HTTP ${res.status} · ${elapsed}ms · ${method} ${endpoint}\n\n${formatted}` - } catch (error) { - $('rawOutput').textContent = error.message || String(error) - showToast('接口调用失败', 4200) - } -} - -function bindEvents() { - $('refreshBtn').addEventListener('click', () => refreshStatus()) - $('homeBtn').addEventListener('click', setActiveHome) - $('addInstanceBtn').addEventListener('click', () => openInstanceDialog()) - $('loginBtn').addEventListener('click', loginActive) - $('runEndpoint').addEventListener('click', runEndpoint) - $('instanceSearch').addEventListener('input', renderHomeCards) - $('backToFleetBtn').addEventListener('click', setActiveHome) - $('densityBtn').addEventListener('click', () => { - document.body.classList.toggle('compact') - localStorage.setItem('multi-simadmin-density', document.body.classList.contains('compact') ? 'compact' : 'normal') - }) - $$('[data-view]').forEach(btn => btn.addEventListener('click', () => switchView(btn.dataset.view))) - $$('#statusFilters .tab-leaf').forEach(btn => btn.addEventListener('click', () => { - activeFilter = btn.dataset.filter - $$('#statusFilters .tab-leaf').forEach(x => x.classList.toggle('active', x === btn)) - renderHomeCards() - })) - $('loginForm').addEventListener('submit', async (event) => { - event.preventDefault() - const loginInstanceId = $('loginDialog').dataset.instanceId - if (!loginInstanceId) return - const password = $('passwordInput').value - $('passwordInput').value = '' - $('loginDialog').close() - const result = await requests.run('login', loginInstanceId, () => api.login(loginInstanceId, password)) - if (!result.accepted || result.ownerId !== activeId) return - if (result.error) { showToast(result.error.message || '登录失败', 4200); return } - showToast('会话已刷新') - await refreshStatus({ silent: true }) - }) - $('instanceForm').addEventListener('submit', async (event) => { - event.preventDefault() - try { - await saveInstanceFromForm() - } catch (error) { - showToast(error.message || '保存失败', 5200) - } - }) - $('deleteInstanceBtn').addEventListener('click', deleteCurrentInstanceFromDialog) - $('endpointInput').addEventListener('keydown', (event) => { - if (event.key === 'Enter') runEndpoint() - }) -} - -function restorePrefs() { - if (localStorage.getItem('multi-simadmin-density') === 'compact') document.body.classList.add('compact') - switchView(localStorage.getItem('multi-simadmin-view') || 'home') -} - -try { - restorePrefs() - bindEvents() - await loadConfig() - await refreshStatus({ silent: true }) - autoRefreshTimer = setInterval(() => refreshStatus({ silent: true }), 30000) - window.addEventListener('beforeunload', () => clearInterval(autoRefreshTimer)) -} catch (error) { - console.error(error) - showToast(error.message || '初始化失败', 8000) - $('rawOutput').textContent = error.stack || String(error) + } catch(error){ persistentError(`请求参数无效:${error.message||error}`); return } + const {owner,method,path:endpoint,body}=draft, btn=$('runEndpoint'), token=requests.next('api-console'); if(btn.disabled)return + btn.dataset.idleLabel='发送请求'; setPending(btn,true,'发送中…'); $('rawOutput').textContent='请求进行中…'; persistentError(''); const started=performance.now() + try{ const init={method}; if(isWriteMethod(method)){ const prepared=await api.prepareConfirmation({instanceId:owner,method,path:endpoint,body}); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; init.headers={'content-type':'application/json','x-confirmation-token':prepared.token}; if(body!==undefined)init.body=JSON.stringify(body) } const res=await api.proxy(owner,endpoint,init), text=await res.text(); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; const elapsed=Math.round(performance.now()-started); outputText=text; try{outputPrettyText=JSON.stringify(JSON.parse(text),null,2)}catch{outputPrettyText=text} $('rawOutput').textContent=`HTTP ${res.status} ${res.statusText} · ${elapsed}ms · ${draft.method} ${draft.path}\n\n${$('outputPretty').getAttribute('aria-pressed')==='true'?outputPrettyText:outputText}`; if(!res.ok)persistentError(`API 请求失败:HTTP ${res.status} ${res.statusText}\n${text}`) }catch(error){ if(requests.isCurrent('api-console',token)&&owner===activeId){$('rawOutput').textContent=error.message||String(error);persistentError(`API 请求失败:${error.message||error}`)} }finally{ if(requests.isCurrent('api-console',token)) setPending(btn,false,'发送中…') } } +async function refreshStatus({silent=false}={}){ const btn=$('refreshBtn'), token=requests.next('fleet-status'); btn.disabled=true; try{const data=await api.status(); if(!requests.isCurrent('fleet-status',token))return; statuses=new Map((data.instances||[]).map(x=>[x.id,x]));lastSuccessfulRefresh=new Date();$('staleBanner').hidden=true;persistentError('');renderHomeCards();if(activeId)renderDetail();if(!silent)showToast(`已刷新 ${statuses.size} 个设备`)}catch(error){if(!requests.isCurrent('fleet-status',token))return;$('staleBanner').textContent=`刷新失败,当前显示上次成功数据。上次成功:${lastSuccessfulRefresh?lastSuccessfulRefresh.toLocaleString():'尚无'}。${error.message}`;$('staleBanner').hidden=false;persistentError(`状态刷新失败:${error.message}`)}finally{if(requests.isCurrent('fleet-status',token))btn.disabled=false} } +async function loadConfig(){ const [data,cat]=await Promise.all([api.config(),api.catalog().catch(()=>({groups:[]}))]);instances=data.instances||[];$('configPath').textContent=data.configPath||'config.json';catalog=cat.groups||[];renderEndpointList();const saved=localStorage.getItem('multi-simadmin-active');activeId=saved&&instances.some(x=>x.id===saved)?saved:null;renderHomeCards() } +async function reload({selectId=activeId}={}){await loadConfig();await refreshStatus({silent:true});if(selectId&&instances.some(x=>x.id===selectId))openDeviceDetail(selectId);else setActiveHome()} +async function initialize(){ $('initialLoading').hidden=false;$('fatalState').hidden=true;$('homeContent').hidden=true;persistentError('');try{await loadConfig();await refreshStatus({silent:true});$('initialLoading').hidden=true;$('homeContent').hidden=false;if(activeId){renderDetail();switchView(resolveView(localStorage.getItem('multi-simadmin-view')||'home',true))}else switchView('home')}catch(error){$('initialLoading').hidden=true;$('fatalText').textContent=error.message;$('fatalState').hidden=false} } +function bindEvents(){ $$('[data-dialog-cancel]').forEach(button=>button.addEventListener('click',()=>closeDialog(button.dataset.dialogCancel))); for(const id of ['instanceDialog','deleteDialog','loginDialog','apiDeleteDialog']) $(id).addEventListener('cancel',()=>invalidateDialog(id)); $('refreshBtn').addEventListener('click',()=>refreshStatus());$('addInstanceBtn').addEventListener('click',()=>openInstanceDialog());$('backToFleetBtn').addEventListener('click',setActiveHome);$('editActiveBtn').addEventListener('click',()=>openInstanceDialog(activeId));$('instanceSearch').addEventListener('input',renderHomeCards);$('sortSelect').addEventListener('change',()=>{activeSort=$('sortSelect').value;renderHomeCards()});$('densityBtn').addEventListener('click',()=>{const compact=document.body.classList.toggle('compact');$('densityBtn').setAttribute('aria-pressed',String(compact))});$$('[data-filter]').forEach(b=>b.addEventListener('click',()=>setFilter(b.dataset.filter)));$$('[data-view]').forEach(b=>b.addEventListener('click',e=>{e.preventDefault();switchView(b.dataset.view)}));$$('[name=passwordAction]').forEach(r=>r.addEventListener('change',updatePasswordUI));$('instanceForm').addEventListener('submit',e=>{e.preventDefault();saveInstanceFromForm()});$('deleteInstanceBtn').addEventListener('click',openDeleteDialog);$('confirmDeleteBtn').addEventListener('click',e=>{e.preventDefault();confirmDelete()});$('endpointSearch').addEventListener('input',renderEndpointList);$('methodSelect').addEventListener('change',updateWriteUI);$('runEndpoint').addEventListener('click',()=>runEndpoint());$('confirmApiDelete').addEventListener('click',e=>{e.preventDefault();$('apiDeleteDialog').close();runEndpoint(true)});$('outputPretty').addEventListener('click',()=>{$('outputPretty').setAttribute('aria-pressed','true');$('outputRaw').setAttribute('aria-pressed','false');if(outputText)$('rawOutput').textContent=$('rawOutput').textContent.split('\n\n')[0]+'\n\n'+outputPrettyText});$('outputRaw').addEventListener('click',()=>{$('outputPretty').setAttribute('aria-pressed','false');$('outputRaw').setAttribute('aria-pressed','true');if(outputText)$('rawOutput').textContent=$('rawOutput').textContent.split('\n\n')[0]+'\n\n'+outputText});$('copyOutput').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('rawOutput').textContent);showToast('结果已复制')}catch{showToast('复制不可用,请手动选择文本')}});$('dismissError').addEventListener('click',()=>persistentError(''));$('retryInit').addEventListener('click',initialize);$('loginBtn').addEventListener('click',()=>{loginDialogEpoch+=1;$('submitLogin').disabled=false;$('loginDialog').dataset.instanceId=activeId;$('passwordInput').value='';$('loginError').textContent='';$('loginError').hidden=true;$('loginDialog').showModal()});$('loginForm').addEventListener('submit',async e=>{e.preventDefault();const epoch=loginDialogEpoch,id=$('loginDialog').dataset.instanceId,btn=$('submitLogin');btn.disabled=true;$('loginError').hidden=true;const password=$('passwordInput').value;const result=await requests.run('login',id,()=>api.login(id,password));$('passwordInput').value='';if(epoch!==loginDialogEpoch)return;if(result.accepted&&id===activeId){if(result.error){$('loginError').textContent=result.error.message;$('loginError').hidden=false}else if(!result.value.authenticated){$('loginError').textContent='登录失败,请检查密码';$('loginError').hidden=false}else{$('loginDialog').close();await refreshStatus({silent:true});showToast('会话已刷新')}}if(epoch===loginDialogEpoch)btn.disabled=false});updateWriteUI() } +bindEvents(); await initialize() diff --git a/public/index.html b/public/index.html index 7869044..ca4557b 100644 --- a/public/index.html +++ b/public/index.html @@ -1,168 +1,63 @@ - - - Multi SimAdmin Island - - - - + + 设备首页 · Multi SimAdmin Island - - -
-
-
-

MULTI SIMADMIN

-

SimAdmin 设备卡片墙

-

首页直接展示每个 SimAdmin 设备卡片;点击卡片进入设备详情页,再进行状态查看和功能交互。

-
-
- - - - -
+ +
+ +
+
+

MULTI SIMADMIN

设备首页

集中巡检你的 SimAdmin 设备

+
- + +
-
-
-
-
设备卡片墙
-

每张卡片对应一个 SimAdmin;卡片上直接显示在线状态、SIM、信号、温度、流量和短信摘要。

-
-
-
- -
- -
- 本地配置 - config.json -
- - - - - - -
-
添加设备地址
-

配置会立即写入本地 config.json,并更新左侧设备列表。

- - - - - - - - - - - - -
-
- - - - - + +
+

登录 SimAdmin

密码仅发送至本地聚合服务。

+

添加设备

保存密码
+

确认删除设备

此操作将立即写入本地配置,且不可撤销。

+

确认发送写操作

该请求将改变设备状态,请核对设备、方法、路径和请求体。

+ + + diff --git a/public/infrastructure/api-client.js b/public/infrastructure/api-client.js index a36c807..3212471 100644 --- a/public/infrastructure/api-client.js +++ b/public/infrastructure/api-client.js @@ -18,6 +18,7 @@ export function createApiClient({ fetchImpl = globalThis.fetch } = {}) { catalog: () => request('/api/catalog'), status: () => request('/api/status'), proxy: (id, endpoint, options) => fetchImpl(`/api/proxy/${encodeURIComponent(id)}${endpoint}`, options), + prepareConfirmation: payload => request('/api/proxy-confirmations/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }), createInstance: payload => request('/api/instances', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }), updateInstance: (id, payload) => request(`/api/instances/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }), deleteInstance: id => request(`/api/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }), diff --git a/public/router/view-router.js b/public/router/view-router.js index 6e4e230..1c0a4f3 100644 --- a/public/router/view-router.js +++ b/public/router/view-router.js @@ -1,10 +1,15 @@ export const VIEW_IDS = Object.freeze(['home', 'detail', 'rawpage', 'api']) export function resolveView(view, hasActiveInstance = false) { + if (!hasActiveInstance && view !== 'home') return 'home' return VIEW_IDS.includes(view) ? view : hasActiveInstance ? 'detail' : 'home' } export function createViewRouter({ panels, controls, storage = globalThis.localStorage } = {}) { return view => { - for (const control of controls()) control.classList.toggle('active', control.dataset.view === view) + for (const control of controls()) { + const active = control.dataset.view === view + control.classList.toggle('active', active) + if (active) control.setAttribute?.('aria-current', 'page'); else control.removeAttribute?.('aria-current') + } for (const panel of panels()) panel.classList.toggle('active', panel.id === `${view}View`) storage?.setItem('multi-simadmin-view', view) } diff --git a/public/state/fleet-view-model.js b/public/state/fleet-view-model.js new file mode 100644 index 0000000..2e61591 --- /dev/null +++ b/public/state/fleet-view-model.js @@ -0,0 +1,30 @@ +export function fleetStatusKind(status) { + if (!status) return 'unknown' + if (!status.reachable) return 'offline' + if (status.authenticated === false) return 'auth' + return 'online' +} + +export function filterAndSortFleet(instances, statuses, { filter = 'all', query = '', sort = 'name' } = {}) { + const needle = String(query).trim().toLocaleLowerCase() + const rank = { online: 0, auth: 1, offline: 2, unknown: 3 } + return instances.filter(item => { + const status = statuses.get(item.id) + if (filter !== 'all' && fleetStatusKind(status) !== filter) return false + if (!needle) return true + return [item.id, item.name, item.url, item.description, ...(item.tags || []), JSON.stringify(status?.summary || {})] + .join(' ').toLocaleLowerCase().includes(needle) + }).slice().sort((a, b) => { + const sa = statuses.get(a.id); const sb = statuses.get(b.id) + if (sort === 'latency') return (sa?.latencyMs ?? Infinity) - (sb?.latencyMs ?? Infinity) || String(a.name || a.id).localeCompare(String(b.name || b.id)) + if (sort === 'status') return rank[fleetStatusKind(sa)] - rank[fleetStatusKind(sb)] || String(a.name || a.id).localeCompare(String(b.name || b.id)) + return String(a.name || a.id).localeCompare(String(b.name || b.id)) + }) +} + +export function emptyFleetReason({ total = 0, query = '', filter = 'all', visible = 0 } = {}) { + if (!total) return 'config' + if (!visible && String(query).trim()) return 'search' + if (!visible && filter !== 'all') return 'filter' + return null +} diff --git a/public/state/operation-draft.js b/public/state/operation-draft.js new file mode 100644 index 0000000..6c3731c --- /dev/null +++ b/public/state/operation-draft.js @@ -0,0 +1,21 @@ +export function instanceDraftPayload(values, editing = false) { + const action = editing ? (values.passwordAction || 'preserve') : (values.passwordAction || (values.password ? 'set' : 'clear')) + if (!['preserve', 'set', 'clear'].includes(action)) throw new Error('无效密码操作') + if (action === 'set' && !String(values.password || '')) throw new Error('设置密码时必须输入密码') + if (action === 'clear' && editing && !values.clearConfirmed) throw new Error('清除密码需要显式确认') + const payload = { + id: String(values.id || '').trim(), name: String(values.name || '').trim(), url: String(values.url || '').trim(), + description: String(values.description || '').trim(), tags: Array.isArray(values.tags) ? values.tags : [], passwordAction: action, + } + if (action === 'set') payload.password = String(values.password) + return payload +} + +export function requestDraft(method, path, body) { + const normalizedMethod = String(method || 'GET').toUpperCase() + const result = { method: normalizedMethod, path: String(path || '') } + if (normalizedMethod !== 'GET' && body !== undefined) result.body = body + return result +} + +export const isWriteMethod = method => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method).toUpperCase()) diff --git a/public/state/request-coordinator.js b/public/state/request-coordinator.js index fc5d6ee..5c1ad57 100644 --- a/public/state/request-coordinator.js +++ b/public/state/request-coordinator.js @@ -1,14 +1,19 @@ export class RequestCoordinator { #versions = new Map() - async run(channel, ownerId, operation) { + next(channel) { const version = (this.#versions.get(channel) || 0) + 1 this.#versions.set(channel, version) + return version + } + isCurrent(channel, version) { return this.#versions.get(channel) === version } + async run(channel, ownerId, operation) { + const version = this.next(channel) try { const value = await operation() - return { accepted: this.#versions.get(channel) === version, ownerId, value } + return { accepted: this.isCurrent(channel, version), ownerId, value } } catch (error) { - return { accepted: this.#versions.get(channel) === version, ownerId, error } + return { accepted: this.isCurrent(channel, version), ownerId, error } } } - invalidate(channel) { this.#versions.set(channel, (this.#versions.get(channel) || 0) + 1) } + invalidate(channel) { return this.next(channel) } } diff --git a/public/styles/base.css b/public/styles/base.css index 75e42f8..fe580c1 100644 --- a/public/styles/base.css +++ b/public/styles/base.css @@ -1,2 +1 @@ -*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;min-height:100vh;font-family:var(--animal-font-family);font-weight:500;letter-spacing:.01em;color:var(--animal-text-color);background:var(--animal-bg-color);overflow:hidden}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,.sim-device-card:focus-visible{outline:3px solid #ffcc00;outline-offset:3px} -.island-bg{position:fixed;inset:0;z-index:-1;overflow:hidden;background:radial-gradient(circle at 12% 10%,rgba(130,213,187,.28),transparent 24%),radial-gradient(circle at 88% 6%,rgba(247,205,103,.28),transparent 22%),linear-gradient(180deg,#f8f8f0 0%,#f4efd9 54%,#e8dfbd 100%)}.island-bg:before{content:"";position:absolute;inset:0;background-image:radial-gradient(circle,rgba(196,184,158,.15) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(196,184,158,.1) 1px,transparent 1px);background-size:28px 28px,14px 14px;background-position:0 0,7px 7px}.cloud{position:absolute;border-radius:999px;background:rgba(255,255,255,.72)}.cloud:before,.cloud:after{content:"";position:absolute;border-radius:50%;background:inherit}.cloud-a{width:170px;height:48px;left:8%;top:7%}.cloud-a:before{width:72px;height:72px;left:28px;top:-28px}.cloud-a:after{width:56px;height:56px;right:26px;top:-18px}.cloud-b{width:210px;height:58px;right:8%;top:12%;opacity:.7}.cloud-b:before{width:86px;height:86px;left:34px;top:-36px}.cloud-b:after{width:72px;height:72px;right:28px;top:-26px}.leaf{position:absolute;color:#8ac68a;font-size:18px;opacity:.42;animation:leafFloat 7s ease-in-out infinite}.leaf-a{left:46%;top:8%}.leaf-b{right:26%;bottom:9%;animation-delay:1.7s}@keyframes leafFloat{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-12px) rotate(12deg)}} +*{box-sizing:border-box;min-width:0}html,body{min-height:100%;max-width:100%}body{margin:0;min-height:100vh;font-family:var(--animal-font-family);font-weight:500;letter-spacing:.01em;color:var(--animal-text-color);background:var(--animal-bg-color);overflow-x:hidden}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{min-height:44px}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #ffcc00;outline-offset:3px}h1,h2,h3,h4,p{margin-top:0}p,span,b,dd,dt,label,h1,h2,h3,h4,a,button{overflow-wrap:break-word;word-break:break-word}.island-bg{position:fixed;inset:0;z-index:-1;background:radial-gradient(circle at 12% 8%,rgba(130,213,187,.3),transparent 25%),radial-gradient(circle at 88% 5%,rgba(247,205,103,.3),transparent 24%),linear-gradient(180deg,#f8f8f0,#f4efd9)}code,pre{overflow-wrap:normal;word-break:normal} diff --git a/public/styles/components.css b/public/styles/components.css index 79d1a3d..73376de 100644 --- a/public/styles/components.css +++ b/public/styles/components.css @@ -1,4 +1 @@ -.island-card{border-radius:24px;background:var(--animal-bg-color-content);color:var(--animal-text-color-body);border:1.5px solid #d4c4a8;box-shadow:var(--animal-shadow-base)}.pattern-default{background:radial-gradient(circle,rgba(196,184,158,.15) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(196,184,158,.1) 1px,transparent 1px),rgb(247,243,223);background-size:28px 28px,14px 14px;background-position:0 0,7px 7px}.pattern-app-teal{background:radial-gradient(circle,rgba(130,213,187,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(170,235,210,.12) 1px,transparent 1px),#e8faf5;background-size:28px 28px,14px 14px;border-color:#82d5bb;color:#2a6b5a}.pattern-app-yellow{background:radial-gradient(circle,rgba(247,205,103,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(255,230,160,.12) 1px,transparent 1px),#fff8e0;background-size:28px 28px,14px 14px;border-color:#f7cd67;color:#7a6528}.pattern-app-blue{background:radial-gradient(circle,rgba(136,157,240,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(180,195,255,.12) 1px,transparent 1px),#e8edff;background-size:28px 28px,14px 14px;border-color:#889df0;color:#4a5a8a}.pattern-purple{background:radial-gradient(circle,rgba(183,125,238,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(220,180,255,.12) 1px,transparent 1px),#f0e8ff;background-size:28px 28px,14px 14px;border-color:#b77dee;color:#6a3a9a}.pattern-app-pink{background:#ffe8ee;border-color:#f8a6b2}.section-ribbon{--rf:#8ac68a;--rb:#509050;display:inline-flex;align-items:center;min-height:34px;padding:6px 18px;border-radius:12px;background:var(--rf);color:#fff;font-weight:900;letter-spacing:.04em;box-shadow:0 4px 0 0 var(--rb)}.color-app-yellow{--rf:#f7cd67;--rb:#d4a030;color:#725d42}.color-app-green{--rf:#8ac68a;--rb:#509050}.color-purple{--rf:#b77dee;--rb:#8a4ac0}.color-app-pink{--rf:#f8a6b2;--rb:#d77485} -h1,h2,h3,h4,p{margin:0}.eyebrow{margin:0 0 6px;color:#8a7b66;font-size:12px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}h1{font-size:34px;line-height:1.05;font-weight:900;color:#794f27}.topbar-copy,.board-caption{margin-top:8px;color:#725d42;line-height:1.5}.animal-btn{position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:45px;padding:0 20px;border:2px solid transparent;border-radius:50px;font-weight:800;text-decoration:none;line-height:1;transition:all var(--animal-motion-duration-base) var(--animal-motion-ease);user-select:none}.primary-btn{color:#794f27;background:#f8f8f0;border-color:#f8f8f0;box-shadow:0 5px 0 0 #bdaea0}.primary-btn:hover:not(:disabled),.default-btn:hover:not(:disabled){transform:translateY(-1px)}.default-btn{color:var(--animal-text-color);background:var(--animal-bg-color);border-color:var(--animal-border-color);box-shadow:var(--animal-shadow-sm)}.danger-btn{color:#fff;background:var(--animal-error-color);border-color:var(--animal-error-color)}.disabled,button:disabled{opacity:.5;pointer-events:none} -.sim-shell{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr) auto;gap:16px;padding:20px}.sim-topbar{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:20px 24px}.hero-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.sim-main{min-height:0;overflow:hidden}.view{display:none;min-height:0;height:100%;overflow:auto}.view.active{display:block}.home-view{display:none}.home-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:16px}.home-toolbar{display:grid;grid-template-columns:minmax(280px,.8fr) minmax(420px,1.2fr);gap:16px;align-items:end;padding:18px}.home-tools{display:grid;grid-template-columns:260px minmax(220px,1fr);gap:12px;align-items:center}.wallet-row{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.fleet-card{border-radius:999px;background:#d1da49;color:#3d5a1a;border:2px solid rgba(90,107,40,.18);padding:9px 8px;text-align:center;box-shadow:inset 0 -2px 0 rgba(61,52,40,.08)}.fleet-card b{display:block;font-size:22px;font-weight:900;line-height:1}.fleet-card span{display:block;margin-top:2px;font-size:11px;font-weight:800}.search-pill{height:48px;display:flex;align-items:center;gap:8px;background:rgb(247,243,223);border:2.5px solid #c4b89e;border-radius:50px;padding:0 16px;color:#8a7b66}.search-pill input{width:100%;border:0;outline:0;background:transparent}.island-tabs{grid-column:1 / -1;display:flex;gap:8px;flex-wrap:wrap}.tab-leaf{border:0;border-radius:999px;background:#f7f3df;color:#725d42;padding:9px 16px;font-weight:900}.tab-leaf.active{background:#19c8b9;color:#fff}.home-device-grid{min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;padding:2px 4px 16px}.sim-device-card{border:2px solid rgba(212,196,168,.88);border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,.76),rgba(247,243,223,.92));padding:18px;box-shadow:0 8px 22px rgba(61,52,40,.1);transition:transform .2s ease, box-shadow .2s ease, border-color .2s ease}.sim-device-card:hover{transform:translateY(-3px);box-shadow:0 14px 30px rgba(61,52,40,.15);border-color:#19c8b9}.sim-card-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.sim-card-head h2{font-size:24px;color:#4d3a22}.instance-url{display:block;margin-top:4px;color:#8a7b66;font-size:12px;word-break:break-all}.status-pill{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:0 10px;border-radius:999px;background:#eee;color:#725d42;font-size:12px;font-weight:900;white-space:nowrap}.status-pill.ok,.sim-device-card.ok .status-pill{background:#dff5d7;color:#3f7624}.status-pill.warn,.sim-device-card.warn .status-pill{background:#fff0bf;color:#8a6010}.status-pill.bad,.sim-device-card.bad .status-pill{background:#ffe0e0;color:#9a3030}.sim-card-model{margin-top:14px;color:#725d42;font-weight:900}.sim-card-kpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:14px}.sim-card-kpis span,.signal-item,.metric{border-radius:18px;background:rgba(255,255,255,.62);border:2px solid rgba(232,226,214,.8);padding:12px;min-width:0}.sim-card-kpis em,.signal-item span,.metric span{display:block;color:#9f927d;font-size:12px;font-weight:900;font-style:normal}.sim-card-kpis b,.signal-item b,.metric b{display:block;margin-top:5px;color:#794f27;font-size:16px;font-weight:900}.sim-card-kpis b{overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.35}.sim-card-foot{display:flex;justify-content:space-between;gap:10px;margin-top:14px;color:#8a7b66;font-size:12px;font-weight:800;flex-wrap:wrap;overflow:visible;text-overflow:clip;white-space:normal}.sim-card-foot span{overflow-wrap:anywhere;word-break:break-word}.sim-card-model,.instance-url{overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.35}.sim-config-note{display:flex;gap:8px;align-items:center;justify-content:flex-end;color:#8a7b66;font-size:12px}.sim-config-note code{background:rgba(255,255,255,.6);border-radius:8px;padding:4px 8px} -.detail-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:16px}.workspace-hero{padding:20px 24px;display:flex;align-items:center;justify-content:space-between;gap:18px}.detail-layout{min-height:0;display:grid;grid-template-columns:minmax(320px,.75fr) minmax(0,1.25fr);grid-template-rows:auto minmax(0,1fr) auto;gap:16px;overflow:auto}.signal-board{padding:18px}.signal-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:18px}.nook-metrics{display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:12px}.device-detail-board{grid-column:2;grid-row:1 / span 2;padding:18px;overflow:auto}.device-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:14px;margin-top:18px}.device-info-card{background:rgba(255,255,255,.62);border:2px solid rgba(212,196,168,.72);border-radius:24px;padding:16px;min-width:0;box-shadow:inset 0 -2px 0 rgba(61,52,40,.04)}.device-info-card h4{margin:0 0 12px;color:#794f27;font-size:18px;font-weight:900}.device-info-card dl{display:grid;gap:8px;margin:0}.device-info-card dl div{display:grid;grid-template-columns:86px minmax(0,1fr);gap:10px;align-items:baseline}.device-info-card dt{color:#9f927d;font-size:12px;font-weight:900}.device-info-card dd{margin:0;color:#4d3a22;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.detail-actions-card{grid-column:1 / -1;padding:18px}.detail-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:18px}.detail-action{min-height:64px;border:2px solid rgba(212,196,168,.85);background:rgba(255,255,255,.72);border-radius:20px;padding:10px 14px;text-align:left;font-weight:900;color:#4d3a22}.detail-action:hover{border-color:#19c8b9;transform:translateY(-1px)}.detail-action span{display:block}.detail-action code{display:block;margin-top:4px;color:#8a7b66;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.raw-action{background:#e8edff;color:#4a5a8a}.api-layout.active{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px}.api-layout:before{content:'调试接口为次级入口;日常使用请从首页卡片进入设备详情。';display:block;grid-column:1 / -1;color:#8a7b66;font-weight:800;margin-left:4px}.api-catalog,.api-console{padding:18px}.endpoint-list{margin-top:16px;display:grid;gap:12px}.endpoint-group h4{margin:0 0 8px}.endpoint-row{width:100%;border:0;border-radius:14px;background:rgba(255,255,255,.58);padding:9px 10px;text-align:left;font-size:12px}.endpoint-row span{font-weight:900;color:#6a3a9a;margin-right:6px}.console-toolbar{display:grid;grid-template-columns:110px minmax(0,1fr) 100px;gap:10px}.console-toolbar select,.console-toolbar input,.request-body{border:2px solid #d4c4a8;border-radius:14px;background:#fff;padding:10px}.request-body{width:100%;min-height:110px;margin-top:12px}.raw-output{min-height:340px;overflow:auto;background:#3a3028;color:#f8f0dc;border-radius:18px;padding:16px;white-space:pre-wrap}.rawpage-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px}.frame-toolbar{padding:14px 18px;display:flex;gap:12px;align-items:center}.frame{width:100%;height:100%;border:0;border-radius:24px;background:#fff}.empty-state{padding:28px;text-align:center}.login-card,.instance-form{width:min(560px,calc(100vw - 32px));padding:24px}.login-card input,.instance-form input,.instance-form textarea{width:100%;border:2px solid #d4c4a8;border-radius:14px;background:#fff;padding:10px;margin-top:6px}.instance-form{display:grid;gap:12px}.instance-form label{font-weight:900}.form-menu,dialog menu{display:flex;justify-content:flex-end;gap:10px;padding:0;margin:10px 0 0}.toast{position:fixed;right:20px;bottom:20px;background:#4d3a22;color:#fff;border-radius:999px;padding:12px 18px;font-weight:900;box-shadow:var(--animal-shadow-lg);z-index:20}.compact .sim-device-card{padding:14px}.compact .sim-card-kpis{grid-template-columns:repeat(3,1fr);gap:8px}.compact .device-info-card{padding:12px}.compact .device-info-card dl{gap:5px} +.app-shell{min-height:100vh;overflow-wrap:break-word}.desktop-sidebar{position:fixed;inset:0 auto 0 0;width:var(--sidebar-width);padding:20px 14px;background:#f0e8d8;border-right:2px solid #d4c9b4;display:flex;flex-direction:column;gap:24px}.brand{display:flex;align-items:center;gap:10px;padding:8px 12px;color:#794f27;text-decoration:none;font-size:20px}.side-nav{display:grid;gap:8px}.side-nav button{display:flex;align-items:center;gap:12px;border:0;border-radius:16px;padding:10px 14px;background:transparent;text-align:left;font-weight:700}.side-nav button.active{background:#82d5bb;color:#4b3b25}.sidebar-foot{margin-top:auto;display:grid;gap:4px;padding:12px;color:#8a7b66}.sidebar-foot code{overflow-x:auto}.app-content{margin-left:var(--sidebar-width);min-height:100vh;padding:18px 22px 28px}.sim-topbar{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:16px}.sim-topbar h1{margin-bottom:4px}.sim-topbar p{margin-bottom:0}.eyebrow{color:#8a7b66;font-size:12px;font-weight:800;letter-spacing:.08em}.hero-actions,.card-actions,menu,.output-tools{display:flex;gap:10px;flex-wrap:wrap}.animal-btn{display:inline-flex;align-items:center;justify-content:center;padding:0 18px;border:2px solid;border-radius:50px;text-decoration:none;font-weight:700;transition:transform var(--animal-motion),background var(--animal-motion)}.animal-btn:hover:not(:disabled){transform:translateY(-1px)}.default-btn{background:#f8f8f0;border-color:#9f927d;box-shadow:0 2px 4px rgba(61,52,40,.06)}.primary-btn{background:#19c8b9;border-color:#19c8b9;color:#4b3b25;box-shadow:0 5px 0 #bdaea0}.danger-btn{background:#f8f8f0;border-color:#e05a5a;color:#c94444}.danger-primary{background:#e05a5a;border-color:#e05a5a;color:#fff;box-shadow:0 5px 0 #c94444}.island-card,.state-card,.empty-state{border:1.5px solid #d4c9b4;border-radius:20px;background:var(--animal-bg-content);padding:18px}.view{display:none}.view.active{display:block}.persistent-message{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:14px;padding:12px 16px;border-radius:16px}.persistent-message.error,.state-card.error,.form-error{background:#ffe8e1;color:#9b3d35;border:2px solid #e05a5a}.persistent-message.warn,.danger-warning{background:#fff2bd;color:#725d42;border:2px solid #f5c31c}.fleet-overview{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:14px}.wallet-row{display:flex;gap:8px;flex-wrap:wrap}.wallet-row button,.status-filters button,.output-tools button{border:2px solid #d4c9b4;border-radius:50px;background:#f8f8f0;padding:6px 13px}.wallet-row button[aria-pressed=true],.status-filters button[aria-pressed=true],.output-tools button[aria-pressed=true]{background:#19c8b9;border-color:#19c8b9}.wallet-row b,.wallet-row span{display:block}.home-toolbar{display:grid;grid-template-columns:auto minmax(180px,1fr) auto minmax(130px,.4fr) auto;gap:10px;align-items:center;margin-bottom:14px}.home-toolbar input,.home-toolbar select,.api-console input,.api-catalog input,.dialog-card input,.dialog-card textarea,.api-console textarea,.console-toolbar select{border:2.5px solid #c4b89e;border-radius:50px;background:var(--animal-bg-content);padding:10px 14px}.status-filters{grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap}.home-device-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr));gap:14px}.sim-device-card{border:2px solid #d4c9b4;border-radius:20px;background:rgba(255,255,255,.6);padding:16px;transition:transform var(--animal-motion)}.sim-device-card:hover{transform:translateY(-2px)}.sim-device-card.online{border-color:#8ac68a}.sim-device-card.auth{border-color:#f7cd67}.sim-device-card.offline{border-color:#e18c6f}.sim-card-head{display:flex;justify-content:space-between;gap:12px}.sim-card-head h2{margin-bottom:8px}.status-pill,.tag-row span{display:inline-flex;align-items:center;border-radius:50px;background:#e6f9f6;padding:5px 10px;font-weight:700}.instance-url{color:#725d42}.tag-row{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0}.sim-card-kpis b{overflow-wrap:break-word}.sim-card-foot{overflow-wrap:break-word}.sim-card-model{padding:10px 0;font-weight:700}.instance-url{color:#725d42;overflow-wrap:break-word}.sim-card-kpis{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0 16px}.sim-card-kpis div{border-radius:14px;background:#f0e8d8;padding:9px}.sim-card-kpis dt{color:#8a7b66;font-size:12px}.sim-card-kpis dd{margin:5px 0 0;font-weight:700}.workspace-hero{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:14px}.detail-layout{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(0,1.3fr);gap:14px}.signal-grid,.nook-metrics{display:grid;grid-template-columns:1fr 1fr;gap:9px}.signal-grid div,.metric{border-radius:16px;background:#f0e8d8;padding:12px}.signal-grid span,.metric span{display:block;color:#8a7b66}.detail-board{grid-column:2;grid-row:1/span 2}.device-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.device-info-card{border:2px solid #d4c9b4;border-radius:20px;background:rgba(255,255,255,.54);padding:14px}.device-info-card dl{display:grid;gap:8px}.device-info-card dl div{display:grid;grid-template-columns:88px 1fr;gap:8px}.device-info-card dd{margin:0}.detail-actions-card{grid-column:1/-1}.detail-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,170px),1fr));gap:10px}.detail-actions button,.endpoint-row{min-height:54px;border:2px solid #d4c9b4;border-radius:16px;background:#f8f8f0;padding:10px;text-align:left}.detail-actions code{display:block;overflow-x:auto;margin-top:4px}.frame-toolbar{display:flex;gap:12px;align-items:center;margin-bottom:12px}.frame{width:100%;height:calc(100vh - 190px);border:2px solid #d4c9b4;border-radius:20px;background:#fff}.api-layout.active{display:grid;grid-template-columns:minmax(220px,.38fr) minmax(0,1fr);gap:14px}.api-catalog,.api-console{display:grid;align-content:start;gap:10px}.endpoint-list{max-height:calc(100vh - 250px);overflow:auto}.endpoint-list section{display:grid;gap:7px}.console-toolbar{display:grid;grid-template-columns:auto 100px auto minmax(180px,1fr) auto;gap:9px;align-items:center}.request-body{width:100%;min-height:160px;border-radius:20px!important;resize:vertical}.raw-output{max-width:100%;min-height:270px;margin:0;padding:16px;border-radius:20px;background:#4d3a22;color:#fff8e0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}.dialog-card{width:min(560px,calc(100vw - 28px));display:grid;gap:10px;border:0;border-radius:20px;background:var(--animal-bg-content);color:#725d42;padding:22px}.dialog-card menu{justify-content:flex-end;margin:8px 0 0;padding:0}.dialog-card fieldset{border:2px solid #d4c9b4;border-radius:20px;display:grid;gap:6px}.dialog-card fieldset label,.confirm-check{display:flex;align-items:center;gap:8px;min-height:44px}.dialog-card input[type=radio],.dialog-card input[type=checkbox]{min-height:auto}.delete-details dl div{display:grid;grid-template-columns:70px 1fr;gap:8px}.delete-details dd{margin:0}.toast{position:fixed;z-index:20;right:20px;bottom:20px;max-width:min(420px,calc(100vw - 40px));border-radius:50px;background:#82d5bb;color:#4b3b25;padding:12px 18px;font-weight:700}.mobile-nav{display:none}.compact .sim-device-card{padding:11px}.compact .sim-card-kpis{gap:5px}.compact .sim-card-kpis div{padding:6px} diff --git a/public/styles/responsive.css b/public/styles/responsive.css index bb933bf..c1a3c72 100644 --- a/public/styles/responsive.css +++ b/public/styles/responsive.css @@ -1 +1,2 @@ -@media(max-width:1260px){.home-toolbar{grid-template-columns:1fr}.detail-layout{grid-template-columns:1fr}.device-detail-board{grid-column:auto;grid-row:auto}.detail-actions-card{grid-column:auto}.device-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.nook-metrics{grid-template-columns:repeat(4,1fr)}}@media(max-width:900px){body{overflow:auto}.sim-shell{height:auto;padding:14px}.sim-topbar,.workspace-hero{flex-direction:column;align-items:flex-start}.sim-main{overflow:visible}.view{overflow:visible}.home-tools{grid-template-columns:1fr}.home-device-grid{overflow:visible;grid-template-columns:1fr}.api-layout.active{grid-template-columns:1fr}.nook-metrics,.device-detail-grid{grid-template-columns:1fr 1fr}.hero-actions{justify-content:flex-start}}@media(max-width:560px){.wallet-row,.signal-grid,.nook-metrics,.device-detail-grid,.sim-card-kpis{grid-template-columns:1fr}.hero-actions{width:100%}.hero-actions .animal-btn{width:100%}.console-toolbar{grid-template-columns:1fr}.device-info-card dl div{grid-template-columns:76px minmax(0,1fr)}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}} +@media(max-width:1023px){.api-layout.active{grid-template-columns:1fr}.endpoint-list{max-height:300px}.console-toolbar{grid-template-columns:auto 100px;align-items:center}.console-toolbar label:nth-of-type(2),.console-toolbar input,.console-toolbar .animal-btn{grid-column:1/-1}.console-toolbar label{margin-bottom:-6px}.dialog-card{max-height:calc(100dvh - 20px);overflow-y:auto;margin-block:10px}} +@media(max-width:767px){button,a,input,select,textarea{min-height:44px}.desktop-sidebar{display:none}.app-content{margin-left:0;padding:12px 12px 82px}.sim-topbar,.fleet-overview,.workspace-hero{align-items:flex-start;flex-direction:column}.sim-topbar .hero-actions,.workspace-hero .hero-actions{width:100%}.home-toolbar,.console-toolbar{grid-template-columns:1fr}.home-toolbar label,.console-toolbar label{margin-bottom:-6px}.status-filters{grid-column:auto}.home-device-grid,.device-detail-grid,.signal-grid,.nook-metrics{grid-template-columns:1fr}.detail-layout{grid-template-columns:1fr}.detail-board,.detail-actions-card{grid-column:auto;grid-row:auto}.api-layout.active{grid-template-columns:1fr}.endpoint-list{max-height:300px}.mobile-nav{position:fixed;z-index:10;inset:auto 0 0;display:grid;grid-template-columns:repeat(4,1fr);background:#f0e8d8;border-top:2px solid #d4c9b4;padding:6px max(6px,env(safe-area-inset-right)) max(6px,env(safe-area-inset-bottom)) max(6px,env(safe-area-inset-left))}.mobile-nav button{min-height:52px;border:0;border-radius:14px;background:transparent;color:#725d42}.mobile-nav button.active{background:#82d5bb}.mobile-nav span{display:block;font-size:11px}.sim-card-kpis{grid-template-columns:1fr}.frame{height:70vh}}@media(min-width:768px){.app-content{padding-inline:18px}.home-device-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1024px){.app-content{padding-inline:24px}.home-device-grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}}@media(min-width:1440px){:root{--sidebar-width:240px}.app-content{padding-inline:32px}.home-device-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}}@media(max-width:390px){.app-content{padding-inline:10px}.hero-actions .animal-btn,.card-actions .animal-btn{flex:1}.dialog-card{padding:16px}}@media(max-width:320px){.app-content{padding-inline:8px}.sim-topbar h1{font-size:26px}.animal-btn{padding-inline:12px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} diff --git a/public/styles/tokens.css b/public/styles/tokens.css index ce3478f..9bb556f 100644 --- a/public/styles/tokens.css +++ b/public/styles/tokens.css @@ -1,10 +1 @@ -:root{ - --animal-primary-color:#19c8b9;--animal-primary-color-hover:#3dd4c6;--animal-primary-color-active:#50b9ab;--animal-primary-color-bg:#e6f9f6; - --animal-success-color:#6fba2c;--animal-warning-color:#f5c31c;--animal-error-color:#e05a5a; - --animal-text-color:#794f27;--animal-text-color-body:#725d42;--animal-text-color-secondary:#9f927d;--animal-text-color-muted:#8a7b66;--animal-text-color-disabled:#c4b89e; - --animal-border-color:#aaa69d;--animal-border-color-hover:#827157;--animal-border-color-light:#e8e2d6; - --animal-bg-color:#f8f8f0;--animal-bg-color-content:rgb(247,243,223);--animal-bg-color-secondary:#f0e8d8;--animal-bg-color-disabled:#f0ece2; - --animal-font-family:Nunito,'Noto Sans SC',-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; - --animal-shadow-sm:0 2px 4px 0 rgba(61,52,40,.06);--animal-shadow-base:0 3px 10px 0 rgba(61,52,40,.1);--animal-shadow-lg:0 8px 24px 0 rgba(61,52,40,.14); - --animal-motion-duration-base:.25s;--animal-motion-ease:cubic-bezier(.4,0,.2,1); -} +:root{--animal-primary-color:#19c8b9;--animal-primary-color-hover:#3dd4c6;--animal-primary-color-active:#11a89b;--animal-success-color:#6fba2c;--animal-warning-color:#f5c31c;--animal-error-color:#e05a5a;--animal-text-color:#794f27;--animal-text-color-body:#725d42;--animal-text-color-secondary:#9f927d;--animal-text-color-muted:#8a7b66;--animal-border-color:#9f927d;--animal-border-light:#d4c9b4;--animal-bg-color:#f8f8f0;--animal-bg-content:rgb(247,243,223);--animal-bg-secondary:#f0e8d8;--animal-font-family:Nunito,'Noto Sans SC','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;--animal-motion:.25s cubic-bezier(.4,0,.2,1);--sidebar-width:224px} diff --git a/server/app.js b/server/app.js index e024b9e..2904733 100644 --- a/server/app.js +++ b/server/app.js @@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url' import { redactInstance } from './core.js' import { registerStatusRoutes } from './status/routes.js' import { registerProxyRoutes } from './proxy/routes.js' +import { defaultProxyPolicy } from './proxy/policy.js' +import { ConfirmationStore } from './proxy/confirmations.js' import { ConfigNotFoundError, ConfigValidationError } from './config/errors.js' +import { isIP } from 'node:net' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const FEATURES = ['passwordless-status', 'password-session-login', 'read-write-api-proxy', 'device-sim-network-sms-data-ota-summary', 'iframe-fallback'] @@ -19,9 +22,41 @@ const CATALOG = [ { id: 'notify-auto-ota', name: '通知/自动化/升级', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] }, ] -export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100 } = {}) { +export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) { if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required') const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 }) + const parseAuthority = raw => { + const value = String(raw || '').trim().toLowerCase() + if (!value || /[\s/@]/.test(value)) return null + let hostname, port = '' + if (value.startsWith('[')) { const match=value.match(/^\[([^\]]+)\](?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match } + else { const match=value.match(/^([^:]+)(?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match } + if(port && Number(port)>65535)return null + return { hostname, port } + } + const loopbackAuthority = raw => { + const authority=parseAuthority(raw); if(!authority)return null + const {hostname}=authority + if(hostname === 'localhost') return { ...authority, hostname: 'localhost' } + if(isIP(hostname) === 6 && (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1')) return { ...authority, hostname: '::1' } + if(isIP(hostname)!==4)return null + const octets=hostname.split('.').map(Number) + return octets[0]===127 ? authority : null + } + const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase() + const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost + const configuredPort = String(configStore.snapshot.server.port) + app.addHook('onRequest', async (request, reply) => { + const authority=loopbackAuthority(request.headers.host) + const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null + if (!authority || (!injectDefault && (authority.hostname !== configuredHost || authority.port !== configuredPort))) return reply.code(421).send({ error: 'configured loopback Host required' }) + if (request.headers.origin) { + let origin + try { origin = new URL(request.headers.origin) } catch {} + const originAuthority = origin ? loopbackAuthority(origin.host) : null + if (!origin || origin.protocol !== 'http:' || !originAuthority || originAuthority.hostname !== authority.hostname || originAuthority.port !== authority.port) return reply.code(403).send({ error: 'cross-origin request rejected' }) + } + }) app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' }) @@ -73,7 +108,7 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {} client.jar.clear(); client.clearEphemeralSecret?.(); return { ok: true } }) - registerProxyRoutes(app, { findClient }) + registerProxyRoutes(app, { findClient, policy: proxyPolicy, configStore, confirmationStore: new ConfirmationStore(confirmationOptions) }) app.get('/api/catalog', async () => ({ groups: CATALOG })) app.get('/api/reload-note', async () => ({ message: 'Configuration changes are applied immediately.' })) app.setNotFoundHandler(async (request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ error: 'not found' }) : staticFiles ? reply.sendFile('index.html') : reply.code(404).send({ error: 'not found' })) diff --git a/server/config/file-config-store.js b/server/config/file-config-store.js index 0c701e5..8c0b4bd 100644 --- a/server/config/file-config-store.js +++ b/server/config/file-config-store.js @@ -32,7 +32,7 @@ export class FileConfigStore { this.examplePath = examplePath this.atomicWrite = atomicWrite this.env = { ...env } - this.#snapshot = immutableSnapshot({ ...snapshot, configPath }) + this.#snapshot = immutableSnapshot({ ...snapshot, revision: Number(snapshot.revision || 0), configPath }) } static async open({ configPath, examplePath, atomicWrite, env } = {}) { let text @@ -52,7 +52,7 @@ export class FileConfigStore { const result = await mutator(draft) const normalized = normalizeConfig(serializeConfig(draft), this.env) await this.atomicWrite(this.configPath, serializeConfig(normalized)) - this.#snapshot = immutableSnapshot({ ...normalized, configPath: this.configPath }) + this.#snapshot = immutableSnapshot({ ...normalized, revision: this.#snapshot.revision + 1, configPath: this.configPath }) return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result }) this.#queue = operation.catch(() => {}) @@ -71,8 +71,16 @@ export class FileConfigStore { const index = draft.instances.findIndex(item => item.id === id) if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`) const current = draft.instances[index] - const merged = { ...current, ...payload, id: payload.id ? String(payload.id).trim() : current.id, - auth: payload.auth !== undefined || payload.password !== undefined ? payload.auth || { password: payload.password || '' } : current.auth } + const action = payload.passwordAction + if (action !== undefined && !['preserve', 'set', 'clear'].includes(action)) throw new ConfigValidationError('passwordAction must be preserve, set or clear') + if (payload.auth && Object.hasOwn(payload.auth, 'password')) throw new ConfigValidationError("auth.password is ambiguous; use passwordAction='set' or 'clear'") + if (Object.hasOwn(payload, 'password') && action === undefined) throw new ConfigValidationError("password requires passwordAction='set'") + if (action === 'set' && !String(payload.password || '')) throw new ConfigValidationError("passwordAction='set' requires a non-empty password") + const auth = action === 'set' ? { mode: 'password', password: String(payload.password) } + : action === 'clear' ? { mode: 'none', password: '' } + : current.auth + const { passwordAction: _passwordAction, password: _password, auth: _payloadAuth, ...metadata } = payload + const merged = { ...current, ...metadata, id: payload.id ? String(payload.id).trim() : current.id, auth } const next = normalizeInstance(merged, index) if (next.id !== id && draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`) draft.instances[index] = next diff --git a/server/config/schema.js b/server/config/schema.js index 986bfe8..405af84 100644 --- a/server/config/schema.js +++ b/server/config/schema.js @@ -1,10 +1,26 @@ import { ConfigValidationError } from './errors.js' +import { isIP } from 'node:net' export function normalizeBaseUrl(rawUrl) { let url try { url = new URL(rawUrl) } catch { throw new ConfigValidationError('instance URL is invalid') } if (!['http:', 'https:'].includes(url.protocol)) throw new ConfigValidationError('instance URL must use http or https') if (url.username || url.password) throw new ConfigValidationError('instance URL must not contain credentials') + const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase() + const ipv4 = hostname.split('.').map(Number) + const isIpv4 = ipv4.length === 4 && ipv4.every(part => Number.isInteger(part) && part >= 0 && part <= 255) + const prohibitedIpv4 = isIpv4 && ( + ipv4[0] === 127 || ipv4[0] === 0 || + (ipv4[0] === 169 && ipv4[1] === 254) || + (ipv4[0] >= 224) + ) + const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:') + const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16' + const prohibitedTransition = hostname.startsWith('2002:') + const isIpLiteral = isIP(hostname) !== 0 + if (!isIpLiteral || prohibitedIpv4 || prohibitedIpv6 || prohibitedMetadata || prohibitedTransition) { + throw new ConfigValidationError('instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited') + } url.hash = '' url.search = '' return url.toString().replace(/\/$/, '') @@ -38,8 +54,15 @@ export function normalizeConfig(raw = {}, env = process.env) { } const port = Number(env.PORT ?? raw.server?.port ?? 8788) if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ConfigValidationError('server.port must be an integer from 1 to 65535') + const host = String(env.HOST ?? raw.server?.host ?? '127.0.0.1').trim() + const bareHost = host.replace(/^\[|\]$/g, '').toLowerCase() + const octets = bareHost.split('.') + const ipv4Loopback = octets.length === 4 && octets[0] === '127' && octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) + if (!(bareHost === 'localhost' || bareHost === '::1' || bareHost === '0:0:0:0:0:0:0:1' || ipv4Loopback)) { + throw new ConfigValidationError('server.host must be a loopback host (127/8, ::1, or localhost)') + } return { - server: { host: raw.server?.host ?? env.HOST ?? '127.0.0.1', port }, + server: { host, port }, instances, raw: { ...raw, instances }, } diff --git a/server/proxy/confirmations.js b/server/proxy/confirmations.js new file mode 100644 index 0000000..9268dcf --- /dev/null +++ b/server/proxy/confirmations.js @@ -0,0 +1,63 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' + +export function canonicalQuery(input = '') { + const params = new URLSearchParams(String(input).replace(/^\?/, '')) + params.sort() + return params.toString() +} + +export function canonicalProxyTarget(input) { + if (typeof input !== 'string' || !input.startsWith('/') || input.startsWith('//')) throw new Error('invalid confirmation path') + let url + try { url = new URL(input, 'http://confirmation.local') } catch { throw new Error('invalid confirmation path') } + if (url.origin !== 'http://confirmation.local' || url.hash) throw new Error('invalid confirmation path') + return `${url.pathname}${canonicalQuery(url.search) ? `?${canonicalQuery(url.search)}` : ''}` +} + +export function bodyDigest(body) { + const stable = value => { + if (value === undefined) return 'undefined' + if (Buffer.isBuffer(value)) return value + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]` + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}` + } + const value = stable(body) + return createHash('sha256').update(value).digest('hex') +} + +export class ConfirmationStore { + #entries = new Map() + constructor({ ttlMs = 30_000, maxEntries = 1024, now = Date.now } = {}) { this.ttlMs = ttlMs; this.maxEntries = maxEntries; this.now = now } + prepare(binding) { + const now = this.now() + for (const [key, entry] of this.#entries) if (entry.expiresAt <= now) this.#entries.delete(key) + while (this.#entries.size >= this.maxEntries) this.#entries.delete(this.#entries.keys().next().value) + const token = randomBytes(32).toString('base64url') + const expiresAt = now + this.ttlMs + this.#entries.set(token, { ...binding, expiresAt }) + return { token, expiresAt } + } + consume(token, binding) { + if (typeof token !== 'string' || token.length < 16) return false + const entry = this.#entries.get(token) + if (!entry) return false + this.#entries.delete(token) + if (entry.expiresAt <= this.now()) return false + const expected = Buffer.from(JSON.stringify(binding)) + const actual = Buffer.from(JSON.stringify(Object.fromEntries(Object.keys(binding).map(key => [key, entry[key]])))) + const matches = expected.length === actual.length && timingSafeEqual(expected, actual) + return matches + } +} + +export function confirmationBinding({ instance, revision, method, target, body }) { + return { + instanceId: instance.id, + revision, + origin: new URL(instance.url).origin, + method: String(method).toUpperCase(), + target: canonicalProxyTarget(target), + bodyDigest: bodyDigest(body), + } +} diff --git a/server/proxy/policy.js b/server/proxy/policy.js new file mode 100644 index 0000000..f273a58 --- /dev/null +++ b/server/proxy/policy.js @@ -0,0 +1,53 @@ +const AUTH_PATH = /^\/api\/(?:auth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/i + +export const DEFAULT_READ_PATHS = Object.freeze([ + '/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity', + '/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation', + '/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells', + '/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles', + '/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status', + '/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles', + '/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status', +]) + +// Write access is deliberately narrower than the readable catalog. Every listed +// operation still requires a one-use server confirmation. +export const DEFAULT_WRITE_PATHS = Object.freeze({ + '/api/sms/send': ['POST'], + '/api/data': ['POST', 'PUT', 'PATCH'], + '/api/roaming': ['POST', 'PUT', 'PATCH'], + '/api/airplane-mode': ['POST', 'PUT', 'PATCH'], + '/api/radio-mode': ['POST', 'PUT', 'PATCH'], + '/api/band-lock': ['POST', 'PUT', 'PATCH', 'DELETE'], + '/api/cell-lock': ['POST', 'PUT', 'PATCH', 'DELETE'], + '/api/apn': ['POST', 'PUT', 'PATCH', 'DELETE'], + '/api/device-network/ddns/config': ['POST', 'PUT', 'PATCH'], + '/api/device-network/wlan/profiles': ['POST', 'PUT', 'PATCH', 'DELETE'], + '/api/work-mode': ['POST', 'PUT', 'PATCH'], + '/api/esim/config': ['POST', 'PUT', 'PATCH'], + '/api/esim/profiles': ['POST', 'DELETE'], + '/api/notifications/config': ['POST', 'PUT', 'PATCH'], + '/api/automation/config': ['POST', 'PUT', 'PATCH'], +}) + +export function createProxyPolicy({ readPaths = DEFAULT_READ_PATHS, writePaths = DEFAULT_WRITE_PATHS } = {}) { + const readable = new Set(readPaths) + const writable = new Map(Object.entries(writePaths).map(([path, methods]) => [path, new Set(methods.map(method => method.toUpperCase()))])) + return Object.freeze({ + authorize(method, path) { + method = String(method).toUpperCase() + if (AUTH_PATH.test(path)) return { allowed: false, statusCode: 403, reason: 'authentication endpoints cannot be proxied' } + if (method === 'GET' || method === 'HEAD') return readable.has(path) + ? { allowed: true, dangerous: false } + : { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' } + if (!writable.has(path)) return readable.has(path) + ? { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' } + : { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' } + return writable.get(path).has(method) + ? { allowed: true, dangerous: true } + : { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' } + }, + }) +} + +export const defaultProxyPolicy = createProxyPolicy() diff --git a/server/proxy/routes.js b/server/proxy/routes.js index b3115b0..d07be32 100644 --- a/server/proxy/routes.js +++ b/server/proxy/routes.js @@ -1,14 +1,57 @@ -import { proxyToInstance } from './service.js' +import { proxyToInstance, safeProxyPath, ProxyRequestError } from './service.js' +import { defaultProxyPolicy } from './policy.js' +import { ConfirmationStore, confirmationBinding } from './confirmations.js' + +function requestTarget(request, rest) { + const path = safeProxyPath(rest) + const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '' + return `${path}${query}` +} + +export function registerProxyRoutes(app, { + findClient, + proxy = proxyToInstance, + policy = defaultProxyPolicy, + configStore, + confirmationStore = new ConfirmationStore(), +}) { + app.post('/api/proxy-confirmations/prepare', async (request, reply) => { + try { + const input = request.body || {} + const client = findClient(input.instanceId, reply) + if (!client) return + const method = String(input.method || '').toUpperCase() + const target = String(input.path || '') + const parsed = new URL(target, 'http://confirmation.local') + if (parsed.origin !== 'http://confirmation.local') throw new Error('invalid confirmation path') + const decision = policy.authorize(method, parsed.pathname) + if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason }) + if (!decision.dangerous) return reply.code(400).send({ error: 'confirmation is only available for dangerous writes' }) + const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method, target, body: input.body }) + return confirmationStore.prepare(binding) + } catch (error) { return reply.code(400).send({ error: error.message }) } + }) -export function registerProxyRoutes(app, { findClient, proxy = proxyToInstance }) { app.all('/api/proxy/:id/*', async (request, reply) => { const client = findClient(request.params.id, reply) if (!client) return - try { return await proxy({ client, request, reply, rest: request.params['*'] }) } - catch (error) { - if (/invalid proxy path/.test(error.message)) return reply.code(400).send({ error: error.message }) - if (error.statusCode === 415) return reply.code(415).send({ error: error.message }) - throw error + try { + const target = requestTarget(request, request.params['*']) + const pathname = new URL(target, 'http://proxy.local').pathname + const decision = policy.authorize(request.method, pathname) + if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason }) + if (decision.dangerous) { + const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method: request.method, target, body: request.body }) + const token = request.headers['x-confirmation-token'] + if (!token) return reply.code(428).send({ error: 'confirmation token required' }) + if (!confirmationStore.consume(token, binding)) return reply.code(403).send({ error: 'invalid, expired, replayed, or mismatched confirmation token' }) + } + return await proxy({ client, request, reply, rest: request.params['*'] }) + } catch (error) { + if (error instanceof ProxyRequestError && error.code === 'invalid_path') return reply.code(400).send({ error: 'invalid proxy path' }) + if (error instanceof ProxyRequestError && error.code === 'unsupported_content_type') return reply.code(415).send({ error: 'unsupported proxy content type' }) + request.log?.error?.({ err: error }, 'upstream proxy failed') + return reply.code(502).send({ error: 'upstream request failed' }) } }) } diff --git a/server/proxy/service.js b/server/proxy/service.js index bd50737..4c36d2e 100644 --- a/server/proxy/service.js +++ b/server/proxy/service.js @@ -5,23 +5,32 @@ const BLOCKED_REQUEST_HEADERS = new Set([ ]) const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding']) +export class ProxyRequestError extends Error { + constructor(code) { + super(code === 'unsupported_content_type' ? 'unsupported proxy content type' : 'invalid proxy path') + this.code = code + } +} + +const invalidPath = () => new ProxyRequestError('invalid_path') + export function safeProxyPath(rest = '') { const raw = String(rest || '') - if (!raw || raw.startsWith('/') || raw.includes('\\')) throw new Error('invalid proxy path') + if (!raw || raw.startsWith('/') || raw.includes('\\')) throw invalidPath() let value = raw const assertSafe = candidate => { - if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw new Error('invalid proxy path') - if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw new Error('invalid proxy path') + if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw invalidPath() + if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw invalidPath() } for (let depth = 0; depth < 16; depth += 1) { assertSafe(value) let decoded - try { decoded = decodeURIComponent(value) } catch { throw new Error('invalid proxy path') } + try { decoded = decodeURIComponent(value) } catch { throw invalidPath() } if (decoded === value) return `/${raw}` value = decoded } // Refuse inputs whose semantics still change after a bounded number of decodes. - throw new Error('invalid proxy path') + throw invalidPath() } export function proxyHeaders(input = {}) { @@ -37,17 +46,17 @@ export async function proxyToInstance({ client, request, reply, rest }) { const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '' const target = new URL(`${targetPath}${query}`, new URL(client.instance.url).origin) const origin = new URL(client.instance.url).origin - if (target.origin !== origin) throw new Error('invalid proxy path') + if (target.origin !== origin) throw invalidPath() const headers = proxyHeaders(request.headers) let body if (!['GET', 'HEAD'].includes(request.method)) { const type = (headers.get('content-type') || '').split(';')[0].trim().toLowerCase() if (request.body === undefined && !type) body = undefined - else if (type.startsWith('multipart/')) { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error } + else if (type.startsWith('multipart/')) throw new ProxyRequestError('unsupported_content_type') else if (type === 'application/json' || type.endsWith('+json')) body = request.body === undefined ? undefined : JSON.stringify(request.body) else if (type.startsWith('text/') || type === 'application/x-www-form-urlencoded') body = request.body else if (Buffer.isBuffer(request.body)) body = request.body - else { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error } + else throw new ProxyRequestError('unsupported_content_type') } const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' }) reply.code(upstream.status) diff --git a/test/phase-one-architecture.test.js b/test/phase-one-architecture.test.js index 3a34266..416e222 100644 --- a/test/phase-one-architecture.test.js +++ b/test/phase-one-architecture.test.js @@ -39,7 +39,7 @@ test('FileConfigStore uses example only as template and commits immutable snapsh const store = await FileConfigStore.open({ configPath, examplePath }) assert.equal(store.snapshot.server.host, '127.0.0.1') assert.ok(Object.isFrozen(store.snapshot.instances)) - await store.add({ id: 'one', url: 'http://127.0.0.1:3000' }) + await store.add({ id: 'one', url: 'http://192.0.2.10:3000' }) assert.equal(JSON.parse(await readFile(configPath, 'utf8')).instances[0].id, 'one') assert.equal(await readFile(examplePath, 'utf8'), before) }) @@ -48,7 +48,7 @@ test('failed persistence does not publish memory and transactions serialize', as const { configPath, examplePath } = await fixtureConfig() let writes = 0 const store = await FileConfigStore.open({ configPath, examplePath, atomicWrite: async () => { writes += 1; throw new Error('disk full') } }) - await assert.rejects(store.add({ id: 'x', url: 'http://127.0.0.1' }), /disk full/) + await assert.rejects(store.add({ id: 'x', url: 'http://192.0.2.11' }), /disk full/) assert.equal(store.snapshot.instances.length, 0) assert.equal(writes, 1) await assert.rejects(access(configPath)) @@ -76,16 +76,17 @@ test('proxy path and target URL contracts reject origin escape vectors', () => { }) test('buildApp supports inject without listening and covers health/readiness/config/auth/proxy/404', async t => { - const { configPath, examplePath } = await fixtureConfig([{ id: 'one', url: 'http://127.0.0.1:3000' }]) + const { configPath, examplePath } = await fixtureConfig([{ id: 'one', url: 'http://192.0.2.10:3000' }]) const store = await FileConfigStore.open({ configPath, examplePath }) const calls = [] const registry = new ClientRegistry({ createClient: instance => fakeClient(instance, calls) }).reconcile(store.snapshot.instances) - const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }) + const testPolicy = { authorize: (_method, path) => path === '/api/test' ? { allowed: true, dangerous: false } : { allowed: false, statusCode: 403, reason: 'test policy' } } + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: testPolicy }) t.after(() => app.close()) assert.deepEqual((await app.inject('/api/health')).json(), { ok: true }) assert.deepEqual((await app.inject('/api/ready')).json(), { ready: true }) assert.equal((await app.inject('/api/config')).statusCode, 200) - const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'two', url: 'https://example.test' } }) + const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'two', url: 'https://192.0.2.15' } }) assert.equal(created.statusCode, 200) const renamed = await app.inject({ method: 'PUT', url: '/api/instances/two', payload: { id: 'renamed' } }) assert.equal(renamed.json().instance.id, 'renamed') @@ -104,7 +105,7 @@ test('buildApp supports inject without listening and covers health/readiness/con assert.equal(calls[0].options.headers.has('authorization'), false) assert.equal(calls[0].options.headers.has('forwarded'), false) assert.equal(calls[0].options.body, JSON.stringify({ hello: 'world' })) - assert.equal(new URL(calls[0].url).origin, 'http://127.0.0.1:3000') + assert.equal(new URL(calls[0].url).origin, 'http://192.0.2.10:3000') assert.deepEqual((await app.inject('/api/missing')).json(), { error: 'not found' }) }) diff --git a/test/phase-one-blockers.test.js b/test/phase-one-blockers.test.js index 3214ada..ec21915 100644 --- a/test/phase-one-blockers.test.js +++ b/test/phase-one-blockers.test.js @@ -38,12 +38,12 @@ test('proxy path rejects normalization and repeated-decoding structure changes', test('FileConfigStore preserves constructor env across transactions and serializes real writes', async () => { const { configPath, examplePath } = await files() - const store = await FileConfigStore.open({ configPath, examplePath, env: { HOST: 'env-host', PORT: '4321' } }) + const store = await FileConfigStore.open({ configPath, examplePath, env: { HOST: 'localhost', PORT: '4321' } }) await Promise.all([ - store.add({ id: 'a', url: 'http://a.test' }), - store.add({ id: 'b', url: 'http://b.test' }), + store.add({ id: 'a', url: 'http://192.0.2.21' }), + store.add({ id: 'b', url: 'http://192.0.2.22' }), ]) - assert.equal(store.snapshot.server.host, 'env-host') + assert.equal(store.snapshot.server.host, 'localhost') assert.equal(store.snapshot.server.port, 4321) assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances.map(x => x.id), ['a', 'b']) }) @@ -59,24 +59,24 @@ test('atomicWriteJson replaces content, leaves no temp and creates POSIX 0600', test('schema rejects invalid port and auth mode', () => { for (const port of [0, 65536, 1.5, 'wat']) assert.throws(() => normalizeConfig({ server: { port }, instances: [] }, {}), /port/) - assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url: 'http://x.test', auth: { mode: 'token' } }] }, {}), /auth.mode/) + assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url: 'http://192.0.2.27', auth: { mode: 'token' } }] }, {}), /auth.mode/) }) test('registry reconcile builds next map before publishing and closes staged clients on create failure', () => { - const old = { id: 'old', url: 'http://old.test', auth: { mode: 'none', password: '' } } + const old = { id: 'old', url: 'http://192.0.2.24', auth: { mode: 'none', password: '' } } let stagedClosed = false const registry = new ClientRegistry({ createClient: item => { if (item.id === 'bad') throw new Error('boom') return { instance: item, close() { if (item.id === 'staged') stagedClosed = true } } } }).reconcile([old]) - assert.throws(() => registry.reconcile([{ id: 'staged', url: 'http://staged.test' }, { id: 'bad', url: 'http://bad.test' }]), /boom/) + assert.throws(() => registry.reconcile([{ id: 'staged', url: 'http://192.0.2.26' }, { id: 'bad', url: 'http://192.0.2.23' }]), /boom/) assert.equal(stagedClosed, true) assert.equal(registry.get('old').instance.id, 'old') assert.equal(registry.get('bad'), undefined) }) test('temporary login credential is ephemeral, saved flag comes from store, logout clears it', async t => { - const { configPath, examplePath } = await files([{ id: 'one', url: 'http://sim.test' }]) + const { configPath, examplePath } = await files([{ id: 'one', url: 'http://192.0.2.25' }]) const store = await FileConfigStore.open({ configPath, examplePath }) let credential let cleared = false @@ -92,7 +92,7 @@ test('temporary login credential is ephemeral, saved flag comes from store, logo }) test('failed temporary login credential is not retained for later automatic attempts', async () => { - const instance = { id: 'x', url: 'http://x.test', auth: { mode: 'none', password: '' } } + const instance = { id: 'x', url: 'http://192.0.2.27', auth: { mode: 'none', password: '' } } const bodies = [] const c = createSimAdminClient(instance, { fetchImpl: async (url, options = {}) => { const pathname = new URL(url).pathname @@ -109,14 +109,15 @@ test('failed temporary login credential is not retained for later automatic atte }) test('proxy body/header contract handles JSON text buffer, gzip metadata and stable 415', async t => { - const { configPath, examplePath } = await files([{ id: 'one', url: 'http://sim.test/base' }]) + const { configPath, examplePath } = await files([{ id: 'one', url: 'http://192.0.2.25/base' }]) const store = await FileConfigStore.open({ configPath, examplePath }) const calls = [] const registry = new ClientRegistry({ createClient: instance => client(instance, async (url, options) => { calls.push({ url: String(url), options }) return new Response('decoded', { status: 503, headers: { 'content-encoding': 'gzip', 'content-length': '999', 'x-upstream': 'yes' } }) }) }).reconcile(store.snapshot.instances) - const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) + const testPolicy = { authorize: () => ({ allowed: true, dangerous: false }) } + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: testPolicy }); t.after(() => app.close()) const json = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?q=1', headers: { 'content-type': 'application/json', 'accept-encoding': 'gzip' }, payload: { x: 1 } }) assert.equal(json.statusCode, 503); assert.equal(calls[0].options.body, '{"x":1}'); assert.equal(new URL(calls[0].url).pathname, '/api/x'); assert.equal(new URL(calls[0].url).search, '?q=1') assert.equal(calls[0].options.headers.has('accept-encoding'), false); assert.equal(json.headers['content-encoding'], undefined); assert.equal(json.headers['content-length'], '7') @@ -132,7 +133,7 @@ test('committed config survives reconcile failure and readiness degrades then re let fail = true const registry = new ClientRegistry({ createClient: instance => { if (fail) throw new Error('factory'); return client(instance, async () => new Response('{}')) } }) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, reconcileRetryMs: 10 }); t.after(() => app.close()) - const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'x', url: 'http://x.test' } }) + const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'x', url: 'http://192.0.2.27' } }) assert.equal(created.statusCode, 200); assert.equal(created.json().reconcilePending, true); assert.equal((await app.inject('/api/ready')).json().ready, false) fail = false await new Promise(resolve => setTimeout(resolve, 30)) @@ -141,7 +142,7 @@ test('committed config survives reconcile failure and readiness degrades then re test('status supports registration_status and health 404 with successful business endpoint', async () => { const values = new Map([['/api/health', { ok: false, status: 404, latencyMs: 2, data: null }], ['/api/auth/status', { ok: false, status: 404, data: null }], ['/api/device', { ok: true, status: 200, data: { model: 'X' } }], ['/api/network', { ok: true, status: 200, data: { registration_status: 'registered' } }]]) - const instance = { id: 'x', name: 'x', url: 'http://x.test', auth: { mode: 'none', password: '' } } + const instance = { id: 'x', name: 'x', url: 'http://192.0.2.27', auth: { mode: 'none', password: '' } } const c = createSimAdminClient(instance, { fetchImpl: async url => { const v = values.get(new URL(url).pathname) || { ok: false, status: 404, data: null }; return new Response(JSON.stringify(v.data), { status: v.status }) } }) const status = await collectInstanceStatus(c) assert.equal(status.reachable, true); assert.notEqual(status.authenticated, false); assert.equal(status.summary.network.registration, 'registered') diff --git a/test/phase-two-security.test.js b/test/phase-two-security.test.js new file mode 100644 index 0000000..7329a8a --- /dev/null +++ b/test/phase-two-security.test.js @@ -0,0 +1,182 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { normalizeConfig } from '../server/config/schema.js' +import { FileConfigStore } from '../server/config/file-config-store.js' +import { ClientRegistry } from '../server/clients/client-registry.js' +import { buildApp } from '../server/app.js' +import { createProxyPolicy } from '../server/proxy/policy.js' +import { ConfirmationStore } from '../server/proxy/confirmations.js' + +async function storeFor(instances = [{ id: 'one', url: 'http://192.168.8.1', auth: { password: 'old-secret' } }]) { + const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-')) + const configPath = path.join(dir, 'config.json') + const examplePath = path.join(dir, 'example.json') + await writeFile(examplePath, JSON.stringify({ instances })) + return FileConfigStore.open({ configPath, examplePath, env: {} }) +} + +function fakeRegistry(store, calls = []) { + return new ClientRegistry({ createClient: instance => ({ + instance, jar: { clear() {} }, clearEphemeralSecret() {}, + fetchJson: async () => ({ ok: true, status: 200, data: {} }), ensureAuthenticated: async () => ({ authenticated: true }), + request: async (url, options) => { calls.push({ url: String(url), options }); return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) }, + }) }).reconcile(store.snapshot.instances) +} + +test('listen schema defaults to loopback and rejects every non-loopback host', () => { + assert.equal(normalizeConfig({}, {}).server.host, '127.0.0.1') + for (const host of ['0.0.0.0', '192.168.1.2', 'example.com', '::', '169.254.1.1']) { + assert.throws(() => normalizeConfig({ server: { host } }, {}), /loopback/i) + } + for (const host of ['localhost', '127.0.0.2', '127.255.255.255', '::1', '[::1]']) { + assert.equal(normalizeConfig({ server: { host } }, {}).server.host, host) + } +}) + +test('instance targets allow LAN but reject credentials and local/metadata targets', () => { + assert.equal(normalizeConfig({ instances: [{ id: 'lan', url: 'http://192.168.1.1' }] }, {}).instances[0].url, 'http://192.168.1.1') + for (const url of [ + 'http://169.254.169.254/latest', 'http://169.254.1.1', 'http://127.0.0.1', + 'http://localhost', 'http://device.example.test', 'http://[::1]', 'http://[fe80::1]', 'http://[fe90::1]', + 'http://[::ffff:127.0.0.1]', 'http://[::ffff:169.254.169.254]', 'http://0x7f000001', + 'http://2130706433', 'http://100.100.100.200/latest/meta-data', + 'http://[64:ff9b::7f00:1]', 'http://[64:ff9b::a9fe:a9fe]', + 'http://168.63.129.16', 'http://[2002:7f00:1::]', + 'http://user:***@192.168.1.1', + ]) { + assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url }] }, {}), /credentials|target/i) + } +}) + +test('control plane rejects non-loopback Host and cross-origin browser requests', async t => { + const store = await storeFor(); const registry = fakeRegistry(store) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788' } })).statusCode, 200) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:1' } })).statusCode, 421) + assert.equal((await app.inject({ url: '/api/health', headers: { host: 'localhost:8788' } })).statusCode, 421) + assert.equal((await app.inject({ url: '/api/health', headers: { host: 'attacker.example' } })).statusCode, 421) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]evil.example' } })).statusCode, 421) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:bad:evil' } })).statusCode, 421) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://attacker.example' } })).statusCode, 403) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:9999' } })).statusCode, 403) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://127.0.0.1:8788' } })).statusCode, 403) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:8788' } })).statusCode, 200) +}) + +test('expanded IPv6 loopback config accepts equivalent exact authorities', async t => { + const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-v6-')) + const configPath = path.join(dir, 'config.json'), examplePath = path.join(dir, 'example.json') + await writeFile(examplePath, JSON.stringify({ server: { host: '0:0:0:0:0:0:0:1', port: 8788 }, instances: [{ id: 'one', url: 'http://192.168.8.1' }] })) + const store = await FileConfigStore.open({ configPath, examplePath, env: {} }) + const registry = fakeRegistry(store) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8788' } })).statusCode, 200) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[::1]:8788' } })).statusCode, 200) + assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8789' } })).statusCode, 421) +}) + +test('proxy failures return a stable error without leaking upstream exception details', async t => { + const store = await storeFor() + const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw new Error('SECRET_UPSTREAM_DETAIL') } }) }).reconcile(store.snapshot.instances) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) + const response = await app.inject({ url: '/api/proxy/one/api/device' }) + assert.equal(response.statusCode, 502) + assert.equal(response.body.includes('SECRET_UPSTREAM_DETAIL'), false) +}) + +test('upstream cannot impersonate controlled proxy errors to leak details', async t => { + const store = await storeFor() + for (const error of [new Error('SECRET invalid proxy path DETAIL'), Object.assign(new Error('SECRET_UNSUPPORTED_DETAIL'), { statusCode: 415 })]) { + const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw error } }) }).reconcile(store.snapshot.instances) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) + const response = await app.inject({ url: '/api/proxy/one/api/device' }) + assert.equal(response.statusCode, 502) + assert.equal(response.body.includes('SECRET'), false) + } +}) + +test('confirmation store bounds pending tokens and invalidates a token on first consume attempt', () => { + const store = new ConfirmationStore({ maxEntries: 2, ttlMs: 1000, now: () => 0 }) + const first = store.prepare({ id: 1 }).token + store.prepare({ id: 2 }); store.prepare({ id: 3 }) + assert.equal(store.consume(first, { id: 1 }), false) + const token = store.prepare({ id: 4 }).token + assert.equal(store.consume(token, { id: 999 }), false) + assert.equal(store.consume(token, { id: 4 }), false) +}) + +test('password update has preserve/set/clear semantics and rejects legacy empty password', async () => { + const store = await storeFor() + assert.equal(store.snapshot.revision, 0) + await store.update('one', { name: 'renamed', passwordAction: 'preserve' }) + assert.equal(store.snapshot.instances[0].name, 'renamed') + assert.equal(store.snapshot.instances[0].auth.password, 'old-secret') + assert.equal(store.snapshot.revision, 1) + await store.update('one', { name: 'changed' }) + assert.equal(store.snapshot.instances[0].auth.password, 'old-secret') + assert.equal(store.snapshot.revision, 2) + await assert.rejects(store.update('one', { auth: { password: '' } }), /ambiguous/i) + await assert.rejects(store.update('one', { passwordAction: 'set', password: '' }), /non-empty/i) + await store.update('one', { passwordAction: 'set', password: 'new-secret' }) + assert.equal(store.snapshot.instances[0].auth.password, 'new-secret') + await store.update('one', { passwordAction: 'clear' }) + assert.equal(store.snapshot.instances[0].auth.password, '') +}) + +test('metadata update API never returns passwords and legacy empty password is 400', async t => { + const store = await storeFor(); const registry = fakeRegistry(store) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) + const bad = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { auth: { password: '' } } }) + assert.equal(bad.statusCode, 400); assert.match(bad.json().error, /ambiguous/i) + const good = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { name: 'safe' } }) + assert.equal(good.statusCode, 200); assert.equal(JSON.stringify(good.json()).includes('old-secret'), false) +}) + +test('production proxy policy rejects unknown/auth paths and disallowed methods before upstream', async t => { + const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) + assert.equal((await app.inject('/api/proxy/one/api/device')).statusCode, 200) + assert.equal((await app.inject('/api/proxy/one/api/x')).statusCode, 403) + assert.equal((await app.inject('/api/proxy/one/api/auth/login')).statusCode, 403) + assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/device' })).statusCode, 405) + assert.equal(calls.length, 1) +}) + +test('injectable policy can allow fake paths while dangerous writes require bound one-use confirmation', async t => { + let now = 1000 + const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls) + const policy = createProxyPolicy({ readPaths: ['/api/x'], writePaths: { '/api/x': ['POST'] } }) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy, confirmationOptions: { now: () => now, ttlMs: 100 } }); t.after(() => app.close()) + + const missing = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', payload: { value: 1 } }) + assert.equal(missing.statusCode, 428); assert.equal(calls.length, 0) + const prepared = await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } }) + assert.equal(prepared.statusCode, 200); const token = prepared.json().token + const tampered = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', headers: { 'x-confirmation-token': token }, payload: { value: 2 } }) + assert.equal(tampered.statusCode, 403); assert.equal(calls.length, 0) + const okToken = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } })).json().token + const ok = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } }) + assert.equal(ok.statusCode, 200); assert.equal(calls.length, 1) + const replay = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } }) + assert.equal(replay.statusCode, 403); assert.equal(calls.length, 1) + + const expiring = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: null } })).json().token + now += 101 + assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': expiring }, payload: null })).statusCode, 403) + assert.equal(calls.length, 1) +}) + +test('confirmation is invalidated by target revision/origin change', async t => { + const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls) + const policy = createProxyPolicy({ writePaths: { '/api/x': ['POST'] } }) + const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy }); t.after(() => app.close()) + const token = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: {} } })).json().token + await store.update('one', { name: 'revision bump' }) + const response = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': token }, payload: {} }) + assert.equal(response.statusCode, 403); assert.equal(calls.length, 0) +}) diff --git a/test/phase-two-ui.test.js b/test/phase-two-ui.test.js new file mode 100644 index 0000000..77ca891 --- /dev/null +++ b/test/phase-two-ui.test.js @@ -0,0 +1,90 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { filterAndSortFleet, emptyFleetReason } from '../public/state/fleet-view-model.js' +import { instanceDraftPayload, requestDraft } from '../public/state/operation-draft.js' +import { resolveView } from '../public/router/view-router.js' + +const root = new URL('../', import.meta.url) +const text = path => readFile(new URL(path, root), 'utf8') + +test('fleet view model filters, searches and sorts without mutating source', () => { + const source = [{ id: 'z', name: 'Zulu', url: 'http://z', tags: ['备用'] }, { id: 'a', name: 'Alpha', url: 'http://a', tags: [] }] + const statuses = new Map([['z', { reachable: false, latencyMs: 80 }], ['a', { reachable: true, authenticated: true, latencyMs: 12 }]]) + assert.deepEqual(filterAndSortFleet(source, statuses, { filter: 'online', sort: 'latency' }).map(x => x.id), ['a']) + assert.deepEqual(filterAndSortFleet(source, statuses, { query: '备用', sort: 'name' }).map(x => x.id), ['z']) + assert.deepEqual(source.map(x => x.id), ['z', 'a']) + assert.equal(emptyFleetReason({ total: 0 }), 'config') + assert.equal(emptyFleetReason({ total: 2, query: 'x', visible: 0 }), 'search') + assert.equal(emptyFleetReason({ total: 2, filter: 'offline', visible: 0 }), 'filter') +}) + +test('operation drafts enforce explicit password and request semantics', () => { + assert.deepEqual(instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'preserve' }, true).passwordAction, 'preserve') + assert.throws(() => instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'set', password: '' }, true), /密码/) + assert.throws(() => instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'clear' }, true), /确认/) + assert.deepEqual(requestDraft('GET', '/api/device', { ignored: true }), { method: 'GET', path: '/api/device' }) + assert.equal(requestDraft('PATCH', '/api/data', { enabled: true }).body.enabled, true) +}) + +test('protected views fall back home without an active device', () => { + for (const view of ['detail', 'rawpage', 'api']) assert.equal(resolveView(view, false), 'home') + assert.equal(resolveView('rawpage', true), 'rawpage') +}) + +test('phase two shell, CRUD, API console and accessibility contracts exist', async () => { + const [html, app, css, responsive, api] = await Promise.all([ + text('public/index.html'), text('public/app.js'), text('public/styles/components.css'), text('public/styles/responsive.css'), text('public/infrastructure/api-client.js'), + ]) + assert.doesNotMatch(html, /fonts\.googleapis|fonts\.gstatic/) + assert.match(html, /class="desktop-sidebar"/) + assert.match(html, /class="mobile-nav"/) + assert.match(html, /aria-live="polite"[^>]*role="status"/) + assert.match(html, /id="fatalState"[^>]*role="alert"/) + assert.match(html, /id="persistentError"[^>]*role="alert"/) + assert.match(html, /id="deleteDialog"[\s\S]*aria-labelledby="deleteDialogTitle"/) + assert.match(html, /id="apiDeleteDialog"[\s\S]*aria-labelledby="apiDeleteTitle"/) + assert.match(html, /id="passwordPreserve"[\s\S]*id="passwordSet"[\s\S]*id="passwordClear"/) + assert.match(html, /id="endpointSearch"/) + assert.match(html, /id="outputPretty"[\s\S]*id="outputRaw"[\s\S]*id="copyOutput"/) + assert.match(html, /