This commit is contained in:
truewhile
2026-10-12 23:01:46 +08:00
parent 079b3ff063
commit 6864a9c7f1
13 changed files with 596 additions and 30 deletions
+139
View File
@@ -1,13 +1,17 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
@@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"localhost"},
// 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
@@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) {
t.Fatal("version should change after files replaced")
}
}
// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致,
// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。
func TestWriteSchemeState(t *testing.T) {
m := newTestServerManager(t)
m.cfg.App.DataDir = t.TempDir()
path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName)
m.writeSchemeState(nil)
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("plain HTTP state: data=%q err=%v", data, err)
}
m.writeSchemeState(&tlsPair{})
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("HTTPS state: data=%q err=%v", data, err)
}
// DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。
m.cfg.App.DataDir = ""
_ = os.Remove(path)
m.writeSchemeState(nil)
if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) {
t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err)
}
}
// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是
// 给用户看 "Client sent an HTTP request to an HTTPS server."。
func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) {
certPEM, keyPEM := makeTestPairPEM(t)
cfg := &config.Config{}
cfg.App.Port = 8080
cfg.App.DataDir = t.TempDir()
cfg.App.HTTPSEnabled = true
cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "secure:"+r.URL.Path)
})
m := newServerManager(cfg, zap.NewNop(), handler)
m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口
if err := m.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = m.Shutdown(context.Background()) })
addr := m.ln.Addr().String()
// 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。
m.addr = addr
schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName)
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("scheme state = %q err=%v, want https", data, err)
}
plain := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := plain.Get("http://" + addr + "/library?tab=1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusFound {
t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = postResp.Body.Close() }()
if postResp.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect)
}
if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want {
t.Fatalf("POST Location = %q, want %q", got, want)
}
// 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(certPEM)) {
t.Fatal("append test certificate to pool failed")
}
secure := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
secureResp, err := secure.Get("https://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = secureResp.Body.Close() }()
body, err := io.ReadAll(secureResp.Body)
if err != nil {
t.Fatal(err)
}
if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" {
t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library")
}
// 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。
cfg.App.HTTPSEnabled = false
if err := m.Reload(); err != nil {
t.Fatal(err)
}
if m.redirectSrv != nil {
t.Fatal("redirect server should be released when HTTPS is off")
}
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("scheme state = %q err=%v, want http", data, err)
}
plainResp, err := plain.Get("http://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainResp.Body.Close() }()
plainBody, err := io.ReadAll(plainResp.Body)
if err != nil {
t.Fatal(err)
}
if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" {
t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody)
}
}