test: 补路由鉴权、配置回滚与 Agent 版本兼容测试并发布 v1.5.3

This commit is contained in:
2026-09-28 15:53:36 +08:00
parent 0feb6132d6
commit 21efc10536
21 changed files with 570 additions and 58 deletions
+29
View File
@@ -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)
}
}
}
+59 -27
View File
@@ -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 目录下的 <name>.versions 目录迁移为隐藏目录。
// 旧布局会被 nginx 的 include * 当成配置文件读取,报 pread() "Is a directory" 导致 nginx -t 失败。
func migrateLegacyVersionDirs(dir string) {
+2 -2
View File
@@ -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 {
+103
View File
@@ -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)
}
}