From f84a1f99b1e417f2fba8d29ec76801f4bf640e51 Mon Sep 17 00:00:00 2001 From: Rain Seven <128443127+RAiNY7Study@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:24:14 +0800 Subject: [PATCH] feat: verify SOCKS5 with real UDP round trip (#35) --- internal/i18n/proxy_probe.go | 10 + internal/proxy/probe.go | 247 +++++++++++++++++++- internal/proxy/probe_test.go | 120 ++++++++++ internal/server/proxy_api.go | 12 +- web/src/components/proxy/UpstreamDialog.tsx | 22 +- web/src/components/proxy/shared.ts | 5 + web/src/lib/i18n-en.ts | 5 + web/src/pages/ProxyPage.tsx | 4 +- web/src/types.ts | 5 + 9 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 internal/i18n/proxy_probe.go create mode 100644 internal/proxy/probe_test.go diff --git a/internal/i18n/proxy_probe.go b/internal/i18n/proxy_probe.go new file mode 100644 index 0000000..b4484ed --- /dev/null +++ b/internal/i18n/proxy_probe.go @@ -0,0 +1,10 @@ +package i18n + +// Keep feature-specific diagnostic strings together so additions to the proxy +// probe do not cause conflicts in the shared dictionary. +func init() { + zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall." + zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed." + zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed." + zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed." +} diff --git a/internal/proxy/probe.go b/internal/proxy/probe.go index 58e1f47..d82af6b 100644 --- a/internal/proxy/probe.go +++ b/internal/proxy/probe.go @@ -3,6 +3,8 @@ package proxy import ( "bufio" "context" + "crypto/rand" + "encoding/binary" "errors" "fmt" "io" @@ -17,18 +19,44 @@ type ProbeResult struct { Reachable bool `json:"reachable"` HandshakeOK bool `json:"handshake_ok"` UDPAssociateOK bool `json:"udp_associate_ok"` + UDPExchangeOK bool `json:"udp_exchange_ok"` AuthMethod string `json:"auth_method,omitempty"` RelayAddr string `json:"relay_addr,omitempty"` + DNSServer string `json:"dns_server,omitempty"` + DNSName string `json:"dns_name,omitempty"` + DNSRCode int `json:"dns_rcode,omitempty"` + RoundTripMS int64 `json:"round_trip_ms,omitempty"` Diagnosis string `json:"diagnosis,omitempty"` Hint string `json:"hint,omitempty"` } +const ( + defaultProbeDNSServer = "1.1.1.1:53" + defaultProbeDNSName = "example.com" +) + func ProbeSOCKS5( ctx context.Context, address string, username string, password string, timeout time.Duration, +) (ProbeResult, error) { + return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName) +} + +// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real +// UDP DNS round trip through the returned relay. Keeping the target injectable +// makes the negative paths deterministic in tests without weakening the +// production probe. +func probeSOCKS5( + ctx context.Context, + address string, + username string, + password string, + timeout time.Duration, + dnsServer string, + dnsName string, ) (ProbeResult, error) { address = strings.TrimSpace(address) if _, _, err := net.SplitHostPort(address); err != nil { @@ -122,11 +150,228 @@ func ProbeSOCKS5( port := int(portBytes[0])<<8 | int(portBytes[1]) result.UDPAssociateOK = true result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port)) + result.DNSServer = dnsServer + result.DNSName = dnsName + + if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil { + if result.Diagnosis == "" { + result.Diagnosis = "udp_no_roundtrip" + } + if result.Hint == "" { + result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。") + } + return result, err + } result.Diagnosis = "ready" - result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。") + result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。") return result, nil } +func probeUDPExchange( + ctx context.Context, + control net.Conn, + result *ProbeResult, + relayHost string, + relayPort int, + dnsServer string, + dnsName string, + timeout time.Duration, +) error { + if result == nil { + return errors.New("proxy: probe result is nil") + } + dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer)) + if err != nil { + result.Diagnosis = "invalid_dns_target" + return fmt.Errorf("proxy: resolve UDP probe target: %w", err) + } + relayHost = strings.TrimSpace(relayHost) + if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() { + remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String()) + if splitErr != nil { + result.Diagnosis = "invalid_udp_relay" + return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr) + } + relayHost = remoteHost + } + relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort))) + if err != nil { + result.Diagnosis = "invalid_udp_relay" + return fmt.Errorf("proxy: resolve UDP relay: %w", err) + } + + localNetwork := "udp4" + if relayAddress.IP != nil && relayAddress.IP.To4() == nil { + localNetwork = "udp6" + } + udpConnection, err := net.ListenUDP(localNetwork, nil) + if err != nil { + result.Diagnosis = "udp_socket_failed" + return fmt.Errorf("proxy: open UDP probe socket: %w", err) + } + defer udpConnection.Close() + + deadline := time.Now().Add(timeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if err := udpConnection.SetDeadline(deadline); err != nil { + return fmt.Errorf("proxy: set UDP probe deadline: %w", err) + } + + query, queryID, err := buildDNSQuery(dnsName) + if err != nil { + result.Diagnosis = "invalid_dns_name" + return err + } + datagram, err := buildSOCKSUDPDatagram(dnsAddress, query) + if err != nil { + result.Diagnosis = "invalid_dns_target" + return err + } + startedAt := time.Now() + if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil { + result.Diagnosis = "udp_send_failed" + return fmt.Errorf("proxy: send UDP DNS probe: %w", err) + } + + responseBuffer := make([]byte, 64*1024) + for { + if err := ctx.Err(); err != nil { + result.Diagnosis = "udp_no_roundtrip" + return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err) + } + count, sender, err := udpConnection.ReadFromUDP(responseBuffer) + if err != nil { + result.Diagnosis = "udp_no_roundtrip" + return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err) + } + if !sameUDPAddress(sender, relayAddress) { + continue + } + payload, err := parseSOCKSUDPDatagram(responseBuffer[:count]) + if err != nil { + result.Diagnosis = "udp_invalid_response" + return fmt.Errorf("proxy: parse UDP relay response: %w", err) + } + rcode, err := validateDNSResponse(payload, queryID) + if err != nil { + result.Diagnosis = "dns_invalid_response" + return err + } + result.UDPExchangeOK = true + result.DNSRCode = rcode + result.RoundTripMS = time.Since(startedAt).Milliseconds() + if result.RoundTripMS < 1 { + result.RoundTripMS = 1 + } + return nil + } +} + +func buildDNSQuery(name string) ([]byte, uint16, error) { + name = strings.TrimSuffix(strings.TrimSpace(name), ".") + if name == "" || len(name) > 253 { + return nil, 0, errors.New("proxy: UDP probe DNS name is invalid") + } + var idBytes [2]byte + if _, err := rand.Read(idBytes[:]); err != nil { + return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err) + } + queryID := binary.BigEndian.Uint16(idBytes[:]) + query := make([]byte, 12, 12+len(name)+6) + binary.BigEndian.PutUint16(query[0:2], queryID) + binary.BigEndian.PutUint16(query[2:4], 0x0100) + binary.BigEndian.PutUint16(query[4:6], 1) + for _, label := range strings.Split(name, ".") { + if label == "" || len(label) > 63 { + return nil, 0, errors.New("proxy: UDP probe DNS label is invalid") + } + query = append(query, byte(len(label))) + query = append(query, label...) + } + query = append(query, 0, 0, 1, 0, 1) + return query, queryID, nil +} + +func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) { + if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 { + return nil, errors.New("proxy: UDP target is invalid") + } + packet := []byte{0, 0, 0} + if ipv4 := target.IP.To4(); ipv4 != nil { + packet = append(packet, 1) + packet = append(packet, ipv4...) + } else if ipv6 := target.IP.To16(); ipv6 != nil { + packet = append(packet, 4) + packet = append(packet, ipv6...) + } else { + return nil, errors.New("proxy: UDP target address family is invalid") + } + packet = append(packet, byte(target.Port>>8), byte(target.Port)) + packet = append(packet, payload...) + return packet, nil +} + +func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) { + if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 { + return nil, errors.New("invalid SOCKS5 UDP header") + } + if packet[2] != 0 { + return nil, errors.New("fragmented SOCKS5 UDP response is unsupported") + } + offset := 4 + switch packet[3] { + case 1: + offset += net.IPv4len + case 3: + if len(packet) <= offset { + return nil, errors.New("truncated SOCKS5 UDP domain") + } + offset += 1 + int(packet[offset]) + case 4: + offset += net.IPv6len + default: + return nil, errors.New("unsupported SOCKS5 UDP address type") + } + if offset+2 > len(packet) { + return nil, errors.New("truncated SOCKS5 UDP endpoint") + } + offset += 2 + if offset >= len(packet) { + return nil, errors.New("empty SOCKS5 UDP payload") + } + return packet[offset:], nil +} + +func validateDNSResponse(payload []byte, queryID uint16) (int, error) { + if len(payload) < 12 { + return 0, errors.New("proxy: DNS response is truncated") + } + if binary.BigEndian.Uint16(payload[0:2]) != queryID { + return 0, errors.New("proxy: DNS response ID does not match") + } + flags := binary.BigEndian.Uint16(payload[2:4]) + if flags&0x8000 == 0 { + return 0, errors.New("proxy: DNS response is not a response") + } + rcode := int(flags & 0x000f) + if rcode != 0 { + return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode) + } + return rcode, nil +} + +func sameUDPAddress(left, right *net.UDPAddr) bool { + if left == nil || right == nil || left.Port != right.Port { + return false + } + if left.IP == nil || right.IP == nil { + return true + } + return left.IP.Equal(right.IP) +} + func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) { switch addressType { case 1: diff --git a/internal/proxy/probe_test.go b/internal/proxy/probe_test.go new file mode 100644 index 0000000..c262c88 --- /dev/null +++ b/internal/proxy/probe_test.go @@ -0,0 +1,120 @@ +package proxy + +import ( + "context" + "io" + "net" + "testing" + "time" +) + +func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) { + address, stop := startProbeSOCKS5Server(t, false) + defer stop() + + result, err := probeSOCKS5( + context.Background(), + address, + "", + "", + 250*time.Millisecond, + "192.0.2.53:53", + "example.test", + ) + if err == nil { + t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data") + } + if !result.UDPAssociateOK { + t.Fatal("UDP ASSOCIATE should have succeeded") + } + if result.UDPExchangeOK { + t.Fatal("UDP exchange should not be reported as successful") + } + if result.Diagnosis != "udp_no_roundtrip" { + t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis) + } +} + +func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) { + address, stop := startProbeSOCKS5Server(t, true) + defer stop() + + result, err := probeSOCKS5( + context.Background(), + address, + "", + "", + time.Second, + "192.0.2.53:53", + "example.test", + ) + if err != nil { + t.Fatalf("Probe returned error: %v", err) + } + if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK { + t.Fatalf("Probe evidence incomplete: %+v", result) + } + if result.Diagnosis != "ready" { + t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis) + } + if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" { + t.Fatalf("Unexpected DNS evidence: %+v", result) + } +} + +func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) { + t.Helper() + udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP: %v", err) + } + tcpListener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + udpConnection.Close() + t.Fatalf("Listen: %v", err) + } + + if echoDNS { + go func() { + buffer := make([]byte, 2048) + count, sender, readErr := udpConnection.ReadFromUDP(buffer) + if readErr != nil || count < 22 { + return + } + // The test target is IPv4, so the SOCKS5 UDP header is ten bytes. + buffer[12] = 0x81 + buffer[13] = 0x80 + _, _ = udpConnection.WriteToUDP(buffer[:count], sender) + }() + } + + go func() { + connection, acceptErr := tcpListener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + greeting := make([]byte, 3) + if _, readErr := io.ReadFull(connection, greeting); readErr != nil { + return + } + if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil { + return + } + associate := make([]byte, 10) + if _, readErr := io.ReadFull(connection, associate); readErr != nil { + return + } + udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port + response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)} + if _, writeErr := connection.Write(response); writeErr != nil { + return + } + _, _ = io.Copy(io.Discard, connection) + }() + + return tcpListener.Addr().String(), func() { + _ = tcpListener.Close() + _ = udpConnection.Close() + } +} diff --git a/internal/server/proxy_api.go b/internal/server/proxy_api.go index 2df6953..17d29c2 100644 --- a/internal/server/proxy_api.go +++ b/internal/server/proxy_api.go @@ -395,8 +395,8 @@ func (s *Server) saveAndProbeUpstream( ) probeResponse := probeMap(probe, probeErr) message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。") - if probeErr == nil && probe.UDPAssociateOK { - message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。") + if probeErr == nil && probe.UDPExchangeOK { + message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。") } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ @@ -425,8 +425,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id 8*time.Second, ) message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。") - if probeErr == nil && result.UDPAssociateOK { - message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。") + if probeErr == nil && result.UDPExchangeOK { + message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。") } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ @@ -479,8 +479,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques 8*time.Second, ) message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。") - if probeErr == nil && result.UDPAssociateOK { - message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。") + if probeErr == nil && result.UDPExchangeOK { + message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。") } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ diff --git a/web/src/components/proxy/UpstreamDialog.tsx b/web/src/components/proxy/UpstreamDialog.tsx index d3e7dc3..5ec6a0b 100644 --- a/web/src/components/proxy/UpstreamDialog.tsx +++ b/web/src/components/proxy/UpstreamDialog.tsx @@ -40,23 +40,35 @@ function ProbeResultPanel({ probe }: { probe: UpstreamProbeResult }) { const { t } = useI18n(); const reachable = !!probe.reachable; const handshakeOk = !!probe.handshakeOk; - const udpOk = !!probe.udpAssociateOk; + const associateOk = !!probe.udpAssociateOk; + const udpOk = !!probe.udpExchangeOk; const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail"; - const udpState: ProbeState = !handshakeOk ? "pending" : udpOk ? "ok" : "fail"; + const associateState: ProbeState = !handshakeOk ? "pending" : associateOk ? "ok" : "fail"; + const udpState: ProbeState = !associateOk ? "pending" : udpOk ? "ok" : "fail"; return (
+ {probe.relayAddr ? (
{t("UDP 中继地址:")}{probe.relayAddr}
) : null} + {probe.dnsName && probe.dnsServer ? ( +
+ {t("UDP 测试:")}{probe.dnsName} @ {probe.dnsServer} +
+ ) : null} {probe.hint ?
{probe.hint}
: null} {probe.error ?
{probe.error}
: null}
@@ -127,7 +139,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o {probe ? (
- +
) : null} diff --git a/web/src/components/proxy/shared.ts b/web/src/components/proxy/shared.ts index a6b2871..2290c79 100644 --- a/web/src/components/proxy/shared.ts +++ b/web/src/components/proxy/shared.ts @@ -20,8 +20,13 @@ export interface UpstreamProbeResult { reachable?: boolean; handshakeOk?: boolean; udpAssociateOk?: boolean; + udpExchangeOk?: boolean; authMethod?: string; relayAddr?: string; + dnsServer?: string; + dnsName?: string; + dnsRcode?: number; + roundTripMs?: number; diagnosis?: string; hint?: string; error?: string; diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 20bb4f2..a86c4ae 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -684,6 +684,11 @@ export const EN_DICT: Record = { "SIM / 设备": "SIM / Device", "SM-DP+ 地址 *": "SM-DP+ Address *", "SOCKS5 认证与 UDP ASSOCIATE 探测通过": "SOCKS5 auth and UDP ASSOCIATE probes passed", + "SOCKS5 认证与真实 UDP 往返探测通过": "SOCKS5 authentication and real UDP round-trip probe passed", + "真实 UDP DNS 往返": "Real UDP DNS round trip", + "已建立": "Established", + "无返回": "No response", + "UDP 测试:": "UDP test: ", "Telegram / Bark / Email / Pushplus / Webhook": "Telegram / Bark / Email / Pushplus / Webhook", "USB 路径": "USB Path", "USSD 交互终端": "USSD Interactive Terminal", diff --git a/web/src/pages/ProxyPage.tsx b/web/src/pages/ProxyPage.tsx index 75d4883..95a3906 100644 --- a/web/src/pages/ProxyPage.tsx +++ b/web/src/pages/ProxyPage.tsx @@ -168,8 +168,8 @@ export default function ProxyPage() { }, }); setUpstreamProbe(data.probe || null); - if (data.probe?.udpAssociateOk) { - message.success(data.message || t("SOCKS5 鉴权和 UDP Associate 探测通过")); + if (data.probe?.udpExchangeOk) { + message.success(data.message || t("SOCKS5 认证与真实 UDP 往返探测通过")); } else { message.warning(data.message || t("代理不能承载 VoWiFi 所需的 UDP")); } diff --git a/web/src/types.ts b/web/src/types.ts index af64804..7f7c1f0 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -330,8 +330,13 @@ export interface UpstreamProxyProbe { reachable?: boolean; handshakeOk?: boolean; udpAssociateOk?: boolean; + udpExchangeOk?: boolean; authMethod?: string; relayAddr?: string; + dnsServer?: string; + dnsName?: string; + dnsRcode?: number; + roundTripMs?: number; diagnosis?: string; hint?: string; error?: string;