diff --git a/DESIGN.md b/DESIGN.md index 2cb79a86a..d8da30c3f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -63,6 +63,7 @@ * 接入点可随时配置“当系统未设定代理令牌时,也强制客户端发送代理认证信息”(默认关闭)。`RESIN_PROXY_TOKEN` 非空时始终执行令牌认证;为空且此选项启用时,HTTP 正向代理缺少可解析且非空的 Basic 用户名会返回 407,SOCKS5 仅接受 `0x02` 并要求用户名和密码均非空,但不校验密码内容。空令牌下,此选项只用于强制携带身份,不构成安全认证。当启用此选项,客户端发来空认证不再视为 Default 平台请求。 4. HTTP 正向代理: * 格式:`Proxy-Authorization: Basic Platform.Account:PROXY_TOKEN`(user=Platform.Account,pass=PROXY_TOKEN);解析时先按最右侧 `:` 切 Token,再对左侧身份串按第一个出现的 `.` 或 `:` 切 `Platform` 与 `Account`。 + * 可选请求头 `X-Resin-Account` 在代理认证成功后为缺失的 Account 提供值;若认证身份中已有 Account,则以认证中的 Account 为准。该头只用于 Resin 路由,普通 HTTP 转发到目标前会删除;HTTPS `CONNECT` 只在建隧道时消费,不会进入隧道。代理 Token 仍按原规则校验。 5. SOCKS5 正向代理: * 仅支持 SOCKS5 `CONNECT`;成功后进入原始双向 TCP 隧道。 * `RESIN_PROXY_TOKEN` 非空时,仅接受 RFC1929 用户名密码认证(method `0x02`):`username=`,`password=`。 diff --git a/README.md b/README.md index 828d49265..a4373c522 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,22 @@ curl --proxy socks5h://127.0.0.1:2260 \ https://api.ipify.org ``` +For HTTP forward proxy clients that can add headers, `X-Resin-Account` supplies +the Account when `Proxy-Authorization` has no Account and is used for sticky +routing. An explicit Account in `Proxy-Authorization` takes precedence. The +proxy token is still required when configured, and the internal header is +removed before a normal HTTP request reaches the target. The same header can +be sent on an HTTPS `CONNECT` request; it is consumed during tunnel setup and +does not enter the tunnel. + +```bash +# HTTP request or HTTPS CONNECT: route both requests as user_tom +curl -x http://127.0.0.1:2260 \ + --proxy-user "Default:my-token" \ + --proxy-header "X-Resin-Account: user_tom" \ + https://api.ipify.org +``` + #### Method 2: Reverse proxy (URL Account, quick/manual debug) By replacing your service BaseURL with Resin reverse-proxy URL, traffic goes through Resin directly. diff --git a/README.zh-CN.md b/README.zh-CN.md index 41e90d357..dc369a427 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -215,6 +215,21 @@ curl --proxy socks5h://127.0.0.1:2260 \ https://api.ipify.org ``` +对于支持自定义请求头的 HTTP 正向代理客户端,可以在 +`Proxy-Authorization` 没有 Account 时使用 `X-Resin-Account` 提供 Account, +并据此启用粘性路由。如果代理认证中已经有 Account,则以认证中的 +Account 为准。代理 Token 在配置时仍然必须提供;普通 HTTP 请求转发到 +目标前会删除这个 Resin 内部请求头。HTTPS `CONNECT` 请求也可以携带该头, +Resin 只在建立隧道时使用它,不会将它传入隧道。 + +```bash +# HTTP 请求或 HTTPS CONNECT:都按 user_tom 进行粘性路由 +curl -x http://127.0.0.1:2260 \ + --proxy-user "Default:my-token" \ + --proxy-header "X-Resin-Account: user_tom" \ + https://api.ipify.org +``` + #### 方式二:反向代理接入(URL 携带 Account,适合简单使用/手动调试) 你可以通过替换业务的 BaseURL 为 Resin 反代地址,将请求直接发给 Resin。 URL 格式进阶为:`http://部署IP:2260/密码/平台.账号/协议/目标地址`: diff --git a/internal/proxy/e2e_test.go b/internal/proxy/e2e_test.go index fae7a1708..1a519d9aa 100644 --- a/internal/proxy/e2e_test.go +++ b/internal/proxy/e2e_test.go @@ -116,6 +116,9 @@ func TestForwardProxy_E2EHTTPSuccess(t *testing.T) { if got := r.Header.Get("Proxy-Authorization"); got != "" { t.Fatalf("Proxy-Authorization leaked to upstream: %q", got) } + if got := r.Header.Get("X-Resin-Account"); got != "" { + t.Fatalf("X-Resin-Account leaked to upstream: %q", got) + } if got := r.URL.Path; got != "/v1/ping" { t.Fatalf("unexpected path: %q", got) } @@ -138,6 +141,7 @@ func TestForwardProxy_E2EHTTPSuccess(t *testing.T) { req := httptest.NewRequest(http.MethodGet, upstream.URL+"/v1/ping?q=1", nil) req.Header.Set("Proxy-Authorization", basicAuth("plat", "tok")) + req.Header.Set("X-Resin-Account", "header-account") req.Header.Set("X-Test", "1") w := httptest.NewRecorder() @@ -156,6 +160,9 @@ func TestForwardProxy_E2EHTTPSuccess(t *testing.T) { select { case logEv := <-emitter.logCh: + if logEv.Account != "header-account" { + t.Fatalf("Account: got %q, want %q", logEv.Account, "header-account") + } if logEv.EgressBytes <= 0 { t.Fatalf("EgressBytes: got %d, want > 0", logEv.EgressBytes) } @@ -739,10 +746,11 @@ func TestForwardProxy_CONNECTTunnelSemantics(t *testing.T) { targetAddr := targetLn.Addr().String() req := fmt.Sprintf( - "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\n\r\n", + "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\nX-Resin-Account: %s\r\n\r\n", targetAddr, targetAddr, basicAuth("plat", "tok"), + "connect-account", ) if _, err := clientConn.Write([]byte(req)); err != nil { t.Fatalf("write connect request: %v", err) @@ -786,6 +794,9 @@ func TestForwardProxy_CONNECTTunnelSemantics(t *testing.T) { select { case logEv := <-emitter.logCh: + if logEv.Account != "connect-account" { + t.Fatalf("CONNECT Account: got %q, want %q", logEv.Account, "connect-account") + } if !logEv.NetOK { t.Fatal("CONNECT log net_ok: got false, want true") } diff --git a/internal/proxy/forward.go b/internal/proxy/forward.go index cb815a1d0..5b80d6ebc 100644 --- a/internal/proxy/forward.go +++ b/internal/proxy/forward.go @@ -126,6 +126,21 @@ func (p *ForwardProxy) authenticateV1(r *http.Request) (string, string, *ProxyEr return platName, account, nil } +// resolveForwardProxyAccount applies the optional HTTP forward-proxy account +// header after proxy authentication has succeeded. An explicit Account in +// Proxy-Authorization wins; the header only fills an absent Account. +func resolveForwardProxyAccount(r *http.Request, authenticatedAccount string) string { + if authenticatedAccount != "" { + return authenticatedAccount + } + if r != nil { + if account := r.Header.Get("X-Resin-Account"); account != "" { + return account + } + } + return authenticatedAccount +} + func requireProxyAuthInfo(r *http.Request) bool { return r != nil && InboundPolicyFromContext(r.Context()).RequireProxyAuthInfo } @@ -211,6 +226,8 @@ func prepareForwardOutboundRequest(in *http.Request) *http.Request { // Do not propagate client-side close semantics to upstream transport reuse. req.Close = false stripHopByHopHeaders(req.Header) + // This header controls Resin routing and must not be exposed to the target. + req.Header.Del("X-Resin-Account") return req } @@ -220,6 +237,7 @@ func (p *ForwardProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { writeProxyError(w, authErr) return } + account = resolveForwardProxyAccount(r, account) lifecycle := newRequestLifecycle(p.events, r, ProxyTypeForward, false) lifecycle.setTarget(r.Host, r.URL.String()) @@ -318,6 +336,7 @@ func (p *ForwardProxy) handleCONNECT(w http.ResponseWriter, r *http.Request) { writeProxyError(w, authErr) return } + account = resolveForwardProxyAccount(r, account) lifecycle := newRequestLifecycle(p.events, r, ProxyTypeForward, true) lifecycle.setTarget(target, "") diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 9ae0b0455..024329b72 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -196,6 +196,7 @@ func TestForwardProxy_AuthFailed(t *testing.T) { fp := &ForwardProxy{token: "correct-token", events: NoOpEventEmitter{}} req := httptest.NewRequest("GET", "http://example.com/", nil) req.Header.Set("Proxy-Authorization", basicAuth("plat.acct", "wrong-token")) + req.Header.Set("X-Resin-Account", "header-account") w := httptest.NewRecorder() fp.ServeHTTP(w, req) @@ -207,6 +208,32 @@ func TestForwardProxy_AuthFailed(t *testing.T) { } } +func TestResolveForwardProxyAccount_HeaderSuppliesMissingAuthenticatedAccount(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-Resin-Account", "header-account") + + if got := resolveForwardProxyAccount(req, ""); got != "header-account" { + t.Fatalf("account: got %q, want %q", got, "header-account") + } +} + +func TestResolveForwardProxyAccount_AuthenticatedAccountWinsOverHeader(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-Resin-Account", "header-account") + + if got := resolveForwardProxyAccount(req, "auth-account"); got != "auth-account" { + t.Fatalf("account: got %q, want %q", got, "auth-account") + } +} + +func TestResolveForwardProxyAccount_AbsentHeaderPreservesAuthenticatedAccount(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + + if got := resolveForwardProxyAccount(req, "auth-account"); got != "auth-account" { + t.Fatalf("account: got %q, want %q", got, "auth-account") + } +} + func TestForwardProxy_AuthFailed_EmitsNoEvents(t *testing.T) { emitter := newMockEventEmitter() fp := &ForwardProxy{token: "tok", events: emitter} @@ -435,6 +462,7 @@ func TestPrepareForwardOutboundRequest_NormalizesClientCloseAndHeaders(t *testin req.RequestURI = "http://example.com/path?q=1" req.Close = true req.Header.Set("Proxy-Authorization", "Basic xxx") + req.Header.Set("X-Resin-Account", "header-account") req.Header.Set("Connection", "close, X-Custom-Header") req.Header.Set("X-Custom-Header", "value") req.Header.Set("X-Normal-Header", "keep") @@ -453,6 +481,9 @@ func TestPrepareForwardOutboundRequest_NormalizesClientCloseAndHeaders(t *testin if out.Header.Get("Proxy-Authorization") != "" { t.Fatal("Proxy-Authorization should be stripped") } + if out.Header.Get("X-Resin-Account") != "" { + t.Fatal("X-Resin-Account should be stripped") + } if out.Header.Get("X-Custom-Header") != "" { t.Fatal("connection-listed header should be stripped") } @@ -470,6 +501,9 @@ func TestPrepareForwardOutboundRequest_NormalizesClientCloseAndHeaders(t *testin if req.Header.Get("Proxy-Authorization") == "" { t.Fatal("original Proxy-Authorization should remain unchanged") } + if req.Header.Get("X-Resin-Account") == "" { + t.Fatal("original X-Resin-Account should remain unchanged") + } if req.Header.Get("X-Custom-Header") == "" { t.Fatal("original custom header should remain unchanged") }