Files
tamabox/route/service/proxy.go
T
2026-09-08 10:10:44 +08:00

149 lines
4.8 KiB
Go

package service
import (
"crypto/tls"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// serviceTransport 复用的共享 Transport,避免每条请求新建导致连接无法复用、
// 且无超时的资源泄漏。带响应头/空闲连接超时,防止后端挂死时请求无限期阻塞。
var (
serviceTransportOnce sync.Once
serviceTransport http.RoundTripper
)
func getServiceTransport() http.RoundTripper {
serviceTransportOnce.Do(func() {
serviceTransport = &http.Transport{
// 默认校验证书;仅当配置 tls_insecure_skip_verify=true(内网自签名证书后端)时跳过。
TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.Service.TLSInsecureSkipVerify},
ResponseHeaderTimeout: 15 * time.Second,
IdleConnTimeout: 30 * time.Second,
ExpectContinueTimeout: 2 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
})
return serviceTransport
}
// singleJoiningSlash 将基础路径与附加路径安全拼接,避免双斜杠或丢失分隔符。
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}
func Proxy(ctx context.Context) error {
span := trace.SpanFromContext(ctx.Request().Context())
var userID uint
if ctx.IsLogged {
userID = ctx.User.ID
}
if span.SpanContext().IsValid() {
span.SetAttributes(
attribute.Int("nekobox.service.user-id", int(userID)),
)
}
uri := ctx.Param("**")
basePath := strings.Split(uri, "/")[0]
forwardPath := strings.TrimPrefix(uri, basePath)
var forwardURLStr string
for _, backend := range conf.Service.Backends {
if backend.Prefix == basePath {
forwardURLStr = backend.ForwardURL
break
}
}
if len(forwardURLStr) == 0 {
return ctx.JSONError(http.StatusNotFound, i18n.T(ctx.Lang(), "proxy.errNotFound"))
}
forwardURL, err := url.Parse(forwardURLStr)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to parse forward URL")
return ctx.JSONError(http.StatusInternalServerError, i18n.T(ctx.Lang(), "proxy.errInternal"))
}
// 路径穿越防护:先用 path.Clean 归一化,剥离 ../ 等相对段,
// 确保转发路径始终相对于后端基址,不会被 ../ 越出白名单前缀。
forwardPath = path.Clean(forwardPath)
if !strings.HasPrefix(forwardPath, "/") {
forwardPath = "/" + forwardPath
}
reverseProxy := httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL = forwardURL
req.URL.Path = singleJoiningSlash(forwardURL.Path, forwardPath)
// 透传原始查询串(此前静默丢弃,导致带参请求后端收不到 query)。
req.URL.RawQuery = ctx.Request().URL.RawQuery
req.Host = forwardURL.Host
// 安全加固:不透传用户浏览器带来的敏感请求头给后端。
// 后端通过 X-NekoBox-User-ID 识别用户身份,不应接收用户的会话
// Cookie / 鉴权头,否则可能被用于横向越权或凭证泄露。
// 同时清除客户端伪造的 X-Forwarded-*,避免请求头注入。
req.Header.Del("Cookie")
req.Header.Del("Authorization")
req.Header.Del("Proxy-Authorization")
req.Header.Del("X-Forwarded-For")
req.Header.Del("X-Forwarded-Host")
req.Header.Del("X-Forwarded-Proto")
traceHeaders := http.Header{}
otel.GetTextMapPropagator().Inject(ctx.Request().Context(), propagation.HeaderCarrier(traceHeaders))
for key := range traceHeaders {
req.Header.Set(key, traceHeaders.Get(key))
}
req.Header.Set("X-NekoBox-From", "nekobox-gateway")
req.Header.Set("X-NekoBox-User-ID", strconv.Itoa(int(userID)))
},
Transport: getServiceTransport(),
ErrorHandler: func(writer http.ResponseWriter, request *http.Request, err error) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to handle reverse proxy request")
_ = ctx.JSONError(http.StatusInternalServerError, i18n.T(ctx.Lang(), "proxy.errInternal"))
},
}
// 审计日志:记录每一次反代转发(谁、哪个前缀、哪条路径、转发到哪个后端),
// 便于事后排查异常访问与越权探测。
logrus.WithContext(ctx.Request().Context()).WithFields(logrus.Fields{
"user_id": userID,
"prefix": basePath,
"path": forwardPath,
"backend": forwardURLStr,
}).Info("Service proxy forwarding request")
reverseProxy.ServeHTTP(ctx.ResponseWriter(), ctx.Request().Request)
return nil
}