diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f1f1f..4ff694d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [v1.5.3] - 2026-09-28 + +### 新增 + +- **P1-4 路由鉴权覆盖测试**:API 路由收敛为可遍历路由表,新增 `internal/api/router_auth_test.go` 遍历 100+ 受保护路由,匿名请求必须返回 401,公开路由不被鉴权误拦;Agent 路由收敛为 `agentRoutes` 声明,新增 `internal/agent/router_auth_test.go` 遍历全部 `/api/v1/*` 验证无 Token 必须 401 +- **配置回滚测试**:本机新增 `internal/nginx/versions_test.go`,覆盖版本列表 / 读取、非法版本名拒绝路径上跳、语法校验失败不覆盖当前配置、成功回滚保留当前版本;Agent 新增写 vhost 前保存版本、校验失败恢复上一可用版本、首次部署失败删除新文件、回滚后执行 reload 的测试 +- **Agent 版本兼容测试**:新增 `internal/frp/agent_versions_compat_test.go`,覆盖 404 / 405 判定端点缺失并提示最低版本与「升级 Agent」、400 / 500 不误判、状态接口 `agent_version` 解析 +- **前端版本字段降级测试**:新增 `agentVersionText` 工具与测试,旧 Agent 缺少 `agent_version` 时显示占位符而不是空白 + +### 变更 + +- 版本号提升到 1.5.3;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化 +- Agent 的 nginx 校验与 reload 增加测试注入钩子;生产路径仍调用真实 nginx,行为不变 + +### 测试 + +- `go test ./...`、`go vet ./...` 通过 +- `npm run typecheck`、`npm test`(19 项)、`npm run build` 通过 + ## [v1.5.2] - 2026-09-28 ### 修复 diff --git a/VERSION b/VERSION index 4cda8f1..8af85be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.2 +1.5.3 diff --git a/docs/README.md b/docs/README.md index 26bc1d9..7cdf5c1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.5.2) +> 最近更新:2026-09-28(v1.5.3) ## 方案与设计(本目录) diff --git a/docs/improvement-roadmap-2026-09-27.md b/docs/improvement-roadmap-2026-09-27.md index 2144ba9..3d101a3 100644 --- a/docs/improvement-roadmap-2026-09-27.md +++ b/docs/improvement-roadmap-2026-09-27.md @@ -843,6 +843,8 @@ func ZoneName(prefix string, parts ...string) string ## 4.4 P1-4:补路由鉴权、配置回滚、Agent 版本兼容测试 +> 实施状态:2026-09-28 已完成(v1.5.3)。API 与 Agent 路由均收敛为可遍历路由表;新增 `router_auth_test.go` 遍历全部受保护路由验证匿名 401;补齐本机与 Agent 的配置回滚测试;新增 `agent_versions_compat_test.go` 与前端版本字段降级测试。 + ### 4.4.1 路由鉴权覆盖测试 目标:防止新增 API 时漏掉 `protect()`。 diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index b66657c..e22a8bb 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.5.2 的验证流程: +示例页面用于验证升级结果。v1.5.3 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.2`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.3`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.5.2`; - - 页面显示验证标识 `v1.5.2-example-page`; + - 页面显示的版本为 `1.5.3`; + - 页面显示验证标识 `v1.5.3-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/agent/router_auth_test.go b/internal/agent/router_auth_test.go new file mode 100644 index 0000000..898c58a --- /dev/null +++ b/internal/agent/router_auth_test.go @@ -0,0 +1,29 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "regexp" + "testing" +) + +var agentRouteParamPattern = regexp.MustCompile(`\{[^}]+\}`) + +// TestAgentRoutesRequireToken 遍历全部 Agent API:匿名请求必须 401,防止新增路由漏挂 auth。 +func TestAgentRoutesRequireToken(t *testing.T) { + server := &Server{cfg: Config{Token: "secret-token"}} + routes := agentRoutes(server) + if len(routes) < 20 { + t.Fatalf("Agent 路由覆盖不足:%d 条", len(routes)) + } + handler := server.Handler() + for _, route := range routes { + path := agentRouteParamPattern.ReplaceAllString(route.Pattern, "1") + req := httptest.NewRequest(route.Method, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("路由 %s %s 匿名请求应返回 401,实际 %d", route.Method, route.Pattern, rec.Code) + } + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index c99c8c9..3cfee60 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -90,6 +90,9 @@ type Server struct { nginxStatusMu sync.Mutex nginxStatusAt time.Time nginxStatus nginxProbe + // 测试注入钩子;生产环境为 nil,走真实 nginx 校验与重载 + validateNginxFn func() error + reloadNginxFn func() error } // nginxProbe Nginx 可用性探测结果。 @@ -217,33 +220,48 @@ func transportSecurity(loopback bool) string { return "plaintext-http" } +type agentRoute struct { + Method string + Pattern string + Handler http.HandlerFunc +} + +// agentRoutes 集中声明 Agent API;Handler 统一包 auth,测试据此遍历验证无 Token 必须 401。 +func agentRoutes(s *Server) []agentRoute { + return []agentRoute{ + {http.MethodGet, "/api/v1/ping", s.ping}, + {http.MethodGet, "/api/v1/status", s.status}, + {http.MethodGet, "/api/v1/routes", s.routes}, + {http.MethodPut, "/api/v1/routes", s.putRoute}, + {http.MethodGet, "/api/v1/routes/{domain}/conf", s.routeConf}, + {http.MethodGet, "/api/v1/routes/{domain}/versions", s.routeVersions}, + {http.MethodGet, "/api/v1/routes/{domain}/versions/{name}", s.routeVersion}, + {http.MethodPost, "/api/v1/routes/{domain}/versions/{name}/rollback", s.rollbackRoute}, + {http.MethodPut, "/api/v1/china-cidr", s.putChinaCIDR}, + {http.MethodPost, "/api/v1/nginx/install", s.installNginx}, + {http.MethodPut, "/api/v1/transport/https", s.putHTTPSProxy}, + {http.MethodGet, "/api/v1/transport/https", s.getHTTPSProxy}, + {http.MethodDelete, "/api/v1/transport/https", s.deleteHTTPSProxy}, + {http.MethodGet, "/api/v1/firewall", s.firewallStatus}, + {http.MethodPost, "/api/v1/firewall/allow", s.firewallAllow}, + {http.MethodDelete, "/api/v1/routes/{domain}", s.deleteRoute}, + {http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig}, + {http.MethodPost, "/api/v1/frps/install", s.installFRPS}, + {http.MethodPost, "/api/v1/frps/action", s.frpsAction}, + {http.MethodPost, "/api/v1/certs", s.putCertificate}, + {http.MethodGet, "/api/v1/metrics", s.metrics}, + {http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs}, + {http.MethodGet, "/api/v1/frps/config", s.frpsConfig}, + {http.MethodGet, "/api/v1/certs", s.certs}, + {http.MethodPost, "/api/v1/nginx/reload", s.nginxReload}, + } +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping)) - mux.HandleFunc("GET /api/v1/status", s.auth(s.status)) - mux.HandleFunc("GET /api/v1/routes", s.auth(s.routes)) - mux.HandleFunc("PUT /api/v1/routes", s.auth(s.putRoute)) - mux.HandleFunc("GET /api/v1/routes/{domain}/conf", s.auth(s.routeConf)) - mux.HandleFunc("GET /api/v1/routes/{domain}/versions", s.auth(s.routeVersions)) - mux.HandleFunc("GET /api/v1/routes/{domain}/versions/{name}", s.auth(s.routeVersion)) - mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", s.auth(s.rollbackRoute)) - mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR)) - mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx)) - mux.HandleFunc("PUT /api/v1/transport/https", s.auth(s.putHTTPSProxy)) - mux.HandleFunc("GET /api/v1/transport/https", s.auth(s.getHTTPSProxy)) - mux.HandleFunc("DELETE /api/v1/transport/https", s.auth(s.deleteHTTPSProxy)) - mux.HandleFunc("GET /api/v1/firewall", s.auth(s.firewallStatus)) - mux.HandleFunc("POST /api/v1/firewall/allow", s.auth(s.firewallAllow)) - mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute)) - mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig)) - mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS)) - mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction)) - mux.HandleFunc("POST /api/v1/certs", s.auth(s.putCertificate)) - mux.HandleFunc("GET /api/v1/metrics", s.auth(s.metrics)) - mux.HandleFunc("GET /api/v1/logs/nginx", s.auth(s.nginxLogs)) - mux.HandleFunc("GET /api/v1/frps/config", s.auth(s.frpsConfig)) - mux.HandleFunc("GET /api/v1/certs", s.auth(s.certs)) - mux.HandleFunc("POST /api/v1/nginx/reload", s.auth(s.nginxReload)) + for _, route := range agentRoutes(s) { + mux.HandleFunc(route.Method+" "+route.Pattern, s.auth(route.Handler)) + } return mux } @@ -1213,11 +1231,11 @@ func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } - if err := s.validateNginx(); err != nil { + if err := s.validateRouteConfig(); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "nginx config rejected: " + err.Error()}) return } - if err := s.reloadNginx(); err != nil { + if err := s.reloadRouteConfig(); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } @@ -1299,6 +1317,20 @@ func (s *Server) validateNginx() error { return runCommand(s.nginxPath(), "-t") } +func (s *Server) validateRouteConfig() error { + if s.validateNginxFn != nil { + return s.validateNginxFn() + } + return s.validateNginx() +} + +func (s *Server) reloadRouteConfig() error { + if s.reloadNginxFn != nil { + return s.reloadNginxFn() + } + return s.reloadNginx() +} + // migrateLegacyVersionDirs 把 conf 目录下的 .versions 目录迁移为隐藏目录。 // 旧布局会被 nginx 的 include * 当成配置文件读取,报 pread() "Is a directory" 导致 nginx -t 失败。 func migrateLegacyVersionDirs(dir string) { diff --git a/internal/agent/versions.go b/internal/agent/versions.go index 997684a..1a0965c 100644 --- a/internal/agent/versions.go +++ b/internal/agent/versions.go @@ -88,7 +88,7 @@ func (s *Server) rollbackRoute(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } - if err := s.reloadNginx(); err != nil { + if err := s.reloadRouteConfig(); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } @@ -107,7 +107,7 @@ func (s *Server) applyRouteContent(path string, content []byte) error { if err := atomicWrite(path, content, 0o640); err != nil { return err } - if err := s.validateNginx(); err != nil { + if err := s.validateRouteConfig(); err != nil { if readErr == nil { _ = atomicWrite(path, previous, 0o640) } else { diff --git a/internal/agent/versions_test.go b/internal/agent/versions_test.go index 8191cc5..2c9c60d 100644 --- a/internal/agent/versions_test.go +++ b/internal/agent/versions_test.go @@ -8,11 +8,16 @@ import ( "crypto/x509" "encoding/hex" "encoding/pem" + "errors" "math/big" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" "time" + + "github.com/fonu/fonu/internal/fsutil" ) func TestMigrateLegacyVersionDirs(t *testing.T) { @@ -77,3 +82,101 @@ func TestCertificatePin(t *testing.T) { t.Fatalf("certificatePin() = %s, 期望 %s", got, want) } } + +func TestApplyRouteContentSavesPreviousVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fonu-a.example.com.conf") + if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil { + t.Fatal(err) + } + server := &Server{cfg: Config{NginxConfDir: dir}, validateNginxFn: func() error { return nil }} + if err := server.applyRouteContent(path, []byte("new config")); err != nil { + t.Fatal(err) + } + current, err := os.ReadFile(path) + if err != nil || string(current) != "new config" { + t.Fatalf("当前配置未更新:%q %v", current, err) + } + versions, err := fsutil.ListVersions(path) + if err != nil || len(versions) != 1 { + t.Fatalf("写 vhost 前应保存上一版本:%v %v", versions, err) + } + previous, err := fsutil.ReadVersion(path, versions[0].Name) + if err != nil || string(previous) != "old config" { + t.Fatalf("历史版本内容不符:%q %v", previous, err) + } +} + +func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fonu-a.example.com.conf") + if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil { + t.Fatal(err) + } + server := &Server{ + cfg: Config{NginxConfDir: dir}, + validateNginxFn: func() error { return errors.New("nginx rejected") }, + } + if err := server.applyRouteContent(path, []byte("new config")); err == nil { + t.Fatal("校验失败应返回错误") + } + current, err := os.ReadFile(path) + if err != nil || string(current) != "old config" { + t.Fatalf("校验失败后应恢复上一可用版本:%q %v", current, err) + } +} + +func TestApplyRouteContentRemovesNewFileOnValidationFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fonu-new.example.com.conf") + server := &Server{ + cfg: Config{NginxConfDir: dir}, + validateNginxFn: func() error { return errors.New("nginx rejected") }, + } + if err := server.applyRouteContent(path, []byte("new config")); err == nil { + t.Fatal("校验失败应返回错误") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("首次部署失败应删除新文件,避免留下坏配置:%v", err) + } +} + +func TestRollbackRouteReloadsAfterApply(t *testing.T) { + dir := t.TempDir() + reloaded := false + server := &Server{ + cfg: Config{NginxConfDir: dir}, + validateNginxFn: func() error { return nil }, + reloadNginxFn: func() error { reloaded = true; return nil }, + } + path := server.routePath("a.example.com") + if err := os.WriteFile(path, []byte("healthy"), 0o640); err != nil { + t.Fatal(err) + } + if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil { + t.Fatal(err) + } + versions, err := fsutil.ListVersions(path) + if err != nil || len(versions) != 1 { + t.Fatalf("准备版本失败:%v %v", versions, err) + } + if err := os.WriteFile(path, []byte("broken"), 0o640); err != nil { + t.Fatal(err) + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", server.rollbackRoute) + req := httptest.NewRequest(http.MethodPost, "/api/v1/routes/a.example.com/versions/"+versions[0].Name+"/rollback", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("回滚应成功:%d %s", rec.Code, rec.Body.String()) + } + if !reloaded { + t.Fatal("回滚后应执行 nginx reload") + } + current, err := os.ReadFile(path) + if err != nil || string(current) != "healthy" { + t.Fatalf("回滚后内容不符:%q %v", current, err) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 5468cd5..a31521d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -10,7 +10,19 @@ import ( "github.com/fonu/fonu/internal/auth" ) +// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。 +type routeSpec struct { + Pattern string + Handler http.HandlerFunc + Public bool +} + func NewRouter(deps Deps) http.Handler { + handler, _ := newRouterWithSpecs(deps) + return handler +} + +func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) { logsHandler := NewLogsHandler(deps.Config) r := &Router{ authHandler: NewAuthHandler(deps.Auth, deps.Notify), @@ -33,17 +45,22 @@ func NewRouter(deps Deps) http.Handler { } mux := http.NewServeMux() - - mux.HandleFunc("GET /api/version", Version(deps.Config)) - // 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流 - mux.HandleFunc("GET /api/status-page", r.statusPageHandler.Get) - mux.HandleFunc("GET /api/auth/status", r.authHandler.Status) - mux.HandleFunc("POST /api/auth/login", r.authHandler.Login) - mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout) - - protect := func(pattern string, handler http.HandlerFunc) { - mux.Handle(pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(http.HandlerFunc(handler)))) + var specs []routeSpec + public := func(pattern string, handler http.HandlerFunc) { + specs = append(specs, routeSpec{Pattern: pattern, Handler: handler, Public: true}) } + protected := func(pattern string, handler http.HandlerFunc) { + specs = append(specs, routeSpec{Pattern: pattern, Handler: handler}) + } + + public("GET /api/version", Version(deps.Config)) + // 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流 + public("GET /api/status-page", r.statusPageHandler.Get) + public("GET /api/auth/status", r.authHandler.Status) + public("POST /api/auth/login", r.authHandler.Login) + public("POST /api/auth/logout", r.authHandler.Logout) + + protect := protected protect("POST /api/auth/password", r.authHandler.ChangePassword) protect("GET /api/settings/tokens", r.authHandler.ListTokens) @@ -177,11 +194,19 @@ func NewRouter(deps Deps) http.Handler { mux.Handle("/", r.spaHandler()) } + for _, spec := range specs { + if spec.Public { + mux.HandleFunc(spec.Pattern, spec.Handler) + continue + } + mux.Handle(spec.Pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(spec.Handler))) + } + logger := deps.Logger if logger == nil { logger = slog.Default() } - return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)) + return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)), specs } type Router struct { diff --git a/internal/api/router_auth_test.go b/internal/api/router_auth_test.go new file mode 100644 index 0000000..bc0155d --- /dev/null +++ b/internal/api/router_auth_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/fonu/fonu/internal/auth" + "github.com/fonu/fonu/internal/certificate" + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/db" + "github.com/fonu/fonu/internal/frp" + "github.com/fonu/fonu/internal/secret" + "github.com/fonu/fonu/internal/settings" +) + +var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`) + +// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。 +func TestProtectedRoutesRequireAuth(t *testing.T) { + dir := t.TempDir() + conn, err := db.Open(filepath.Join(dir, "fonu.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer conn.Close() + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatalf("migrate: %v", err) + } + + authSvc := auth.New(conn) + box, err := secret.NewBox("router-auth-test-key") + if err != nil { + t.Fatal(err) + } + cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn)) + + handler, specs := newRouterWithSpecs(Deps{ + Config: cfg, + Auth: authSvc, + FRPMulti: frpSvc, + Settings: settings.NewStore(conn), + Logger: logger, + }) + + protected, public := 0, 0 + for _, spec := range specs { + req := httptest.NewRequest(routeMethod(spec.Pattern), sampleRoutePath(spec.Pattern), nil) + code := callRouteSafely(handler, req) + if spec.Public { + public++ + if code == http.StatusUnauthorized { + t.Fatalf("公开路由 %s 不应被鉴权拦截", spec.Pattern) + } + continue + } + protected++ + if code != http.StatusUnauthorized { + t.Fatalf("受保护路由 %s 匿名请求应返回 401,实际 %d", spec.Pattern, code) + } + } + if protected < 100 { + t.Fatalf("受保护路由覆盖不足:%d 条", protected) + } + if public < 4 { + t.Fatalf("公开路由覆盖不足:%d 条", public) + } +} + +func routeMethod(pattern string) string { + method, _, _ := strings.Cut(pattern, " ") + return method +} + +func sampleRoutePath(pattern string) string { + _, path, _ := strings.Cut(pattern, " ") + return routeParamPattern.ReplaceAllString(path, "1") +} + +// callRouteSafely 在依赖缺失导致 handler panic 时返回 0,避免公开路由的构造依赖影响鉴权断言。 +func callRouteSafely(handler http.Handler, req *http.Request) (code int) { + defer func() { + if recover() != nil { + code = 0 + } + }() + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code +} diff --git a/internal/frp/agent_versions_compat_test.go b/internal/frp/agent_versions_compat_test.go new file mode 100644 index 0000000..6fd060d --- /dev/null +++ b/internal/frp/agent_versions_compat_test.go @@ -0,0 +1,73 @@ +package frp + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestAgentEndpointMissingDetection 锁定旧 Agent 的 404 / 405 兼容提示,且不把其它错误误判为版本过低。 +func TestAgentEndpointMissingDetection(t *testing.T) { + cases := []struct { + name string + status int + minVersion string + route bool + wantMissing bool + }{ + {"404 判定版本过低", http.StatusNotFound, agentMinVersionInsights, false, true}, + {"405 判定版本过低", http.StatusMethodNotAllowed, agentMinVersionRouteVersions, true, true}, + {"500 不判定版本过低", http.StatusInternalServerError, agentMinVersionInsights, false, false}, + {"400 不判定版本过低", http.StatusBadRequest, agentMinVersionInsights, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{"error":"test"}`)) + })) + defer server.Close() + client := NewAgentClient(server.URL, "token") + var err error + if tc.route { + _, err = client.RouteVersions(context.Background(), "example.com") + } else { + _, err = client.Metrics(context.Background()) + } + if tc.wantMissing { + if !errors.Is(err, ErrAgentEndpointMissing) { + t.Fatalf("应判定为端点缺失:%v", err) + } + msg := err.Error() + if !strings.Contains(msg, tc.minVersion) || !strings.Contains(msg, "升级 Agent") { + t.Fatalf("错误信息应包含最低版本与升级提示:%q", msg) + } + return + } + if errors.Is(err, ErrAgentEndpointMissing) { + t.Fatalf("不应误判为版本过低:%v", err) + } + if err == nil { + t.Fatal("应返回错误") + } + }) + } +} + +func TestAgentStatusVersionField(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"agent_version":"0.10.0","listen_addr":"127.0.0.1:7700"}`)) + })) + defer server.Close() + client := NewAgentClient(server.URL, "token") + status, err := client.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if status["agent_version"] != "0.10.0" { + t.Fatalf("agent_version 解析失败:%#v", status) + } +} diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index 6cf1507..d91652c 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 24478ab..3d29815 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/nginx/versions_test.go b/internal/nginx/versions_test.go new file mode 100644 index 0000000..2eb1fa9 --- /dev/null +++ b/internal/nginx/versions_test.go @@ -0,0 +1,112 @@ +package nginx + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/fsutil" +) + +func newVersionTestManager(t *testing.T) (*Manager, config.Config) { + t.Helper() + dir := t.TempDir() + cfg := config.Config{ + DataDir: dir, + NginxBin: "fonu-nginx-not-installed", + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + mgr := NewManager(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err := mgr.EnsureDirs(); err != nil { + t.Fatal(err) + } + return mgr, cfg +} + +func TestConfigVersionsListAndRead(t *testing.T) { + mgr, cfg := newVersionTestManager(t) + path := cfg.NginxConfigPath() + if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil { + t.Fatal(err) + } + versions, err := mgr.ListConfigVersions() + if err != nil || len(versions) != 1 { + t.Fatalf("应列出 1 个版本:%v %v", versions, err) + } + content, err := mgr.ReadConfigVersion(versions[0].Name) + if err != nil || content != "worker_processes 1;\n" { + t.Fatalf("版本内容不符:%q %v", content, err) + } +} + +func TestRollbackConfigVersionRejectsTraversal(t *testing.T) { + mgr, _ := newVersionTestManager(t) + if _, err := mgr.RollbackConfigVersion(context.Background(), "../nginx.conf"); err == nil { + t.Fatal("带路径上跳的版本名应被拒绝") + } +} + +func TestRollbackConfigVersionKeepsCurrentOnSyntaxError(t *testing.T) { + mgr, cfg := newVersionTestManager(t) + path := cfg.NginxConfigPath() + if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + dir := fsutil.VersionsDir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + badName := "20260101T000000.000-bad.conf" + if err := os.WriteFile(filepath.Join(dir, badName), []byte("server {\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := mgr.RollbackConfigVersion(context.Background(), badName); err == nil { + t.Fatal("语法错误的版本应拒绝回滚") + } + current, err := os.ReadFile(path) + if err != nil || string(current) != "worker_processes 1;\n" { + t.Fatalf("校验失败时不应覆盖当前配置:%q %v", current, err) + } +} + +func TestRollbackConfigVersionRestoresAndKeepsCurrent(t *testing.T) { + mgr, cfg := newVersionTestManager(t) + path := cfg.NginxConfigPath() + if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil { + t.Fatal(err) + } + versions, err := mgr.ListConfigVersions() + if err != nil || len(versions) != 1 { + t.Fatalf("准备版本失败:%v %v", versions, err) + } + if err := os.WriteFile(path, []byte("worker_processes 2;\n"), 0o644); err != nil { + t.Fatal(err) + } + result, err := mgr.RollbackConfigVersion(context.Background(), versions[0].Name) + if err != nil { + t.Fatalf("回滚失败: %v", err) + } + if !strings.Contains(result.Message, "已回滚") { + t.Fatalf("回滚结果缺少提示:%q", result.Message) + } + current, err := os.ReadFile(path) + if err != nil || string(current) != "worker_processes 1;\n" { + t.Fatalf("回滚后内容不符:%q %v", current, err) + } + after, err := mgr.ListConfigVersions() + if err != nil || len(after) < 2 { + t.Fatalf("回滚前的当前配置也应留存为版本:%v %v", after, err) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index e241912..67092ed 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.5.2", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.5.2", + "version": "1.5.3", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index f95601c..db693cb 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.5.2", + "version": "1.5.3", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/utils/agent.spec.ts b/web/src/utils/agent.spec.ts new file mode 100644 index 0000000..f9c5395 --- /dev/null +++ b/web/src/utils/agent.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { agentVersionText } from './agent' + +describe('agentVersionText', () => { + it('有版本号时原样展示', () => { + expect(agentVersionText('0.14.1')).toBe('0.14.1') + }) + + it('旧 Agent 缺少版本字段时降级为占位符', () => { + expect(agentVersionText()).toBe('—') + expect(agentVersionText('')).toBe('—') + expect(agentVersionText(' ')).toBe('—') + }) +}) diff --git a/web/src/utils/agent.ts b/web/src/utils/agent.ts new file mode 100644 index 0000000..6981773 --- /dev/null +++ b/web/src/utils/agent.ts @@ -0,0 +1,4 @@ +/** Agent 版本展示:旧 Agent 未返回 agent_version 时降级为占位符,避免页面出现空白。 */ +export function agentVersionText(version?: string): string { + return version?.trim() || '—' +} diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index 2f33b6e..b251855 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -188,7 +188,7 @@
-
{{ agentStatus?.agent_version || '—' }}
+
{{ agentVersionText(agentStatus?.agent_version) }}
@@ -705,6 +705,7 @@ import type { import { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format' import type { StatusKind } from '../utils/status' import { copyText } from '../utils/clipboard' +import { agentVersionText } from '../utils/agent' import { useVisibilityPolling } from '../composables/useVisibilityPolling' use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent]) diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index b3cd104..93acf81 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.2。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.3。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.5.2。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.5.2。
  • -
  • 验证标识更新为 v1.5.2-example-page。
  • +
  • 页面版本提示更新为 v1.5.3。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.5.3。
  • +
  • 验证标识更新为 v1.5.3-example-page。
@@ -87,8 +87,8 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationRound = 'v1.5.2' -const verificationMarker = 'v1.5.2-example-page' +const verificationRound = 'v1.5.3' +const verificationMarker = 'v1.5.3-example-page' async function refreshVersion() { refreshing.value = true