diff --git a/docs/README.md b/docs/README.md index 2123ed3..0c4ccfe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,12 +1,14 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-24(v1.2.7) +> 最近更新:2026-09-27(v1.2.19) ## 方案与设计(本目录) | 文件 | 主题 | 状态 | | --- | --- | --- | +| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 | +| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1 已完成,其余待实施 | | `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 | | `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 | | `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 | diff --git a/docs/improvement-roadmap-2026-09-27.md b/docs/improvement-roadmap-2026-09-27.md new file mode 100644 index 0000000..047b657 --- /dev/null +++ b/docs/improvement-roadmap-2026-09-27.md @@ -0,0 +1,2072 @@ +# Fonu 改进路线实施方案(P0-P3) + +> 制定日期:2026-09-27 +> 适用版本:v1.2.19 之后的迭代 +> 依据文档:`docs/project-analysis-2026-09-27.md` +> 文档性质:实施方案,不代表本轮已经修改代码 +> 目标:把改进清单拆成可评审、可实施、可验证、可回滚的工程任务 + +--- + +## 0. 执行摘要 + +### 0.1 优先级与目标 + +| 优先级 | 目标 | 建议周期 | 预计工作量 | 产出 | +| --- | --- | --- | ---: | --- | +| P0 | 修复明确问题与安全边界 | 1 个短迭代 | 3-6 人日 | v1.2.x 修复版 | +| P1 | 降低 FRP、Agent、前端巨型模块风险 | 2-4 个迭代 | 15-25 人日 | 架构重构版 | +| P2 | 工程质量收口与测试补强 | 与 P1 并行,后续收尾 | 10-18 人日 | 稳定维护版 | +| P3 | 能力增强 | 独立产品迭代 | 40-70 人日 | v1.3/v1.4 功能版 | + +预计工作量为熟悉本项目的一名工程师估算,不包含需求反复、真机环境等待和外部依赖调整。 + +### 0.2 推荐实施顺序 + +```text +M1:P0 修复与安全收口 + → M2:P1-4 测试基线与 P1-2 文件拆分 + → M3:P1-1 共享 Nginx 模板 + → M4:P1-3 前端拆分 + P1-5 轮询统一 + → M5:P2 工程收口 + → M6:P3 能力按单项排期 +``` + +不推荐一次性同时推进 P1、P2、P3。FRP / Agent、前端大组件、数据库性能、迁移包和 2FA 都属于高回归风险区域,必须分批交付。 + +### 0.3 总体原则 + +1. **每一步都保持可运行**:先补测试和特征化断言,再做重构。 +2. **优先移动而不是重写**:拆分大文件时先保持函数体不变,只改变文件归属。 +3. **Agent 行为变更必须递增 `AgentVersion`**:并同步重编 amd64、arm64 内嵌二进制。 +4. **数据库变更只向前迁移**:新增迁移从 `028` 开始,不修改已经发布的迁移。 +5. **敏感数据默认不回传**:新增接口必须遵守现有的掩码与“只显示一次”约定。 +6. **新增接口必须有测试**:至少覆盖无鉴权、正常路径、失败路径和兼容路径。 +7. **重构不得顺手改业务语义**:同一 PR 只做一类事情,便于审查和回滚。 +8. **删除前先确认引用**:P2 清理旧代码前,先用 `rg` 证明无运行时引用;删除归档内容需单独确认。 + +--- + +## 1. 基线与约束 + +### 1.1 当前代码基线 + +| 项目 | 当前值 | +| --- | --- | +| 版本 | `1.2.19` | +| Agent 版本 | `0.12.7` | +| Go 模块 | `github.com/fonu/fonu` | +| Go 声明版本 | 1.23 | +| Go 文件 | 235 个,其中 90 个测试文件 | +| 内部包 | 29 个 | +| 数据库迁移 | 27 个 | +| 受保护 API | 约 113 条 | +| Agent API | 20 条 `/api/v1/*` | +| 前端源文件 | 52 个 TypeScript / Vue 文件 | +| 前端测试 | 2 个 spec、13 条测试 | + +### 1.2 当前验证门禁 + +每次合并前至少执行: + +```bash +go test ./... -count=1 +go vet ./... + +cd web +npm run typecheck +npm test +npm run build +``` + +涉及 Agent、Docker、Nginx 或跨平台路径时,还需要补: + +```bash +go build ./... +``` + +必要时使用 `scripts/build-linux.ps1` 验证 Linux 安装包。 + +### 1.3 当前已知工程状态 + +| 项目 | 当前状态 | 影响 | +| --- | --- | --- | +| `gofmt -l internal cmd` | 12 个文件未格式化 | 无法立即开启格式化 CI 卡口 | +| 前端硬编码颜色 | 约 287 处匹配 `#hex`、`rgb()`、`rgba()` | 主题一致性与维护成本 | +| FRP 主服务 | `internal/frp/service.go` 约 1805 行 | 修改风险集中 | +| Agent 主服务 | `internal/agent/server.go` 约 1551 行 | HTTP、Nginx、frps、配置写盘耦合 | +| 前端大页面 | `ProxyView.vue` 约 3150 行 | 组件复用、测试和样式隔离困难 | +| 工作区 | 有未提交的删除、二进制与品牌图片修改 | 开始迭代前需确认基线提交 | + +### 1.4 开始实施前的仓库准备 + +1. 确认 `Fonu-1.0.2` 内当前未提交改动是否要保留。 +2. 将品牌图片、内嵌 Agent 二进制更新拆成独立提交,避免和功能改动混在一起。 +3. 把 `internal/frp-1.0.2` 与 `FrpView-1.0.2.vue` 的删除单独提交,不与其他重构同批。 +4. 从最新 `main` 建分支,建议前缀 `codex/`。 +5. 所有方案项都从当前 `origin/main` 的已提交状态开始,不基于未确认的工作区改动。 + +--- + +## 2. 全局完成定义 + +### 2.1 单个任务的完成定义 + +一个任务只有同时满足以下条件才算完成: + +- 代码已实现,并保持现有接口兼容; +- 新增或更新了测试; +- `go test ./... -count=1` 通过; +- `go vet ./...` 通过; +- 前端改动执行 `npm run typecheck`、`npm test`、`npm run build`; +- Agent 行为变化已递增 `AgentVersion` 并重编双架构二进制; +- 数据库变化已新增迁移且验证旧库升级; +- 文档与 `CHANGELOG.md` 已更新; +- 明确记录回滚方式; +- 未把无关格式化、品牌资源、二进制替换混入同一提交。 + +### 2.2 单个 PR 的边界 + +| PR 类型 | 允许包含 | 不允许包含 | +| --- | --- | --- | +| P0 修复 | 单一缺陷、对应测试、必要文档 | 大范围重构、格式化全仓 | +| 拆分重构 | 文件移动、符号迁移、必要的包内适配 | 行为变化、顺手改接口 | +| 模板收敛 | 共享纯函数、两组调用方改造、golden 测试 | 顺手改变 Nginx 输出语义 | +| 前端拆分 | 组件搬迁、props/emits、样式同步搬迁 | 同时重做视觉样式 | +| 性能优化 | 基准、索引、确认后的连接策略 | 未量化就改 SQLite 配置 | +| P3 功能 | 迁移、接口、前端、测试、文档 | 多个 P3 功能混在一个 PR | + +--- + +## 3. P0:先处理明确问题 + +P0 的目标是修复已经确认的缺陷与安全边界,不进行架构大改。 + +--- + +## 3.1 P0-1:修正 DDNS 页旧 `/api/frp` 调用 + +> 实施状态:2026-09-27 已完成。 + +### 3.1.1 现状 + +- `web/src/views/DdnsView.vue` 的 `init()` 调用 `api.getFRP()`。 +- `web/src/api/client.ts` 仍保留旧方法: + - `getFRP` + - `saveFRP` + - `getFRPStatus` + - `syncFRPDomains` + - `getFRPLogs` +- 后端 `internal/api/router.go` 当前不注册 `/api/frp`,只注册 `/api/frpmulti/*`。 +- `api.getFRP()` 因此失败,进入 `catch`,`frpEnabled.value = false`。 +- DDNS 页顶部“FRP 内网穿透已启用”提示不会正确出现。 + +### 3.1.2 目标 + +1. DDNS 页使用当前 FRP 多服务端接口。 +2. 提示只在确实存在可用穿透配置时显示。 +3. 不再依赖已退役的旧 API。 + +### 3.1.3 实施步骤 + +1. 将 `DdnsView.vue` 的调用改为: + + ```ts + const status = await api.getFrpStatus() + frpEnabled.value = status.configured && status.enabled_proxies > 0 + ``` + +2. 如产品语义希望“配置了服务端就提示”,可调整为: + + ```ts + frpEnabled.value = status.configured + ``` + + 推荐使用 `configured && enabled_proxies > 0`,避免用户刚建服务端但没有任何穿透规则时看到误导提示。 + +3. 将 `DdnsView.vue` 中 `api.getFRP` 的导入或调用彻底替换。 +4. 暂时保留旧方法不做删除,删除工作纳入 P2-2,避免 P0 同时扩大改动范围。 + +### 3.1.4 测试 + +- 前端单元测试: + - 新增 `DdnsView` 的辅助纯函数测试,或抽出 `shouldShowFrpHint(status)`。 + - 覆盖 `configured=false`、`enabled_proxies=0`、`configured=true && enabled_proxies>0`。 +- 手工验证: + - 无 FRP 服务端时无提示; + - 有服务端但无启用规则时无提示; + - 有启用规则时出现提示; + - 接口失败时不阻塞 DDNS 页面。 + +### 3.1.5 验收标准 + +- `rg -n "api\\.getFRP" web/src/views/DdnsView.vue` 无结果。 +- 页面不再向 `/api/frp` 发请求。 +- DDNS 主要功能在接口异常时仍可加载。 + +### 3.1.6 风险 + +| 风险 | 处理 | +| --- | --- | +| `getFrpStatus` 返回状态与旧字段不同 | 使用明确映射函数,禁止直接把旧 `enabled` 字段改名为新字段 | +| 状态接口超时影响页面初始化 | 提示查询与主数据加载分离,失败只影响提示 | + +### 3.1.7 实施记录 + +- `web/src/views/DdnsView.vue` 已改用 `api.getFrpStatus()`。 +- 新增 `web/src/utils/frp.ts`,由 `shouldShowFrpHint` 统一判断“已配置且有启用规则”。 +- 新增 `web/src/utils/frp.spec.ts`,覆盖配置、未配置、无启用规则和空状态。 +- 旧 `/api/frp` 客户端方法尚未删除,按要求留到 P2-2 统一清理。 +- 验证通过:`npm test` 17 条、`npm run typecheck`、`npm run build`。 + +--- + +## 3.2 P0-2:初始管理员密码不再写入持久应用日志 + +### 3.2.1 现状 + +- `internal/auth/auth.go` 的 `EnsureDefaultAdmin` 使用 `slog` 记录用户名和密码。 +- `internal/app/app.go` 的日志同时写入 stdout 和 `/logs/app.log`。 +- `internal/logstore/display.go` 的 `appendSystemAttrs` 专门把初始密码渲染进系统日志。 +- `internal/api/logs_handler.go` 的 `/api/logs/system` 会读取 `app.log`。 +- `scripts/fonu-install.sh` 当前从 `app.log` 中提取初始密码。 + +这意味着“初始密码只出现一次”的现实边界并不成立,密码会长期留在数据目录日志中。 + +### 3.2.2 目标 + +1. 初始密码只在首次启动时输出一次。 +2. 默认输出到进程 stdout/stderr,不写入 `app.log`。 +3. Docker 用户可通过 `docker logs fonu` 获取。 +4. systemd 用户可通过 `journalctl -u fonu` 获取。 +5. 安装脚本改为从 journal 或服务日志读取,不再读取 `app.log`。 +6. 日志中心不再能读到初始密码。 + +### 3.2.3 推荐设计 + +修改 `auth.Service.EnsureDefaultAdmin`: + +```go +type BootstrapAdmin struct { + Username string + Password string + Created bool +} + +func (s *Service) EnsureDefaultAdmin(ctx context.Context) (BootstrapAdmin, error) +``` + +- `Created=false` 时不返回密码。 +- 不在 `auth` 包内部写密码日志。 +- `app.New` 收到 `Created=true` 后,使用独立的 `os.Stderr` 或标准输出直接打印,不经过 `slog` 的 `MultiWriter`。 + +示例输出: + +```text +Fonu 首次启动已创建管理员账户 +用户名:admin +初始密码:<一次性密码> +请登录后立即修改密码。 +``` + +### 3.2.4 实施步骤 + +1. 重构 `EnsureDefaultAdmin` 返回值。 +2. `app.New` 调整调用与一次性输出。 +3. 删除 `logstore.appendSystemAttrs` 中初始密码的特殊拼接分支。 +4. 更新 `logstore/display_test.go`,断言日志中出现初始密码构建消息时不再拼接 `password`。 +5. 更新 `scripts/fonu-install.sh`: + - 删除从 `app.log` grep 密码的逻辑; + - 改为读取 `journalctl -u fonu` 最近日志; + - 如果服务尚未写入日志,则提示用户执行 `journalctl` 或 `docker logs`。 +6. 更新 Docker、原生安装和 FAQ 文档。 + +### 3.2.5 测试 + +- `internal/auth/auth_test.go` + - 首次创建返回 `Created=true` 且有密码; + - 第二次调用返回 `Created=false` 且无密码。 +- `internal/app` 测试或日志测试: + - `app.log` 不包含初始密码; + - 标准输出可以包含一次初始密码。 +- `internal/logstore/display_test.go` + - 系统日志渲染不再输出 `password`。 +- shell 验证: + - 原生安装后 `journalctl -u fonu` 能读到初始密码; + - `app.log` 中 grep 不到初始密码。 + +### 3.2.6 验收标准 + +- `/logs/app.log` 中不出现初始密码。 +- `/api/logs/system` 响应中不出现初始密码。 +- 安装脚本仍能提示首次密码。 +- 已初始化实例不受影响。 + +### 3.2.7 兼容与回滚 + +- 已存在的日志不会被自动清理;可增加“安全清理旧日志”的手工建议,不在代码中自动删除。 +- 回滚仅恢复日志输出方式,不影响管理员数据。 +- 该修复不要求 Agent 升级。 + +--- + +## 3.3 P0-3:Agent Token 使用常量时间比较 + +### 3.3.1 现状 + +`internal/agent/server.go` 的 `auth` 方法使用普通字符串比较: + +```go +if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token { +``` + +这不是直接可利用漏洞,但不符合安全敏感的 Token 比较约定。 + +### 3.3.2 目标 + +将 Bearer Token 解析成独立函数,并使用 `crypto/subtle.ConstantTimeCompare`。 + +### 3.3.3 推荐实现 + +```go +func bearerToken(r *http.Request) string { + header := strings.TrimSpace(r.Header.Get("Authorization")) + const prefix = "Bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return "" + } + return strings.TrimSpace(header[len(prefix):]) +} + +func secureEqual(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} +``` + +`auth` 方法: + +```go +token := bearerToken(r) +if token == "" || !secureEqual(token, s.cfg.Token) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return +} +``` + +### 3.3.4 测试 + +新增 `internal/agent/auth_test.go`: + +- 正确 Token 返回 200; +- 错误 Token 返回 401; +- 缺失 Authorization 返回 401; +- `bearer` 大小写兼容; +- 多空格处理; +- 长度不同时不 panic。 + +### 3.3.5 验收标准 + +- 所有 Agent 接口仍使用相同 Bearer Token。 +- 正确 Token 通过,错误 Token 拒绝。 +- Agent 不依赖应用侧 API 中间件代码。 + +--- + +## 3.4 P0-4:限流状态解析失败时拒绝覆盖原状态 + +### 3.4.1 现状 + +`internal/agent/server.go` 的 `readLimitState`: + +```go +_ = json.Unmarshal(data, &state) +return state +``` + +如果 `route-limits.json` 损坏: + +1. 返回空 map; +2. `applyLimitState` 只写入当前域名; +3. `writeLimitState` 用不完整状态覆盖原文件; +4. 其他域名的限流配置丢失。 + +### 3.4.2 目标 + +- 文件不存在:返回空状态,不报错。 +- 文件存在但读取失败或 JSON 损坏:返回错误,不继续写。 +- 调用方把错误回传到 HTTP 502/500,保留原文件和现有 Nginx zone。 + +### 3.4.3 推荐接口 + +```go +func readLimitState() (map[string]routeLimit, error) + +func (s *Server) applyLimitState(req routeRequest) error +func (s *Server) clearLimitState(domain string) error +``` + +建议错误文案: + +```text +读取限流状态失败: /var/lib/fonu-agent/route-limits.json: <原因> +``` + +### 3.4.4 实施步骤 + +1. 修改 `readLimitState` 返回 `(map, error)`。 +2. `os.IsNotExist` 返回空 map 和 nil。 +3. `os.ReadFile` 其他错误返回错误。 +4. `json.Unmarshal` 错误返回错误。 +5. `applyLimitState`、`clearLimitState` 直接传播错误。 +6. 新增测试: + - 文件不存在; + - 空对象; + - 正常对象; + - 截断 JSON; + - 类型错误; + - 解析失败时原文件内容不变。 +7. 可选:在损坏时先生成 `.corrupt-` 快照,但不得自动删除原文件。 + +### 3.4.5 验收标准 + +- 损坏状态不会导致其他域名限流配置被清空。 +- 错误能显示在“应用配置”或路由保存响应中。 +- `nginx -t` 仍以真实生成结果为准。 + +--- + +## 3.5 P0-5:明确 Agent 默认监听策略 + +### 3.5.1 现状 + +- `cmd/fonu-agent/main.go` 默认 `:7700`。 +- `internal/agent.NewServer` 空配置时也回落到 `:7700`。 +- `scripts/agent-install.sh` 的 systemd 单位只设置环境文件,不显式限制监听地址。 +- Agent 当前为明文 HTTP,Bearer Token 在网络中裸传。 +- 安装脚本打印 `http://:7700`,鼓励直接公网访问。 + +### 3.5.2 策略选择 + +推荐采用两阶段策略,避免一次性破坏现有部署: + +#### 阶段 A:先警告与显式标记 + +适用于下一个修复版本: + +1. Agent 状态接口增加: + - `listen_addr` + - `transport_scheme` + - `transport_secure` + - `security_warning` +2. 当监听非环回地址且未启用 TLS 时: + - 启动日志打印醒目的中文警告; + - 前端“公网服务端”页展示警告; + - 安装脚本提示必须使用 Nginx HTTPS 或 SSH 隧道; + - 不立即阻止启动。 + +#### 阶段 B:默认仅本机,显式允许远程 + +适用于一个明确的 minor 版本: + +1. 默认监听改为 `127.0.0.1:7700`。 +2. 新增 `FONU_AGENT_ALLOW_REMOTE=1` 或 `-allow-remote`: + - 没有该显式选项时,拒绝监听非环回地址。 +3. 新增可选直接 TLS: + - `FONU_AGENT_TLS_CERT` + - `FONU_AGENT_TLS_KEY` +4. 非环回监听满足以下任一条件才允许: + - TLS 证书和私钥已配置; + - 明确使用 `--insecure-allow-http` 并打印高危警告。 +5. 安装脚本默认写入: + + ```dotenv + FONU_AGENT_LISTEN=127.0.0.1:7700 + ``` + +6. 如用户选择远程模式,安装脚本引导配置 Nginx HTTPS 反向代理,或写入显式不安全开关。 + +### 3.5.3 中央 Fonu 的连接方式 + +推荐远程连接路径: + +```text +Fonu 控制面 + → https://agent.example.com + → VPS Nginx 443 + → 127.0.0.1:7700 fonu-agent +``` + +备选路径: + +```text +Fonu 控制面 + → SSH 本地转发或 WireGuard + → 127.0.0.1:7700 fonu-agent +``` + +不推荐: + +```text +Fonu 控制面 + → http://公网IP:7700 +``` + +### 3.5.4 前端与接口变化 + +1. `AgentView.vue` 状态卡增加“传输安全”字段。 +2. 当 `transport_secure=false` 且监听地址非本机时,显示橙色警告。 +3. 安装脚本输出改为: + + ```text + Agent 默认仅监听 127.0.0.1:7700。 + 如需远程连接,请先配置 HTTPS 反向代理,再设置 FONU_AGENT_ALLOW_REMOTE=1。 + ``` + +### 3.5.5 兼容迁移 + +升级已有 Agent 时: + +1. 保留 `/etc/fonu-agent/agent.env`。 +2. 旧配置未设置 `FONU_AGENT_LISTEN` 时: + - 阶段 A 继续使用旧默认值; + - 阶段 B 给出迁移提示,并在一个过渡版本内保持旧行为。 +3. 新安装默认 loopback。 +4. 如果旧部署使用公网 `:7700`,升级说明必须明确如何加 Nginx HTTPS。 +5. `agent_url` 当前是 `http://` 时,前端显示不安全提示。 + +### 3.5.6 测试 + +- 默认启动监听 loopback。 +- 非环回监听无显式允许时拒绝启动。 +- `--insecure-allow-http` 时启动并输出警告。 +- 配置 TLS 时启动 HTTPS。 +- 安装脚本生成的环境文件符合所选模式。 +- 旧环境文件升级不丢 Token。 + +### 3.5.7 验收标准 + +- 新安装不会默认暴露明文 Agent 到公网。 +- 用户仍可通过明确配置保留远程管理能力。 +- 文档给出 Nginx HTTPS 与 SSH 隧道两种方案。 +- 安全状态在前端可见。 + +--- + +## 4. P1:降低核心模块风险 + +P1 的核心是控制复杂度,不改变可见功能。 + +--- + +## 4.1 P1-1:抽共享 Nginx 模板包 + +### 4.1.1 现状 + +本机与公网 Agent 各有一套 Nginx 生成逻辑: + +| 位置 | 用途 | +| --- | --- | +| `internal/nginx/generate.go` | 本机反代主配置与规则块 | +| `internal/nginx/security.go` | 本机安全、限流、geo、TLS | +| `internal/agent/server.go` 的 `nginxRoute` | VPS vhost | +| `internal/agent/server.go` 的 `sslDirectives` | VPS TLS | +| `internal/agent/server.go` 的 `zoneKey` / `writeLimitState` | VPS 限流 zone | + +同一类安全问题需要改两处,容易漂移。 + +### 4.1.2 共享边界 + +新建纯生成包,建议命名: + +```text +internal/nginxtmpl +``` + +该包只负责把结构化参数渲染成字符串,不依赖: + +- `internal/proxy` +- `internal/config` +- `internal/frp` +- `internal/agent` +- 文件系统 +- 进程执行 + +这样 `internal/nginx` 和 `internal/agent` 都可以安全导入,不会循环依赖。 + +### 4.1.3 建议 API + +```go +package nginxtmpl + +type TLSConfig struct { + CertPath string + KeyPath string + TLS13Only bool +} + +type AccessControl struct { + Allow []string + Deny []string + ChinaOnly bool +} + +type LimitConfig struct { + Zone string + Rate int + Burst int + Conn int +} + +type SecurityHeaders struct { + HTTPS bool + Enabled bool + HSTSMaxAgeDays int +} + +func SSLDirectives(cfg TLSConfig) string +func ServerSecurityHeaders(cfg SecurityHeaders) string +func AccessDirectives(cfg AccessControl, indent string) string +func LimitDirectives(cfg LimitConfig, indent string) string +func RateLimitZone(zone string, rate int) string +func ConnLimitZone(zone string) string +func ZoneName(prefix string, parts ...string) string +``` + +### 4.1.4 先做特征化测试 + +在改动前,为两份输出增加或固定以下测试: + +- HTTPS 证书指令; +- TLS 1.3 开关; +- HSTS 与安全响应头; +- allow / deny 顺序; +- 白名单末尾 `deny all`; +- WebSocket Upgrade 头; +- 限流与连接数指令; +- 中国 IP geo 变量; +- 错误页 location; +- 空参数不输出多余指令。 + +这些测试先锁定现有语义,再替换为共享函数。 + +### 4.1.5 实施步骤 + +1. 新增 `internal/nginxtmpl`。 +2. 先迁出无业务依赖的函数: + - TLS 指令; + - 安全响应头; + - allow / deny; + - WebSocket 头; + - zone 命名。 +3. 本机 `internal/nginx` 改为调用共享函数,保持输出逐字节一致。 +4. Agent `nginxRoute` 分阶段改为调用共享函数。 +5. 为中国 IP 仅大陆规则保留差异适配层: + - 本机使用 `geo $fonu_client_ip` 与规则 ID 变量; + - Agent 使用 `$fonu_china_client`; + - 两者共享 allow/deny 与返回 403 结构,不强行共享变量名。 +6. 删除重复实现,但保留本机与 Agent 各自的上下文拼装函数。 + +### 4.1.6 验收标准 + +- 同一参数在两端的 TLS、安全头、WebSocket、限流输出一致。 +- `internal/agent` 不再自行实现通用 TLS 指令。 +- 两端测试均可独立覆盖共享模板。 +- 本机 Nginx 与 Agent 生成内容没有未解释的语义变化。 + +### 4.1.7 风险 + +| 风险 | 处理 | +| --- | --- | +| 两端缩进与上下文不同 | 共享函数接受 `indent`,不强制服务块结构 | +| Agent 使用域名 zone,本机使用规则 ID | `ZoneName` 只负责合法化,不负责决定命名规则 | +| 修改模板导致现有 vhost 重写 | 先做 golden 对比,再在部署时保留版本回滚 | + +--- + +## 4.2 P1-2:拆分 `internal/frp/service.go` 与 `internal/agent/server.go` + +### 4.2.1 拆分原则 + +1. 同一 Go 包内拆文件,不改变外部接口。 +2. 只移动函数、类型和常量,不重写逻辑。 +3. 每个文件目标 300-500 行以内。 +4. 每个函数继续遵守函数长度不超过 50 行的仓库约定。 +5. 拆分后先跑全量测试,再做后续优化。 + +### 4.2.2 `internal/frp` 目标文件 + +| 新文件 | 责任 | +| --- | --- | +| `service.go` | Service 结构、构造、公共装配 | +| `service_servers.go` | 服务端 CRUD、配置保存、凭证与 TLS 资产 | +| `service_proxies.go` | 穿透规则 CRUD、列表、日志 | +| `service_runtime.go` | frpc 启动、停止、重载、状态、自动恢复 | +| `service_download.go` | 版本列表、下载任务、二进制激活与清理 | +| `service_diagnostics.go` | 服务端五项诊断与延迟记录 | +| `service_agent_lifecycle.go` | SSH 安装、升级、卸载、Probe、急救诊断 | +| `service_agent_routes.go` | vhost 部署、删除、漂移、版本与证书推送 | +| `service_health.go` | 规则健康、可用率、汇总 | +| `service_config.go` | frpc.toml / frps.toml 渲染与原子写 | +| `service_traffic.go` | frps dashboard 流量适配 | + +### 4.2.3 `internal/agent` 目标文件 + +| 新文件 | 责任 | +| --- | --- | +| `server.go` | Config、Server、NewServer、路由注册、ListenAndServe | +| `auth.go` | Bearer Token 解析与常量时间比较 | +| `handlers_status.go` | ping、status、metrics 状态输出 | +| `handlers_routes.go` | route CRUD、conf、版本、回滚 | +| `handlers_nginx.go` | Nginx 探测、安装、校验、重载、日志 | +| `handlers_frps.go` | frps 配置、安装、启停、日志 | +| `handlers_certs.go` | 证书写入与列表 | +| `handlers_china.go` | 中国 IP 段写入与 geo 文件生成 | +| `limits.go` | route-limits.json、zone 文件、限流状态 | +| `nginx_template.go` | Agent vhost 模板拼装 | +| `path_detect.go` | Nginx 目录、可执行文件与版本探测 | +| `basic_auth.go` | htpasswd 生成与复用 | + +### 4.2.4 拆分顺序 + +1. 先拆 `internal/frp` 中低耦合的下载、诊断、健康文件。 +2. 再拆服务端 CRUD 与运行管理。 +3. 最后拆 Agent 生命周期与路由部署。 +4. `internal/agent` 先拆 auth、limits、handlers_nginx,再拆 routes。 +5. 每拆一组文件执行: + + ```bash + go test ./internal/frp ./internal/agent -count=1 + go test ./... -count=1 + ``` + +### 4.2.5 验收标准 + +- 包外 API 没有变化。 +- 没有新增循环依赖。 +- 测试结果与拆分前一致。 +- 文件行数显著下降,主文件不再承担所有职责。 +- 行为变更与文件移动没有混在同一 PR。 + +--- + +## 4.3 P1-3:拆分前端巨型页面 + +### 4.3.1 拆分原则 + +1. 组件只接收 props,通过 emits 通知父页面。 +2. 网络请求留在页面或 composable 中,避免弹窗组件私自保存。 +3. scoped 样式必须随模板一起搬迁。 +4. 每次只拆一个页面,先拆纯展示区域,再拆表单。 +5. 拆分后视觉应与原页面一致。 + +### 4.3.2 `ProxyView.vue` 拆分建议 + +| 新组件 / composable | 责任 | +| --- | --- | +| `ProxyRuleFormModal.vue` | 新建/编辑表单、基础/安全/Nginx 三个页签 | +| `ProxyRuleDetailModal.vue` | 规则详情、概览与日志 | +| `ProxyClientsPanel.vue` | 当前连接与客户端 IP | +| `ProxyNginxPanel.vue` | 自动/自定义配置、版本与回滚 | +| `useProxyRules.ts` | 列表、保存、删除、排序 | +| `useProxyTraffic.ts` | 流量与在线连接轮询 | + +### 4.3.3 `SettingsView.vue` 拆分建议 + +| 新组件 / composable | 责任 | +| --- | --- | +| `SettingsGeneralPanel.vue` | 主题、时区、日志、DDNS、证书默认值 | +| `SettingsNotificationsPanel.vue` | 通知渠道与告警事件 | +| `SettingsSecurityPanel.vue` | 登录失败、全局 IP 策略、信任代理 | +| `SettingsAdvancedPanel.vue` | 中国 IP、Nginx 主配置、状态页、指标 | +| `BackupPanel.vue` | 立即备份、归档、恢复、删除 | +| `ApiTokensPanel.vue` | Token 创建、范围、有效期、撤销 | +| `useSettingsForm.ts` | 设置加载、脏状态、保存与掩码字段 | + +### 4.3.4 `AgentView.vue` 拆分建议 + +| 新组件 / composable | 责任 | +| --- | --- | +| `AgentServerFormModal.vue` | 服务端、Agent、SSH、frps 配置表单 | +| `AgentServerDetailModal.vue` | 详情、frps 同源配置、控制入口 | +| `AgentServerLogsModal.vue` | Agent / frps 日志 | +| `AgentDiagnosticsPanel.vue` | 五项诊断与可用率 | +| `AgentMetricsPanel.vue` | 资源、延迟、日志和证书 | +| `useAgentServers.ts` | 列表、选择、加载、生命周期调用 | +| `useAgentPolling.ts` | 状态与诊断轮询 | + +### 4.3.5 测试与验证 + +- 拆分后保留原有交互路径。 +- 为 props/emits 增加 `@vue/test-utils` 测试。 +- 使用 Playwright 对桌面与移动视口截图: + - `/proxies` + - `/settings` + - `/frp-agent` +- 检查弹窗尺寸、滚动区域、按钮位置、移动端文本溢出。 +- 检查 `vue-tsc` 与 `vite build`。 + +### 4.3.6 验收标准 + +- 单个页面文件不再超过约 1200 行。 +- 新组件职责单一,父页面只负责协调。 +- 原页面的交互和视觉不变。 +- 拆分过程中未丢失 scoped 样式。 + +--- + +## 4.4 P1-4:补路由鉴权、配置回滚、Agent 版本兼容测试 + +### 4.4.1 路由鉴权覆盖测试 + +目标:防止新增 API 时漏掉 `protect()`。 + +建议改造 `internal/api/router.go`: + +```go +type routeSpec struct { + Method string + Pattern string + Handler http.HandlerFunc + Public bool + TokenDeny bool +} +``` + +- 构造统一路由表; +- 注册循环只负责挂载与包中间件; +- 测试直接遍历路由表; +- 对受保护路由发匿名请求,期望 401; +- 对公开路由断言不因鉴权而 401。 + +测试文件: + +```text +internal/api/router_auth_test.go +``` + +Agent 同样增加: + +```text +internal/agent/router_auth_test.go +``` + +遍历 20 个 `/api/v1/*` 路径,无 Bearer Token 必须返回 401。 + +### 4.4.2 配置回滚测试 + +覆盖本机: + +- `internal/nginx/versions.go` 的版本列表、读取、回滚; +- 非法版本名拒绝路径上跳; +- 回滚时先校验语法; +- 新配置校验失败时不覆盖当前配置; +- `fsutil.BackupVersioned` 去重逻辑。 + +覆盖 Agent: + +- 写 vhost 前保存版本; +- `nginx -t` 失败后恢复上一可用版本; +- 首次部署失败时删除新文件而不破坏已有文件; +- 回滚接口调用后执行 `nginx -s reload`; +- 旧 Agent 无版本接口时返回升级提示。 + +建议测试文件: + +```text +internal/nginx/versions_test.go +internal/agent/versions_test.go +internal/frp/agent_versions_compat_test.go +``` + +### 4.4.3 Agent 版本兼容测试 + +建立最低版本矩阵: + +| 能力 | 最低 Agent 版本 | +| --- | --- | +| 基础状态 / 路由 | 现有最低版本 | +| metrics / logs / frps-config | 0.11.0 | +| route versions / rollback | 0.12.2 | +| 新版安全监听策略 | 新版本 | + +测试内容: + +- `AgentClient` 遇到 404 / 405 返回 `ErrAgentEndpointMissing`; +- 错误信息包含最低版本和升级提示; +- 其他 4xx / 5xx 不误判为版本过低; +- 状态接口返回 `agent_version` 后可正确展示; +- 新字段缺失时前端降级,不显示空白页。 + +### 4.4.4 验收标准 + +- 新增受保护路由漏鉴权时,测试会失败。 +- 配置回滚失败不会覆盖当前可用配置。 +- 旧 Agent 与新控制端组合时有明确提示,不崩溃。 + +--- + +## 4.5 P1-5:统一前端轮询组合式函数 + +### 4.5.1 现状 + +已有 `web/src/composables/useVisibilityPolling.ts`,并被: + +- `ProxyRemoteView.vue` +- `FrpView.vue` + +使用,但其他页面仍自行维护计时器: + +- `DashboardView.vue` +- `ProxyView.vue` +- `LogsView.vue` +- `AgentView.vue` +- `SettingsView.vue` 的中国 IP 状态轮询 + +当前 composable 没有 in-flight 互斥,慢请求仍可能堆积。 + +### 4.5.2 目标 + +统一行为: + +1. 页面隐藏时暂停; +2. 回到前台立即补一次; +3. 上一轮未结束时跳过下一轮; +4. 支持 `enabled` 开关; +5. 组件卸载时清理; +6. 可选 `immediate` 立即执行; +7. 返回 `busy` 状态,便于 UI 展示。 + +### 4.5.3 建议 API + +```ts +export interface VisibilityPollingOptions { + enabled?: Ref + immediate?: boolean + onError?: (error: unknown) => void +} + +export function useVisibilityPolling( + fn: () => void | Promise, + intervalMs: number, + options: VisibilityPollingOptions = {}, +) { + // return { start, stop, busy, lastError } +} +``` + +### 4.5.4 实施步骤 + +1. 更新 composable,增加 in-flight 锁。 +2. `tick()` 中: + + ```ts + if (busy.value) return + busy.value = true + Promise.resolve(fn()) + .catch(onError) + .finally(() => { busy.value = false }) + ``` + +3. 将页面手动计时器替换为 composable。 +4. 页面已有的“首次加载”和“轮询”分开: + - 首次加载立即执行; + - 轮询只刷新增量或状态。 +5. SSE 日志不纳入轮询 composable。 + +### 4.5.5 测试 + +- 隐藏时不执行; +- 回到前台执行一次; +- 请求未结束时跳过下一轮; +- `enabled=false` 不执行; +- 卸载后无计时器; +- `onError` 被调用且不影响后续轮询。 + +### 4.5.6 验收标准 + +- 各页面不再重复实现 `setInterval + visibilitychange`。 +- 慢接口不会产生请求堆积。 +- 页面前后台切换行为一致。 + +--- + +## 5. P2:工程质量提升 + +P2 的重点是减少噪音、建立长期质量门禁,不改变大功能。 + +--- + +## 5.1 P2-1:全仓 `gofmt` 与 CI 卡口 + +### 5.1.1 当前结果 + +执行: + +```bash +gofmt -l internal cmd +``` + +当前仍有未格式化文件,包括: + +- `internal/acme/job.go` +- `internal/api/auth_handler_test.go` +- `internal/api/logs_handler_test.go` +- `internal/api/version_handler.go` +- `internal/ddns/store.go` +- `internal/ddns/tencentcloud_test.go` +- `internal/frp/diagnostics.go` +- `internal/frp/store.go` +- `internal/nginx/generate_multi_test.go` +- `internal/nginx/generate_seed_mix_test.go` +- `internal/nginx/realip.go` +- `internal/nginx/security.go` + +### 5.1.2 实施步骤 + +1. 单独创建“格式化”提交。 +2. 执行: + + ```bash + gofmt -w ./cmd ./internal + ``` + +3. 检查 diff,确认只有格式变化。 +4. 运行: + + ```bash + go test ./... -count=1 + go vet ./... + ``` + +5. 在 `.github/workflows/ci.yml` 的 `go vet` 前增加: + + ```yaml + - name: gofmt + run: test -z "$(gofmt -l ./cmd ./internal)" + ``` + +### 5.1.3 验收标准 + +- `gofmt -l internal cmd` 无输出。 +- CI 对后续未格式化文件立即失败。 +- 格式化 PR 不包含业务逻辑变化。 + +--- + +## 5.2 P2-2:清理旧 FRP 类型、旧 API 方法与未使用样式 + +### 5.2.1 旧 API 清理 + +目标: + +- `web/src/api/client.ts` 删除: + - `getFRP` + - `saveFRP` + - `getFRPStatus` + - `syncFRPDomains` + - `getFRPLogs` +- `web/src/api/types.ts` 删除不再使用的旧接口: + - `FRPConfig` + - `FRPResponse` + - `FRPSaveResponse` + - `FRPSavePayload` + - 以及只服务旧 `/api/frp` 的 `FRPStatus` + +注意:`FrpStatus` 这个名字与多服务端 `FrpStatus` 有潜在冲突,清理时先确认类型引用。 + +### 5.2.2 前端样式清理流程 + +1. 枚举模板中的 class; +2. 与 `