diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25d6c6f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # 顺序不能颠倒:cmd/fonu/main.go 用 //go:embed all:web/dist 嵌入前端, + # 干净检出时该目录不存在,go vet / go build 会直接失败。 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install web dependencies + working-directory: web + run: npm ci + + - name: Type-check and build web + working-directory: web + run: npm run build + + - name: Sync web dist into embed directory + run: | + rm -rf cmd/fonu/web/dist + cp -r web/dist cmd/fonu/web/dist + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... -count=1 + + - name: go build + run: go build ./... + + # 说明:gofmt 卡口暂未开启——仓库里还有 25 个历史未格式化文件(见 docs/project-audit-2026-09-24.md §八), + # 全仓 `gofmt -w` 之后再补一步 `gofmt -l internal cmd` 作为门禁。 diff --git a/CHANGELOG.md b/CHANGELOG.md index b30c9c7..2804010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. +## [v1.2.7] - 2026-09-24 + +### 修复 + +- 安全(P0):备份恢复的上传文件名不再拼进临时路径,改用 `os.CreateTemp`——此前 multipart 文件名完全可控,`../../root/.ssh/authorized_keys` 之类可逃出临时目录(`internal/api/backup_handler.go`) +- 安全(P0):备份还原的条目路径校验从「字符串前缀比较」改为 `filepath.Rel` 判定并拒绝 `..`——此前 `DataDir=/data` 时条目 `../data-other/x` 能通过前缀匹配写到 DataDir 之外;同时不再照搬归档里的权限位(固定文件 `0o640` / 目录 `0o750`),避免构造的归档造出 0777 或带执行位的文件 +- 安全(P0):agent 安装 frps 时 `proxy` 参数改走白名单(与「FRP 下载加速代理」同一组前缀),且改为先取官方 `frp__sha256sums.txt`、下载后比对 SHA-256 通过才解压安装——此前该参数未校验就拼进下载地址,等于可让 VPS 拉取任意地址的二进制并装成 frps +- 安全(P0):SSH 不再 `InsecureIgnoreHostKey()`,改为 known_hosts + TOFU——首次连接把主机指纹记到 `/ssh_known_hosts`(新增 `config.SSHKnownHostsPath()`),之后指纹不一致直接拒绝连接,错误里带上「期望 / 实际」指纹与处理提示 +- 安全(P0):`GET /api/settings` 不再回传 ZeroSSL API Key 明文,改为只回 `********` 掩码(前端以「非空」判断是否已配置,保存时仅在重新输入才会提交该字段) +- 安全(P0):agent 环境探测不再回传 Agent Token 明文,改为 `agent_token_configured` + `agent_token_hint`(末 4 位) +- 安全(P0):反代自动生成的 Basic Auth 密码不再写进 nginx 配置注释(此前会被「查看配置」永久读到),改为在部署响应里一次性返回、页面上提示「仅显示这一次」;部署失败会丢弃刚生成的凭据 +- 公网服务端页:服务端详情弹窗的状态点恒为灰色——`serverStartupType()` 返回的是 n-tag 的 type 值,被当成状态值传给 `StatusBadge :value` 后落到 unknown;新增返回 `StatusKind` 的 `serverStartupKind()` 并改用 `:kind` +- 仪表盘:域名卡与证书卡的徽标固定显示「正常」,后端返回异常状态时会出现「红/灰点 + 正常」的矛盾,改为按状态值推导文案并在无值时隐藏 + +### 变更 + +- `AgentVersion` 0.11.0 → **0.12.0**(frps 安装校验 + Basic Auth 密码处理),内嵌 `fonu-agent-linux-amd64`(10.4 MB)与 `-arm64`(9.7 MB)已重编,VPS 上需点「升级 Agent」才会生效 +- 新增 `.github/workflows/ci.yml`:push / PR 触发,按「前端构建 → 同步 dist 到 `cmd/fonu/web/dist` → `go vet` → `go test ./... -count=1` → `go build ./...`」执行(顺序不可颠倒:`cmd/fonu` 用 `//go:embed all:web/dist`,干净检出缺该目录时编译直接失败);gofmt 卡口待全仓格式化后再开 +- `web/package.json` 版本由 1.0.2 对齐到 `VERSION`,并声明 `packageManager: npm@10.9.4`(与 Dockerfile 的 `node:22-alpine` 一致);删除 `web/pnpm-workspace.yaml`(占位残留,仓库并无 pnpm 锁文件) +- 版本号提升到 1.2.7(版本单一来源仍是根目录 `VERSION`) + ## [v1.2.6] - 2026-09-24 ### 改进 diff --git a/VERSION b/VERSION index 3c43790..c04c650 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.6 +1.2.7 diff --git a/docs/project-audit-2026-09-24.md b/docs/project-audit-2026-09-24.md new file mode 100644 index 0000000..78fa2bf --- /dev/null +++ b/docs/project-audit-2026-09-24.md @@ -0,0 +1,290 @@ +# Fonu 全项目审查报告 + +> 审查日期:2026-09-24 对象:`f:\Fonu-main\Fonu-1.0.2`(VERSION 1.2.6;Go 后端 `cmd/` + `internal/` 202 个 `.go`,前端 `web/src` 44 个源文件) +> 方法:三路并行静态审查(Go 后端 / 前端 / 工程与部署)+ 高信号 grep 统计。 +> 可信度说明:§1.1、§5.1 的高危项已逐条读取原文核对;其余结论来自 grep 与源码阅读,未做运行时验证;行号为 2026-09-24 快照,实施时以标识符为准。 +> 用途:把「哪里要改、哪里要补」按优先级摊开,供逐项实施与勾选。 + +--- + +## 〇、结论速览 + +| 级别 | 条数 | 内容 | +| --- | --- | --- | +| **P0 · 安全** | 4 | 备份恢复的任意文件写;agent 安装 frps 的 SSRF + 二进制无校验;SSH 不校验主机密钥;三处凭据明文回传/落盘 | +| **P0 · 用户可见缺陷** | 2 | `/frp-agent` 服务端详情弹窗状态点恒灰;仪表盘 DDNS/证书卡「红灰点 + 正常」文案矛盾 | +| **P1 · 正确性** | 6 | 安装失败仍报「已启动」;备份失败仍覆盖唯一副本;限流状态损坏静默清空;请求体解析错误被忽略等 | +| **P1 · 工程** | 5 | CI 只发版不校验;三套锁文件并存;版本号双源(本地构建显示 1.0.2);`fonu-agent.exe` 入库;前端零测试设施 | +| **P1 · 前端质量** | 4 | 死样式与重复样式块;231 处硬编码色值;轮询无 in-flight 互斥且只有仪表盘处理页面隐藏 | +| **P2 · 重构/增补** | 若干 | 5 套「安全写文件」、4 套 `.bak`、agent 与宿主机各写一份 nginx 模板;8 个包零测试;i18n 缺失 | + +**已确认无问题、不必重复投入**:无命令注入(`exec.Command` 全为固定 argv);无路由漏挂鉴权(API 97 条受保护 + 4 条公开,agent 17 条全包在 `s.auth`);无无界 `io.ReadAll`(全配 `io.LimitReader`);无 ticker 泄漏(两处 `NewTicker` 都有 `defer Stop`);无 `console.log`/`debugger`;无 `v-html`;密钥落盘权限普遍 0600;前端定时器清理齐全。 + +--- + +## 一、安全 + +### 1.1 P0 高危(已逐条复核原文) + +| 位置 | 问题 | 修法 | +| --- | --- | --- | +| `internal/api/backup_handler.go:52-53` | `tmpPath := filepath.Join(os.TempDir(), header.Filename)` 后 `os.Create` —— multipart 文件名完全可控,`../../root/.ssh/authorized_keys` 之类会逃出 `/tmp` 并被截断写入(`filepath.Join` 会 Clean 掉 `..`,不构成防护) | 改 `os.CreateTemp("", "fonu-restore-*.zip")`,或用 `filepath.Base(header.Filename)` | +| `internal/agent/server.go:257-260` | `req.Proxy` 未做任何校验就被拼成 `proxy + "/" + downloadURL` 交给 VPS 下载,随后解压安装为 `frps` 可执行文件 → SSRF + 任意二进制安装;且整条路径**不做 SHA-256 校验**(对比 `internal/frp/runtime.go:203` 主程序下载是校验的) | `req.Proxy` 走 `internal/frp/runtime.go:320 downloadProxyOptions` 白名单;下载后校验 `sha256sums` | +| `internal/frp/ssh_installer.go:42` | `HostKeyCallback: ssh.InsecureIgnoreHostKey()` —— 接受任意主机密钥,MITM 可截获该 VPS 的 root SSH 凭据 | 用 `knownhosts` 校验;或首次连接回显指纹让用户确认后落库 | +| `internal/notify/store.go:400-404` + `internal/api/settings_handler.go:31,50` | `SanitizeSettings` 只删 SMTP 密码 / Webhook Secret / Telegram Token,而 `KeyZeroSSLAPIKey` 在可写白名单里且随 `GET /api/settings` 原样返回 → **ZeroSSL API Key 明文回传浏览器** | 把 `KeyZeroSSLAPIKey` 加入 `SanitizeSettings`,改用掩码(2026-09-24 实施:只回 `MaskedSecret`,未删键——`SettingsView` 与 `CertificatesView` 都用「值非空」判断已配置,删键会让「已配置」状态与 ZeroSSL 申请门槛失效) | + +### 1.2 密钥与凭据(中危) + +| 位置 | 问题 | 修法 | +| --- | --- | --- | +| `internal/frp/agent_installer.go:34` → `internal/frp/service.go:694-700` → `POST /api/frpmulti/servers/{id}/agent/probe` | Probe 用 `sed` 从 `/etc/fonu-agent/agent.env` 读出 `FONU_AGENT_TOKEN` 放进返回体,**明文回传前端**(库里同一字段走 `has_token` 掩码,`internal/frp/model.go:102`) | 只返回 `has_token` + 后 4 位 | +| `internal/agent/server.go:1243` | 自动生成的 Basic Auth 密码被写成 nginx 注释 `# Basic Auth: user=… password=…`,文件 0640 落在 VPS vhost 目录,且被 `GET /api/v1/routes/{domain}/conf` 原样返回 | 只在 `PUT /api/v1/routes` 响应里返回一次密码,不写注释 | +| `internal/auth/auth.go:57-62` | 首启生成的 admin 密码明文写入应用日志(`internal/app/app.go:54-55` 同时进 stdout 与 `app.log`),而 `GET /api/logs/system` 会把该文件内容返回 UI | 仅写 stderr,或改为环境变量 / 一次性文件下发初始密码 | +| `internal/agent/server.go:97,892` | 目录缓存文件用 `0o644` | 统一 `0o600` | + +### 1.3 鉴权强度与会话 + +| 位置 | 级别 | 问题 | 修法 | +| --- | --- | --- | --- | +| `internal/api/auth_handler.go:52-70` | 中 | 登录失败只记录,**无限速/锁定**,可无限爆破 | 按 IP + 用户名做失败计数与退避(`notify` 里已有窗口统计可复用) | +| `internal/api/auth_handler.go:90-99` | 中 | 会话 cookie 缺 `Secure`,而应用默认明文 HTTP(`internal/app/app.go:122`) | 依 `X-Forwarded-Proto` 或配置项决定是否加 `Secure` | +| `internal/api/auth_handler.go:114` | 低 | `clearSessionCookie` 未设 `SameSite`(其余 cookie 是 Lax) | 补齐 `SameSite=Lax` | +| 全局 | 中 | 无 CSRF 令牌,状态变更仅靠 cookie | 校验 `Origin`/`Referer`,或加双提交令牌 | +| `internal/auth/auth.go:22,120-138` | 中 | 会话固定 7 天 TTL,改密码**不失效其他会话** | `ChangePassword` 时 `DELETE FROM sessions WHERE admin_id = ?` | +| `internal/agent/server.go:191` | 低 | token 比较用 `!=`(非常量时间) | `crypto/subtle.ConstantTimeCompare` | +| `internal/agent/server.go:114`、`cmd/fonu-agent/main.go:17` | 中 | agent 默认监听 `:7700` 且为明文 HTTP,Bearer token 在公网裸奔;`-token` 还会进 `ps` 输出 | 默认绑 `127.0.0.1`(Nginx 前置)或加 TLS | +| `internal/api/router.go:180` + `:42` | 低 | `SessionMiddleware` 被套两层,重复解析 cookie | 只在外层套一次 | + +### 1.4 路径穿越(除 1.1 外) + +| 位置 | 级别 | 问题 | 修法 | +| --- | --- | --- | --- | +| `internal/backup/service.go:88` | 中 | `strings.HasPrefix(cleaned, cleaned(dataDir))` 是**不带分隔符的前缀判断**:`DataDir=/data` 时条目 `../data-other/x` 清理后为 `/data-other/x`,前缀匹配通过 → 写到 DataDir 之外 | 用 `filepath.Rel` + 拒绝 `..`(`internal/acme/download.go:75-89 validateCertFilePath` 已是正确写法,直接复用) | +| `internal/backup/service.go:100` | 中 | `os.FileMode(header.Mode)` 未掩码,构造的归档可造出 0777 / 带执行位文件 | `& 0o777`,目录/文件强制 `0o750/0o640` | +| `internal/api/backup_handler.go:41` | 低 | 单请求 64MB 且该端点无频率限制 | 降到 16MB 并加限速 | + +--- + +## 二、错误处理与正确性 + +| 位置 | 级别 | 问题 | 修法 | +| --- | --- | --- | --- | +| `internal/agent/server.go:318` | 高 | `_ = exec.Command("systemctl","enable","--now","frps").Run()` 后无条件 `notes = append(notes, "frps.service 已启动")` —— 启动失败仍报成功 | 判 `err`,失败写 stderr 到 `notes` 并置 `ok:false` | +| `internal/frp/service.go:1498` | 中 | `_ = os.WriteFile(path+".bak", current, 0o600)` 失败被忽略,紧接着 `os.Rename` 覆盖唯一副本,可能丢配置 | 备份失败即 return,不覆盖 | +| `internal/agent/server.go:1188` 附近 `readLimitState` | 中 | `_ = json.Unmarshal` 失败静默变空 map,`writeLimitState` 随后删掉 zone 文件 → 全部路由限流静默消失 | 反序列化失败返回 error 并保留原文件 | +| `internal/frp/service.go:775-779` | 中 | `InstallFRPSOnAgent` 里 `_ = client.PushFRPSConfig(...)` 忽略下发失败,前端只看到「安装成功」 | 把该错误并入返回结果 | +| `internal/api/cert_handler.go:47,144`、`ddns_handler.go:122`、`frpmulti_handler.go:609` | 中 | `_ = json.NewDecoder(r.Body).Decode(&req)` 忽略解析错误,继续用零值请求体 | `if err := ...; err != nil { 400 }` | +| `internal/backup/service.go:30`、`internal/api/backup_handler.go:53-71` | 中 | 写入方 `defer Close()` 不检查错误:ENOSPC 时半个备份被当有效文件下载;`out.Close()` 失败后仍去 Restore | 写入方改显式 `if err := f.Close(); err != nil` | +| `internal/api/logs_handler.go:85,112` | 低 | `_ = logstore.StreamFile(...)` 丢弃 SSE 写出错误 | `slog.Warn` 记录 | +| `internal/api/auth_handler.go:98` | 低 | `_ = h.auth.Logout(...)` 忽略删会话失败,客户端仍清 cookie | 失败返回 500 | +| `internal/traffic/collector.go:148,151` | 低 | `_ = c.persist(ctx)` 落库失败无痕迹 | 记 warn 并保留脏标记重试 | +| `internal/frp/agent_client.go:58` | 低 | `data, _ := io.ReadAll(...)` 忽略读错误,非 2xx 时错误体可能为空 | 判 err | +| `internal/frp/agent_installer.go:24-25` | 低 | `Probe` 把所有 SSH 失败吞成空串,与「VPS 上确实没有」无法区分 | 收集失败原因,结果里带 `warnings` | +| `internal/frp/ssh_installer.go:125-126` | 低 | `var _ io.Reader = (*os.File)(nil)` / `var _ = filepath.Separator` 是为保留导入的哑声明 | 删两行及两个导入 | +| `internal/agent/server.go:229,422` | 低 | `dashboardAddr` 注释自称已废弃,仍被 `status` 调用,每次读一遍 frps.toml | 删函数与调用 | + +**已核查无问题**:`err` 遮蔽(`service.go:1494` 用 `readErr` 是有意为之);无命令注入(22 处 `exec.Command` 全固定 argv;SSH 侧唯一插值点 `uploadBytes:95` 经 `shellPath:123` 单引号转义,传入常量 `/tmp/fonu-agent`)。 + +--- + +## 三、并发与生命周期 + +| 位置 | 级别 | 问题 | 修法 | +| --- | --- | --- | --- | +| `internal/frp/service.go:166,170-172` | 中 | `go s.runDownload(version)` 用 `context.Background()` + 10 分钟超时,不随 App 关闭取消,`internal/app/app.go:196-215 Shutdown` 也不等待;任务状态只在内存,重启即丢 | 传 App ctx,`sync.WaitGroup` 在 Shutdown 等待 | +| `internal/scheduler/scheduler.go:31,37` | 中 | `job.Run(ctx)` 无 `recover()`,任一 tick panic 会崩掉整个进程;`time.NewTicker(job.Interval)` 在 Interval ≤ 0 时 panic,目前只靠 `internal/app/app.go:135-143` 兜住 | 每个 job 包 `defer recover()`;`New`/`Start` 跳过非正间隔 | +| `internal/traffic/collector.go:176-198` | 中 | `ingestLine` 持 `c.mu` 写锁时调 `notify.RecordAccessHit`(DB 写 + 通知判断),**锁内做 I/O**,阻塞 `/api/proxies/traffic`、`/clients` 读 | 锁内只累积 IP,锁外统一 notify | +| `internal/frp/service.go:1258` | 中 | `agentClient` 每次调用都 `NewAgentClient` → 新建 `http.Client`/`Transport`,批量同步 N 条路由就建 N 个连接池 | 按 serverID 缓存 client | +| `internal/agent/server.go:107 preloadNginxConfDir` | 低 | 启动 `go func()` 一次性任务无 ctx,退出时可能正在跑 `nginx -T` | 加 ctx 或 WaitGroup | +| `internal/logstore/stream.go:65-70` | 低 | 每个 SSE 客户端一个 goroutine + 500ms 轮询(ctx 检查正确、无泄漏) | 可接受;量级上去后换 fsnotify | + +**已核查无问题**:全部 `http.Client` 构造点都带 `Timeout`;两处 `time.NewTicker` 都有 `defer Stop()`。 + +--- + +## 四、重复逻辑(可收敛点) + +| 重复项 | 证据 | 建议 | +| --- | --- | --- | +| **「安全写文件」5 套实现** | `internal/agent/server.go:1428 atomicWrite`、`internal/frp/service.go:1816 writeConfigAtomically`、`internal/nginx/nginx.go:114-141`(固定 `.tmp` 名,0644,且同一段在 `:194`、`:394` 再抄两遍)、`internal/chinacidr/service.go:99-117`、`internal/frp/service.go:1488-1501` | 抽 `internal/fsutil.WriteFileAtomic(path, data, mode)`;`nginx.go` 三处合并为 `applyConfigFile` | +| **非原子直写** | `internal/nginx/custom.go:98,144,151,158`、`internal/nginx/errors.go:32,78` | 同上 | +| **`.bak` 备份 4 套** | `internal/frp/service.go:1496-1498`、`:1821-1825`、`internal/nginx/nginx.go:132-136`、`internal/nginx/custom.go:190-197` | 统一 `fsutil.Backup(path)` | +| **nginx 模板双份** | `internal/agent/server.go`(`nginxRoute:1155`、`sslDirectives:1420`、`zoneKey:1390`、限流/geo 生成)与 `internal/nginx/generate.go`(有 30 个测试文件覆盖)各写一遍 vhost/TLS/限流模板 | 抽到共享包,agent 与宿主机同源;否则改一处必须记得改两处 | +| **agent HTTP 客户端双份** | `internal/frp/agent_client.go` 与 `internal/frp/agent_insights.go`、`internal/agent/insights.go` 对同一批端点各自解包 | insights 也走 `AgentClient.request` | +| **规则自定义配置两处入口** | `internal/service/nginx_config.go` 与 `internal/nginx/custom.go` | 只保留 `internal/nginx/custom.go` | + +--- + +## 五、前端 + +### 5.1 用户可见缺陷(已复核) + +| 位置 | 级别 | 问题 | 修法 | +| --- | --- | --- | --- | +| `web/src/views/AgentView.vue:582` | **P0** | `` —— `serverStartupType()`(`:1382-1387`)返回的是 `'success'\|'warning'\|'error'\|'default'`(`n-tag` 的 type 值),却被当状态值传给 `:value`;`statusKind('success')` 与 `statusKind('default')` 都落到 unknown → **服务端详情弹窗「运行中」和未知态都是灰点**(`FrpView.vue:777` 注释记录过同一个坑,那边修了、这边没修) | 改 `:kind="serverStartupType(viewingServer)"`,函数标注返回 `StatusKind` 并把 `'default'` 换成 `'unknown'`;`:49` 的 `n-tag :type` 另拆 `serverStartupTagType()` | +| `web/src/views/DashboardView.vue:35,46` | **P1** | `:value="status?.ddns_status"` / `:value="status?.certificate_status"` 配静态 `text="正常"` —— 后端返回 `error`/`none` 时出现「红/灰点 + 正常」自相矛盾 | `text` 改由 `statusLabel(value)` 推导 | +| `DdnsView.vue:170,213`、`ProxyView.vue:139`、`DashboardView.vue:22,59,70,162,238,263` | P2 | 目前值恰好是 `statusKind()` 的合法输入,但语义错,后端一改值就静默变灰 | 统一改 `:kind` | +| `web/src/views/ProxyRemoteView.vue:489-514` | P1 | `refreshAll` 里 7 个独立接口用 `await track(...)` 串行发,每次刷新耗时是 7 个往返相加;15 秒轮询时同样串行 | 改 `Promise.all`(`track` 已把异常收进 `failures`,并行不影响「单点失败不中断整轮」);健康检查可拉长周期或只在手动刷新时跑 | + +### 5.2 死代码与重复样式(重构遗留) + +| 位置 | 级别 | 问题 | +| --- | --- | --- | +| `web/src/views/FrpView.vue:1229-1236`、`:1396-1398`、`:1468-1474`、`:1487`、`:1524` | P1 | `frp-server-table`、`frp-detail-card`、`server-detail*`、`detail-stats`、`server-status-summary`、`status-bars` 等类名在模板与脚本 **0 引用**(活体已迁到 `AgentView.vue:578-610` / `1818-1844`),整块可删 | +| `web/src/views/AgentView.vue:1853-1855` | P1 | `.detail-subgroup`/`__title`/`.detail-muted` 与 `:1850-1852` **逐字重复两遍**,删后一组 | +| 弹窗外壳 `.proxy-modal__*` | P1 | 在 `FrpView.vue`(`.frp-proxy-modal` 与 `.frp-guide-modal` 各一份)、`ProxyRemoteView.vue:942-958`、`AgentView.vue`、`ProxyView.vue` **四份**,差异仅 1–2 个属性 | 抽 `ProxyModalShell.vue`(header/tabbar/scroll/help 四插槽)或非 scoped 的 `styles/proxy-modal.css` | +| 左列表骨架 | P1 | `DdnsView`(`.ddns-layout`/`.task-sidebar`/`.task-item`)与 `AgentView`/`FrpView`/`ProxyRemoteView`(`.server-sidebar`/`.server-item`)两套命名同构重复;`.server-item__name`/`__endpoint` 三条规则在三个文件逐字相同 | 抽 `SidebarList.vue`(items + activeKey + 行插槽) | + +> 说明:`FrpView.vue` 里 `.server-sidebar` / `.server-item`、`.frp-proxy-modal`、`.frp-guide-modal` **仍在模板中使用,不是死代码**,不要一起删。 + +### 5.3 硬编码颜色(违反「零硬编码色值」约定) + +grep 计 `#hex`/`rgb()`/`rgba()` 共 267 处 / 19 文件;扣除 `styles/variables.css`(36 处,令牌定义本体)后 231 处。按文件:`useTheme.ts` 45、`CertificatesView.vue` 44、`ProxyView.vue` 34、`ProxyAccessLogBox.vue` 22、`DdnsView.vue` 18、`DashboardView.vue` 15、`MiniTrafficChart.vue` 11、`MiniBarChart.vue` 9、`SettingsView.vue` 8、`StatCard.vue` 6、`MiniDonutChart.vue` 5、`LogsView.vue` 4、`AgentView.vue` 3、`StatusBadge.vue` 3,其余各 1。 + +| 优先级 | 位置 | 问题 | +| --- | --- | --- | +| P1 | `DashboardView.vue:1124-1131` | 横幅写死 `#f4fffc/#f1fbff/#eef9ff/#0f172a/#134e4a` 与 `rgba(16,185,129,…)`,而 `variables.css:17,58` 已有 `--fonu-banner-bg`/`--fonu-banner-border` | +| P1 | `CertificatesView.vue:1256-1272`、`DdnsView.vue:1263-1279`、`ProxyView.vue:2225-2228` | 同一组状态色 `#3b82f6/#10b981/#f59e0b/#ef4444` 复制三处 | 收敛到 `StatCard` 的 tone 类或 `--fonu-tone-*` 令牌 | +| P1 | `CertificatesView.vue:1182-1211`、`ProxyAccessLogBox.vue:149-150` | 同一暗色终端色板写两遍 | 抽 `--fonu-log-*` | +| P1 | `StatusBadge.vue:43,48,53`、`StatCard.vue:84-95` | 组件内 `rgba(16,185,129,.12)` 绕过 `variables.css:4` 已有的 `--fonu-brand-soft` | 改用令牌并补 warning/error 的 soft 令牌 | +| P2 | `ProxyView.vue`、`DdnsView.vue`、`CertificatesView.vue`、`SettingsView.vue:1251-1282,1601-1613`、`LogsView.vue:854-898` 等 | 零散违规,含 `color-mix(in srgb, #10b981 40%, …)` 与 `var(--n-error-color, #e11d48)` 直接引用 Naive UI 变量 | 逐条换语义令牌 | +| P2 | `MiniBarChart`/`MiniDonutChart`/`MiniTrafficChart`/`useTheme.ts` | 图表色板三份重复拷贝,且 `AgentView.vue:1257` 用 `#18a058` 与品牌绿不一致 | `useTheme.ts` 导出 `CHART_COLORS` 供各图表复用 | + +**有意保留**:`FrpView.vue:1385`、`AgentView.vue:1808` 的 `color: #fff`(品牌底色上的文字);ECharts 系列色(按既有约定保留,但建议收敛为常量)。 + +### 5.4 类型 + +- `api/client.ts:288,331` 的 `Record` 是全仓仅有的 2 处 `any` → 定义 `FrpAgentProbeResult`。 +- `FrpView.vue:778` 用 `as 'success' | 'disabled' | 'warning'` 硬转 → 改 `: StatusKind`。 +- `DdnsView.vue:669-673` 的 `taskStatusKind()` 名为 Kind 实为状态值,与 `StatusBadge` 的 `kind` 语义相反 → 改名 `taskStatusValue`。 +- `AgentView.vue:1382-1387` 的 `serverStartupType()` 无返回类型注解,是 §5.1 那条 P0 的根因。 + +### 5.5 轮询与页面可见性 + +- 清理齐全(9 处 `clearInterval` 都在 `onUnmounted`/`onBeforeUnmount`)。 +- **只有仪表盘处理了页面隐藏**:`DashboardView.vue:846-856` 只停 traffic(2s)/status(15s),同文件的 `cidrPollTimer`(`:946-974`)与 `loadRouteHealth` 不在暂停范围;`ProxyView.vue:1799,1825`、`FrpView.vue:1207,914`、`LogsView.vue:782`、`ProxyRemoteView.vue:865` 完全没有隐藏页处理 → 抽 `useVisibilityPolling()`。 +- **轮询无 in-flight 互斥**:`DashboardView.vue:808-812` 每 2s 调 `refreshTraffic()`,体内只有 try/catch,慢链路会堆积并发请求 → 加 `if (loading.value) return` 或改递归 `setTimeout`。 +- 频率偏高:2s 类(Dashboard/ProxyView)建议降到 5s 或按激活状态降频。 + +### 5.6 超大组件 + +`ProxyView.vue` 3480、`AgentView.vue` 1966、`CertificatesView.vue` 1875、`DashboardView.vue` 1758、`DdnsView.vue` 1745、`SettingsView.vue` 1672、`FrpView.vue` 1557。 + +- `ProxyView.vue` 优先拆 `RuleFormModal.vue`、`NginxSettingsPanel.vue`、`RuleClientsPanel.vue`。 +- `AgentView.vue`:模板 `440-559` → `ServerFormModal.vue`;`561-577` → `ServerLogsModal.vue`;`578-640` → `ServerDetailModal.vue` + `LatencyChart.vue`。 +- 搬迁硬约束:**scoped 样式不随模板走,必须整份复制**(`AgentView.vue:1850-1855` 的重复块就是漏搬/重搬的产物)。 + +### 5.7 空态 / 可访问性 / i18n + +- `v-html` 0 处;空态覆盖良好(`EmptyState`/`LoadError` 各页 2–11 处);未发现纯图标按钮缺 `aria-label`。 +- 缺口:`AgentView.vue:609`(代理表)与 `:610`(连接日志)用裸文本兜底,未走 `EmptyState`;`DashboardView.vue:244` 的 `health-item__icon--${tone}` 仅靠颜色传达状态,建议补文字或 `aria-label`。 +- i18n:无 i18n 设施,用户可见文案全部中文硬编码(含中文的行 1882 行 / 17 文件,含注释故为上限;最多 `AgentView` 388、`FrpView` 352、`ProxyView` 227)。建议先建 `src/locales/zh-CN.ts` 按 views 命名空间搬迁,或至少把高频重复串提为常量(样板:`utils/status.ts:3-11` 的 `LABELS`)。 + +--- + +## 六、测试缺口 + +**Go:71 个 `*_test.go`,但 8 个包零测试** —— `internal/app`、`internal/service`、`internal/scheduler`、`internal/settings`、`internal/backup`、`internal/secret`、`internal/discovery`、`internal/version`,以及 `cmd/fonu`、`cmd/fonu-agent`。 + +关键文件零覆盖(按风险排序): + +1. `internal/agent/server.go`(1465 行,VPS 侧所有文件写入/nginx 操作/exec)—— 包内仅 `apr1_test.go`、`insights_test.go`;`validateRoute`、`nginxRoute`、`atomicWrite`、`detectNginxConfDir`、`installFRPS` 全无测试。 +2. `internal/api/router.go` + `middleware.go` —— 没有任何测试断言「每条路由都在 `RequireAuth` 之后」,新增路由漏 `protect()` 不会有信号。 +3. `internal/backup/service.go` —— §1.4 的路径校验 medium 无测试;`backup_handler.go` 的临时文件命名也无测试。 +4. `internal/frp/service.go`(1847 行)—— 仅 `service_diagnostics_test.go` 间接触及。 +5. `internal/frp/ssh_installer.go`、`agent_installer.go` —— `extractToken`、`isELFBinary`、`shellPath`(含单引号转义)都是纯函数,测试成本极低。 + +建议补的最小集:① 路由鉴权覆盖测试(遍历 `NewRouter`,非公开路径断言 401);② `backup.Restore` 的 `../` 与 `../data-other/` 两组用例;③ `agent.nginxRoute` + `validateRoute` 表驱动测试;④ `shellPath`/`extractToken`/`isELFBinary` 纯函数测试;⑤ `scheduler` panic 恢复测试;⑥ `internal/notify` 补「SMTP 密文损坏时 Telegram 仍可发送」。 + +**前端:完全没有测试设施** —— `web/package.json` 无 `test` 脚本,devDependencies 无 vitest/jest/@vue/test-utils,`web/src` 无 `*.spec.ts`/`*.test.ts`;唯一质量门是 `npm run build` 内部的 `vue-tsc --noEmit`。建议加 vitest 骨架,从 `src/api` 与格式化纯函数起步。 + +--- + +## 七、工程化 / CI / Docker / 版本 + +| 位置 | 级别 | 问题 | 修法 | +| --- | --- | --- | --- | +| `.github/workflows/`(`docker-release.yml` 106 行、`github-release.yml` 52 行) | **高** | CI 存在但**只发版不校验**:仅 tag / `workflow_dispatch` 触发,零 `go build`/`vet`/`test`、零 `vue-tsc`/`vite build` | 新增 `.github/workflows/ci.yml`,`on: [push, pull_request]` | +| 同上 + `cmd/fonu/main.go:19` | **高** | 步骤顺序不可颠倒:`//go:embed all:web/dist`,而 `web/dist/` 被 `.gitignore` 忽略 → 干净检出下 `go build ./...` 直接失败。CI 必须「前端构建 → 复制 dist 到 `cmd/fonu/web/dist` → `go vet` → `go test ./... -count=1` → `go build`」 | 按此顺序写 `ci.yml` | +| `web/package-lock.json` + `pnpm-lock.yaml` + `pnpm-workspace.yaml` | **高** | 三套依赖描述并存,`package.json` 无 `packageManager`,全仓无 pnpm 调用,Dockerfile 用 `npm ci` | 删 `pnpm-lock.yaml` 与 `pnpm-workspace.yaml`,加 `"packageManager": "npm@10"`(2026-09-24 实施:仓库里实际只有 `web/pnpm-workspace.yaml`,无 `pnpm-lock.yaml`;已删该文件并声明 `npm@10.9.4`) | +| `VERSION`(1.2.6) vs `web/package.json:4`(1.0.2) vs `web/vite.config.ts:10` | **高** | 版本号双源:Docker 链正确注入 `VERSION`,但本地 `npm run dev/build` 回落 `pkg.version` → UI(`AppLayout.vue:140`)显示 1.0.2 | 同步 `package.json` 版本,或让 `vite.config.ts` 直接读 `../VERSION`;CI 加一致性校验 | +| `web/package-lock.json`(如 `:1360`) | 中 | `resolved` 指向 `registry.npmmirror.com`,海外 runner 会从镜像拉包 | 加 `web/.npmrc` 或 CI 用 `npm ci --registry=https://registry.npmjs.org` | +| `fonu-agent.exe`(项目根) | 高 | 未被 `.gitignore` 覆盖(规则里没有 `*.exe`) | 加 `*.exe` 或精确到 `/fonu-agent.exe` | +| `internal/frp/agentbin/*` | 中 | 是 `//go:embed` 编译必需资源(须入库),但无二进制标记 | `.gitattributes` 加 `internal/frp/agentbin/* binary` | +| `docker-release.yml` 的 `build-args: VERSION=…` | 中 | 死配置:Dockerfile 只有 `ARG VERSION_FILE`,从不读 `ARG VERSION`,tag 与文件不一致时静默 | workflow 加一步校验 tag == `VERSION` 文件,或 Dockerfile 支持 `ARG VERSION` 优先 | +| `.dockerignore:15` | 中 | ` docs` 带前导空格;`:12-13` 仍排除已不存在的 `internal/frp-1.0.2`、`FrpView-1.0.2.vue` | 去掉空格与两行过期排除项 | +| `Dockerfile:21` | 低 | `COPY web/package.json web/package-lock.json* ./` 通配符会在锁文件缺失时静默退化 | 去掉 `*` | +| `Dockerfile:53` | 低 | `COPY web/assets/image` 与 `internal/nginx/error_assets_embed.go` 的 embed 可能重复 | 确认运行时用途,若已被 embed 覆盖则移除 | +| `README.md:156`、`docs/DEPLOYMENT.md:42,53`、`internal/frp/frps_template.go:45` | 中 | 文档称「镜像内置 frpc 0.71.0」,而 `Dockerfile:46` 明确「frpc 不预装」 | 三处改为「按需下载」 | + +**已确认正确**:镜像链路自洽(`npm ci` → `vite build` → dist 拷入 `cmd/fonu/web/dist` 正好匹配 embed;`web/assets/image` 与 `cmd/fonu/web/dist` 均真实存在,COPY 不会失败);`.data/`、`*.db` 已被忽略。 + +--- + +## 八、文档与仓库卫生 + +| 位置 | 级别 | 问题 | 修法 | +| --- | --- | --- | --- | +| git 根 `f:\Fonu-main` | **高** | 仓库根在此且 `No commits yet on main`,`Fonu-1.0.2/` 整目录未跟踪,根无 `.gitignore`;源码与文档全在版本控制之外 | 明确仓库边界:在 `Fonu-1.0.2/` 内独立 `git init`,或根补 `.gitignore` 并首次提交 | +| `f:\Fonu-main` 根目录 | **高** | 散落 `fonu-1.1.0-image.tar.gz`、`fonu-1.1.0-linux-amd64.tar.gz`、`fonu-1.1.0-linux-amd64`、`fonu-1.1.0-src.zip`、`agent-tsc.log`、`frps-m1-tsc.log`、`fonu-image-build4.log` | 移出仓库或删除;根 `.gitignore` 加 `*.tar.gz`/`*.zip`/`*.log`/`fonu-*` | +| `.breezell/`、`.gocache-agent/` | 中 | 未被 gitignore(`.dockerignore:20` 只挡了前者进镜像) | 根 `.gitignore` 加两行 | +| `internal/frp-1.0.2/`、`web/src/views/FrpView-1.0.2.vue` | 中 | 工作树已删除但**尚未提交**(`git status` 显示为未暂存删除),误 `git checkout` 即回滚清理 | 随下次提交一并提交,并在 CHANGELOG 记一条「移除 1.0.2 归档副本」 | +| `Fonu-1.0.2/docs/`(14 份 md) | 中 | 无索引;`upstream-sync-plan.md` 对 `/frp` 现状的描述基于 2026-09-23(已被 v1.2.6 重构推翻);`agent-view-design.md` / `-v2.md` 的「顶部下拉」结构已过期;`agent-capability-plan.md` 的 P0/P1/P2 已实施但仍以「方案」语气;`frps管理方案.md` 疑似被 `frps服务端管理方案.md` 取代 | 加 `docs/README.md` 索引表(文件 / 主题 / 状态 / 更新日期);给三份文档加时效或实施状态标注 | +| `f:\Fonu-main\docs\` 与 `Fonu-1.0.2\docs\` | 中 | 两套文档根并存(根下 `111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`) | 合并,或声明根 docs 为临时区 | +| 仓库根 | 低 | 无 README(`Fonu-1.0.2/README.md` 本身 189 行、质量良好) | 上移或放最短指路 README | +| `gofmt` | 低 | `internal/api/auth_handler.go:29-32`、`internal/api/settings_handler.go:44-79`、`internal/traffic/collector.go:18-24,31-38` 从列对齐看确定未格式化(**待 `gofmt -l internal cmd` 确认全量**) | 跑 `gofmt -w` 并在 CI 加卡口 | + +--- + +## 九、增补清单(按优先级,可勾选) + +**P0(先做)** + +> **实施状态(2026-09-24)**:以下 8 项已全部完成并随 v1.2.7 提交;agent 侧同时把 `AgentVersion` 递增到 0.12.0 并重编了内嵌双架构二进制(VPS 上需点「升级 Agent」才生效)。两处与本文写法不同的取舍见 §1.1 末行与 §七 pnpm 行。 + +- [ ] `internal/api/backup_handler.go:52` 改 `os.CreateTemp`;`internal/backup/service.go:88` 改 `filepath.Rel` 校验;`:100` 掩码 mode +- [ ] `internal/agent/server.go:258` 的 `req.Proxy` 走白名单 + 下载后校验 SHA-256 +- [ ] `internal/frp/ssh_installer.go:42` 换 `knownhosts`(或指纹确认) +- [ ] `internal/notify/store.go:400` 增删 `KeyZeroSSLAPIKey`;`agent_installer.go:34` 只回 `has_token`;`agent/server.go:1243` 不写密码注释 +- [ ] `web/src/views/AgentView.vue:582` 改 `:kind` + 返回类型标注;`DashboardView.vue:35,46` 文案随状态推导 +- [ ] `.github/workflows/ci.yml`(前端构建 → dist 同步 → vet → test → build) +- [ ] 删 `web/pnpm-lock.yaml` + `pnpm-workspace.yaml`;`web/package.json` 加 `packageManager` 并与 `VERSION` 对齐 +- [ ] 根目录 `.gitignore`(含 `*.exe`、`.breezell/`、`.gocache-agent/`)并清理散落产物 + +**P1** + +- [ ] `internal/agent/server.go:318` 假成功、`internal/frp/service.go:1498` 备份失败仍覆盖、`:775-779` 忽略下发失败 +- [ ] 4 处请求体解析忽略错误改 400(`cert_handler.go:47,144`、`ddns_handler.go:122`、`frpmulti_handler.go:609`) +- [ ] 两个 `http.Server` 补 `ReadTimeout`/`IdleTimeout`;`runDownload` 挂 App ctx;`scheduler` 加 `recover()` +- [ ] 登录失败退避 + 改密失效旧会话 + cookie `Secure`/`SameSite` +- [ ] `ProxyRemoteView.vue:489-514` 改 `Promise.all` +- [ ] 清 `FrpView.vue` 四组死样式 + `AgentView.vue:1853-1855` 重复块(即 merge 方案的步 4) +- [ ] 抽 `ProxyModalShell.vue` / `SidebarList.vue`;抽 `useVisibilityPolling()`;轮询加 in-flight 互斥 +- [ ] 修 README/DEPLOYMENT/`frps_template.go:45` 的「内置 frpc」说法;删 `.dockerignore:12-13,15` 问题行 +- [ ] `docs/README.md` 索引 + 三份文档时效标注 + +**P2** + +- [ ] 抽 `internal/fsutil`(原子写 + 备份),合并 5 套写文件 / 4 套 `.bak` +- [ ] nginx 模板抽共享包,agent 与宿主机同源 +- [ ] `traffic/collector.go:176` 锁外 notify;`frp/service.go:1258` 缓存 agent client +- [ ] 补测试:路由鉴权覆盖、`backup.Restore` 路径、`agent.nginxRoute`、纯函数组、`scheduler` panic、notify 隔离 +- [ ] 前端加 vitest;颜色令牌收敛(231 处);i18n 起步 +- [ ] 拆 `ProxyView.vue`(3480 行)等超大组件 + +--- + +## 十、需用户决策 + +1. **仓库边界**:`f:\Fonu-main`(根,未跟踪全部源码)还是在 `Fonu-1.0.2/` 内独立 `git init`?这决定 `.gitignore` 与 README 放哪。 +2. **包管理器**:确定 npm(删 pnpm 两个文件)还是保留 pnpm(需同步改 Dockerfile/README)。 +3. **SSH 主机密钥策略**:`knownhosts` 落库(严格、需用户首次确认指纹)还是仅做指纹变更告警(宽松)。 +4. **agent 监听地址**:改绑 `127.0.0.1` 需确认 VPS 上 Nginx/SSH 隧道的前置方式,属于会改变部署方式的改动。 +5. **版本号粒度**:v1.2.4/1.2.5/1.2.6 同日三段,是否改为「同日合并一段」。 +6. **前端 i18n**:是否要真正引入(工作量大),还是只做文案常量集中化。 + +## 十一、需真机验证(静态审查无法覆盖) + +- agent 升到 0.11.0 后:VPS 资源数值随负载变化、公网 Nginx 日志、frps.toml 手工改动比对、VPS 证书清单、故意写错配置后点「重载 Nginx」看失败原因。 +- `/frp-agent`、`/frp`、`/public-proxy` 三页新分栏结构的实际渲染(本地登录墙无凭据,未做视觉验证)。 +- `bin\fonu.exe` 需重建并重启才能看到本轮前端改动。 diff --git a/internal/agent/server.go b/internal/agent/server.go index 33eb1d0..bb10473 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -35,7 +36,9 @@ import ( // 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。 // 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。 // 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。 -const AgentVersion = "0.11.0" +// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进 +// nginx 配置注释(改为只在 PUT 响应里回一次)。 +const AgentVersion = "0.12.0" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -237,6 +240,17 @@ type frpsInstallRequest struct { Proxy string `json:"proxy,omitempty"` } +// frps 安装包只允许从官方 release 下载,或经这几个加速前缀。白名单与 internal/frp/runtime.go 的 +// downloadProxyOptions 同源(agent 不依赖应用侧包,这里保留一份副本,改一处需同步另一处)。 +var frpsDownloadProxies = map[string]struct{}{ + "": {}, + "https://gh-proxy.org/": {}, + "https://v4.gh-proxy.org/": {}, + "https://cdn.gh-proxy.org/": {}, +} + +const frpsReleaseBaseURL = "https://github.com/fatedier/frp/releases/download/" + // installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。 func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { var req frpsInstallRequest @@ -254,10 +268,14 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported agent arch: " + runtime.GOARCH}) return } - downloadURL := fmt.Sprintf("https://github.com/fatedier/frp/releases/download/v%s/frp_%s_linux_%s.tar.gz", version, version, arch) - if proxy := strings.TrimSpace(req.Proxy); proxy != "" { - downloadURL = strings.TrimRight(proxy, "/") + "/" + downloadURL + proxy := strings.TrimSpace(req.Proxy) + if _, ok := frpsDownloadProxies[proxy]; !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不支持的下载加速代理"}) + return } + asset := fmt.Sprintf("frp_%s_linux_%s.tar.gz", version, arch) + releaseURL := proxy + frpsReleaseBaseURL + "v" + version + "/" + downloadURL := releaseURL + asset workDir := filepath.Join(s.cfg.DataDir, "tmp-frps-"+version) _ = os.RemoveAll(workDir) defer os.RemoveAll(workDir) @@ -265,11 +283,26 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } + // 先取官方校验文件,再下载安装包并按 SHA-256 校验:避免装上来路不明的 frps 可执行文件 + wantSum, err := fetchChecksumFor(releaseURL+"frp_"+version+"_sha256sums.txt", asset) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "获取 frps 官方校验信息失败: " + err.Error()}) + return + } tarball := filepath.Join(workDir, "frp.tar.gz") if err := downloadToFile(downloadURL, tarball); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "下载 frps 失败: " + err.Error()}) return } + gotSum, err := fileSHA256(tarball) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "校验 frps 安装包失败: " + err.Error()}) + return + } + if !strings.EqualFold(gotSum, wantSum) { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "frps 安装包 SHA-256 校验失败"}) + return + } installPath := s.cfg.FrpsBinary if !filepath.IsAbs(installPath) { installPath = "/usr/local/bin/frps" @@ -278,7 +311,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "解压 frps 失败: " + err.Error()}) return } - notes := []string{"frps 二进制已安装: " + installPath} + notes := []string{"frps 二进制已安装: " + installPath, "安装包 SHA-256 校验通过"} unitPath := "/etc/systemd/system/frps.service" writeUnit := false if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) { @@ -381,6 +414,43 @@ func downloadToFile(url, path string) error { return err } +// fetchChecksumFor 取官方 sha256sums.txt 中 asset 对应的校验值(格式: <文件名>)。 +func fetchChecksumFor(url, asset string) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == asset { + return fields[0], nil + } + } + return "", fmt.Errorf("校验文件中未找到 %s", asset) +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + // extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。 func extractFile(tarball, name, dest string, mode os.FileMode) error { file, err := os.Open(tarball) @@ -692,11 +762,27 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() defer s.mu.Unlock() + // Basic Auth 密码留空时首次自动生成:生成的明文只在本次响应里回一次, + // 不再写进配置文件注释(否则「查看配置」会永久暴露密码)。已有凭据时返回空串。 + generatedPassword := "" + if user := strings.TrimSpace(req.BasicAuthUser); user != "" && strings.TrimSpace(req.BasicAuthPassword) == "" { + if generated, err := ensureBasicAuthSecret(req.Domain, user); err == nil { + generatedPassword = generated + } + } if err := s.writeRoute(req); err != nil { + if generatedPassword != "" { + // 部署失败就丢弃刚生成的凭据,否则重试时文件已存在、密码再也拿不到 + _ = os.Remove(basicAuthFilePath(req.Domain)) + } writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket} + if generatedPassword != "" { + result["basic_auth_user"] = strings.TrimSpace(req.BasicAuthUser) + result["basic_auth_password"] = generatedPassword + } // 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态: // 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常 if vhost := req.VhostPort; vhost > 0 { @@ -1229,22 +1315,18 @@ func nginxRoute(req routeRequest, certDir string) string { if len(req.AllowIPs) > 0 { acl.WriteString(" deny all;\n") } - // Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式) + // Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式); + // 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释 authLine := "" - var authNote string if user := strings.TrimSpace(req.BasicAuthUser); user != "" { if password := strings.TrimSpace(req.BasicAuthPassword); password != "" { if err := writeBasicAuthSecret(req.Domain, user, password); err == nil { authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain)) } - } else if generated, err := ensureBasicAuthSecret(req.Domain, user); err == nil { + } else if _, err := ensureBasicAuthSecret(req.Domain, user); err == nil { authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain)) - if generated != "" { - authNote = fmt.Sprintf("# Basic Auth: user=%s password=%s\n", user, generated) - } } } - b.WriteString(authNote) // 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义) var limitText strings.Builder if req.RateLimitRate > 0 { diff --git a/internal/api/backup_handler.go b/internal/api/backup_handler.go index 7a6f739..ba72feb 100644 --- a/internal/api/backup_handler.go +++ b/internal/api/backup_handler.go @@ -42,19 +42,20 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusBadRequest, "上传文件无效") return } - file, header, err := r.FormFile("file") + file, _, err := r.FormFile("file") if err != nil { writeError(r, w, http.StatusBadRequest, "请选择备份文件") return } defer file.Close() - tmpPath := filepath.Join(os.TempDir(), header.Filename) - out, err := os.Create(tmpPath) + // 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成 + out, err := os.CreateTemp("", "fonu-restore-*.tar.gz") if err != nil { writeError(r, w, http.StatusInternalServerError, "保存上传文件失败") return } + tmpPath := out.Name() if _, err := io.Copy(out, file); err != nil { out.Close() _ = os.Remove(tmpPath) diff --git a/internal/backup/service.go b/internal/backup/service.go index 78e2b7f..0cbe680 100644 --- a/internal/backup/service.go +++ b/internal/backup/service.go @@ -16,6 +16,12 @@ type Service struct { dataDir string } +// 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位 +const ( + restoreFileMode = 0o640 + restoreDirMode = 0o750 +) + func New(dataDir string) *Service { return &Service{dataDir: dataDir} } @@ -85,19 +91,22 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error { continue } target := filepath.Join(s.dataDir, header.Name) - if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(s.dataDir)) { - return fmt.Errorf("备份条目非法") + // 归档条目必须落在 DataDir 内:前缀比较会被 ../data-other 这类同前缀路径绕过,用 Rel 判定 + rel, err := filepath.Rel(s.dataDir, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("备份条目非法: %s", header.Name) } switch header.Typeflag { case tar.TypeDir: - if err := os.MkdirAll(target, 0o755); err != nil { + if err := os.MkdirAll(target, restoreDirMode); err != nil { return err } case tar.TypeReg: - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(target), restoreDirMode); err != nil { return err } - out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + // 归档里的权限位不照搬(构造的归档可带 0777 / 执行位):备份内容只有数据库、证书与 nginx 配置 + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreFileMode) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 5279847..81f54ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -94,6 +94,11 @@ func (c Config) CertsDir() string { return c.DataDir + "/certs" } +// SSHKnownHostsPath 是 SSH 主机密钥的 TOFU 记录文件(首次连接记录指纹,之后必须一致)。 +func (c Config) SSHKnownHostsPath() string { + return filepath.Join(c.DataDir, "ssh_known_hosts") +} + func (c Config) ErrorsDir() string { return c.NginxDir() + "/errors" } diff --git a/internal/frp/agent_installer.go b/internal/frp/agent_installer.go index fd50405..66d9967 100644 --- a/internal/frp/agent_installer.go +++ b/internal/frp/agent_installer.go @@ -31,15 +31,17 @@ func (i AgentInstaller) Probe(ctx context.Context) (map[string]any, error) { "agent_exist": out("test -x /usr/local/bin/fonu-agent && echo yes || echo no") == "yes", "agent_running": out("systemctl is-active fonu-agent 2>/dev/null || true"), "agent_version": out("/usr/local/bin/fonu-agent -version 2>/dev/null || echo 未知"), - "agent_token": out("test -f /etc/fonu-agent/agent.env && sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env || true"), - "frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"), - "frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"), - "frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"), - "frps_unit": out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"), - "frps_log": out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"), - "port7000": out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"), - "port80": out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"), - "port443": out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"), + // Token 只回「是否已配置 + 末 4 位」:明文不经过 HTTP 返回前端(库里同一字段也是掩码) + "agent_token_configured": out("test -s /etc/fonu-agent/agent.env && grep -q '^FONU_AGENT_TOKEN=' /etc/fonu-agent/agent.env && echo yes || echo no") == "yes", + "agent_token_hint": out("sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env 2>/dev/null | tr -d '\\n' | tail -c 4"), + "frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"), + "frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"), + "frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"), + "frps_unit": out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"), + "frps_log": out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"), + "port7000": out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"), + "port80": out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"), + "port443": out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"), }, nil } diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index e382a07..78a4274 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 7236bf8..194edf3 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/frp/service.go b/internal/frp/service.go index fb4e6b2..07847d4 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -682,7 +682,7 @@ func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSH return Server{}, SSHInstaller{}, err } } - installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser} + installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser, KnownHostsPath: s.cfg.SSHKnownHostsPath()} if server.SSHAuth == "password" { installer.Password = secret } else { diff --git a/internal/frp/ssh_installer.go b/internal/frp/ssh_installer.go index 574fac8..eb1925b 100644 --- a/internal/frp/ssh_installer.go +++ b/internal/frp/ssh_installer.go @@ -2,14 +2,17 @@ package frp import ( "context" + "errors" "fmt" "io" + "net" "os" "path/filepath" "strings" "time" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" ) // SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。 @@ -21,6 +24,58 @@ type SSHInstaller struct { Password string PrivateKey string Timeout time.Duration + // KnownHostsPath 是主机密钥记录文件:首次连接按 TOFU 记录指纹,之后必须一致 + KnownHostsPath string +} + +// hostKeyCallback 用 known_hosts 校验主机密钥:首次见到的主机按 TOFU 记录指纹, +// 已记录但与当前不一致则拒绝连接(避免中间人截获 VPS 的 root 凭据)。 +func (i SSHInstaller) hostKeyCallback() (ssh.HostKeyCallback, error) { + path := strings.TrimSpace(i.KnownHostsPath) + if path == "" { + return nil, fmt.Errorf("未配置 SSH 主机密钥记录文件") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.WriteFile(path, nil, 0o600); err != nil { + return nil, err + } + } + known, err := knownhosts.New(path) + if err != nil { + return nil, fmt.Errorf("读取 SSH 主机密钥记录失败: %w", err) + } + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := known(hostname, remote, key) + if err == nil { + return nil + } + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) { + if len(keyErr.Want) == 0 { + return appendKnownHost(path, remote, key) + } + return fmt.Errorf("SSH 主机密钥与首次记录不一致(%s 期望 %s,实际 %s);若确认 VPS 重装过系统,请删除 %s 后重连", + remote.String(), ssh.FingerprintSHA256(keyErr.Want[0].Key), ssh.FingerprintSHA256(key), path) + } + return fmt.Errorf("SSH 主机密钥校验失败: %w", err) + }, nil +} + +// appendKnownHost 把首次见到的主机密钥追加进 known_hosts(TOFU:首次信任,之后必须一致)。 +func appendKnownHost(path string, remote net.Addr, key ssh.PublicKey) error { + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer file.Close() + line := knownhosts.Line([]string{knownhosts.Normalize(remote.String())}, key) + if _, err := file.WriteString(line + "\n"); err != nil { + return err + } + return nil } func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) { @@ -39,7 +94,11 @@ func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) { } else { return nil, fmt.Errorf("SSH 密码或私钥不能为空") } - config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: i.Timeout} + hostKey, err := i.hostKeyCallback() + if err != nil { + return nil, err + } + config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: hostKey, Timeout: i.Timeout} if config.Timeout == 0 { config.Timeout = 15 * time.Second } diff --git a/internal/notify/store.go b/internal/notify/store.go index bb08f16..a0eff4d 100644 --- a/internal/notify/store.go +++ b/internal/notify/store.go @@ -34,9 +34,9 @@ func (s *Store) LoadRuntime(ctx context.Context) (RuntimeConfig, error) { } type runtimeSecrets struct { - smtpPassword string - webhookSecret string - telegramToken string + smtpPassword string + webhookSecret string + telegramToken string } func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets, error) { @@ -401,6 +401,11 @@ func SanitizeSettings(values map[string]string) { delete(values, settings.KeyNotifySMTPPassword) delete(values, settings.KeyNotifyWebhookSecret) delete(values, settings.KeyNotifyTelegramToken) + // ZeroSSL API Key 属证书申请凭据:API 永不回明文,只回掩码。前端以「非空」判断是否已配置, + // 且只在用户重新输入时才提交该字段,因此掩码不会被当成新密钥写回。 + if values[settings.KeyZeroSSLAPIKey] != "" { + values[settings.KeyZeroSSLAPIKey] = MaskedSecret + } } func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) { @@ -415,10 +420,10 @@ func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) { } in := SaveInput{ - Type: NotifyType(values[settings.KeyNotifyType]), - SMTPPassword: values[settings.KeyNotifySMTPPassword], - WebhookSecret: values[settings.KeyNotifyWebhookSecret], - TelegramToken: values[settings.KeyNotifyTelegramToken], + Type: NotifyType(values[settings.KeyNotifyType]), + SMTPPassword: values[settings.KeyNotifySMTPPassword], + WebhookSecret: values[settings.KeyNotifyWebhookSecret], + TelegramToken: values[settings.KeyNotifyTelegramToken], } if raw := values[settings.KeyNotifyEmailJSON]; raw != "" { _ = json.Unmarshal([]byte(raw), &in.Email) diff --git a/web/package.json b/web/package.json index 1d43ebc..4b53033 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,8 @@ { "name": "fonu-web", "private": true, - "version": "1.0.2", + "version": "1.2.6", + "packageManager": "npm@10.9.4", "type": "module", "scripts": { "dev": "vite", diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml deleted file mode 100644 index 00f6fc4..0000000 --- a/web/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -allowBuilds: - esbuild: set this to true or false diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8b5bf2c..60bc475 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -312,7 +312,7 @@ export const api = { }), getFrpAgentRoutes: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes`), getFrpAgentRouteCandidates: (id: number) => request(`/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) => diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index 36d75e3..442e83d 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -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 }, diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index c9589b0..e0aefdf 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -579,7 +579,7 @@

