Files
webproxy/proxy.go
T
yexuejc a9ebb5e2cd
CI / Build / Vet / Test (push) Canceled after 0s
chore: 开源化准备(LICENSE/CI/测试/文档)+ 全量中文注释
首个提交。webproxy 是部署在服务器上的 Web 正向代理:打开网页输入任意
URL,由服务器代为请求目标站点,经 HTML/CSS 重写、静态资源代理、
重定向/Cookie/表单处理后回填浏览器,核心场景是经服务器访问其所在
内网的客户网站。

- 开源件:MIT LICENSE、根级 SECURITY.md、CONTRIBUTING.md、
  CHANGELOG.md、Makefile、CI(gofmt/vet/build/test)
- 安全止损:.gitignore 排除 debug.log(含内网 IP)/二进制/调试散文件/
  本地 config.json;config.example.json 默认 blacklist,不关 SSRF
- module 路径规范化为 github.com/yexuejc/webproxy
- 测试:新增 proxy_test.go(URL 编解码/子域解析/SSRF 分类/Origin 改写);
  tests/cookietest 手工 E2E 程序加 build tag 并拆为独立子包,不污染
  go test ./...
- 注释:全部 Go 源码按函数作用/场景/入参/出参 + 分支 + 变量标准补齐
- 清理:删除旧"浏览器持有 Cookie"方案弃用代码(rewriteSetCookie/
  stripDomainSecure/reCookieDomain/reCookieSecure);会话 Cookie 改由
  服务端按 origin 的 jar 持有,绕开跨站 iframe 的 SameSite 扣发
2026-10-20 21:33:28 +08:00

