fix(security): P0 修复——备份恢复路径与权限、frps 安装白名单与 SHA-256、SSH 主机密钥 TOFU、三处凭据掩码,另修状态徽标与仪表盘文案(v1.2.7)
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-24 17:06:34 +08:00
parent 0fce234ef3
commit 6abedbbe42
21 changed files with 651 additions and 65 deletions
+1 -1
View File
@@ -312,7 +312,7 @@ export const api = {
}),
getFrpAgentRoutes: (id: number) => request<import('./types').FrpAgentRouteDetail[]>(`/api/frpmulti/servers/${id}/agent/routes`),
getFrpAgentRouteCandidates: (id: number) => request<import('./types').FrpProxy[]>(`/api/frpmulti/servers/${id}/agent/route-candidates`),
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean; basic_auth_user?: string; basic_auth_password?: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
getFrpAgentRouteConf: (serverId: number, proxyId: number) =>
request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
installFrpAgentNginx: (serverId: number) =>
+1 -1
View File
@@ -173,7 +173,7 @@ const menuDefs = [
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
{ label: '公网 Agent', key: 'frp-agent', icon: HardwareChipOutline },
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline },
+10 -1
View File
@@ -579,7 +579,7 @@
<div v-if="viewingServer" class="server-detail">
<div class="server-detail__top"><div><h2>{{ viewingServer.name }}</h2><p>服务端详情和运行监测</p></div><n-button v-if="viewingServer.boot_status === 'running'" size="small" type="error" ghost @click="stopServer(viewingServer)"><template #icon><n-icon :component="StopOutline" /></template>停止</n-button><n-button v-else size="small" type="success" ghost @click="startServer(viewingServer)"><template #icon><n-icon :component="PlayOutline" /></template>启动</n-button><n-button size="small" @click="openFRPSConfig(viewingServer)"><template #icon><n-icon :component="DocumentOutline" /></template>frps.toml</n-button></div>
<div class="server-detail__grid">
<section class="server-detail__panel"><h3>基本信息</h3><div class="detail-fields"><div><label>主机地址</label><strong class="mono">{{ viewingServer.server_addr }}</strong></div><div><label>端口</label><strong>{{ viewingServer.server_port }}</strong></div><div><label>用户</label><strong>{{ viewingServer.options.user || '未设置' }}</strong></div><div><label>位置</label><strong class="server-detail__location"><n-icon :component="LocationOutline" /> {{ locationText(viewingServer) }}</strong></div><div><label>状态</label><StatusBadge :value="serverStartupType(viewingServer)" :text="serverStartupText(viewingServer)" /></div><div><label>协议</label><strong>{{ viewingServer.options.protocol.toUpperCase() }}</strong></div><div><label>创建时间</label><span>{{ formatDate(viewingServer.created_at) }}</span></div><div><label>最后更新</label><span>{{ formatDate(viewingServer.updated_at) }}</span></div></div></section>
<section class="server-detail__panel"><h3>基本信息</h3><div class="detail-fields"><div><label>主机地址</label><strong class="mono">{{ viewingServer.server_addr }}</strong></div><div><label>端口</label><strong>{{ viewingServer.server_port }}</strong></div><div><label>用户</label><strong>{{ viewingServer.options.user || '未设置' }}</strong></div><div><label>位置</label><strong class="server-detail__location"><n-icon :component="LocationOutline" /> {{ locationText(viewingServer) }}</strong></div><div><label>状态</label><StatusBadge :kind="serverStartupKind(viewingServer)" :text="serverStartupText(viewingServer)" /></div><div><label>协议</label><strong>{{ viewingServer.options.protocol.toUpperCase() }}</strong></div><div><label>创建时间</label><span>{{ formatDate(viewingServer.created_at) }}</span></div><div><label>最后更新</label><span>{{ formatDate(viewingServer.updated_at) }}</span></div></div></section>
<section class="server-detail__panel">
<h3>配置</h3>
<div class="detail-fields detail-fields--single">
@@ -665,6 +665,7 @@ import type {
FrpServerOptions, FrpServerPayload, FrpServerStatusDetail,
} from '../api/types'
import { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format'
import type { StatusKind } from '../utils/status'
import { copyText } from '../utils/clipboard'
use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
@@ -1386,6 +1387,14 @@ function serverStartupType(server: FrpServer) {
return 'default'
}
// StatusBadge 的 kind 只认 success/warning/error/disabled/unknown,n-tag 的 'default' 要映射成 unknown
function serverStartupKind(server: FrpServer): StatusKind {
if (server.boot_status === 'running') return 'success'
if (server.boot_status === 'starting') return 'warning'
if (server.boot_status === 'error') return 'error'
return 'unknown'
}
function processStateText(server: FrpServer) {
return ({ starting: '启动中', running: '运行中', stopped: '已停止', error: '异常', unknown: '未知' } as Record<string, string>)[server.process_state] ?? '未知'
}
+3 -3
View File
@@ -32,7 +32,7 @@
<StatCard label="域名" tone="green">
<template #icon><n-icon :component="WifiOutline" /></template>
<template #extra><StatusBadge :value="status?.ddns_status" text="正常" /></template>
<template #extra><StatusBadge v-if="status?.ddns_status" :value="status.ddns_status" :text="statusLabel(status.ddns_status)" /></template>
<template #value>{{ domainCountLabel }}</template>
<p class="stat-desc">{{ domainSubLabel }}</p>
<div class="stat-foot">
@@ -43,7 +43,7 @@
<StatCard label="HTTPS 证书" tone="blue">
<template #icon><n-icon :component="ShieldCheckmarkOutline" /></template>
<template #extra><StatusBadge :value="status?.certificate_status" text="正常" /></template>
<template #extra><StatusBadge v-if="status?.certificate_status" :value="status.certificate_status" :text="statusLabel(status.certificate_status)" /></template>
<template #value>{{ certCountLabel }}</template>
<p class="stat-desc">{{ certSubLabel }}</p>
<div v-if="primaryCert" class="stat-foot">
@@ -395,7 +395,7 @@ import {
formatUptime,
} from '../utils/format'
import { displaySystemLog } from '../utils/logDisplay'
import { httpStatusKind } from '../utils/status'
import { httpStatusKind, statusLabel } from '../utils/status'
const message = useMessage()
const status = ref<DashboardStatus | null>(null)
+64 -17
View File
@@ -4,13 +4,6 @@
description="把穿透规则的域名发布到 VPS 的 Nginx:部署 / 移除、部署设置(HTTPS、跳转、白名单、Basic Auth、限流)、健康检查与配置查看"
>
<template #actions>
<n-select
v-model:value="selectedId"
:options="serverOptions"
placeholder="选择公网服务端"
class="proxy-remote-select"
:disabled="servers.length === 0"
/>
<n-button :loading="manualRefreshing" :disabled="!server" @click="refreshAll({ silent: false })">
<template #icon><n-icon :component="RefreshOutline" /></template>
刷新
@@ -24,14 +17,47 @@
<EmptyState
v-if="servers.length === 0"
title="尚未配置 FRP 服务端"
description="先在「内网穿透」页添加公网服务端并保存 Agent/SSH 信息,再回到本页管理公网反代。"
description="服务端的添加与 Agent / SSH 配置在「公网服务端」页;本页只把启用中的 http 规则发布到 VPS 的 Nginx。"
>
<template #action>
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
<router-link to="/frp-agent"><n-button type="primary">去公网服务端页</n-button></router-link>
</template>
</EmptyState>
<template v-else-if="server">
<div v-else-if="server" class="proxy-layout">
<!-- 左:服务端列表;右:该服务端的漂移提示 / 统计 / Nginx 与反代台账 -->
<aside class="server-sidebar">
<div class="server-sidebar__head">
<div class="server-sidebar__head-row">
<h3 class="server-sidebar__title">服务端列表</h3>
</div>
</div>
<div class="server-sidebar__list">
<button
v-for="item in servers"
:key="item.id"
type="button"
class="server-item"
:class="{ 'server-item--active': item.id === selectedId }"
@click="selectedId = item.id"
>
<div class="server-item__top">
<span class="server-item__name">{{ item.name }}</span>
<n-tag size="tiny" :bordered="false" :type="item.agent_configured ? 'success' : 'warning'">{{ item.agent_configured ? 'Agent 就绪' : 'Agent 未配' }}</n-tag>
</div>
<div class="server-item__endpoint mono">{{ item.server_addr }}:{{ item.server_port }}</div>
<div class="server-item__meta">
<span>{{ item.ssh_configured ? 'SSH 已配置' : 'SSH 未配置' }}</span>
</div>
<div class="server-item__tags">
<n-tag size="tiny" :bordered="false" :type="item.tls_enabled ? 'success' : 'warning'">TLS</n-tag>
<n-tag size="tiny" :bordered="false" :type="item.has_token ? 'success' : 'error'">Token</n-tag>
</div>
</button>
</div>
</aside>
<div class="proxy-detail">
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
@@ -76,7 +102,7 @@
<!-- 入口行:agent / frps 生命周期在另一页 -->
<div class="entrance-row">
<span class="entrance-row__text">
Agent 与 frps 的安装、升级、启停、配置下发在「公网 Agent」页;本页只负责把规则发布到 Nginx
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx
</span>
<router-link to="/frp-agent"><n-button size="small" quaternary>前往公网 Agent</n-button></router-link>
</div>
@@ -179,6 +205,8 @@
</details>
</div>
</FonuCard>
</div>
</div>
<!-- 部署设置 -->
<n-modal v-model:show="settingsModal" :mask-closable="false">
@@ -333,7 +361,6 @@
</div>
</div>
</n-modal>
</template>
</template>
</template>
@@ -405,9 +432,6 @@ const confContent = ref('')
let timer: number | undefined
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
const serverOptions = computed(() => servers.value
.filter((item) => item.agent_configured || item.ssh_configured)
.map((item) => ({ label: item.name, value: item.id })))
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
// 统计:已生效 / 待处理 / 健康通过 / 当前连接
@@ -725,7 +749,12 @@ async function deployRoute(candidate: FrpProxy) {
const current = server.value
if (!current) return
const result = await run(`deploy:${candidate.id}`, '部署反代', () => api.deployFrpAgentRoute(current.id, candidate.id))
if (result !== undefined) await refreshAll()
if (result === undefined) return
// 自动生成的 Basic Auth 密码只在这一次响应里返回:不再写进 nginx 配置,错过就得在「部署设置」里重设
if (result.basic_auth_password) {
message.warning(`Basic Auth 已生成(仅显示这一次,请立即保存):${result.basic_auth_user} / ${result.basic_auth_password}`, { duration: 15000 })
}
await refreshAll()
}
async function removeRoute(candidate: FrpProxy) {
@@ -953,7 +982,25 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); font-size: 13px; }
.mono-wrap { white-space: normal; word-break: break-all; }
.proxy-remote-select { width: 220px; }
/* 左:服务端列表 / 右:该服务端的反代台账(与「内网穿透」「公网服务端」页同构) */
.proxy-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: var(--fonu-space-4); align-items: start; }
.proxy-layout .section-card { min-width: 0; }
.proxy-detail { min-width: 0; }
.server-sidebar { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); overflow: hidden; }
.server-sidebar__head { padding: var(--fonu-space-4); border-bottom: 1px solid var(--fonu-border); }
.server-sidebar__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.server-sidebar__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--fonu-text); flex-shrink: 0; }
.server-sidebar__list { display: flex; flex-direction: column; gap: 10px; padding: var(--fonu-space-3); max-height: 720px; overflow: auto; }
.server-item { width: 100%; font: inherit; text-align: left; padding: 12px 14px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); background: var(--fonu-surface); cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.server-item:hover { border-color: var(--fonu-brand-hover); }
.server-item--active { border-color: var(--fonu-brand); box-shadow: 0 0 0 1px var(--fonu-brand-soft); }
.server-item__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.server-item__name { font-size: 14px; font-weight: 600; color: var(--fonu-text); overflow-wrap: anywhere; }
.server-item__endpoint { margin-top: 6px; font-size: 12px; color: var(--fonu-text-secondary); overflow-wrap: anywhere; }
.server-item__meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-3); margin-top: 6px; font-size: 12px; color: var(--fonu-text-muted); }
.server-item__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
@media (max-width: 1100px) { .proxy-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } }
/* 首屏一次性入场;用户偏好减少动效时关闭 */
@keyframes fonu-fade-up {