This commit is contained in:
parent
be097cc109
commit
ceacd99a74
@ -269,10 +269,10 @@ func (h *HookSender) SettingsHtml(ctx *context.Context, url string, requestData
|
||||
|
||||
func NewHookSender(socketPath string, name string, serverVersion string, authToken ...string) *HookSender {
|
||||
httpc := http.Client{
|
||||
Timeout: time.Second * 10,
|
||||
Timeout: time.Second * 35,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx oContext.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -349,6 +349,7 @@ func Init(serverVersion string) {
|
||||
}
|
||||
env := pluginEnvironment(root, logicalID)
|
||||
env = appendPluginEnv(env, "SMAIL_PLUGIN_RPC_TOKEN", authToken)
|
||||
env = appendPluginEnv(env, "SMAIL_PLUGIN_ID", logicalID)
|
||||
launchSpec, launchErr := newPluginLaunchSpec(pluginPath, root, executableName, socketName, env)
|
||||
if launchErr != nil {
|
||||
if !config.DevelopmentMode {
|
||||
@ -411,8 +412,18 @@ func Init(serverVersion string) {
|
||||
}
|
||||
if instance, generation, ok := pluginInstanceForStartup(logicalID); ok {
|
||||
payload, payloadErr := pluginRuntimeConfig(logicalID, generation, instance)
|
||||
if payloadErr != nil || hook.ValidateConfig(payload) != nil || hook.Configure(payload) != nil {
|
||||
log.Warnf("[%s] Plugin runtime configuration failed", logicalID)
|
||||
if payloadErr != nil {
|
||||
log.Warnf("[%s] 插件运行配置构建失败", logicalID)
|
||||
_ = process.Kill()
|
||||
continue
|
||||
}
|
||||
if hook.ValidateConfig(payload) != nil {
|
||||
log.Warnf("[%s] 插件运行配置校验失败", logicalID)
|
||||
_ = process.Kill()
|
||||
continue
|
||||
}
|
||||
if hook.Configure(payload) != nil {
|
||||
log.Warnf("[%s] 插件运行配置应用失败", logicalID)
|
||||
_ = process.Kill()
|
||||
continue
|
||||
}
|
||||
|
||||
86
backend/hooks/plugin_egress_linux.go
Normal file
86
backend/hooks/plugin_egress_linux.go
Normal file
@ -0,0 +1,86 @@
|
||||
//go:build linux
|
||||
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/utils/credential"
|
||||
)
|
||||
|
||||
func startPluginEgress(spec *pluginLaunchSpec, uid, gid int) error {
|
||||
logicalID := ""
|
||||
for _, item := range spec.procAttr.Env {
|
||||
if strings.HasPrefix(item, "SMAIL_PLUGIN_ID=") {
|
||||
logicalID = strings.TrimPrefix(item, "SMAIL_PLUGIN_ID=")
|
||||
}
|
||||
}
|
||||
if logicalID == "" {
|
||||
return errors.New("plugin egress identity is unavailable")
|
||||
}
|
||||
token, err := newPluginAuthToken()
|
||||
if err != nil {
|
||||
return errors.New("plugin egress authorization generation failed")
|
||||
}
|
||||
socketPath := filepath.Join(spec.runtimeDir, filepath.Base(credential.PluginEgressSocket))
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
|
||||
if err != nil {
|
||||
return errors.New("plugin egress listener creation failed")
|
||||
}
|
||||
if err := os.Chmod(socketPath, 0600); err != nil {
|
||||
_ = listener.Close()
|
||||
return errors.New("plugin egress socket permissions failed")
|
||||
}
|
||||
if err := os.Chown(socketPath, uid, gid); err != nil {
|
||||
_ = listener.Close()
|
||||
return errors.New("plugin egress socket identity failed")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
server := &http.Server{
|
||||
Handler: credential.NewPluginEgressHandler(token, func() []credential.NetworkTarget {
|
||||
instance, _, ok := pluginInstanceForStartup(logicalID)
|
||||
if !ok || validatePluginRuntimeBinding(logicalID, instance) != nil {
|
||||
return nil
|
||||
}
|
||||
canNetwork := false
|
||||
for _, capability := range instance.Permissions.Capabilities {
|
||||
canNetwork = canNetwork || capability == "network"
|
||||
}
|
||||
if !canNetwork {
|
||||
return nil
|
||||
}
|
||||
targets := make([]credential.NetworkTarget, 0, len(instance.Permissions.Network))
|
||||
for _, target := range instance.Permissions.Network {
|
||||
if target.Scheme == "https" {
|
||||
targets = append(targets, credential.NetworkTarget{Scheme: target.Scheme, Host: target.Host, Port: target.Port})
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}),
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 65 * time.Second,
|
||||
IdleTimeout: 5 * time.Second,
|
||||
MaxHeaderBytes: 32 << 10,
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
}
|
||||
server.SetKeepAlivesEnabled(false)
|
||||
spec.egressStop = func() {
|
||||
cancel()
|
||||
_ = listener.Close()
|
||||
_ = server.Close()
|
||||
}
|
||||
spec.procAttr.Env = appendPluginEnv(spec.procAttr.Env, "SMAIL_PLUGIN_EGRESS_TOKEN", token)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
return nil
|
||||
}
|
||||
@ -15,6 +15,7 @@ type pluginLaunchSpec struct {
|
||||
socketPath string
|
||||
runtimeDir string
|
||||
parentFiles []*os.File
|
||||
egressStop func()
|
||||
}
|
||||
|
||||
func compatibilityPluginLaunchSpec(pluginPath, root, name, socketName string, env []string) *pluginLaunchSpec {
|
||||
@ -43,6 +44,9 @@ func (s *pluginLaunchSpec) cleanup() {
|
||||
return
|
||||
}
|
||||
s.closeParentFiles()
|
||||
if s.egressStop != nil {
|
||||
s.egressStop()
|
||||
}
|
||||
if s.runtimeDir != "" {
|
||||
_ = os.RemoveAll(s.runtimeDir)
|
||||
}
|
||||
|
||||
@ -77,7 +77,7 @@ func newPluginLaunchSpec(pluginPath, root, name, socketName string, env []string
|
||||
return nil, fmt.Errorf("prepare plugin listener: %w", err)
|
||||
}
|
||||
cloneFlags := uintptr(unix.CLONE_NEWNET)
|
||||
return &pluginLaunchSpec{
|
||||
spec := &pluginLaunchSpec{
|
||||
path: "/" + name,
|
||||
args: []string{name, "fd:3"},
|
||||
socketPath: abstractSocket,
|
||||
@ -104,7 +104,12 @@ func newPluginLaunchSpec(pluginPath, root, name, socketName string, env []string
|
||||
Setsid: true,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
if err := startPluginEgress(spec, uid, gid); err != nil {
|
||||
spec.cleanup()
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func copyPluginConfig(root, runtimeDir, name string, uid, gid int) error {
|
||||
|
||||
@ -100,3 +100,35 @@ func TestInheritedAbstractSocketRemainsReachableAcrossListenerCopy(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginEgressSocketLifecycle(t *testing.T) {
|
||||
spec := &pluginLaunchSpec{
|
||||
runtimeDir: t.TempDir(),
|
||||
procAttr: &os.ProcAttr{Env: []string{
|
||||
"SMAIL_PLUGIN_ID=wechat_push",
|
||||
"SMAIL_PLUGIN_RPC_TOKEN=" + strings.Repeat("b", 64),
|
||||
}},
|
||||
}
|
||||
if err := startPluginEgress(spec, os.Getuid(), os.Getgid()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer spec.cleanup()
|
||||
socketPath := filepath.Join(spec.runtimeDir, "egress.socket")
|
||||
info, err := os.Stat(socketPath)
|
||||
if err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("egress socket is missing or permissions are unsafe")
|
||||
}
|
||||
token := ""
|
||||
for _, value := range spec.procAttr.Env {
|
||||
if strings.HasPrefix(value, "SMAIL_PLUGIN_EGRESS_TOKEN=") {
|
||||
token = strings.TrimPrefix(value, "SMAIL_PLUGIN_EGRESS_TOKEN=")
|
||||
}
|
||||
}
|
||||
if len(token) != 64 || token == strings.Repeat("b", 64) {
|
||||
t.Fatal("egress token is missing or reuses the RPC token")
|
||||
}
|
||||
spec.egressStop()
|
||||
if _, err := os.Stat(socketPath); !os.IsNotExist(err) {
|
||||
t.Fatal("egress socket survived shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -101,6 +102,10 @@ type NetworkTarget struct {
|
||||
|
||||
// NewRestrictedHTTPClientForTargets 创建仅允许访问授权目标的 HTTP 客户端。
|
||||
func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) *http.Client {
|
||||
return newRestrictedHTTPClientForTargets(timeoutMS, targets, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1")
|
||||
}
|
||||
|
||||
func newRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget, sandbox bool) *http.Client {
|
||||
timeout := time.Duration(NormalizeTimeoutMS(timeoutMS)) * time.Millisecond
|
||||
allowedTargets := normalizeNetworkTargets(targets)
|
||||
transport := &http.Transport{
|
||||
@ -113,9 +118,13 @@ func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) *
|
||||
ResponseHeaderTimeout: timeout,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
var outbound http.RoundTripper = transport
|
||||
if sandbox {
|
||||
outbound = newPluginEgressTransport(timeout)
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: restrictedRoundTripper{transport: transport, targets: allowedTargets},
|
||||
Transport: restrictedRoundTripper{transport: outbound, targets: allowedTargets},
|
||||
CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("too many redirects")
|
||||
@ -132,11 +141,24 @@ type restrictedRoundTripper struct {
|
||||
|
||||
func (r restrictedRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if err := validateRestrictedRequest(request, r.targets, "remote target is not authorized"); err != nil {
|
||||
closeRequestBody(request)
|
||||
return nil, err
|
||||
}
|
||||
return r.transport.RoundTrip(request)
|
||||
}
|
||||
|
||||
func (r restrictedRoundTripper) CloseIdleConnections() {
|
||||
if transport, ok := r.transport.(interface{ CloseIdleConnections() }); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func closeRequestBody(request *http.Request) {
|
||||
if request != nil && request.Body != nil {
|
||||
_ = request.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func validateRestrictedRequest(request *http.Request, targets []NetworkTarget, targetError string) error {
|
||||
if request == nil || request.URL == nil {
|
||||
return errors.New("invalid endpoint")
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -130,3 +132,131 @@ func TestNormalizeTimeoutMS(t *testing.T) {
|
||||
t.Fatal("timeout limit was not normalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginEgressRejectsInvalidRequests(t *testing.T) {
|
||||
token := strings.Repeat("a", 64)
|
||||
allowed := []NetworkTarget{{Scheme: "https", Host: "allowed.example.com", Port: 443}}
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*http.Request)
|
||||
status int
|
||||
}{
|
||||
{"missing_token", func(r *http.Request) { r.Header.Del(egressTokenHeader) }, http.StatusForbidden},
|
||||
{"wrong_token", func(r *http.Request) { r.Header.Set(egressTokenHeader, strings.Repeat("b", 64)) }, http.StatusForbidden},
|
||||
{"duplicate_token", func(r *http.Request) { r.Header.Add(egressTokenHeader, token) }, http.StatusForbidden},
|
||||
{"wrong_host", func(r *http.Request) { r.Host = "other" }, http.StatusBadRequest},
|
||||
{"wrong_path", func(r *http.Request) { r.URL.Path = "/other" }, http.StatusBadRequest},
|
||||
{"query", func(r *http.Request) { r.URL.RawQuery = "x=1" }, http.StatusBadRequest},
|
||||
{"connect", func(r *http.Request) { r.Method = http.MethodConnect }, http.StatusBadRequest},
|
||||
{"upgrade", func(r *http.Request) { r.Header.Set("Upgrade", "websocket") }, http.StatusBadRequest},
|
||||
{"duplicate_target", func(r *http.Request) { r.Header.Add(egressTargetHeader, "https://allowed.example.com") }, http.StatusForbidden},
|
||||
{"http_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "http://allowed.example.com") }, http.StatusForbidden},
|
||||
{"private_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://127.0.0.1") }, http.StatusForbidden},
|
||||
{"other_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://other.example.com") }, http.StatusForbidden},
|
||||
{"other_port", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://allowed.example.com:8443") }, http.StatusForbidden},
|
||||
{"oversized_body", func(r *http.Request) { r.ContentLength = maxEgressRequestBytes + 1 }, http.StatusRequestEntityTooLarge},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/relay", nil)
|
||||
request.Host = "plugin-egress"
|
||||
request.Header.Set(egressTokenHeader, token)
|
||||
request.Header.Set(egressTargetHeader, "https://allowed.example.com/send")
|
||||
item.change(request)
|
||||
response := httptest.NewRecorder()
|
||||
NewPluginEgressHandler(token, func() []NetworkTarget { return allowed }).ServeHTTP(response, request)
|
||||
if response.Code != item.status {
|
||||
t.Fatalf("unexpected rejection status: %d", response.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginEgressEmptyAuthorizationFailsClosed(t *testing.T) {
|
||||
for _, authorize := range []func() []NetworkTarget{
|
||||
nil,
|
||||
func() []NetworkTarget { return nil },
|
||||
func() []NetworkTarget { return []NetworkTarget{{Scheme: "invalid", Host: "allowed.example.com"}} },
|
||||
} {
|
||||
token := strings.Repeat("a", 64)
|
||||
request := httptest.NewRequest(http.MethodPost, "/relay", nil)
|
||||
request.Host = "plugin-egress"
|
||||
request.Header.Set(egressTokenHeader, token)
|
||||
request.Header.Set(egressTargetHeader, "https://allowed.example.com/send")
|
||||
response := httptest.NewRecorder()
|
||||
NewPluginEgressHandler(token, authorize).ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("empty authorization was accepted: %d", response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginEgressRechecksAuthorizationBeforeOutbound(t *testing.T) {
|
||||
token := strings.Repeat("a", 64)
|
||||
calls := 0
|
||||
handler := NewPluginEgressHandler(token, func() []NetworkTarget {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return nil
|
||||
}
|
||||
return []NetworkTarget{{Scheme: "https", Host: "allowed.example.com", Port: 443}}
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/relay", strings.NewReader("payload"))
|
||||
request.Host = "plugin-egress"
|
||||
request.Header.Set(egressTokenHeader, token)
|
||||
request.Header.Set(egressTargetHeader, "https://allowed.example.com/send")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden || calls != 2 {
|
||||
t.Fatalf("authorization was not rechecked: status=%d calls=%d", response.Code, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripEgressHeaders(t *testing.T) {
|
||||
header := make(http.Header)
|
||||
for _, name := range []string{egressTokenHeader, egressTargetHeader, "Proxy-Authorization", "X-Hop", "Upgrade"} {
|
||||
header.Set(name, "sensitive")
|
||||
}
|
||||
header.Set("Connection", "X-Hop, Upgrade")
|
||||
header.Set("Content-Type", "application/json")
|
||||
stripEgressHeaders(header)
|
||||
if len(header) != 1 || header.Get("Content-Type") != "application/json" {
|
||||
t.Fatal("internal or hop-by-hop headers survived filtering")
|
||||
}
|
||||
}
|
||||
|
||||
type egressTrackedBody struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (b *egressTrackedBody) Close() error {
|
||||
b.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRestrictedTransportsCloseRejectedRequestBody(t *testing.T) {
|
||||
for _, transport := range []http.RoundTripper{restrictedRoundTripper{}, &pluginEgressTransport{}} {
|
||||
body := &egressTrackedBody{Reader: strings.NewReader("payload")}
|
||||
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/", body)
|
||||
if _, err := transport.RoundTrip(request); err == nil || !body.closed {
|
||||
t.Fatal("rejected request body was not closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type egressIdleTransport struct {
|
||||
http.RoundTripper
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (t *egressIdleTransport) CloseIdleConnections() { t.closed = true }
|
||||
|
||||
func TestRestrictedTransportForwardsIdleConnectionCleanup(t *testing.T) {
|
||||
transport := &egressIdleTransport{}
|
||||
client := &http.Client{Transport: restrictedRoundTripper{transport: &pluginEgressTransport{transport: transport}}}
|
||||
client.CloseIdleConnections()
|
||||
if !transport.closed {
|
||||
t.Fatal("idle connection cleanup was not forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
191
backend/utils/credential/egress.go
Normal file
191
backend/utils/credential/egress.go
Normal file
@ -0,0 +1,191 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PluginEgressSocket = "/egress.socket"
|
||||
egressTokenHeader = "X-Smail-Egress-Token"
|
||||
egressTargetHeader = "X-Smail-Egress-Target"
|
||||
maxEgressRequestBytes int64 = 32 << 20
|
||||
maxEgressResponseBytes int64 = 8 << 20
|
||||
)
|
||||
|
||||
type pluginEgressTransport struct {
|
||||
transport http.RoundTripper
|
||||
token string
|
||||
}
|
||||
|
||||
func newPluginEgressTransport(timeout time.Duration) http.RoundTripper {
|
||||
return &pluginEgressTransport{
|
||||
token: os.Getenv("SMAIL_PLUGIN_EGRESS_TOKEN"),
|
||||
transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: timeout}).DialContext(ctx, "unix", PluginEgressSocket)
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
MaxResponseHeaderBytes: 32 << 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *pluginEgressTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if len(t.token) != 64 {
|
||||
closeRequestBody(request)
|
||||
return nil, errors.New("plugin egress authorization is unavailable")
|
||||
}
|
||||
if request == nil || request.URL == nil {
|
||||
closeRequestBody(request)
|
||||
return nil, errors.New("invalid plugin egress request")
|
||||
}
|
||||
// 目标仅在本地通道传递,主进程仍独立校验,不信任插件侧校验结果。
|
||||
envelope := request.Clone(request.Context())
|
||||
envelope.URL = &url.URL{Scheme: "http", Host: "plugin-egress", Path: "/relay"}
|
||||
envelope.Host = "plugin-egress"
|
||||
envelope.RequestURI = ""
|
||||
if envelope.Header == nil {
|
||||
envelope.Header = make(http.Header)
|
||||
}
|
||||
envelope.Header.Set(egressTokenHeader, t.token)
|
||||
envelope.Header.Set(egressTargetHeader, request.URL.String())
|
||||
response, err := t.transport.RoundTrip(envelope)
|
||||
if err != nil {
|
||||
return nil, errors.New("plugin egress channel is unavailable")
|
||||
}
|
||||
response.Request = request
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (t *pluginEgressTransport) CloseIdleConnections() {
|
||||
if transport, ok := t.transport.(interface{ CloseIdleConnections() }); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// NewPluginEgressHandler 只供沙箱专属本地通道使用,不注册到公网路由。
|
||||
// 每次重新取权限;空权限拒绝访问,禁止继承通用客户端的空名单语义。
|
||||
func NewPluginEgressHandler(token string, authorize func() []NetworkTarget) http.Handler {
|
||||
slots := make(chan struct{}, 2)
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if len(token) != 64 || len(request.Header.Values(egressTokenHeader)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(request.Header.Get(egressTokenHeader)), []byte(token)) != 1 {
|
||||
http.Error(writer, "出站认证失败", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if request.URL.Path != "/relay" || request.URL.RawQuery != "" || request.URL.IsAbs() ||
|
||||
request.Host != "plugin-egress" || request.Header.Get("Upgrade") != "" ||
|
||||
(request.Method != http.MethodGet && request.Method != http.MethodPost && request.Method != http.MethodHead) {
|
||||
http.Error(writer, "不支持的出站请求", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
default:
|
||||
http.Error(writer, "出站请求繁忙", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var targets []NetworkTarget
|
||||
if authorize != nil {
|
||||
targets = normalizeNetworkTargets(authorize())
|
||||
}
|
||||
if len(targets) == 0 || len(request.Header.Values(egressTargetHeader)) != 1 {
|
||||
http.Error(writer, "出站目标未获授权", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
rawTarget := request.Header.Get(egressTargetHeader)
|
||||
if _, err := NormalizeHTTPSURL(rawTarget); err != nil {
|
||||
http.Error(writer, "出站目标无效", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
endpoint, err := url.Parse(rawTarget)
|
||||
if err != nil || !matchesNetworkTarget(endpoint, targets) {
|
||||
http.Error(writer, "出站目标未获授权", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if request.ContentLength > maxEgressRequestBytes {
|
||||
http.Error(writer, "出站请求过大", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
// 先读取有界请求体,防止未知长度请求在越限前已部分提交给上游。
|
||||
body, err := io.ReadAll(http.MaxBytesReader(writer, request.Body, maxEgressRequestBytes))
|
||||
if err != nil {
|
||||
http.Error(writer, "出站请求体无效或过大", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
// 请求体读取期间权限可能被撤销,连接上游前再次按当前配置核对。
|
||||
targets = normalizeNetworkTargets(authorize())
|
||||
if len(targets) == 0 || !matchesNetworkTarget(endpoint, targets) {
|
||||
http.Error(writer, "出站目标未获授权", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
outbound, err := http.NewRequestWithContext(ctx, request.Method, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(writer, "出站请求无效", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
outbound.Header = request.Header.Clone()
|
||||
stripEgressHeaders(outbound.Header)
|
||||
client := newRestrictedHTTPClientForTargets(30_000, targets, false)
|
||||
// 每次请求使用独立连接,避免配置撤销后继续复用已有上游连接。
|
||||
if restricted, ok := client.Transport.(restrictedRoundTripper); ok {
|
||||
if transport, ok := restricted.transport.(*http.Transport); ok {
|
||||
transport.DisableKeepAlives = true
|
||||
transport.MaxResponseHeaderBytes = 32 << 10
|
||||
}
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
// 重定向交还调用方,每一跳重新通过身份、权限与公网地址检查。
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
response, err := client.Do(outbound)
|
||||
if err != nil {
|
||||
// 原始错误可能包含上游 URL 中的令牌,绝不回传或记录。
|
||||
http.Error(writer, "获准目标连接失败", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, maxEgressResponseBytes+1))
|
||||
if err != nil || int64(len(payload)) > maxEgressResponseBytes {
|
||||
http.Error(writer, "上游响应无效或过大", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
stripEgressHeaders(response.Header)
|
||||
response.Header.Del("Content-Length")
|
||||
for name, values := range response.Header {
|
||||
for _, value := range values {
|
||||
writer.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
writer.WriteHeader(response.StatusCode)
|
||||
_, _ = writer.Write(payload)
|
||||
})
|
||||
}
|
||||
|
||||
func stripEgressHeaders(header http.Header) {
|
||||
for _, value := range header.Values("Connection") {
|
||||
for _, name := range strings.Split(value, ",") {
|
||||
header.Del(strings.TrimSpace(name))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
|
||||
"TE", "Trailer", "Transfer-Encoding", "Upgrade", egressTokenHeader, egressTargetHeader,
|
||||
} {
|
||||
header.Del(name)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user