diff --git a/backend/go.sum b/backend/go.sum index 828f840..45fe83b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -20,12 +20,16 @@ github.com/cyi-cc/fun v1.4.2/go.mod h1:+093hp81tF4m0LZX6FNIfL+N9vsUardWCJwjkMVB5 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -63,8 +67,8 @@ github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -103,9 +107,8 @@ github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93 github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= diff --git a/backend/internal/proxyx/proxyx.go b/backend/internal/proxyx/proxyx.go index 4a7682b..3eff3f2 100644 --- a/backend/internal/proxyx/proxyx.go +++ b/backend/internal/proxyx/proxyx.go @@ -1,7 +1,9 @@ package proxyx import ( + "bufio" "context" + "crypto/tls" "encoding/json" "fmt" "io" @@ -268,13 +270,27 @@ var continents = map[string]string{ // probeTarget 测活拨测目标:公网稳定 HTTP 站点 const probeHost = "www.gstatic.com" -const probePort = 80 +const probePort = uint16(443) + +// tunnelTLS CONNECT 隧道建立后做一次真实 TLS 握手验证书:能隧道不代表没被 +// 拦截——过期/伪造证书的蜜罐代理在这一步被过滤。 +func tunnelTLS(conn net.Conn, target string, timeout time.Duration) error { + conn.SetDeadline(time.Now().Add(timeout)) + c := tls.Client(conn, &tls.Config{ServerName: target}) + return c.Handshake() +} // Check 协议感知测活:按 protocols 依次尝试 SOCKS5/SOCKS4/HTTP 握手, -// 纯 TCP 连通不算数——必须说出协议正确的应答才算活。 +// CONNECT 目标默认 www.gstatic.com:443,可用 targetHost 覆盖为消费方 +// 实际要访问的站点(提取接口按目标测活,拿到的才是"对你有用"的活)。 +// CONNECT 成功后还会做一次真实 TLS 握手验证书,过滤 MITM/蜜罐。 // 返回握手成功的协议名(socks5/socks4/http)与拨测耗时。 -func Check(ctx context.Context, ip string, port int, protocols string, timeout time.Duration) (string, int, error) { - probes := []func(context.Context, string, int, time.Duration) error{ +func Check(ctx context.Context, ip string, port int, protocols string, timeout time.Duration, targetHost ...string) (string, int, error) { + target := probeHost + if len(targetHost) > 0 && targetHost[0] != "" { + target = targetHost[0] + } + probes := []func(context.Context, string, int, string, time.Duration) error{ probeSOCKS5, probeSOCKS4, probeHTTP, } names := []string{"socks5", "socks4", "http"} @@ -291,7 +307,7 @@ func Check(ctx context.Context, ip string, port int, protocols string, timeout t var lastErr error for _, i := range order { start := time.Now() - if err := probes[i](ctx, ip, port, timeout); err == nil { + if err := probes[i](ctx, ip, port, target, timeout); err == nil { return names[i], int(time.Since(start).Milliseconds()), nil } else { lastErr = err @@ -305,7 +321,7 @@ func dial(ctx context.Context, ip string, port int, timeout time.Duration) (net. return d.DialContext(ctx, "tcp", net.JoinHostPort(ip, fmt.Sprint(port))) } -func probeSOCKS5(ctx context.Context, ip string, port int, timeout time.Duration) error { +func probeSOCKS5(ctx context.Context, ip string, port int, target string, timeout time.Duration) error { conn, err := dial(ctx, ip, port, timeout) if err != nil { return err @@ -323,33 +339,55 @@ func probeSOCKS5(ctx context.Context, ip string, port int, timeout time.Duration if buf[0] != 0x05 || buf[1] == 0xff { return fmt.Errorf("socks5 greeting rejected: %x", buf) } - // CONNECT domain:port - req := append([]byte{0x05, 0x01, 0x00, 0x03, byte(len(probeHost))}, []byte(probeHost)...) - req = append(req, byte(probePort>>8), byte(probePort)) + // CONNECT domain:443 + req := append([]byte{0x05, 0x01, 0x00, 0x03, byte(len(target))}, []byte(target)...) + req = append(req, byte(probePort>>8), byte(probePort & 0xff)) if _, err := conn.Write(req); err != nil { return err } - // reply: VER REP RSV ATYP ... — 收到合法的 socks5 应答头即算活 - head := make([]byte, 5) + // reply: VER REP RSV ATYP BND.ADDR BND.PORT —— VER=5 且 REP=0x00 + // (连接被授予)才算通。BND.ADDR 长度随 ATYP 变,必须按类型读干净, + // 否则余量会混进后面的 TLS 握手。 + head := make([]byte, 4) if _, err := io.ReadFull(conn, head); err != nil { return err } - // VER=5 且 REP=0x00(连接被授予)才算活;REP!=0 是拒绝(如 host unreachable) if head[0] != 0x05 || head[1] != 0x00 { return fmt.Errorf("bad socks5 reply: %x", head[:2]) } - return nil + var n int + switch head[3] { + case 0x01: + n = 4 + case 0x04: + n = 16 + case 0x03: + lb := make([]byte, 1) + if _, err := io.ReadFull(conn, lb); err != nil { + return err + } + n = int(lb[0]) + default: + return fmt.Errorf("bad socks5 atyp: %x", head[3]) + } + if _, err := io.ReadFull(conn, make([]byte, n+2)); err != nil { + return err + } + return tunnelTLS(conn, target, timeout) } -func probeSOCKS4(ctx context.Context, ip string, port int, timeout time.Duration) error { +func probeSOCKS4(ctx context.Context, ip string, port int, target string, timeout time.Duration) error { conn, err := dial(ctx, ip, port, timeout) if err != nil { return err } defer conn.Close() conn.SetDeadline(time.Now().Add(timeout)) - // CONNECT 1.1.1.1:80 - req := []byte{0x04, 0x01, 0x00, 0x50, 1, 1, 1, 1, 0x00} + // SOCKS4a CONNECT domain:443(DST.IP=0.0.0.1 表示后续跟域名): + // 纯 SOCKS4 只能连 IP,不支持 4a 的直接判死——反正也隧道不了域名。 + req := []byte{0x04, 0x01, byte(probePort >> 8), byte(probePort & 0xff), 0, 0, 0, 1, 0x00} + req = append(req, []byte(target)...) + req = append(req, 0x00) if _, err := conn.Write(req); err != nil { return err } @@ -361,22 +399,24 @@ func probeSOCKS4(ctx context.Context, ip string, port int, timeout time.Duration if (buf[0] != 0x00 && buf[0] != 0x04) || buf[1] != 0x5a { return fmt.Errorf("bad socks4 reply: %x", buf[:2]) } - return nil + return tunnelTLS(conn, target, timeout) } -func probeHTTP(ctx context.Context, ip string, port int, timeout time.Duration) error { +func probeHTTP(ctx context.Context, ip string, port int, target string, timeout time.Duration) error { conn, err := dial(ctx, ip, port, timeout) if err != nil { return err } defer conn.Close() conn.SetDeadline(time.Now().Add(timeout)) - req := fmt.Sprintf("GET http://%s/generate_204 HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", probeHost, probeHost) + // CONNECT www.gstatic.com:443 —— 必须能隧道 HTTPS 才算活。 + // 只测正向 GET 会把不支持 CONNECT 的网关/透明代理误判为活 + // (下游走 HTTPS 时 CONNECT 直接被 400/断连)。 + req := fmt.Sprintf("CONNECT %s:443 HTTP/1.1\r\nHost: %s:443\r\n\r\n", target, target) if _, err := conn.Write([]byte(req)); err != nil { return err } - // 读状态行前 12 字节:"HTTP/1.x NNN" —— 必须是 2xx(generate_204 应答 204)。 - // 只验 "HTTP/" 前缀会被 CDN/透明网关的错误页(403/530/error 1001)误判为活。 + // 读状态行前 12 字节:"HTTP/1.x NNN" —— 必须 200(隧道建立)。 buf := make([]byte, 12) if _, err := io.ReadFull(conn, buf); err != nil { return err @@ -385,7 +425,21 @@ func probeHTTP(ctx context.Context, ip string, port int, timeout time.Duration) if !strings.HasPrefix(s, "HTTP/1.") || len(s) < 12 || s[8] != ' ' || s[9] != '2' { return fmt.Errorf("bad http reply: %q", s) } - return nil + // 读完响应头余量(到空行为止),别把代理响应头喂给 TLS 握手 + br := bufio.NewReader(conn) + for { + line, err := br.ReadString('\n') + if err != nil { + return err + } + if line == "\r\n" || line == "\n" { + break + } + } + if br.Buffered() > 0 { + return fmt.Errorf("proxy pipelined extra bytes") + } + return tunnelTLS(conn, target, timeout) } // ZhName maps an ISO-like country code to a Chinese display name. diff --git a/backend/internal/service/proxy_routes.go b/backend/internal/service/proxy_routes.go index 5f3dba6..6d1b8fb 100644 --- a/backend/internal/service/proxy_routes.go +++ b/backend/internal/service/proxy_routes.go @@ -15,9 +15,9 @@ import ( const ( extractBatchSize = 15 // 每批并行探测的候选数 - extractBatches = 4 // 最多取几批 - extractProbeTO = 3 * time.Second // 单代理探测超时 - extractDeadline = 20 * time.Second // 整个请求的最长耗时 + extractBatches = 8 // 最多取几批 + extractProbeTO = 6 * time.Second // 单代理探测超时(TLS+数据面验证,3s 会误杀慢但可用的) + extractDeadline = 30 * time.Second // 整个请求的最长耗时 ) type extractResult struct { @@ -26,10 +26,11 @@ type extractResult struct { ok bool } -// ProxyExtractHandler GET /v1/proxy?key=xxx[&fmt=url] +// ProxyExtractHandler GET /v1/proxy?key=xxx[&fmt=url][&host=example.com] // 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。 // 并行探测一批候选,谁先探活返回谁;返回带握手实测协议的 "scheme://ip:port"(socks5h/http/socks4)。 -// 两批全死返回 503。 +// 两批全死返回 503。host 可选:探测目标改为主机名(CONNECT+TLS 验证到该站, +// 默认 gstatic)——消费方传真实目标,拿到的才是"对这个站可用"的代理。 func ProxyExtractHandler(st *store.Store) fun.RouteHandler { return func(rc *fun.RouteCtx) error { reqCtx := rc.RequestCtx @@ -43,6 +44,22 @@ func ProxyExtractHandler(st *store.Store) fun.RouteHandler { routeJSONErr(reqCtx, 401, "invalid key") return nil } + // host 参数:提取探测的 CONNECT+TLS 目标,默认 gstatic。 + // 只收合法主机名,去掉端口/路径等脏输入。 + target := "" + if h := strings.TrimSpace(string(reqCtx.QueryArgs().Peek("host"))); h != "" { + h = strings.SplitN(h, ":", 2)[0] + valid := len(h) <= 253 + for _, r := range h { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' || r == '_') { + valid = false + break + } + } + if valid && strings.Contains(h, ".") { + target = h + } + } ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(extractDeadline)) defer cancel() @@ -56,7 +73,7 @@ func ProxyExtractHandler(st *store.Store) fun.RouteHandler { if len(items) == 0 { break } - if p, proto, _, ok := probeBatch(ctx, st, items); ok { + if p, proto, _, ok := probeBatch(ctx, st, items, target); ok { st.TouchProxyApiKey(k.Id) reqCtx.SetContentType("text/plain; charset=utf-8") reqCtx.SetBodyString(fmt.Sprintf("%s://%s:%d\n", urlScheme(proto), p.Ip, p.Port)) @@ -77,8 +94,9 @@ func urlScheme(proto string) string { } // probeBatch 并行探测一批候选:先活先赢,取消其余;死代理顺手标记移出池。 +// target 非空时把 CONNECT+TLS 探测指向该主机(消费方真实目标)。 // 返回胜出的代理及其握手协议名(socks5/socks4/http)。 -func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy) (store.RandomProxy, string, int, bool) { +func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy, target string) (store.RandomProxy, string, int, bool) { type hit struct { p store.RandomProxy proto string @@ -90,7 +108,7 @@ func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy) wg.Add(1) go func(p store.RandomProxy) { defer wg.Done() - proto, lat, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, extractProbeTO) + proto, lat, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, extractProbeTO, target) if err != nil { _ = st.MarkProxyFailed(p.Id, time.Now().Unix(), p.FailCount+1) return