增加真实Nginx隔离挑战反代与严格TLS回归

This commit is contained in:
xuans 2026-10-04 02:55:13 +08:00
parent 8b96968569
commit 9c26db081e
2 changed files with 196 additions and 0 deletions

View File

@ -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("挑战响应被重定向或允许缓存")
}
}
})
}
}

View File

@ -7,6 +7,7 @@
- [x] 首批原生 Linux 回归已发现接口测试结构体编译错误和 SQLite 历史快照新增列误报;已修复,等待重跑,不隐藏失败。
- [~] 同步及异步用户签发在实际配置快照上重新授权,覆盖 HTTP 切换 DNS、项目归属变化及重试权限;补充回归用例。
- [x] 核对正式发布脚本:完整复制 nginx 目录,因此新增挑战片段会被打包;实际产物仍待检查。
- [~] 新增正式 Go 集成用例,读取真实 Nginx 挑战片段,在独立回环进程验证挑战、错误 Host、普通跳转、共站业务、凭据剥离及错误上游证书拒绝;不是辅助验收脚本,结果待获取。
- [~] 完成 Linux 全量、静态检查、竞态和三数据库迁移,再更新主清单及正式部署记录。
- [!] 公共入口切换、系统通知授权和首次 Web 全矩阵仍未通过,不将单测当作生产验收。