fix(security): P0 修复——备份恢复路径与权限、frps 安装白名单与 SHA-256、SSH 主机密钥 TOFU、三处凭据掩码,另修状态徽标与仪表盘文案(v1.2.7)
CI / verify (push) Canceled after 0s
CI / verify (push) Canceled after 0s
This commit is contained in:
@@ -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` 作为门禁。
|
||||||
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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_<version>_sha256sums.txt`、下载后比对 SHA-256 通过才解压安装——此前该参数未校验就拼进下载地址,等于可让 VPS 拉取任意地址的二进制并装成 frps
|
||||||
|
- 安全(P0):SSH 不再 `InsecureIgnoreHostKey()`,改为 known_hosts + TOFU——首次连接把主机指纹记到 `<DataDir>/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
|
## [v1.2.6] - 2026-09-24
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|||||||
@@ -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** | `<StatusBadge :value="serverStartupType(viewingServer)" :text="serverStartupText(viewingServer)" />` —— `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<string, any>` 是全仓仅有的 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` 需重建并重启才能看到本轮前端改动。
|
||||||
+94
-12
@@ -5,6 +5,7 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -35,7 +36,9 @@ import (
|
|||||||
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
// 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.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
|
||||||
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
|
// 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 配置。
|
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -237,6 +240,17 @@ type frpsInstallRequest struct {
|
|||||||
Proxy string `json:"proxy,omitempty"`
|
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 服务(幂等,重复调用=升级)。
|
// installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。
|
||||||
func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
||||||
var req frpsInstallRequest
|
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})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported agent arch: " + runtime.GOARCH})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
downloadURL := fmt.Sprintf("https://github.com/fatedier/frp/releases/download/v%s/frp_%s_linux_%s.tar.gz", version, version, arch)
|
proxy := strings.TrimSpace(req.Proxy)
|
||||||
if proxy := strings.TrimSpace(req.Proxy); proxy != "" {
|
if _, ok := frpsDownloadProxies[proxy]; !ok {
|
||||||
downloadURL = strings.TrimRight(proxy, "/") + "/" + downloadURL
|
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)
|
workDir := filepath.Join(s.cfg.DataDir, "tmp-frps-"+version)
|
||||||
_ = os.RemoveAll(workDir)
|
_ = os.RemoveAll(workDir)
|
||||||
defer 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()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
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")
|
tarball := filepath.Join(workDir, "frp.tar.gz")
|
||||||
if err := downloadToFile(downloadURL, tarball); err != nil {
|
if err := downloadToFile(downloadURL, tarball); err != nil {
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "下载 frps 失败: " + err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "下载 frps 失败: " + err.Error()})
|
||||||
return
|
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
|
installPath := s.cfg.FrpsBinary
|
||||||
if !filepath.IsAbs(installPath) {
|
if !filepath.IsAbs(installPath) {
|
||||||
installPath = "/usr/local/bin/frps"
|
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()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "解压 frps 失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notes := []string{"frps 二进制已安装: " + installPath}
|
notes := []string{"frps 二进制已安装: " + installPath, "安装包 SHA-256 校验通过"}
|
||||||
unitPath := "/etc/systemd/system/frps.service"
|
unitPath := "/etc/systemd/system/frps.service"
|
||||||
writeUnit := false
|
writeUnit := false
|
||||||
if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -381,6 +414,43 @@ func downloadToFile(url, path string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchChecksumFor 取官方 sha256sums.txt 中 asset 对应的校验值(格式:<sha256> <文件名>)。
|
||||||
|
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。
|
// extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。
|
||||||
func extractFile(tarball, name, dest string, mode os.FileMode) error {
|
func extractFile(tarball, name, dest string, mode os.FileMode) error {
|
||||||
file, err := os.Open(tarball)
|
file, err := os.Open(tarball)
|
||||||
@@ -692,11 +762,27 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 err := s.writeRoute(req); err != nil {
|
||||||
|
if generatedPassword != "" {
|
||||||
|
// 部署失败就丢弃刚生成的凭据,否则重试时文件已存在、密码再也拿不到
|
||||||
|
_ = os.Remove(basicAuthFilePath(req.Domain))
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
|
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 的回源请求,区分三种状态:
|
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
|
||||||
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
|
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
|
||||||
if vhost := req.VhostPort; vhost > 0 {
|
if vhost := req.VhostPort; vhost > 0 {
|
||||||
@@ -1229,22 +1315,18 @@ func nginxRoute(req routeRequest, certDir string) string {
|
|||||||
if len(req.AllowIPs) > 0 {
|
if len(req.AllowIPs) > 0 {
|
||||||
acl.WriteString(" deny all;\n")
|
acl.WriteString(" deny all;\n")
|
||||||
}
|
}
|
||||||
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式)
|
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式);
|
||||||
|
// 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释
|
||||||
authLine := ""
|
authLine := ""
|
||||||
var authNote string
|
|
||||||
if user := strings.TrimSpace(req.BasicAuthUser); user != "" {
|
if user := strings.TrimSpace(req.BasicAuthUser); user != "" {
|
||||||
if password := strings.TrimSpace(req.BasicAuthPassword); password != "" {
|
if password := strings.TrimSpace(req.BasicAuthPassword); password != "" {
|
||||||
if err := writeBasicAuthSecret(req.Domain, user, password); err == nil {
|
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))
|
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))
|
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 上下文定义)
|
// 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义)
|
||||||
var limitText strings.Builder
|
var limitText strings.Builder
|
||||||
if req.RateLimitRate > 0 {
|
if req.RateLimitRate > 0 {
|
||||||
|
|||||||
@@ -42,19 +42,20 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(r, w, http.StatusBadRequest, "上传文件无效")
|
writeError(r, w, http.StatusBadRequest, "上传文件无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, header, err := r.FormFile("file")
|
file, _, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
|
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
tmpPath := filepath.Join(os.TempDir(), header.Filename)
|
// 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成
|
||||||
out, err := os.Create(tmpPath)
|
out, err := os.CreateTemp("", "fonu-restore-*.tar.gz")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
|
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tmpPath := out.Name()
|
||||||
if _, err := io.Copy(out, file); err != nil {
|
if _, err := io.Copy(out, file); err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ type Service struct {
|
|||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位
|
||||||
|
const (
|
||||||
|
restoreFileMode = 0o640
|
||||||
|
restoreDirMode = 0o750
|
||||||
|
)
|
||||||
|
|
||||||
func New(dataDir string) *Service {
|
func New(dataDir string) *Service {
|
||||||
return &Service{dataDir: dataDir}
|
return &Service{dataDir: dataDir}
|
||||||
}
|
}
|
||||||
@@ -85,19 +91,22 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
target := filepath.Join(s.dataDir, header.Name)
|
target := filepath.Join(s.dataDir, header.Name)
|
||||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(s.dataDir)) {
|
// 归档条目必须落在 DataDir 内:前缀比较会被 ../data-other 这类同前缀路径绕过,用 Rel 判定
|
||||||
return fmt.Errorf("备份条目非法")
|
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 {
|
switch header.Typeflag {
|
||||||
case tar.TypeDir:
|
case tar.TypeDir:
|
||||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
if err := os.MkdirAll(target, restoreDirMode); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case tar.TypeReg:
|
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
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ func (c Config) CertsDir() string {
|
|||||||
return c.DataDir + "/certs"
|
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 {
|
func (c Config) ErrorsDir() string {
|
||||||
return c.NginxDir() + "/errors"
|
return c.NginxDir() + "/errors"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ 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_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_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_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"),
|
// 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_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_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_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -682,7 +682,7 @@ func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSH
|
|||||||
return Server{}, SSHInstaller{}, err
|
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" {
|
if server.SSHAuth == "password" {
|
||||||
installer.Password = secret
|
installer.Password = secret
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ package frp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
|
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
|
||||||
@@ -21,6 +24,58 @@ type SSHInstaller struct {
|
|||||||
Password string
|
Password string
|
||||||
PrivateKey string
|
PrivateKey string
|
||||||
Timeout time.Duration
|
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) {
|
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 {
|
} else {
|
||||||
return nil, fmt.Errorf("SSH 密码或私钥不能为空")
|
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 {
|
if config.Timeout == 0 {
|
||||||
config.Timeout = 15 * time.Second
|
config.Timeout = 15 * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,11 @@ func SanitizeSettings(values map[string]string) {
|
|||||||
delete(values, settings.KeyNotifySMTPPassword)
|
delete(values, settings.KeyNotifySMTPPassword)
|
||||||
delete(values, settings.KeyNotifyWebhookSecret)
|
delete(values, settings.KeyNotifyWebhookSecret)
|
||||||
delete(values, settings.KeyNotifyTelegramToken)
|
delete(values, settings.KeyNotifyTelegramToken)
|
||||||
|
// ZeroSSL API Key 属证书申请凭据:API 永不回明文,只回掩码。前端以「非空」判断是否已配置,
|
||||||
|
// 且只在用户重新输入时才提交该字段,因此掩码不会被当成新密钥写回。
|
||||||
|
if values[settings.KeyZeroSSLAPIKey] != "" {
|
||||||
|
values[settings.KeyZeroSSLAPIKey] = MaskedSecret
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
|
func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.2",
|
"version": "1.2.6",
|
||||||
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
esbuild: set this to true or false
|
|
||||||
@@ -312,7 +312,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getFrpAgentRoutes: (id: number) => request<import('./types').FrpAgentRouteDetail[]>(`/api/frpmulti/servers/${id}/agent/routes`),
|
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`),
|
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) =>
|
getFrpAgentRouteConf: (serverId: number, proxyId: number) =>
|
||||||
request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
|
request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
|
||||||
installFrpAgentNginx: (serverId: number) =>
|
installFrpAgentNginx: (serverId: number) =>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ const menuDefs = [
|
|||||||
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
|
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
|
||||||
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
|
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
|
||||||
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
|
{ 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: 'public-proxy', icon: ShieldCheckmarkOutline },
|
||||||
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
|
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
|
||||||
{ label: '系统设置', key: 'settings', icon: CogOutline },
|
{ label: '系统设置', key: 'settings', icon: CogOutline },
|
||||||
|
|||||||
@@ -579,7 +579,7 @@
|
|||||||
<div v-if="viewingServer" class="server-detail">
|
<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__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">
|
<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">
|
<section class="server-detail__panel">
|
||||||
<h3>配置</h3>
|
<h3>配置</h3>
|
||||||
<div class="detail-fields detail-fields--single">
|
<div class="detail-fields detail-fields--single">
|
||||||
@@ -665,6 +665,7 @@ import type {
|
|||||||
FrpServerOptions, FrpServerPayload, FrpServerStatusDetail,
|
FrpServerOptions, FrpServerPayload, FrpServerStatusDetail,
|
||||||
} from '../api/types'
|
} from '../api/types'
|
||||||
import { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format'
|
import { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format'
|
||||||
|
import type { StatusKind } from '../utils/status'
|
||||||
import { copyText } from '../utils/clipboard'
|
import { copyText } from '../utils/clipboard'
|
||||||
|
|
||||||
use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
|
use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
|
||||||
@@ -1386,6 +1387,14 @@ function serverStartupType(server: FrpServer) {
|
|||||||
return 'default'
|
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) {
|
function processStateText(server: FrpServer) {
|
||||||
return ({ starting: '启动中', running: '运行中', stopped: '已停止', error: '异常', unknown: '未知' } as Record<string, string>)[server.process_state] ?? '未知'
|
return ({ starting: '启动中', running: '运行中', stopped: '已停止', error: '异常', unknown: '未知' } as Record<string, string>)[server.process_state] ?? '未知'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<StatCard label="域名" tone="green">
|
<StatCard label="域名" tone="green">
|
||||||
<template #icon><n-icon :component="WifiOutline" /></template>
|
<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>
|
<template #value>{{ domainCountLabel }}</template>
|
||||||
<p class="stat-desc">{{ domainSubLabel }}</p>
|
<p class="stat-desc">{{ domainSubLabel }}</p>
|
||||||
<div class="stat-foot">
|
<div class="stat-foot">
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<StatCard label="HTTPS 证书" tone="blue">
|
<StatCard label="HTTPS 证书" tone="blue">
|
||||||
<template #icon><n-icon :component="ShieldCheckmarkOutline" /></template>
|
<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>
|
<template #value>{{ certCountLabel }}</template>
|
||||||
<p class="stat-desc">{{ certSubLabel }}</p>
|
<p class="stat-desc">{{ certSubLabel }}</p>
|
||||||
<div v-if="primaryCert" class="stat-foot">
|
<div v-if="primaryCert" class="stat-foot">
|
||||||
@@ -395,7 +395,7 @@ import {
|
|||||||
formatUptime,
|
formatUptime,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
import { displaySystemLog } from '../utils/logDisplay'
|
import { displaySystemLog } from '../utils/logDisplay'
|
||||||
import { httpStatusKind } from '../utils/status'
|
import { httpStatusKind, statusLabel } from '../utils/status'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const status = ref<DashboardStatus | null>(null)
|
const status = ref<DashboardStatus | null>(null)
|
||||||
|
|||||||
@@ -4,13 +4,6 @@
|
|||||||
description="把穿透规则的域名发布到 VPS 的 Nginx:部署 / 移除、部署设置(HTTPS、跳转、白名单、Basic Auth、限流)、健康检查与配置查看"
|
description="把穿透规则的域名发布到 VPS 的 Nginx:部署 / 移除、部署设置(HTTPS、跳转、白名单、Basic Auth、限流)、健康检查与配置查看"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<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 })">
|
<n-button :loading="manualRefreshing" :disabled="!server" @click="refreshAll({ silent: false })">
|
||||||
<template #icon><n-icon :component="RefreshOutline" /></template>
|
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||||
刷新
|
刷新
|
||||||
@@ -24,14 +17,47 @@
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
v-if="servers.length === 0"
|
v-if="servers.length === 0"
|
||||||
title="尚未配置 FRP 服务端"
|
title="尚未配置 FRP 服务端"
|
||||||
description="先在「内网穿透」页添加公网服务端并保存 Agent/SSH 信息,再回到本页管理公网反代。"
|
description="服务端的添加与 Agent / SSH 配置在「公网服务端」页;本页只把启用中的 http 规则发布到 VPS 的 Nginx。"
|
||||||
>
|
>
|
||||||
<template #action>
|
<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>
|
</template>
|
||||||
</EmptyState>
|
</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 写了但从未渲染) -->
|
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
|
||||||
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
|
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
|
||||||
|
|
||||||
@@ -76,7 +102,7 @@
|
|||||||
<!-- 入口行:agent / frps 生命周期在另一页 -->
|
<!-- 入口行:agent / frps 生命周期在另一页 -->
|
||||||
<div class="entrance-row">
|
<div class="entrance-row">
|
||||||
<span class="entrance-row__text">
|
<span class="entrance-row__text">
|
||||||
Agent 与 frps 的安装、升级、启停、配置下发在「公网 Agent」页;本页只负责把规则发布到 Nginx
|
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx
|
||||||
</span>
|
</span>
|
||||||
<router-link to="/frp-agent"><n-button size="small" quaternary>前往公网 Agent</n-button></router-link>
|
<router-link to="/frp-agent"><n-button size="small" quaternary>前往公网 Agent</n-button></router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,6 +205,8 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 部署设置 -->
|
<!-- 部署设置 -->
|
||||||
<n-modal v-model:show="settingsModal" :mask-closable="false">
|
<n-modal v-model:show="settingsModal" :mask-closable="false">
|
||||||
@@ -335,7 +363,6 @@
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
@@ -405,9 +432,6 @@ const confContent = ref('')
|
|||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
|
|
||||||
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
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)
|
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
|
||||||
|
|
||||||
// 统计:已生效 / 待处理 / 健康通过 / 当前连接
|
// 统计:已生效 / 待处理 / 健康通过 / 当前连接
|
||||||
@@ -725,7 +749,12 @@ async function deployRoute(candidate: FrpProxy) {
|
|||||||
const current = server.value
|
const current = server.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const result = await run(`deploy:${candidate.id}`, '部署反代', () => api.deployFrpAgentRoute(current.id, candidate.id))
|
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) {
|
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; }
|
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
|
||||||
.mono { font-family: var(--fonu-mono); font-size: 13px; }
|
.mono { font-family: var(--fonu-mono); font-size: 13px; }
|
||||||
.mono-wrap { white-space: normal; word-break: break-all; }
|
.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 {
|
@keyframes fonu-fade-up {
|
||||||
|
|||||||
Reference in New Issue
Block a user