fix: 修复 TLS ConnectionState、SPA 回退、健康检查与监听小问题

This commit is contained in:
Nixevol
2026-09-30 16:22:38 +08:00
parent c0b2903ab7
commit 7c926a0904
17 changed files with 590 additions and 62 deletions
+22 -4
View File
@@ -7,7 +7,8 @@ import (
)
// ClientIP 按 DEVELOPMENT 4.5:仅当对端在 trusted 网段内时采信
// X-Forwarded-For(从右往左第一个不在 trusted 内的地址)。
// X-Forwarded-For(合并多行,从右往左第一个不在 trusted 内的地址)。
// 遇到无法解析的项立即停下,回退到对端地址。
func ClientIP(r *http.Request, trusted []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
@@ -20,17 +21,20 @@ func ClientIP(r *http.Request, trusted []*net.IPNet) string {
if !ipInNets(ip, trusted) {
return ip.String()
}
xff := r.Header.Get("X-Forwarded-For")
xff := strings.Join(r.Header.Values("X-Forwarded-For"), ",")
if xff == "" {
return ip.String()
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
cand := strings.TrimSpace(parts[i])
parsed := net.ParseIP(cand)
if parsed == nil {
if cand == "" {
continue
}
parsed := parseForwardedIP(cand)
if parsed == nil {
return ip.String()
}
if !ipInNets(parsed, trusted) {
return parsed.String()
}
@@ -38,6 +42,20 @@ func ClientIP(r *http.Request, trusted []*net.IPNet) string {
return ip.String()
}
func parseForwardedIP(s string) net.IP {
if ip := net.ParseIP(s); ip != nil {
return ip
}
host, _, err := net.SplitHostPort(s)
if err == nil {
return net.ParseIP(host)
}
if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
return net.ParseIP(s[1 : len(s)-1])
}
return nil
}
// IsHTTPS 判定请求是否视为 HTTPS(直连 TLS 或受信任代理的 X-Forwarded-Proto)。
func IsHTTPS(r *http.Request, trusted []*net.IPNet) bool {
if r.TLS != nil {
+40
View File
@@ -32,3 +32,43 @@ func TestParseCIDRsAndTrustedXFF(t *testing.T) {
t.Fatal("expected https via proxy")
}
}
func TestClientIPMultiLinePortAndIPv6(t *testing.T) {
trusted := httpx.ParseCIDRs([]string{"10.0.0.0/8", "127.0.0.1/32"})
t.Run("two header lines", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.1.2.3:9"
r.Header["X-Forwarded-For"] = []string{"198.51.100.1", "203.0.113.8, 10.9.9.9"}
if got := httpx.ClientIP(r, trusted); got != "203.0.113.8" {
t.Fatalf("got %q", got)
}
})
t.Run("port on rightmost client", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.1.2.3:9"
r.Header.Set("X-Forwarded-For", "198.51.100.9, 203.0.113.10:1234, 10.9.9.9")
if got := httpx.ClientIP(r, trusted); got != "203.0.113.10" {
t.Fatalf("got %q", got)
}
})
t.Run("unparseable stops", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.1.2.3:9"
r.Header.Set("X-Forwarded-For", "198.51.100.1, not-an-ip, 10.9.9.9")
if got := httpx.ClientIP(r, trusted); got != "10.1.2.3" {
t.Fatalf("got %q", got)
}
})
t.Run("ipv6 brackets", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.1.2.3:9"
r.Header.Set("X-Forwarded-For", "[2001:db8::1], 10.9.9.9")
if got := httpx.ClientIP(r, trusted); got != "2001:db8::1" {
t.Fatalf("got %q", got)
}
})
}
+9 -1
View File
@@ -1,6 +1,8 @@
package httpx
import (
"crypto/sha256"
"crypto/subtle"
"net/http"
"strings"
)
@@ -16,7 +18,7 @@ func MetricsGate(token string, next http.Handler) http.Handler {
}
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) || auth[len(prefix):] != token {
if !strings.HasPrefix(auth, prefix) || !tokenEqual(auth[len(prefix):], token) {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -27,3 +29,9 @@ func MetricsGate(token string, next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
func tokenEqual(got, want string) bool {
sumGot := sha256.Sum256([]byte(got))
sumWant := sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(sumGot[:], sumWant[:]) == 1
}
+57
View File
@@ -0,0 +1,57 @@
package httpx
import (
"io"
"io/fs"
"net/http"
"path"
"strings"
)
// SPA 提供后台静态页:存在的文件原样返回;无扩展名的前端路由回退 index.html;
// 有扩展名但不存在返回 404;访问目录不列清单,同样回退 index.html。
func SPA(fsys fs.FS) http.Handler {
files := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
cleaned := path.Clean("/" + r.URL.Path)
rel := strings.TrimPrefix(cleaned, "/")
if rel == "" {
rel = "."
}
fi, err := fs.Stat(fsys, rel)
if err == nil && !fi.IsDir() {
files.ServeHTTP(w, r)
return
}
if path.Ext(cleaned) != "" {
http.NotFound(w, r)
return
}
serveIndex(w, r, fsys)
})
}
func serveIndex(w http.ResponseWriter, r *http.Request, fsys fs.FS) {
f, err := fsys.Open("index.html")
if err != nil {
http.NotFound(w, r)
return
}
defer func() { _ = f.Close() }()
st, err := f.Stat()
if err != nil || st.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = io.Copy(w, f)
}
+70
View File
@@ -0,0 +1,70 @@
package httpx_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.asio.asia/nixevol/NixMsg/internal/httpx"
)
func TestSPAFallbackAndMissingAsset(t *testing.T) {
fsys := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<html>app</html>")},
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
}
h := httpx.SPA(fsys)
t.Run("frontend route", func(t *testing.T) {
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/endpoints", nil))
if res.Code != http.StatusOK {
t.Fatalf("status=%d", res.Code)
}
ct := res.Header().Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("content-type=%q", ct)
}
if !strings.Contains(res.Body.String(), "app") {
t.Fatalf("body=%q", res.Body.String())
}
if res.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("cache-control=%q", res.Header().Get("Cache-Control"))
}
})
t.Run("missing js", func(t *testing.T) {
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/assets/nope.js", nil))
if res.Code != http.StatusNotFound {
t.Fatalf("status=%d", res.Code)
}
})
t.Run("directory no listing", func(t *testing.T) {
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/assets/", nil))
if res.Code != http.StatusOK {
t.Fatalf("status=%d", res.Code)
}
if strings.Contains(res.Body.String(), "app.js") {
t.Fatal("directory listing leaked")
}
if !strings.Contains(res.Body.String(), "app") {
t.Fatalf("want spa index, body=%q", res.Body.String())
}
})
t.Run("existing file", func(t *testing.T) {
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if res.Code != http.StatusOK {
t.Fatalf("status=%d", res.Code)
}
if res.Body.String() != "console.log(1)" {
t.Fatalf("body=%q", res.Body.String())
}
})
}