Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<Platform.Account|Platform:Account>`,`password=<PROXY_TOKEN>`。
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/密码/平台.账号/协议/目标地址`:
Expand Down
13 changes: 12 additions & 1 deletion internal/proxy/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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()

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down
19 changes: 19 additions & 0 deletions internal/proxy/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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())
Expand Down Expand Up @@ -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, "")
Expand Down
34 changes: 34 additions & 0 deletions internal/proxy/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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}
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand Down