diff --git a/backend/internal/httpchallenge/nginx_integration_test.go b/backend/internal/httpchallenge/nginx_integration_test.go new file mode 100644 index 0000000..6cb5654 --- /dev/null +++ b/backend/internal/httpchallenge/nginx_integration_test.go @@ -0,0 +1,195 @@ +//go:build linux + +package httpchallenge + +import ( + "context" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// 本测试只启动独立回环实例,不读取线上配置、不重载生产 Nginx。 +func TestNginxHTTPChallengeRouting(t *testing.T) { + binary := os.Getenv("SCERT_NGINX_TEST_BIN") + if binary == "" { + t.Skip("未启用真实 Nginx 隔离测试") + } + if !filepath.IsAbs(binary) { + t.Fatal("Nginx 测试程序必须使用绝对路径") + } + root := t.TempDir() + write := func(name, content string) string { + t.Helper() + path := filepath.Join(root, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path + } + store := NewStore() + lease, err := store.NewLease(t.Context(), []string{"issued.example.test"}) + if err != nil { + t.Fatal(err) + } + defer lease.Close() + const token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" + const value = token + "." + token + if err := lease.Present(t.Context(), "issued.example.test", token, value); err != nil { + t.Fatal(err) + } + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" { + w.WriteHeader(http.StatusForbidden) + return + } + store.ServeHTTP(w, r) + })) + defer upstream.Close() + upstreamURL, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + caPath := write("upstream-ca.pem", string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: upstream.Certificate().Raw}))) + data, err := os.ReadFile(filepath.Join("..", "..", "..", "deploy", "nginx", "http-challenge.conf.example")) + if err != nil { + t.Fatal(err) + } + // 仅把系统信任库替换为此测试上游的 CA,证书验证与名称校验始终开启。 + render := func(name string) string { + return strings.NewReplacer( + "__UPSTREAM_PORT__", upstreamURL.Port(), + "__MANAGEMENT_HOST__", name, + "/etc/ssl/certs/ca-certificates.crt", caPath, + ).Replace(string(data)) + } + good := write("challenge.conf", render(upstream.Certificate().DNSNames[0])) + bad := write("untrusted-name.conf", render("wrong-upstream.invalid")) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + config := fmt.Sprintf(`pid %s/nginx.pid; +error_log %s/error.log warn; +events { worker_connections 32; } +http { + access_log %s/access.log; + proxy_temp_path %s/proxy; + client_body_temp_path %s/body; + server { + listen %s; + server_name issued.example.test other.example.test; + include %s; + location / { return 308 https://$host$request_uri; } + } + server { + listen %s; + server_name bad-tls.example.test; + include %s; + location / { return 200 "existing-business"; } + } + server { + listen %s; + server_name business.example.test; + location / { return 200 "existing-business"; } + } +} +`, root, root, root, root, root, address, good, address, bad, address) + configPath := write("nginx.conf", config) + checkCtx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if output, err := exec.CommandContext(checkCtx, binary, "-p", root, "-c", configPath, "-t").CombinedOutput(); err != nil { + t.Fatalf("隔离 Nginx 配置错误:%v %s", err, output) + } + cmd := exec.Command(binary, "-p", root, "-c", configPath, "-g", "daemon off; master_process off;") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + t.Cleanup(func() { + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Error("隔离 Nginx 未及时退出") + } + }) + client := &http.Client{Timeout: 2 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + defer client.CloseIdleConnections() + deadline := time.Now().Add(5 * time.Second) + for { + response, err := client.Get("http://" + address + "/") + if err == nil { + response.Body.Close() + break + } + if time.Now().After(deadline) { + t.Fatal("隔离 Nginx 未就绪:", err) + } + time.Sleep(25 * time.Millisecond) + } + for _, tc := range []struct { + name, host, path, method string + status int + body string + }{ + {"challenge", "issued.example.test", PathPrefix + token, "GET", 200, value}, + {"head", "issued.example.test", PathPrefix + token, "HEAD", 200, ""}, + {"wrong_host", "other.example.test", PathPrefix + token, "GET", 404, ""}, + {"unknown", "issued.example.test", PathPrefix + "unknown", "GET", 404, ""}, + {"query", "issued.example.test", PathPrefix + token + "?invalid=1", "GET", 404, ""}, + {"encoded", "issued.example.test", PathPrefix + "%61" + token[1:], "GET", 404, ""}, + {"post", "issued.example.test", PathPrefix + token, "POST", 404, ""}, + {"redirect", "issued.example.test", "/dashboard", "GET", 308, ""}, + {"business", "business.example.test", PathPrefix + token, "GET", 200, "existing-business"}, + {"tls_rejected", "bad-tls.example.test", PathPrefix + token, "GET", 502, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + request, err := http.NewRequestWithContext(t.Context(), tc.method, "http://"+address+tc.path, nil) + if err != nil { + t.Fatal(err) + } + request.Host = tc.host + request.Header.Set("X-Forwarded-Host", "issued.example.test") + request.Header.Set("Cookie", "must-not-reach-upstream") + request.Header.Set("Authorization", "must-not-reach-upstream") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != tc.status || tc.status != 308 && tc.status != 502 && string(body) != tc.body { + t.Fatalf("路由结果不符:%d %s", response.StatusCode, body) + } + if tc.status == 308 && response.Header.Get("Location") != "https://issued.example.test/dashboard" { + t.Fatal("普通业务重定向被挑战配置改变") + } + if tc.status == 404 || tc.name == "challenge" || tc.name == "head" { + if response.Header.Get("Location") != "" || response.Header.Get("Cache-Control") != "no-store" { + t.Fatal("挑战响应被重定向或允许缓存") + } + } + }) + } +} diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 0d932e0..e118f8c 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -7,6 +7,7 @@ - [x] 首批原生 Linux 回归已发现接口测试结构体编译错误和 SQLite 历史快照新增列误报;已修复,等待重跑,不隐藏失败。 - [~] 同步及异步用户签发在实际配置快照上重新授权,覆盖 HTTP 切换 DNS、项目归属变化及重试权限;补充回归用例。 - [x] 核对正式发布脚本:完整复制 nginx 目录,因此新增挑战片段会被打包;实际产物仍待检查。 +- [~] 新增正式 Go 集成用例,读取真实 Nginx 挑战片段,在独立回环进程验证挑战、错误 Host、普通跳转、共站业务、凭据剥离及错误上游证书拒绝;不是辅助验收脚本,结果待获取。 - [~] 完成 Linux 全量、静态检查、竞态和三数据库迁移,再更新主清单及正式部署记录。 - [!] 公共入口切换、系统通知授权和首次 Web 全矩阵仍未通过,不将单测当作生产验收。