686 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本文件是 webproxy 的转发核心,包含四大部分:
//
// 1. 入口路由(rootHandler 及三个分支处理器):把请求按形态分流到
// 子域名反代 / base64 路径代理 / UI 页;
// 2. 反代内核(doProxy):SSRF 校验 -> 上游请求(带服务端会话 jar)
// -> 3xx 重定向改写 -> HTML/CSS 内容改写;
// 3. WebSocket 隧道(proxyWebSocket):基于 httputil.ReverseProxy
// 透传升级连接,并在服务端注入会话 Cookie;
// 4. 调试日志(dbg*):环境变量开关,排障用。
//
// 设计要点回顾:
// - 会话 Cookie 由服务端按 origin 持有(jarFor),不下发浏览器,
// 绕开跨站 iframe 的 SameSite 扣发问题(详见 jarFor 注释);
// - Origin/Referer 改写为上游源,兼容按源站做校验的目标(fnOS 等)。
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"time"
)
// isHopHeader 判断是否为 HTTP/1.1 逐跳头(hop-by-hop header)。
//
// 作用:逐跳头只对单段连接有意义,代理转发时必须剔除。
// 场景:doProxy 构造上游请求头时逐项过滤。
// 入参:
// - k:原始头名称(大小写任意)。
//
// 出参:
// - bool:true 表示该头禁止向上游透传。
func isHopHeader(k string) bool {
// 标准库同样提供 httpguts.HeaderContain 等判断,这里显式列举以保证
// 行为透明可控。各头的含义:
// connection/proxy-connection/keep-alive:连接管理;
// proxy-authenticate/proxy-authorization:代理鉴权;
// te/trailer/transfer-encoding:分块传输与预告头;
// upgrade:协议升级(WebSocket 场景走单独隧道,不在此复制)。
switch strings.ToLower(k) {
case "connection", "proxy-connection", "keep-alive", "proxy-authenticate",
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade":
return true
}
return false
}
// isRespSkip 判断上游响应头是否不得回传给浏览器。
//
// 作用:响应侧的第二道过滤——除逐跳头外,还有一类头会破坏代理
// 的页面嵌入与重定向改写语义,也必须拦下。
// 场景:doProxy 复制响应头给浏览器之前逐项过滤。
// 入参:
// - k:原始头名称。
//
// 出参:
// - bool:true 表示不回传。
//
// 除逐跳头外,以下头的拦截理由:
// - x-frame-options / frame-ancestors 类 CSP / COOP:目标站禁止被
// iframe 嵌入的策略头;不回传才能让页面在 UI 外框 iframe 中显示;
// - content-length:改写后的 HTML/CSS 长度与上游不同,回传旧值会
// 造成浏览器截断或挂起,必须让 Go 按实际输出重新计算;
// - location:3xx 目标由 doProxy 自己改写后单独下发(见 doProxy
// 的 30x 分支),不透传上游原值。
func isRespSkip(k string) bool {
if isHopHeader(k) {
return true
}
switch strings.ToLower(k) {
case "x-frame-options", "content-security-policy",
"content-security-policy-report-only", "cross-origin-opener-policy",
"content-length", "location":
return true
}
return false
}
// makeTransport 基于 SSRF 校验阶段解析出的 IP 列表构造 HTTP Transport。
//
// 作用:把"域名 -> IP"的解析结果固化到 DialContext,让请求只连已
// 校验过的地址。
// 场景:doProxy / proxyWebSocket 每次请求都会新建(DisableKeepAlives
// 使 Transport 无连接复用,新建成本可忽略)。
// 入参:
// - ips:ssrfCfg.resolveAndCheck 返回并检查通过的 IP 列表,按顺序
// 尝试拨号(等价于按 DNS 应答优先级)。
//
// 出参:
// - *http.Transport:拨号绑定上述 IP 的 Transport。
//
// 关键行为:
// - DialContext 忽略 addr 里的域名、只用 ips 拨号:防止 TOCTOU
// (双重解析)——SSRF 检查与真实连接之间域名若被换成内网地址,
// 绑定 IP 后攻击面即被关闭;
// - TLS:最低 TLS 1.2;证书校验默认开启,InsecureSkipVerify 由
// 配置决定(访问自签证书的内网站点时开启);
// - ForceAttemptHTTP2=false:WebSocket 升级要求 HTTP/1.1,
// 禁用 HTTP/2 尝试,避免升级协商失败;
// - DisableKeepAlives=true:代理目标千差万别,不复用连接可避免
// 空闲连接堆积与跨目标连接误用。
func makeTransport(ips []net.IP) *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// 从标准库传入的 host:port 中只取端口,主机部分一律用
// 上面校验过的 ips(addr 里的域名故意忽略)
_, port, err := net.SplitHostPort(addr)
// 分支:addr 异常时回落 80(https 场景由 TLS 拨号自行处理)
if err != nil {
port = "80"
}
// lastErr 记住最后一个失败原因,全部 IP 试完再返回
var lastErr error
// 按 DNS 应答顺序逐个尝试,任一成功即用(与标准库行为一致)
for _, ip := range ips {
c, e := net.DialTimeout(network, net.JoinHostPort(ip.String(), port), 10*time.Second)
// 分支:拨号成功——立即返回该连接
if e == nil {
return c, nil
}
lastErr = e
}
// 分支:全部 IP 均失败——报最后一个错误
return nil, lastErr
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify, MinVersion: tls.VersionTLS12},
DisableKeepAlives: true,
ForceAttemptHTTP2: false, // WebSocket 升级需要 HTTP/1.1,禁用 HTTP/2 尝试
}
}
// isWebSocket 判断请求是否为 WebSocket 升级请求
func isWebSocket(r *http.Request) bool {
// Upgrade 头必须含 websocket(大小写不敏感)
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
return false
}
// Connection 头需含 upgrade(有的客户端写成 "keep-alive, Upgrade",
// 用包含判断而非相等判断)
return strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade")
}
// rootHandler 统一入口:
// - 子域名 <base64url(origin)>.localhost:<port> → 子域名反代(解决 SPA 绝对路径 import/fetch)
// - 路径以 base_path(/p/) 开头 → 传统整 URL base64 反代(兼容)
// - 其它 → UI 控制页
func rootHandler(w http.ResponseWriter, r *http.Request) {
// 分支一:路径以 basePath(/p/)开头——base64 整 URL 反代
if strings.HasPrefix(r.URL.Path, basePath) {
proxyHandler(w, r)
return
}
// 分支二:Host 是 <hex>.localhost 子域名形式——子域名反代
if isSubdomain(r.Host) {
proxySubdomain(w, r)
return
}
// 分支三:其余请求——UI 控制页(含 favicon 204 短路)
uiHandler(w, r)
}
// isSubdomain 判断 Host 是否为本代理的子域名反代形式:
// <hex(origin)>.localhost[:port]
// 注意:浏览器会把主机名规范化为小写,因此子域名标签必须用大小写无关编码。
// 这里用十六进制(0-9a-f 小写化无损),而 /p/ 的 base64url 仅用于 path/query,
// 不被小写化,故仍可保留。
func isSubdomain(host string) bool {
h := host
// 去掉端口部分,只留主机名(如 "abcd.localhost:8765" -> "abcd.localhost")
if i := strings.Index(h, ":"); i >= 0 {
h = h[:i]
}
// 分支:不是 .localhost 结尾——不可能是本代理子域
if !strings.HasSuffix(h, ".localhost") {
return false
}
// 取子域标签(.localhost 前面的部分)
label := strings.TrimSuffix(h, ".localhost")
// 分支:空标签(即请求直接打 localhost)——是控制页入口,不是子域
if label == "" {
return false
}
// 分支:标签必须是合法十六进制串——否则可能是用户自建的
// *.localhost 站点,不属于本代理,交回 UI 处理。
// 这也是"任意 <hex>.localhost 都视为代理地址"的判定核心。
_, err := hex.DecodeString(label)
return err == nil
}
// decodeSubdomainOrigin 从 Host 解出目标 origin(scheme://host:port)
func decodeSubdomainOrigin(host string) *url.URL {
h := host
// 去端口(同 isSubdomain 的处理)
if i := strings.Index(h, ":"); i >= 0 {
h = h[:i]
}
// 分支:非 .localhost 后缀——无从解起
if !strings.HasSuffix(h, ".localhost") {
return nil
}
// 取出十六进制标签
label := strings.TrimSuffix(h, ".localhost")
// 分支:hex 解码失败(非本代理生成的标签)——放弃
raw, err := hex.DecodeString(label)
if err != nil {
return nil
}
// 解码结果应为完整 origin 字符串(如 "http://192.168.0.107:5666")
u, err := url.Parse(string(raw))
// 分支:解析失败或要素不全(无 scheme/host)——视为非法子域
if err != nil || u.Scheme == "" || u.Host == "" {
return nil
}
return u
}
// proxySubdomain 子域名反代:
// 文档源即目标,浏览器对 /x、./x、fetch('/api')、import('/x') 的解析都会落到
// 子域名下,被本处理器正确映射回目标,从而让重 SPA(如飞牛 fnOS)正常加载。
func proxySubdomain(w http.ResponseWriter, r *http.Request) {
// 从 Host 反解目标 origin;失败说明有人伪造/误用了子域格式
origin := decodeSubdomainOrigin(r.Host)
// 分支:解码失败——直接 400,不猜目标
if origin == nil {
http.Error(w, "非法子域名代理请求", 400)
return
}
// 用 origin 的 scheme/host 拼上本次请求的 path/query,得到完整目标
target := *origin
target.Path = r.URL.Path
// 分支:路径为空时补 "/"(url.URL 序列化时空 Path 不规范)
if target.Path == "" {
target.Path = "/"
}
// 查询串逐个拷入并剔除内部标记
q := r.URL.Query()
q.Del("_wpframe") // 内部标记,不向上游透传
target.RawQuery = q.Encode()
// 顶层导航改道到控制页(带 UI 外框),与 /p/ 行为一致。
// 注意:必须显式指向控制页主机(localhost:<port>),不能用相对路径——
// 否则相对 302 会落在子域名主机上,导致新标签再次丢失外框。
// 判定"顶层导航":GET 且未带 _wpframe=1 内部标记。
if r.Method == http.MethodGet && r.URL.Query().Get("_wpframe") != "1" {
// 用 Sec-Fetch-Dest 识别导航请求;老浏览器没有该头时退回
// Accept 含 text/html 的启发式判断。
dest := r.Header.Get("Sec-Fetch-Dest")
topNav := dest == "document" ||
(dest == "" && strings.HasPrefix(r.Header.Get("Accept"), "text/html"))
// 分支:是顶层导航——302 到控制页 ?url=<base64(target)>
if topNav {
// 保留原始端口,避免 8765 之外的部署场景改丢端口
portPart := ""
if i := strings.Index(r.Host, ":"); i >= 0 {
portPart = r.Host[i:]
}
http.Redirect(w, r, "http://localhost"+portPart+"/?url="+encodeURL(target.String()), http.StatusFound)
return
}
}
// 其余请求(iframe 内文档、XHR、静态资源)直接走反代内核;
// 子域模式下 JS 桩以 location.origin 为 BASE(buildProxyJS 第二参数 true)。
doProxy(w, r, &target, buildProxyJS(target.String(), true))
}
// proxyHandler 传统整 URL base64 反代(/p/<base64url>)
func proxyHandler(w http.ResponseWriter, r *http.Request) {
// 剥掉前缀并去除多余斜杠,得到 base64 段
enc := strings.TrimPrefix(r.URL.Path, basePath)
enc = strings.Trim(enc, "/")
// 分支:空段——连目标地址都没有,400
if enc == "" {
http.Error(w, "缺少目标地址", 400)
return
}
// 顶层导航改道:/p/xxx 被新标签页/地址栏直接打开时(没有 UI 外框),
// 302 到 /?url=xxx 由外框页通过 iframe 加载,保证任何新标签都有地址栏外框。
// iframe 内的加载(Sec-Fetch-Dest: iframe/frame,或 UI 自带 ?_wpframe=1 标记)不受影响。
// 判定条件与 proxySubdomain 一致:GET 且非内部标记的顶层导航。
if r.Method == http.MethodGet && r.URL.Query().Get("_wpframe") != "1" {
dest := r.Header.Get("Sec-Fetch-Dest")
topNav := dest == "document" ||
(dest == "" && strings.HasPrefix(r.Header.Get("Accept"), "text/html"))
// 分支:顶层导航——带 enc 原样跳控制页(base64 段本身就是 URL 内容)
if topNav {
http.Redirect(w, r, "/?url="+enc, http.StatusFound)
return
}
}
// base64url 解码(RawURLEncoding:无 '=' 填充,与前端 enc() 对应)
raw, err := base64.RawURLEncoding.DecodeString(enc)
// 分支:解码失败——格式非法
if err != nil {
http.Error(w, "目标地址解码失败", 400)
return
}
target, err := url.Parse(string(raw))
// 分支:URL 非法或缺 scheme/host——不是可代理的目标
if err != nil || target.Scheme == "" || target.Host == "" {
http.Error(w, "目标地址无效", 400)
return
}
// 分支:目标就是代理自身——防止自指死循环
if target.Host == r.Host {
http.Error(w, "禁止请求代理自身", 400)
return
}
// 进入反代内核;路径模式(非子域)下 JS 桩第二参数为 false
doProxy(w, r, target, buildProxyJS(target.String(), false))
}
// doProxy 公共反代核心:SSRF 校验 → 上游请求 → 头透传 → HTML/CSS 重写或透传
func doProxy(w http.ResponseWriter, r *http.Request, target *url.URL, stubJS string) {
// 归一化 ws/wss → http/https:WebSocket 隧道建立在 HTTP 之上,
// 这样 SSRF 校验与上游拨号都能按普通 HTTP 处理(ws 协议本身不在白名单内)。
// wasWS 必须先记录:scheme 改写后就拿不到原始信息了。
wasWS := isWebSocket(r)
switch target.Scheme {
// 分支:明文 WS —— 归一化为 http
case "ws":
target.Scheme = "http"
// 分支:TLS WS —— 归一化为 https
case "wss":
target.Scheme = "https"
}
// SSRF 闸门:协议与 IP 分类检查,失败直接 403 并说明原因
ips, err := ssrfCfg.resolveAndCheck(target)
// 分支:被拦截——403 + 中文原因(不发向上游)
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(403)
fmt.Fprintf(w, "SSRF 拦截: %v\n", err)
return
}
// 分支:WebSocket 升级请求——走独立隧道处理(不走下面的普通请求流)
if wasWS {
proxyWebSocket(w, r, target, ips)
return
}
// 请求体:仅非 GET/HEAD 且确实有 body 时才透传(GET 带 body 无意义,
// 且 Go 的 NewRequest 对 GET body 会拒绝)
var body io.Reader
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
body = r.Body
}
// 构造上游请求:方法、目标、体三者就位
outReq, err := http.NewRequest(r.Method, target.String(), body)
// 分支:构造失败——500(一般是 method 字符串非法)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// 复制请求头:逐跳头剔除,Accept-Encoding 也剔除(下面强制 identity,
// 因为改写 HTML/CSS 必须拿到明文,压缩流无法正确改写)。
for k, vs := range r.Header {
if isHopHeader(k) {
continue
}
if strings.EqualFold(k, "Accept-Encoding") {
continue
}
for _, v := range vs {
outReq.Header.Add(k, v)
}
}
// 强制不压缩:改写器需要可读的 HTML/CSS
outReq.Header.Set("Accept-Encoding", "identity")
// 反向代理场景下,把浏览器发出的 Origin/Referer 改写为上游自身源,
// 避免 fnOS 等后端基于源站做签名/防 CSRF 校验时因看到代理子域名而拒绝(验签失败)。
// 注意:浏览器侧的同源判断不受影响——它看到的是子域名文档源,仍按同源处理,无 CORS 预检。
upstreamOrigin := target.Scheme + "://" + target.Host
// 分支:带 Origin(POST/PUT/CORS 场景常见)——替换为上游源
if orig := outReq.Header.Get("Origin"); orig != "" {
outReq.Header.Set("Origin", upstreamOrigin)
}
// 分支:带 Referer——保留路径,仅替换源站部分(见 rewriteOriginInURL)
if ref := outReq.Header.Get("Referer"); ref != "" {
outReq.Header.Set("Referer", rewriteOriginInURL(ref, target))
}
// 调试日志:改动后的 Origin/Referer,以及原始请求摘要(Cookie 截断防泄露刷屏)
dbg("[OUT.h] Origin=%q Referer=%q", outReq.Header.Get("Origin"), outReq.Header.Get("Referer"))
dbg("[REQ] %s %s -> %s%s", r.Method, r.Host, target.Host, r.URL.Path)
dbg("[REQ.h] Origin=%q Referer=%q Auth=%q XRW=%q CT=%q Cookie=%q",
r.Header.Get("Origin"), r.Header.Get("Referer"),
truncate(r.Header.Get("Authorization"), 80),
r.Header.Get("X-Requested-With"), r.Header.Get("Content-Type"),
truncate(r.Header.Get("Cookie"), 100))
// 会话 Cookie 由代理服务端 jar 持有(见 jarFor),不再依赖浏览器存储:
// 页面以 iframe 内嵌(localhost 顶层 + <hex>.localhost 子域)在 Chrome 看来是
// 跨站上下文,SameSite=Lax 的会话 Cookie 会被浏览器扣发(不发往 iframe 内的
// fetch/XHR/WS),导致 fnOS 登录成功后 /app/token 仍 401、验签失败连环 reload。
// 服务端 jar 完全绕开浏览器第三方 Cookie 策略,且 WebSocket 隧道也能注入同一份 Cookie。
client := &http.Client{
// 拨号绑定 SSRF 校验过的 IP 列表
Transport: makeTransport(ips),
// 按目标 origin 取/建会话 jar:登录态由服务端自动维护
Jar: jarFor(target.Scheme + "://" + target.Host),
// 重定向不自动跟随:3xx 交给下方改写分支处理,转发给浏览器
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
// 整体超时 120s:覆盖慢内网站点;WS 不走此 client 不受影响
Timeout: 120 * time.Second,
}
// 发出上游请求
resp, err := client.Do(outReq)
// 分支:上游不可达/超时——502 + 原因
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(502)
fmt.Fprintf(w, "上游请求失败: %v\n", err)
return
}
defer resp.Body.Close()
// 3xx 分支:手动改写 Location 头为代理地址后转发 302。
// 因 isRespSkip 拦掉了上游 Location,这里必须自己补。
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
// 分支:有 Location 且能解析为绝对地址——改写并直接返回
if loc != "" {
if abs := resolveURL(target, loc); abs != "" {
w.Header().Set("Location", proxyURL(abs))
w.WriteHeader(302)
return
}
}
// 分支:没有/不可解析 Location——按无 Location 的 3xx 正常下发
}
// 调试日志:响应摘要;Set-Cookie 一并打印(截断)便于核对服务端 jar 收集情况
dbg("[RESP] %s %s%s -> %d ct=%q sc=%q",
r.Method, target.Host, r.URL.Path, resp.StatusCode,
resp.Header.Get("Content-Type"), truncate(strings.Join(resp.Header.Values("Set-Cookie"), " | "), 200))
// ≥400 的响应把 body 读出来打日志(限长 800)后放回流:
// fnOS 验签失败(errno:65534)是 HTTP 200 + JSON 体,真正的 4xx/5xx
// 走这条能直接在日志里看到上游原文。HTML/CSS 的 4xx 不改写不记录,
// 避免把大段错误页刷进日志。
if resp.StatusCode >= 400 && !strings.Contains(resp.Header.Get("Content-Type"), "text/html") && !strings.Contains(resp.Header.Get("Content-Type"), "text/css") {
if b, e := io.ReadAll(resp.Body); e == nil {
dbg("[RESP.body] %s", truncate(string(b), 800))
resp.Body = io.NopCloser(bytes.NewReader(b))
}
}
// 复制响应头给浏览器:过滤头剔除;Set-Cookie 一律不下发
// (服务端 jar 已收集,详见下方注释)
for k, vs := range resp.Header {
if isRespSkip(k) {
continue
}
// 分支:Set-Cookie —— 会话 Cookie 统一保存在服务端 jar
// (client.Jar 自动收集,见 jarFor),不再下发浏览器:跨站
// iframe 上下文里浏览器会扣发 SameSite Cookie,下发了也用不上,
// 反而让登录态看起来"时有时无"。
if strings.EqualFold(k, "Set-Cookie") {
continue
}
for _, v := range vs {
w.Header().Add(k, v)
}
}
// 取内容类型,决定改写策略
ct := resp.Header.Get("Content-Type")
// 分支一:HTML —— 全量读内存后交给 rewriteHTML 改写。
// 已知取舍:大文档会进内存;视频/二进制不走此分支(Content-Type 不匹配)。
if strings.Contains(ct, "text/html") {
data, _ := io.ReadAll(resp.Body)
out, err := rewriteHTML(data, target, r.Host, stubJS, isSubdomain(r.Host))
// 分支:改写失败(解析/渲染异常)——回退为原始 HTML 原样返回,
// 保证"最坏也能看到没改写的页面"而不是 500。
if err != nil {
w.Header().Set("Content-Type", ct)
w.WriteHeader(resp.StatusCode)
w.Write(data)
return
}
// 统一声明 UTF-8:rewriteHTML 输出必然是 UTF-8(charset 探测已转)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(resp.StatusCode)
w.Write(out)
return
}
// 分支二:CSS —— 同样全量读内存后改写 url()
if strings.Contains(ct, "text/css") {
data, _ := io.ReadAll(resp.Body)
out := rewriteCSS(string(data), target, isSubdomain(r.Host))
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.WriteHeader(resp.StatusCode)
w.Write([]byte(out))
return
}
// 分支三:其余内容(JS/图片/字体/二进制)——流式拷贝,不落地内存
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
// ---- 服务端会话 Cookie jar ----
// 按目标 origin 全局保存上游会话 Cookie(单用户自用代理,多目标互不干扰)。
// 关键:Go 默认 cookiejar 对"无 Domain 属性的 host-only Cookie"(fnOS 正是这种:
// ost=...; Path=/; HttpOnly; SameSite=Lax)按上游主机正常存储,IP 主机也没问题;
// 之前探测失败是因为显式 Domain=IP 的 Cookie 本身非法(RFC 禁止 IP 作 Domain)。
// 注意:jar 为进程内内存结构,代理重启即丢失,届时需重新登录目标站。
var jars sync.Map
// jarFor 按 origin 获取(不存在则创建)服务端 Cookie jar。
//
// 作用:让 http.Client 自动收集/回填某目标站的会话 Cookie。
// 场景:doProxy 构造 client 时按目标 origin 取 jar;proxyWebSocket
// 经 cookiesForJar 读取同一 jar 注入 WS 握手。
// 入参:
// - origin:目标源,形如 "http://192.168.0.107:5666"(scheme 必须带,
// cookiejar 的 SetCookies 对是否 Secure 的判断依赖它)。
//
// 出参:
// - http.CookieJar:该 origin 对应的 jar,永不为 nil。
func jarFor(origin string) http.CookieJar {
// 快路径:已存在直接返回
if v, ok := jars.Load(origin); ok {
return v.(http.CookieJar)
}
// 慢路径:新建并登记(并发首次创建时后写覆盖先写,内容等价无影响)
jar, _ := cookiejar.New(nil)
jars.Store(origin, jar)
return jar
}
// cookiesForJar 取出某 origin 已存的会话 Cookie(用于 WebSocket 握手注入)。
func cookiesForJar(origin string, u *url.URL) string {
v, ok := jars.Load(origin)
// 分支:该 origin 还没有 jar——无 Cookie 可注入
if !ok {
return ""
}
// cookiejar 内部按 URL 的 scheme/host/path 过滤过期与作用域不符的项,
// 这里传目标 URL 让库代劳,避免自己判断 Secure/Path 规则。
cks := v.(http.CookieJar).Cookies(u)
// 分支:没有任何有效 Cookie
if len(cks) == 0 {
return ""
}
// 预分配切片容量,逐个拼 "name=value"
parts := make([]string, 0, len(cks))
for _, c := range cks {
parts = append(parts, c.Name+"="+c.Value)
}
// 按 HTTP 头惯例用 "; " 连接
return strings.Join(parts, "; ")
}
// proxyWebSocket 透传 WebSocket 升级:复用 httputil.ReverseProxy,
// 它会转发 Upgrade 请求、在收到后端 101 后劫持双向连接并拷贝字节,
// 实现真正的 WebSocket 隧道(而非当普通 HTTP 抓取)。
func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, ips []net.IP) {
// 注入服务端 jar 中同一份会话 Cookie:浏览器不会在跨站 iframe 里为 WS 握手
// 携带 SameSite Cookie,登录态必须由代理在服务端补上。
// 没有 Cookie 时不动请求头,保持浏览器原样(避免覆盖无意义空值)。
if ck := cookiesForJar(target.Scheme+"://"+target.Host, target); ck != "" {
r.Header.Set("Cookie", ck)
dbg("[WS] 注入 Cookie: %q", truncate(ck, 100))
}
// ReverseProxy 自带 101 劫持与双向拷贝;这里只定制 Transport、
// 目标改写与错误处理。
rp := &httputil.ReverseProxy{
// 复用 SSRF 绑定 IP 的 Transport(其 ForceAttemptHTTP2=false,
// 正是 HTTP/1.1 升级所需要的)
Transport: makeTransport(ips),
// Director:把请求整体改写到目标上
Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme // 已归一化为 http/https
req.URL.Host = target.Host
req.URL.Path = target.Path
req.URL.RawQuery = target.RawQuery
req.Host = target.Host
// 让后端看到同源 Origin,提升兼容性(部分 WS 服务端会校验 Origin)
req.Header.Set("Origin", target.Scheme+"://"+target.Host)
},
// 握手/隧道失败时给浏览器明确原因(502),便于前端识别
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(502)
fmt.Fprintf(w, "WebSocket 代理失败: %v\n", err)
},
}
rp.ServeHTTP(w, r)
}
// ---- 调试日志(设置环境变量 WEBPROXY_DEBUG 即开启;可选 WEBPROXY_DEBUG_FILE 落盘) ----
// dbgFile 是可选日志文件句柄;dbgOnce 保证文件只打开一次。
var (
dbgFile *os.File
dbgOnce sync.Once
)
// dbgEnabled 判断调试日志开关(WEBPROXY_DEBUG 环境变量非空即开)。
func dbgEnabled() bool { return os.Getenv("WEBPROXY_DEBUG") != "" }
// dbg 输出一行调试日志(stderr;配置了 WEBPROXY_DEBUG_FILE 时同步落盘)。
//
// 作用:默认零开销的排障钩子——未设置环境变量时每条 dbg 调用仅一次
// 函数进入即返回。
// 场景:请求进出、头改写、SSRF 结果、WS 注入等关键路径打点。
// 入参:
// - format / args:同 fmt.Sprintf 的格式串与参数清单。
//
// 出参:无。
func dbg(format string, args ...interface{}) {
// 分支:未开启调试——立即返回(热路径上唯一开销)
if !dbgEnabled() {
return
}
// 首次调用时打开日志文件(若配置了路径);失败则仅用 stderr
dbgOnce.Do(func() {
if p := os.Getenv("WEBPROXY_DEBUG_FILE"); p != "" {
if f, e := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); e == nil {
dbgFile = f
}
}
})
// 先格式化一次,两处复用
s := fmt.Sprintf(format, args...)
// stderr 保证容器日志(docker logs)可见
fmt.Fprintln(os.Stderr, s)
// 分支:文件打开成功——同步落盘一份(重启排障用)
if dbgFile != nil {
fmt.Fprintln(dbgFile, s)
}
}
// truncate 把字符串截断到 n 字符并在尾部追加 "...[trunc]"。
//
// 作用:日志脱敏/防刷屏(Cookie/Authorization/错误页等敏感或超长值)。
// 场景:dbg 打印所有截断场景。
// 入参:
// - s:原始字符串。
// - n:保留的最大字符数(按字节,中文可能截半角——仅日志用途可接受)。
//
// 出参:
// - string:截断后的字符串;原长不超过 n 时原样返回。
func truncate(s string, n int) string {
// 分支:没超长——原样
if len(s) <= n {
return s
}
return s[:n] + "...[trunc]"
}
// rewriteOriginInURL 把一个可能为绝对 URL 的头部值(如 Referer)的源站部分替换为上游源站,
// 路径与查询保持不变。无法解析时原样返回。
func rewriteOriginInURL(raw string, target *url.URL) string {
u, err := url.Parse(raw)
// 分支:不是可解析的绝对 URL(相对值/空值)——原样返回,
// 相对 Referer 一般是同源引用,改写反而失真
if err != nil || u.Scheme == "" || u.Host == "" {
return raw
}
// 只换 scheme+host,path/query/fragment 原样保留
u.Scheme = target.Scheme
u.Host = target.Host
return u.String()
}