diff --git a/.dockerignore b/.dockerignore index ee07cc2..e7c15bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,11 +6,8 @@ bin /data # 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文) .data -# 已弃用归档代码,不参与编译 -internal/frp-1.0.2 -web/src/views/FrpView-1.0.2.vue # 文档与本地杂项 - docs +docs # 本地导出/打包产物与日志(预防性:不进入构建上下文) *.log *.rar diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25d6c6f..aff01d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,10 @@ jobs: working-directory: web run: npm run build + - name: Run web unit tests + working-directory: web + run: npm test + - name: Sync web dist into embed directory run: | rm -rf cmd/fonu/web/dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 2804010..7a835dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ All notable changes to this project will be documented in this file. +## [v1.2.8] - 2026-09-24 + +### 修复 + +- 公网服务端:`frps` 启停失败不再假成功——之前忽略 `systemctl` 的 err 后无条件写「frps.service 已启动」,现在失败时返回 `ok:false` 与 stderr,运维不会误以为服务在跑 +- frps 配置写入:`.bak` 备份失败时立即中止并删除 `.verify`,不再覆盖唯一副本(旧配置得留退路) +- 安装 frps 后下发同源 `frps.toml` 失败不再静默吞掉:结果里带 `config_warning` 并追加到 `notes` +- 请求体解析:证书申请 / 续期、DDNS 测试改为严格 400;frps 安装允许空请求体(走默认版本)但拒绝 JSON 语法错误 +- 会话与登录:同一 IP 在 5 分钟内失败 5 次后返回 429(成功后清零,带过期清理与上限);改密码只失效该账号的**其他**会话,不再把当前会话一并踢掉;会话 cookie 按 `r.TLS` / `X-Forwarded-Proto` 决定是否加 `Secure`(默认明文部署不加,否则浏览器丢 cookie 导致登不进去),`clearSessionCookie` 补 `SameSite=Lax` +- 后台下载改为继承应用 ctx(新增 `Service.SetBaseContext`),App 关闭时随之中止;`scheduler` 每个任务包 `recover()`,单个任务 panic 不再带走整个进程,且建表时跳过非正间隔(`time.NewTicker` 会 panic) +- 两个 `http.Server` 补 `ReadTimeout` / `IdleTimeout`(agent 60s/2min;应用 5min/2min,**不设 `WriteTimeout`**——否则日志 SSE 会被中途切断) +- 公网反代页:`refreshAll` 的 7 个独立接口由串行 `await` 改为 `Promise.all`,每次刷新不再等于 7 个往返相加 +- 内网穿透页:删除 74 条随服务端 UI 迁走的死样式;公网服务端页合并逐字重复的样式块 +- 文档与工程:修正 README / DEPLOYMENT / `frps_template.go` 中「Docker 镜像内置 frpc 0.71.0」的不准确说法(镜像不预装、按需下载);`.dockerignore` 删掉两行已失效的归档排除项并修掉 ` docs` 的前导空格 + +### 改进 + +- 新增 `internal/fsutil`:`WriteFileAtomic`(同目录随机名临时文件 + rename + 显式 chmod)与 `Backup`(文件不存在视为无需备份),收敛 agent / frp / nginx / chinacidr 各处自建的 `.tmp` 与 `.bak` 逻辑(其中 nginx 原来用固定 `.tmp` 名,并发写同一路径会互相踩) +- 流量采集:`notify.RecordAccessHit`(写库 + 判断通知)从写锁内移到锁外,不再顶住 `/api/proxies/traffic` 与 `/clients` 的读 +- `internal/frp` 的 agent 客户端改为按 `serverID` 缓存(指纹 = `AgentURL` + Token 密文),批量同步 N 条路由不再新建 N 个连接池与 TLS 握手 +- 前端新增 `useVisibilityPolling()`:标签页隐藏时暂停轮询、回前台立即补一次;公网反代页与内网穿透页已接入,仪表盘流量轮询加 in-flight 互斥避免堆积并发请求 +- 新增 `docs/README.md` 文档索引;给 `agent-capability-plan` / `agent-view-design` / `-v2` / `upstream-sync-plan` 加实施状态与时效说明 + +### 测试 + +- Go 新增 4 个测试文件:`backup`(`../` 逃逸与 `../data-other/` 同前缀绕过、还原权限固定)、`scheduler`(非正间隔跳过、panic 后继续)、`frp`(`shellPath` 单引号转义、`isELFBinary`)、`agent`(`validateRoute` 表驱动、`nginxRoute` 关键行、`fileSHA256`) +- 前端引入 vitest(`npm test` = `vitest run`),新增 13 个单测覆盖 `utils/format.ts` 与 `utils/status.ts`(含一条守住「n-tag 的 type 值不能当状态值」的回归用例);CI 增加「Run web unit tests」一步 + +### 变更 + +- `AgentVersion` 0.12.0 → **0.12.1**(frps 启停失败如实回报、HTTP 服务补超时);内嵌 `fonu-agent-linux-amd64` 与 `-arm64` 已重编,VPS 上需点「升级 Agent」才会生效 +- 版本号提升到 1.2.8(版本单一来源仍是根目录 `VERSION`);`web/package.json` 的版本同步对齐,本地构建不再显示 1.0.2 + ## [v1.2.7] - 2026-09-24 ### 修复 diff --git a/README.md b/README.md index 8183ddf..22935da 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ docker compose up -d --build | `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 | | `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 | | `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) | -| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(Docker 镜像内置 0.71.0) | +| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) | | `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 | ## 注意事项 diff --git a/VERSION b/VERSION index c04c650..db6fb4a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.7 +1.2.8 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 670313f..77631d0 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -39,7 +39,7 @@ | Docker | 20.10+(含 compose v2) | | 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) | | 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) | -| 网络 | 构建期需访问 npm registry、Go module proxy、GitHub(下载 frpc 0.71.0);运行期需访问你的 DNS 服务商 API(DDNS/证书) | +| 网络 | 构建期需访问 npm registry 与 Go module proxy;运行期需访问 GitHub(按需下载 frpc)与你的 DNS 服务商 API(DDNS/证书) | | 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) | ## 2. 构建镜像(所有部署方式的前提) @@ -50,7 +50,7 @@ docker compose build ``` -一次构建同时产出:前端 `dist`、Go 后端二进制、内置 frpc 0.71.0 的运行镜像,标签为 `fonu:local`。 +一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `fonu:local`。 **版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2123ed3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# Fonu 文档索引 + +> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 +> 最近更新:2026-09-24(v1.2.7) + +## 方案与设计(本目录) + +| 文件 | 主题 | 状态 | +| --- | --- | --- | +| `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 三项不做 | +| `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 | +| `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 | +| `agent-view-refactor-plan.md` | 公网 Agent 页四段式(状态 → 控制 → 生命周期 → 输出)整改 | 已实施 | +| `proxy-remote-design.md` | 公网反代页设计方案(以本地反代页为参照) | 已实施;列表已改为左服务端 + 右台账 | +| `agent-proxy-settings-plan.md` | 公网反代「部署设置」对齐本地反代的语义迁移 | 已实施 | +| `agent-proxy-roadmap.md` | 公网反代功能增强规划(含部署踩坑史) | 部分已实施(漂移检测、全量重部署),其余待排期 | +| `frps-features.md` | frps 服务端功能实现清单(对照官方 frps_full_example.toml) | 参考 | +| `frps服务端管理方案.md` | FRPS 服务端管理方案(fonu-agent 部署在公网服务器) | 已实施(Agent 管理面板) | +| `frps管理方案.md` | FRPS 管理方案(早期版本) | 疑似已被 `frps服务端管理方案.md` 取代,保留供追溯(待确认) | +| `upstream-sync-plan.md` | 上游 1.0.2 → 1.0.5 对照与可借鉴项 | 部分过期:对 `/frp` 现状的描述基于 2026-09-23 | +| `FRP公网暴露操作手册.md` | FRP 公网暴露操作手册(面向使用) | 参考 | +| `DEPLOYMENT.md` | 部署安装:裸机 / Docker / 飞牛 host 模式 | 参考;适用 `fonu-1.1.0` 安装包 | + +## 其它位置 + +- 仓库根 `../docs/`(`111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`)为**临时区**,不随本项目维护;正式文档一律放本目录。 +- `screenshots/` 供 README 引用。 diff --git a/docs/agent-capability-plan.md b/docs/agent-capability-plan.md index 0e7472b..8bbc989 100644 --- a/docs/agent-capability-plan.md +++ b/docs/agent-capability-plan.md @@ -1,5 +1,7 @@ # fonu-agent 能力扩展方案 +> **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。 + > 本文盘点 VPS 侧 `fonu-agent` 现有的接口面与数据面,给出「能直接做」「需要新增 agent 端点」「不做」三类功能的详细设计。 > > 依据:本仓库 `internal/agent/`、`internal/frp/agent_client.go`、`internal/frp/agent_installer.go`、`internal/frp/ssh_installer.go`、`internal/api/frpmulti_handler.go`、`web/src/views/AgentView.vue`、`web/src/views/ProxyRemoteView.vue`(均为读代码确认,非推测)。 diff --git a/docs/agent-view-design-v2.md b/docs/agent-view-design-v2.md index 5c77bfe..c17c6b0 100644 --- a/docs/agent-view-design-v2.md +++ b/docs/agent-view-design-v2.md @@ -1,5 +1,7 @@ # 公网 Agent 页(`AgentView.vue`)设计方案 v2 +> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。 + > 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**: > 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。 > diff --git a/docs/agent-view-design.md b/docs/agent-view-design.md index 40074c7..82597d0 100644 --- a/docs/agent-view-design.md +++ b/docs/agent-view-design.md @@ -1,5 +1,7 @@ # 公网 Agent 页(`AgentView.vue`)设计方案 +> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。 + > 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**, > 并结合本项目(Fonu)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。 > diff --git a/docs/upstream-sync-plan.md b/docs/upstream-sync-plan.md index f62378d..b62782e 100644 --- a/docs/upstream-sync-plan.md +++ b/docs/upstream-sync-plan.md @@ -1,5 +1,7 @@ # 上游同步方案:Fonu 1.0.2 → 1.0.5 对照与可借鉴项 +> **时效说明(2026-09-24)**:文中对 `/frp` 页现状的描述基于 2026-09-23,已被 v1.2.6 的重构推翻(现为**左服务端列表 + 右规则表格**,服务端增删改已并入 `/frp-agent`)。 + > 本项目基于**上游 v1.0.2**(2026-09-18)开发,上游已迭代到 **v1.0.5**(2026-09-23)。 > 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**, > 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。 diff --git a/internal/agent/route_test.go b/internal/agent/route_test.go new file mode 100644 index 0000000..33d30fb --- /dev/null +++ b/internal/agent/route_test.go @@ -0,0 +1,65 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateRoute(t *testing.T) { + cases := []struct { + name string + req routeRequest + ok bool + }{ + {"正常", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, true}, + {"非法域名", routeRequest{Domain: "bad domain", Upstream: "127.0.0.1:8080"}, false}, + {"空上游", routeRequest{Domain: "a.example.com"}, false}, + {"上游带分号", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80; rm -rf /"}, false}, + {"相对证书目录", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80", TLS: true, CertDir: "certs"}, false}, + } + for _, c := range cases { + err := validateRoute(c.req) + if c.ok && err != nil { + t.Errorf("%s:期望通过,实际 %v", c.name, err) + } + if !c.ok && err == nil { + t.Errorf("%s:期望报错", c.name) + } + } +} + +// TestNginxRouteRendersUpstream:vhost 模板的关键行(标记、server_name、proxy_pass)。 +// 不构造 Basic Auth 场景——那条路径会往 /var/lib/fonu-agent 写 htpasswd,不适合在单测里触发。 +func TestNginxRouteRendersUpstream(t *testing.T) { + out := nginxRoute(routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, "") + if !strings.HasPrefix(out, "# Managed by fonu-agent.") { + t.Errorf("缺少托管标记,其它工具无法识别该文件归属:\n%s", out) + } + if !strings.Contains(out, "server_name a.example.com;") { + t.Errorf("缺少 server_name:\n%s", out) + } + if !strings.Contains(out, "proxy_pass http://127.0.0.1:8080;") { + t.Errorf("缺少 proxy_pass 回源:\n%s", out) + } + if strings.Contains(out, "listen 443") { + t.Errorf("未启用 TLS 时不应监听 443:\n%s", out) + } +} + +// TestFileSHA256 覆盖 frps 安装包的校验路径。 +func TestFileSHA256(t *testing.T) { + path := filepath.Join(t.TempDir(), "payload.txt") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + got, err := fileSHA256(path) + if err != nil { + t.Fatal(err) + } + const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + if got != want { + t.Fatalf("sha256 不符:got %s", got) + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index bb10473..0fa0ffb 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -25,6 +25,7 @@ import ( "sync" "time" + "github.com/fonu/fonu/internal/fsutil" "github.com/fonu/fonu/internal/sysinfo" ) @@ -38,7 +39,8 @@ import ( // 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。 // 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进 // nginx 配置注释(改为只在 PUT 响应里回一次)。 -const AgentVersion = "0.12.0" +// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。 +const AgentVersion = "0.12.1" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -181,7 +183,7 @@ func (s *Server) Handler() http.Handler { } func (s *Server) ListenAndServe(ctx context.Context) error { - server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} + server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, IdleTimeout: 2 * time.Minute} go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -341,18 +343,27 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { _ = exec.Command("systemctl", "daemon-reload").Run() } // nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制 - if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() != nil { - if exec.Command("pgrep", "-x", "frps").Run() == nil { - _ = exec.Command("pkill", "-x", "frps").Run() - time.Sleep(time.Second) - notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程") - } - _ = exec.Command("systemctl", "enable", "--now", "frps").Run() - notes = append(notes, "frps.service 已启动") - } else { - _ = exec.Command("systemctl", "restart", "frps").Run() - notes = append(notes, "frps.service 已重启以加载新版本") + active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil + if !active && exec.Command("pgrep", "-x", "frps").Run() == nil { + _ = exec.Command("pkill", "-x", "frps").Run() + time.Sleep(time.Second) + notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程") } + args := []string{"restart", "frps"} + label := "已重启以加载新版本" + if !active { + args = []string{"enable", "--now", "frps"} + label = "已启动" + } + // 启动失败必须如实回报:之前忽略 err 后无条件写「已启动」,运维会以为服务在跑 + if out, err := exec.Command("systemctl", args...).CombinedOutput(); err != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, "version": version, "binary": installPath, "notes": notes, + "error": fmt.Sprintf("frps 二进制已安装,但 systemctl %s 失败:%v:%s", strings.Join(args, " "), err, tailText(string(out), 400)), + }) + return + } + notes = append(notes, "frps.service "+label) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version, "binary": installPath, "notes": notes}) } @@ -1511,24 +1522,7 @@ func atomicWrite(path string, data []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } - tmp, err := os.CreateTemp(filepath.Dir(path), ".fonu-agent-*") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - if err := tmp.Chmod(mode); err != nil { - tmp.Close() - return err - } - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpPath, path) + return fsutil.WriteFileAtomic(path, data, mode) } func writeJSON(w http.ResponseWriter, status int, value any) { diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go index 59d8c26..4a4a56c 100644 --- a/internal/api/auth_handler.go +++ b/internal/api/auth_handler.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "strings" + "sync" "time" "github.com/fonu/fonu/internal/auth" @@ -15,10 +16,67 @@ import ( type AuthHandler struct { auth *auth.Service notify *notify.Service + guard *loginGuard } func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler { - return &AuthHandler{auth: authSvc, notify: notifySvc} + return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()} +} + +const ( + loginFailureLimit = 5 + loginLockoutWindow = 5 * time.Minute +) + +// loginGuard 是进程内的登录失败退避:同一 IP 在窗口内失败到阈值后直接拒绝, +// 避免明文 HTTP 部署下被无限爆破;与用于通知的失败统计相互独立。 +type loginGuard struct { + mu sync.Mutex + failures map[string][]time.Time +} + +func newLoginGuard() *loginGuard { + return &loginGuard{failures: make(map[string][]time.Time)} +} + +// blocked 返回该 IP 是否仍在锁定窗口内(顺带清理过期记录)。 +func (g *loginGuard) blocked(ip string) bool { + g.mu.Lock() + defer g.mu.Unlock() + kept := g.recentLocked(ip) + g.failures[ip] = kept + return len(kept) >= loginFailureLimit +} + +func (g *loginGuard) fail(ip string) { + g.mu.Lock() + defer g.mu.Unlock() + if len(g.failures) > 1024 { + for key := range g.failures { + if len(g.recentLocked(key)) == 0 { + delete(g.failures, key) + } + } + } + g.failures[ip] = append(g.recentLocked(ip), time.Now()) +} + +func (g *loginGuard) reset(ip string) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.failures, ip) +} + +// recentLocked 返回窗口内的失败时间(调用方需持有锁)。 +func (g *loginGuard) recentLocked(ip string) []time.Time { + now := time.Now() + kept := make([]time.Time, 0, len(g.failures[ip])) + for _, at := range g.failures[ip] { + if now.Sub(at) < loginLockoutWindow { + kept = append(kept, at) + } + } + return kept } type loginRequest struct { @@ -27,7 +85,7 @@ type loginRequest struct { } type authStatusResponse struct { - Initialized bool `json:"initialized"` + Initialized bool `json:"initialized"` Authenticated bool `json:"authenticated"` } @@ -55,11 +113,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusBadRequest, "请求格式无效") return } + ip := clientIP(r) + if h.guard.blocked(ip) { + writeError(r, w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试") + return + } sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password) if err != nil { if errors.Is(err, auth.ErrUnauthorized) { + h.guard.fail(ip) if h.notify != nil { - h.notify.RecordLoginFailure(r.Context(), clientIP(r)) + h.notify.RecordLoginFailure(r.Context(), ip) } writeError(r, w, http.StatusUnauthorized, "用户名或密码错误") return @@ -67,7 +131,8 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusInternalServerError, "登录失败") return } - setSessionCookie(w, sessionID) + h.guard.reset(ip) + setSessionCookie(w, r, sessionID) writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"}) } @@ -86,7 +151,7 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期") return } - if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword); err != nil { + if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword, sessionID); err != nil { writeError(r, w, http.StatusBadRequest, err.Error()) return } @@ -97,21 +162,31 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { if sessionID := sessionIDFromContext(r.Context()); sessionID != "" { _ = h.auth.Logout(r.Context(), sessionID) } - clearSessionCookie(w) + clearSessionCookie(w, r) writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"}) } -func setSessionCookie(w http.ResponseWriter, sessionID string) { +func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) { http.SetCookie(w, &http.Cookie{ Name: "fonu_session", Value: sessionID, Path: "/", HttpOnly: true, + Secure: requestIsHTTPS(r), SameSite: http.SameSiteLaxMode, MaxAge: int((7 * 24 * time.Hour).Seconds()), }) } +// requestIsHTTPS:直连 TLS 或经反代(X-Forwarded-Proto)时给会话 cookie 加 Secure; +// 默认明文 HTTP 部署下不加,否则浏览器会直接丢弃 cookie 导致登录不了。 +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") +} + func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { @@ -120,12 +195,14 @@ func clientIP(r *http.Request) string { return host } -func clearSessionCookie(w http.ResponseWriter) { +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "fonu_session", Value: "", Path: "/", HttpOnly: true, + Secure: requestIsHTTPS(r), + SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } diff --git a/internal/api/cert_handler.go b/internal/api/cert_handler.go index b749fb2..cc9d8f7 100644 --- a/internal/api/cert_handler.go +++ b/internal/api/cert_handler.go @@ -44,7 +44,10 @@ func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) { CA string `json:"ca"` Email string `json:"email"` } - _ = json.NewDecoder(r.Body).Decode(&req) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(r, w, http.StatusBadRequest, "请求格式无效") + return + } domains := req.Domains if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" { domains = []string{req.Domain} @@ -141,7 +144,10 @@ func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) { Domain string `json:"domain"` CA string `json:"ca"` } - _ = json.NewDecoder(r.Body).Decode(&req) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(r, w, http.StatusBadRequest, "请求格式无效") + return + } record, err := h.svc.Renew(r.Context(), req.Domain, req.CA) if err != nil { writeError(r, w, http.StatusBadRequest, err.Error()) diff --git a/internal/api/ddns_handler.go b/internal/api/ddns_handler.go index 292f764..a4ad1a2 100644 --- a/internal/api/ddns_handler.go +++ b/internal/api/ddns_handler.go @@ -23,13 +23,13 @@ type ddnsRequest struct { RecordNames []string `json:"record_names"` Domains []string `json:"domains"` IPv4Enabled *bool `json:"ipv4_enabled"` - IPv6Enabled *bool `json:"ipv6_enabled"` - Enabled *bool `json:"enabled"` - CustomIPv4 string `json:"custom_ipv4"` - CustomIPv6 string `json:"custom_ipv6"` - APIToken string `json:"api_token"` - APITokenID string `json:"api_token_id"` - APISecret string `json:"api_secret"` + IPv6Enabled *bool `json:"ipv6_enabled"` + Enabled *bool `json:"enabled"` + CustomIPv4 string `json:"custom_ipv4"` + CustomIPv6 string `json:"custom_ipv6"` + APIToken string `json:"api_token"` + APITokenID string `json:"api_token_id"` + APISecret string `json:"api_secret"` } func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput { @@ -119,7 +119,10 @@ func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) { APITokenID string `json:"api_token_id"` APISecret string `json:"api_secret"` } - _ = json.NewDecoder(r.Body).Decode(&req) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(r, w, http.StatusBadRequest, "请求格式无效") + return + } if err := h.svc.Test(r.Context(), ddns.TestInput{ ConfigID: req.ConfigID, Provider: req.Provider, diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index 5c7bae4..021e459 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -2,6 +2,8 @@ package api import ( "encoding/json" + "errors" + "io" "net/http" "strconv" "strings" @@ -606,7 +608,11 @@ func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Requ Version string `json:"version"` Proxy string `json:"proxy"` } - _ = json.NewDecoder(r.Body).Decode(&body) + // 允许空请求体(走默认版本),但 JSON 语法错误要报 400 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { + writeError(r, w, http.StatusBadRequest, "请求格式无效") + return + } if body.Version == "" { body.Version = "0.71.0" } diff --git a/internal/app/app.go b/internal/app/app.go index 8dd20a3..695ca36 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -123,9 +123,13 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) Addr: cfg.ListenAddr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, + // 读超时放到 5 分钟:备份恢复要上传最大 64MB;不设 WriteTimeout,否则日志 SSE 会被中途切断 + ReadTimeout: 5 * time.Minute, + IdleTimeout: 2 * time.Minute, } ctx, cancel := context.WithCancel(context.Background()) + frpMultiSvc.SetBaseContext(ctx) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) if intervalMinutes <= 0 { intervalMinutes = 5 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fd86b0e..6bb175d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -118,7 +118,7 @@ func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int, return adminID, nil } -func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword string) error { +func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword, keepSessionID string) error { if err := validate.Password(newPassword); err != nil { return err } @@ -134,7 +134,11 @@ func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, if err != nil { return err } - _, err = s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID) + if _, err := s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID); err != nil { + return err + } + // 改密后失效该账号的其他会话(保留当前这次,否则用户会被自己踢下线) + _, err = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE admin_id = ? AND id <> ?`, adminID, keepSessionID) return err } diff --git a/internal/backup/service_test.go b/internal/backup/service_test.go new file mode 100644 index 0000000..fa69b4d --- /dev/null +++ b/internal/backup/service_test.go @@ -0,0 +1,90 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "os" + "path/filepath" + "runtime" + "testing" +) + +// writeArchive 造一个含指定条目的 tar.gz,用于验证还原侧的路径校验与权限处理。 +func writeArchive(t *testing.T, entries map[string]string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "backup.tar.gz") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + gz := gzip.NewWriter(file) + tw := tar.NewWriter(gz) + for name, content := range entries { + header := &tar.Header{Name: name, Mode: 0o777, Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return path +} + +// TestRestoreRejectsEscapingEntries 覆盖「同前缀绕过」:旧实现用 strings.HasPrefix 判断, +// DataDir=/data 时 ../data-other/x 会通过前缀匹配写到 DataDir 之外。 +func TestRestoreRejectsEscapingEntries(t *testing.T) { + root := t.TempDir() + dataDir := filepath.Join(root, "data") + if err := os.MkdirAll(dataDir, 0o750); err != nil { + t.Fatal(err) + } + archive := writeArchive(t, map[string]string{"../data-other/evil.txt": "x"}) + if err := New(dataDir).Restore(context.Background(), archive); err == nil { + t.Fatal("应当拒绝逃出 DataDir 的条目") + } + if _, err := os.Stat(filepath.Join(root, "data-other", "evil.txt")); err == nil { + t.Fatal("逃逸条目被写到了 DataDir 之外") + } +} + +func TestRestoreRejectsParentEscape(t *testing.T) { + root := t.TempDir() + dataDir := filepath.Join(root, "data") + if err := os.MkdirAll(dataDir, 0o750); err != nil { + t.Fatal(err) + } + archive := writeArchive(t, map[string]string{"../../evil.txt": "x"}) + if err := New(dataDir).Restore(context.Background(), archive); err == nil { + t.Fatal("应当拒绝带 .. 的条目") + } +} + +// TestRestoreAppliesFixedMode:归档里的权限位不照搬(构造的归档可带 0777 / 执行位)。 +func TestRestoreAppliesFixedMode(t *testing.T) { + dataDir := t.TempDir() + archive := writeArchive(t, map[string]string{"nginx/nginx.conf": "worker_processes 1;"}) + if err := New(dataDir).Restore(context.Background(), archive); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dataDir, "nginx", "nginx.conf")) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); runtime.GOOS == "windows" { + // Windows 不保留 Unix 权限位(可写文件统一报 0666):只断言没带上执行位 + if got&0o111 != 0 { + t.Fatalf("还原后的文件不应带执行位,实际 %o", got) + } + } else if got != restoreFileMode { + t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got) + } +} diff --git a/internal/chinacidr/service.go b/internal/chinacidr/service.go index 0af1c85..2d31f6a 100644 --- a/internal/chinacidr/service.go +++ b/internal/chinacidr/service.go @@ -14,14 +14,15 @@ import ( "time" "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/fsutil" "github.com/fonu/fonu/internal/nginx" "github.com/fonu/fonu/internal/proxy" "github.com/fonu/fonu/internal/settings" ) const ( - defaultV4URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt" - ghfastProxyPrefix = "https://ghfast.top/" + defaultV4URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt" + ghfastProxyPrefix = "https://ghfast.top/" ) // metowolf/iplist 已下线 CN/ipv6.txt;按优先级尝试可用源。 @@ -96,7 +97,7 @@ func (s *Service) Update(ctx context.Context) error { } tmpPath := s.cfg.ChinaCIDRTempPath() - if err := os.WriteFile(tmpPath, []byte(body), 0o644); err != nil { + if err := fsutil.WriteFileAtomic(tmpPath, []byte(body), 0o644); err != nil { return err } diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index 78a4274..a23edfd 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 194edf3..6b6f503 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/frp/frps_template.go b/internal/frp/frps_template.go index 1d1b6c6..c0df5da 100644 --- a/internal/frp/frps_template.go +++ b/internal/frp/frps_template.go @@ -42,7 +42,7 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str var b strings.Builder b.WriteString("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n") - b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(Docker 镜像内置 frp 0.71.0;INI 自 v0.52.0 起已被官方弃用)\n") + b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(INI 自 v0.52.0 起已被官方弃用)\n") b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n") // TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表 b.WriteString("bindAddr = \"0.0.0.0\"\n") diff --git a/internal/frp/service.go b/internal/frp/service.go index 07847d4..e945d4f 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -14,6 +14,7 @@ import ( "github.com/fonu/fonu/internal/certificate" "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/fsutil" "github.com/fonu/fonu/internal/secret" ) @@ -39,8 +40,14 @@ type Service struct { downloadMu sync.RWMutex downloadTask DownloadTask - locationMu sync.Mutex - locations map[string]locationCache + // baseCtx 是应用级 ctx:后台下载随之取消(之前用 context.Background(),App 关闭后仍在跑) + baseCtx context.Context + locationMu sync.Mutex + locations map[string]locationCache + + // agentClients 缓存每台服务端的 agent HTTP 客户端(含连接池),按 URL+Token 指纹失效 + agentMu sync.Mutex + agentClients map[int64]cachedAgentClient // 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测 diagMu sync.Mutex @@ -167,6 +174,13 @@ func (s *Service) StartDownload(version string) (DownloadTask, error) { return s.downloadTask, nil } +// SetBaseContext 注入应用级 ctx,供后台下载继承;由 app 启动时调用。 +func (s *Service) SetBaseContext(ctx context.Context) { + s.downloadMu.Lock() + defer s.downloadMu.Unlock() + s.baseCtx = ctx +} + func (s *Service) DownloadStatus() DownloadTask { s.downloadMu.RLock() defer s.downloadMu.RUnlock() @@ -174,7 +188,13 @@ func (s *Service) DownloadStatus() DownloadTask { } func (s *Service) runDownload(version string) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + s.downloadMu.RLock() + base := s.baseCtx + s.downloadMu.RUnlock() + if base == nil { + base = context.Background() + } + ctx, cancel := context.WithTimeout(base, 10*time.Minute) defer cancel() binary, err := s.downloadRelease(ctx, version, s.updateDownload) if err != nil { @@ -772,10 +792,20 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio if err != nil { return nil, err } - // 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置 - if server, err := s.store.GetServer(ctx, serverID); err == nil { - if content, err := s.buildFRPSServerConfig(ctx, server); err == nil { - _ = client.PushFRPSConfig(ctx, content) + // 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置。 + // 失败不能吞掉:否则前端只看到「安装成功」,VPS 上跑的仍是旧配置。 + warning := "" + if server, err := s.store.GetServer(ctx, serverID); err != nil { + warning = "frps 已安装,但读取服务端配置失败:" + err.Error() + } else if content, err := s.buildFRPSServerConfig(ctx, server); err != nil { + warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error() + } else if err := client.PushFRPSConfig(ctx, content); err != nil { + warning = "frps 已安装,但 frps.toml 下发失败:" + err.Error() + } + if warning != "" { + result["config_warning"] = warning + if list, ok := result["notes"].([]any); ok { + result["notes"] = append(list, warning) } } return result, nil @@ -1259,6 +1289,12 @@ func (s *Service) DeleteProxy(ctx context.Context, id int64) error { return nil } +// cachedAgentClient 是按 URL+Token 指纹缓存的 agent 客户端;指纹用密文而非明文,避免内存里多存一份 Token。 +type cachedAgentClient struct { + fingerprint string + client *AgentClient +} + func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) { server, err := s.store.GetServer(ctx, serverID) if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured { @@ -1272,7 +1308,19 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient if err != nil || token == "" { return nil, false } - return NewAgentClient(server.AgentURL, token), true + // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手 + fingerprint := server.AgentURL + "|" + encoded + s.agentMu.Lock() + defer s.agentMu.Unlock() + if s.agentClients == nil { + s.agentClients = make(map[int64]cachedAgentClient) + } + if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint { + return cached.client, true + } + client := NewAgentClient(server.AgentURL, token) + s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client} + return client, true } func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) { @@ -1494,8 +1542,10 @@ func (s *Service) persistServerConfigForVerify(ctx context.Context, server Serve _ = os.Remove(officialPath + ".verify") return err } - if current, readErr := os.ReadFile(officialPath); readErr == nil { - _ = os.WriteFile(officialPath+".bak", current, 0o600) + // 备份失败就不能覆盖唯一副本:否则旧配置没有回退、只剩 .verify 里的新内容 + if err := fsutil.Backup(officialPath); err != nil { + _ = os.Remove(officialPath + ".verify") + return fmt.Errorf("备份现有 frps 配置失败,已保留原配置: %w", err) } return os.Rename(officialPath+".verify", officialPath) } @@ -1820,10 +1870,8 @@ func writeConfigAtomically(path, content string) error { } // 23.5:写入前把当前版本留存为 .bak。临时文件写入本身已原子(CreateTemp+Rename), // 但 frpc verify 失败时上层需要回退目标,这里提供版本化备份。 - if current, err := os.ReadFile(path); err == nil { - if err := os.WriteFile(path+".bak", current, 0o600); err != nil { - return err - } + if err := fsutil.Backup(path); err != nil { + return err } tmp, err := os.CreateTemp(dir, "frpc-*.toml") if err != nil { diff --git a/internal/frp/ssh_installer_test.go b/internal/frp/ssh_installer_test.go new file mode 100644 index 0000000..1a269c3 --- /dev/null +++ b/internal/frp/ssh_installer_test.go @@ -0,0 +1,32 @@ +package frp + +import "testing" + +// TestShellPathEscapesSingleQuotes:uploadBytes 把路径拼进远程 shell 命令, +// 单引号必须转义,否则路径里的引号能截断命令。 +func TestShellPathEscapesSingleQuotes(t *testing.T) { + cases := []struct{ in, want string }{ + {"/tmp/fonu-agent", "'/tmp/fonu-agent'"}, + {"/tmp/it's here", `'/tmp/it'\''s here'`}, + {"", "''"}, + } + for _, c := range cases { + if got := shellPath(c.in); got != c.want { + t.Errorf("shellPath(%q) = %s,期望 %s", c.in, got, c.want) + } + } +} + +// TestIsELFBinary:内嵌二进制在开发构建里是占位文件,必须识别出来并提前报错, +// 否则 VPS 上 systemd 会反复 Exec format error。 +func TestIsELFBinary(t *testing.T) { + if isELFBinary([]byte("not an elf")) { + t.Error("普通文本不应被判为 ELF") + } + if isELFBinary(nil) { + t.Error("空内容不应被判为 ELF") + } + if !isELFBinary([]byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0}) { + t.Error("ELF 魔数应被识别") + } +} diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go new file mode 100644 index 0000000..67fcd29 --- /dev/null +++ b/internal/fsutil/fsutil.go @@ -0,0 +1,55 @@ +// Package fsutil 收敛「原子写配置」与「覆盖前备份」两个到处重复的动作。 +// +// 之前 agent、frp、nginx、chinacidr 各自实现了一遍:有的用固定 .tmp 名(并发写同一路径会互相踩), +// 有的直接 os.WriteFile(读到写了一半的配置),有的写完不 chmod(权限跟着 umask 走)。 +package fsutil + +import ( + "os" + "path/filepath" +) + +// WriteFileAtomic 先写同目录的临时文件再 rename:读者要么看到旧内容、要么看到新内容。 +// 临时文件名带随机后缀,避免并发写同一路径时互相覆盖。 +func WriteFileAtomic(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + if tmpName != "" { + _ = os.Remove(tmpName) + } + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + // CreateTemp 建出来的是 0600,这里显式对齐调用方要求的权限 + if err := os.Chmod(tmpName, mode); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + tmpName = "" // rename 成功,不用再清理 + return nil +} + +// Backup 把现有文件复制成 .bak;文件不存在视为无需备份。 +// 返回的错误必须由调用方处理——备份是「覆盖前留退路」的唯一保障。 +func Backup(path string) error { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + return WriteFileAtomic(path+".bak", data, 0o600) +} diff --git a/internal/fsutil/fsutil_test.go b/internal/fsutil/fsutil_test.go new file mode 100644 index 0000000..c2e4f9d --- /dev/null +++ b/internal/fsutil/fsutil_test.go @@ -0,0 +1,71 @@ +package fsutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteFileAtomicReplacesContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "frps.toml") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := WriteFileAtomic(path, []byte("new"), 0o640); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "new" { + t.Fatalf("内容未替换:%q", data) + } +} + +// TestWriteFileAtomicLeavesNoTempFiles:临时文件必须被 rename 或清理,不能在配置目录里留垃圾。 +func TestWriteFileAtomicLeavesNoTempFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nginx.conf") + if err := WriteFileAtomic(path, []byte("worker_processes 1;"), 0o600); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.Contains(entry.Name(), ".tmp-") { + t.Fatalf("残留临时文件:%s", entry.Name()) + } + } + if len(entries) != 1 { + t.Fatalf("目录里应只剩目标文件,实际 %d 个", len(entries)) + } +} + +func TestBackupSkipsMissingFile(t *testing.T) { + if err := Backup(filepath.Join(t.TempDir(), "nope.conf")); err != nil { + t.Fatalf("文件不存在时应视为无需备份,实际 %v", err) + } +} + +func TestBackupCopiesContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "frpc.toml") + if err := os.WriteFile(path, []byte("token = \"x\""), 0o600); err != nil { + t.Fatal(err) + } + if err := Backup(path); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path + ".bak") + if err != nil { + t.Fatal(err) + } + if string(data) != "token = \"x\"" { + t.Fatalf("备份内容不符:%q", data) + } +} diff --git a/internal/nginx/custom.go b/internal/nginx/custom.go index c16c1b1..cb070c4 100644 --- a/internal/nginx/custom.go +++ b/internal/nginx/custom.go @@ -9,6 +9,7 @@ import ( "time" "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/fsutil" ) const maxCustomBackups = 10 @@ -141,21 +142,21 @@ func RestoreBackup(backupDir, backupName, targetPath string) error { if err != nil { return err } - return os.WriteFile(targetPath, data, 0o644) + return fsutil.WriteFileAtomic(targetPath, data, 0o644) } func WriteGlobalCustom(cfg config.Config, content string) error { if err := EnsureCustomDirs(cfg); err != nil { return err } - return os.WriteFile(cfg.NginxGlobalCustomPath(), []byte(content), 0o644) + return fsutil.WriteFileAtomic(cfg.NginxGlobalCustomPath(), []byte(content), 0o644) } func WriteRuleCustom(cfg config.Config, ruleID int64, content string) error { if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil { return err } - return os.WriteFile(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644) + return fsutil.WriteFileAtomic(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644) } func RemoveRuleCustom(cfg config.Config, ruleID int64) error { diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go index 149b8c5..947f032 100644 --- a/internal/nginx/nginx.go +++ b/internal/nginx/nginx.go @@ -13,6 +13,7 @@ import ( "time" "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/fsutil" "github.com/fonu/fonu/internal/proxy" ) @@ -112,7 +113,7 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou } tmpPath := m.cfg.NginxConfigPath() + ".tmp" - if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil { + if err := fsutil.WriteFileAtomic(tmpPath, []byte(content), 0o644); err != nil { return ApplyResult{}, err } @@ -130,12 +131,10 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou } currentPath := m.cfg.NginxConfigPath() - backupPath := currentPath + ".bak" - if _, err := os.Stat(currentPath); err == nil { - if err := copyFile(currentPath, backupPath); err != nil { - _ = os.Remove(tmpPath) - return ApplyResult{}, err - } + // 覆盖前留 .bak(fsutil.Backup 对文件不存在视为无需备份) + if err := fsutil.Backup(currentPath); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err } if err := os.Rename(tmpPath, currentPath); err != nil { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 9b8aa41..718f45a 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -2,6 +2,7 @@ package scheduler import ( "context" + "log" "time" ) @@ -15,8 +16,17 @@ type Scheduler struct { jobs []Job } +// New 会丢掉间隔非正的任务:time.NewTicker 对非正间隔直接 panic,这类任务应由调用方单独启动。 func New(jobs ...Job) *Scheduler { - return &Scheduler{jobs: jobs} + valid := make([]Job, 0, len(jobs)) + for _, job := range jobs { + if job.Interval <= 0 { + log.Printf("scheduler: 任务 %s 间隔为 %v,已跳过", job.Name, job.Interval) + continue + } + valid = append(valid, job) + } + return &Scheduler{jobs: valid} } func (s *Scheduler) Start(ctx context.Context) { @@ -28,13 +38,22 @@ func (s *Scheduler) Start(ctx context.Context) { func (s *Scheduler) runJob(ctx context.Context, job Job) { ticker := time.NewTicker(job.Interval) defer ticker.Stop() - job.Run(ctx) + // 单个任务 panic 不能带走整个进程:包一层 recover,记录后继续下一轮 + run := func() { + defer func() { + if r := recover(); r != nil { + log.Printf("scheduler: 任务 %s panic: %v", job.Name, r) + } + }() + job.Run(ctx) + } + run() for { select { case <-ctx.Done(): return case <-ticker.C: - job.Run(ctx) + run() } } } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..6d79c10 --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,40 @@ +package scheduler + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +// TestNewSkipsNonPositiveIntervals:time.NewTicker 对非正间隔会 panic,这类任务必须在建表时丢掉。 +func TestNewSkipsNonPositiveIntervals(t *testing.T) { + s := New( + Job{Name: "zero", Interval: 0, Run: func(context.Context) {}}, + Job{Name: "negative", Interval: -time.Second, Run: func(context.Context) {}}, + Job{Name: "ok", Interval: time.Hour, Run: func(context.Context) {}}, + ) + if len(s.jobs) != 1 || s.jobs[0].Name != "ok" { + t.Fatalf("非正间隔的任务应被跳过,实际保留 %d 个", len(s.jobs)) + } +} + +// TestRunJobRecoversFromPanic:单个任务 panic 不能带走整个进程,且要能继续下一轮。 +func TestRunJobRecoversFromPanic(t *testing.T) { + var calls int32 + s := New(Job{Name: "panic", Interval: 5 * time.Millisecond, Run: func(context.Context) { + atomic.AddInt32(&calls, 1) + panic("boom") + }}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s.Start(ctx) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && atomic.LoadInt32(&calls) < 3 { + time.Sleep(5 * time.Millisecond) + } + if got := atomic.LoadInt32(&calls); got < 3 { + t.Fatalf("panic 后应继续后续轮次,实际只执行了 %d 次", got) + } +} diff --git a/internal/traffic/collector.go b/internal/traffic/collector.go index 0d1ff26..fb97635 100644 --- a/internal/traffic/collector.go +++ b/internal/traffic/collector.go @@ -17,19 +17,19 @@ import ( ) const ( - rateWindow = 5 * time.Second - activeWindow = 65 * time.Second - persistInterval = 30 * time.Second + rateWindow = 5 * time.Second + activeWindow = 65 * time.Second + persistInterval = 30 * time.Second maxSamplesPerHost = 256 ) type RuleTraffic struct { - RuleID int64 `json:"rule_id"` - UploadTotal int64 `json:"upload_total"` - DownloadTotal int64 `json:"download_total"` - UploadRate float64 `json:"upload_rate"` - DownloadRate float64 `json:"download_rate"` - Connections int `json:"connections"` + RuleID int64 `json:"rule_id"` + UploadTotal int64 `json:"upload_total"` + DownloadTotal int64 `json:"download_total"` + UploadRate float64 `json:"upload_rate"` + DownloadRate float64 `json:"download_rate"` + Connections int `json:"connections"` } type ClientConn struct { @@ -176,6 +176,7 @@ func (c *Collector) ingestLine(line string, trackLive bool) { } now := time.Now() + hitIP := "" c.mu.Lock() st := c.ensureHost(host) st.uploadTotal += up @@ -189,13 +190,15 @@ func (c *Collector) ingestLine(line string, trackLive bool) { } if entry.ClientIP != "" && entry.ClientIP != "-" { st.clients[entry.ClientIP] = now - if c.notify != nil { - c.notify.RecordAccessHit(context.Background(), entry.ClientIP) - } + // 锁内只记 IP:RecordAccessHit 会写库并判断通知,放在锁外,避免阻塞读侧 + hitIP = entry.ClientIP } c.pruneHost(st, now) } c.mu.Unlock() + if hitIP != "" && c.notify != nil { + c.notify.RecordAccessHit(context.Background(), hitIP) + } } func (c *Collector) ensureHost(host string) *hostState { diff --git a/web/package-lock.json b/web/package-lock.json index a49e148..b55cf32 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.0.1", + "version": "1.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.0.1", + "version": "1.2.6", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", @@ -30,6 +30,7 @@ "@vitejs/plugin-vue": "^5.2.1", "typescript": "^5.7.2", "vite": "^6.0.3", + "vitest": "^5.0.1", "vue-tsc": "^2.2.0" } }, @@ -662,12 +663,33 @@ "node": ">=18" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@juggle/resize-observer": { "version": "3.4.0", "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", @@ -1124,6 +1146,24 @@ "win32" ] }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", @@ -1173,6 +1213,64 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", @@ -1349,6 +1447,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", @@ -1372,6 +1480,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", @@ -1463,6 +1581,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", @@ -1517,6 +1642,16 @@ "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -1663,6 +1798,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1769,6 +1918,13 @@ "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sortablejs": { "version": "1.15.7", "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", @@ -1784,12 +1940,46 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1920,6 +2110,99 @@ } } }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/vooks": { "version": "0.2.12", "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", @@ -2042,6 +2325,23 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zrender": { "version": "6.1.0", "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", diff --git a/web/package.json b/web/package.json index 4b53033..849abd1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,14 +1,15 @@ { "name": "fonu-web", "private": true, - "version": "1.2.6", + "version": "1.2.8", "packageManager": "npm@10.9.4", "type": "module", "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", - "typecheck": "vue-tsc --noEmit" + "typecheck": "vue-tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@codemirror/commands": "^6.11.1", @@ -33,6 +34,7 @@ "@vitejs/plugin-vue": "^5.2.1", "typescript": "^5.7.2", "vite": "^6.0.3", + "vitest": "^5.0.1", "vue-tsc": "^2.2.0" } } diff --git a/web/src/composables/useVisibilityPolling.ts b/web/src/composables/useVisibilityPolling.ts new file mode 100644 index 0000000..151bee4 --- /dev/null +++ b/web/src/composables/useVisibilityPolling.ts @@ -0,0 +1,44 @@ +import { onBeforeUnmount, onMounted, type Ref } from 'vue' + +/** + * 页面可见时才轮询:标签页隐藏期间跳过这一轮,回到前台立即补一次。 + * + * 各页原来的写法是「自己 setInterval + 在回调里判断 document.visibilityState」, + * 但只有仪表盘这么做了,其余页面在后台标签页里仍在持续打接口(公网反代页每轮 7 个请求)。 + * + * @param fn 每轮执行的回调;由调用方自行保证幂等与「无数据时直接 return」 + * @param intervalMs 轮询间隔 + * @param enabled 可选开关,为 false 时整轮跳过(例如未选中服务端) + */ +export function useVisibilityPolling(fn: () => void | Promise, intervalMs: number, enabled?: Ref) { + let timer: number | undefined + + const tick = () => { + if (document.visibilityState === 'hidden') return + if (enabled && !enabled.value) return + void fn() + } + + const stop = () => { + if (timer === undefined) return + window.clearInterval(timer) + timer = undefined + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') return + tick() // 回到前台立刻补一次,不用等下一个周期 + } + + onMounted(() => { + document.addEventListener('visibilitychange', onVisibilityChange) + timer = window.setInterval(tick, intervalMs) + }) + + onBeforeUnmount(() => { + document.removeEventListener('visibilitychange', onVisibilityChange) + stop() + }) + + return { stop } +} diff --git a/web/src/utils/format.spec.ts b/web/src/utils/format.spec.ts new file mode 100644 index 0000000..4e6023f --- /dev/null +++ b/web/src/utils/format.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, formatLogLine, formatLogTime, formatRate, formatRelativeTime, formatUptime } from './format' + +describe('formatBytes', () => { + it('按 1024 进制换算并按量级保留有效位', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1024)).toBe('1.00 KB') + expect(formatBytes(1536)).toBe('1.50 KB') + expect(formatBytes(20 * 1024)).toBe('20.0 KB') + expect(formatBytes(200 * 1024)).toBe('200 KB') + expect(formatBytes(3 * 1024 ** 3)).toBe('3.00 GB') + }) + + it('非法输入回落 0 B,不产生 NaN', () => { + expect(formatBytes(Number.NaN)).toBe('0 B') + expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('0 B') + expect(formatBytes(-1)).toBe('0 B') + }) +}) + +describe('formatRate', () => { + it('复用字节格式化并加 /s', () => { + expect(formatRate(0)).toBe('0 B/s') + expect(formatRate(2048)).toBe('2.00 KB/s') + }) +}) + +describe('formatUptime', () => { + it('小时为 0 时只显示分钟', () => { + expect(formatUptime(90)).toBe('1 分钟') + expect(formatUptime(3661)).toBe('1 小时 1 分钟') + }) +}) + +describe('formatRelativeTime', () => { + it('按分钟 / 小时 / 天分档', () => { + const ago = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString() + expect(formatRelativeTime(undefined)).toBe('-') + expect(formatRelativeTime(ago(0))).toBe('刚刚') + expect(formatRelativeTime(ago(5))).toBe('5 分钟前') + expect(formatRelativeTime(ago(3 * 60))).toBe('3 小时前') + expect(formatRelativeTime(ago(2 * 24 * 60))).toBe('2 天前') + }) + + it('无法解析的字符串原样返回,不显示 NaN', () => { + expect(formatRelativeTime('not-a-date')).toBe('not-a-date') + }) +}) + +describe('formatLogTime', () => { + it('三种日志时间格式统一成 YYYY-MM-DD HH:mm:ss', () => { + expect(formatLogTime('2026-09-24 18:30:05')).toBe('2026-09-24 18:30:05') + expect(formatLogTime('2026-09-24T18:30:05Z')).toBe('2026-09-24 18:30:05') + expect(formatLogTime('2026/09/24 18:30:05')).toBe('2026-09-24 18:30:05') + expect(formatLogTime('')).toBe('-') + }) +}) + +describe('formatLogLine', () => { + it('只改写行首时间戳,保留其余内容', () => { + expect(formatLogLine('2026/09/24 18:30:05 [error] boom')).toBe('2026-09-24 18:30:05 [error] boom') + expect(formatLogLine('2026-09-24T18:30:05Z upstream timeout')).toBe('2026-09-24 18:30:05 upstream timeout') + }) + + it('JSON 日志只替换 time 字段', () => { + expect(formatLogLine('{"time":"2026-09-24T18:30:05Z","level":"info"}')).toBe('{"time":"2026-09-24 18:30:05","level":"info"}') + }) +}) diff --git a/web/src/utils/status.spec.ts b/web/src/utils/status.spec.ts new file mode 100644 index 0000000..48de4fd --- /dev/null +++ b/web/src/utils/status.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { httpStatusKind, statusKind, statusLabel } from './status' + +describe('statusKind', () => { + it('把后端状态值映射成 StatusKind', () => { + expect(statusKind('ok')).toBe('success') + expect(statusKind('running')).toBe('success') + expect(statusKind('warning')).toBe('warning') + expect(statusKind('error')).toBe('error') + expect(statusKind('stopped')).toBe('disabled') + expect(statusKind('none')).toBe('disabled') + expect(statusKind(undefined)).toBe('unknown') + expect(statusKind('')).toBe('unknown') + }) + + it('回归:n-tag 的 type 值不是状态值,必须落到 unknown', () => { + // 曾把 serverStartupType() 返回的 'success'/'default' 传给 StatusBadge :value, + // 导致服务端详情弹窗「运行中」也显示灰点。 + expect(statusKind('success')).toBe('unknown') + expect(statusKind('default')).toBe('unknown') + }) +}) + +describe('statusLabel', () => { + it('已知值给中文,未知值原样返回', () => { + expect(statusLabel('ok')).toBe('正常') + expect(statusLabel('stopped')).toBe('已停止') + expect(statusLabel('weird')).toBe('weird') + expect(statusLabel()).toBe('未知') + }) +}) + +describe('httpStatusKind', () => { + it('按状态码分段', () => { + expect(httpStatusKind(200)).toBe('success') + expect(httpStatusKind(301)).toBe('unknown') + expect(httpStatusKind(404)).toBe('warning') + expect(httpStatusKind(503)).toBe('error') + }) +}) diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index e0aefdf..c5bdc4e 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -1859,9 +1859,6 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) }) .detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); } .detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); } .detail-muted { color: var(--fonu-text-secondary); font-size: 12px; } -.detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); } -.detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); } -.detail-muted { color: var(--fonu-text-secondary); font-size: 12px; } .diag-checks { display: inline-flex; flex-wrap: wrap; gap: 6px; } .server-detail__location { display: flex; align-items: center; gap: 5px; } .detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); } diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 3b6d7ec..f377a63 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -794,7 +794,12 @@ function recordRateSample() { rateHistory.value = rateHistory.value.filter((p) => p.at >= cutoff) } +let trafficInFlight = false + async function refreshTraffic() { + // 2 秒一轮且链路慢时会堆积并发请求:上一轮没回来就跳过这一轮 + if (trafficInFlight) return + trafficInFlight = true try { const rows = asList(await api.getProxyTraffic()) const next: Record = {} @@ -805,6 +810,8 @@ async function refreshTraffic() { recordRateSample() } catch { // ignore polling errors + } finally { + trafficInFlight = false } } diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue index 458f3ea..24aa98b 100644 --- a/web/src/views/FrpView.vue +++ b/web/src/views/FrpView.vue @@ -657,6 +657,7 @@ import { formatBytes, formatDate, formatRate } from '../utils/format' import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types' import FonuCard from '../components/FonuCard.vue' import EmptyState from '../components/EmptyState.vue' +import { useVisibilityPolling } from '../composables/useVisibilityPolling' import LoadError from '../components/LoadError.vue' import PageHeader from '../components/PageHeader.vue' import StatusBadge from '../components/StatusBadge.vue' @@ -698,7 +699,6 @@ const downloadProxyOptions = [ { label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' }, ] const editingProxy = ref(null) -let timer: number | undefined let downloadTimer: number | undefined function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } { @@ -1204,8 +1204,11 @@ function proxyTransportTags(proxy: FrpProxy) { return tags } -onMounted(async () => { await load(); timer = window.setInterval(load, 5_000) }) -onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() }) +// 轮询走 useVisibilityPolling:标签页隐藏时暂停,回到前台补一次 +useVisibilityPolling(() => load(), 5_000) + +onMounted(async () => { await load() }) +onUnmounted(() => { if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() })