{{ viewingServer.name }}

服务端详情和运行监测

停止启动frps.toml
-

基本信息

{{ viewingServer.server_addr }}
{{ viewingServer.server_port }}
{{ viewingServer.options.user || '未设置' }}
{{ locationText(viewingServer) }}
{{ viewingServer.options.protocol.toUpperCase() }}
{{ formatDate(viewingServer.created_at) }}
{{ formatDate(viewingServer.updated_at) }}
+

基本信息

{{ viewingServer.server_addr }}
{{ viewingServer.server_port }}
{{ viewingServer.options.user || '未设置' }}
{{ locationText(viewingServer) }}
{{ viewingServer.options.protocol.toUpperCase() }}
{{ formatDate(viewingServer.created_at) }}
{{ formatDate(viewingServer.updated_at) }}

配置

@@ -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)[server.process_state] ?? '未知' } diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 8139a82..3b6d7ec 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -32,7 +32,7 @@ - +

{{ domainSubLabel }}

@@ -43,7 +43,7 @@ - +

{{ certSubLabel }}

@@ -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(null) diff --git a/web/src/views/ProxyRemoteView.vue b/web/src/views/ProxyRemoteView.vue index 53d126d..45afa48 100644 --- a/web/src/views/ProxyRemoteView.vue +++ b/web/src/views/ProxyRemoteView.vue @@ -4,13 +4,6 @@ description="把穿透规则的域名发布到 VPS 的 Nginx:部署 / 移除、部署设置(HTTPS、跳转、白名单、Basic Auth、限流)、健康检查与配置查看" > @@ -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 {