diff --git a/.github/workflows/.golangci.yml b/.github/workflows/.golangci.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml old mode 100644 new mode 100755 diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/client/ca_bundle.go b/client/ca_bundle.go old mode 100644 new mode 100755 diff --git a/client/captcha_parse_test.go b/client/captcha_parse_test.go old mode 100644 new mode 100755 diff --git a/client/certs/cacert.pem b/client/certs/cacert.pem old mode 100644 new mode 100755 diff --git a/client/main.go b/client/main.go old mode 100644 new mode 100755 index 2475e39..623d928 --- a/client/main.go +++ b/client/main.go @@ -345,6 +345,56 @@ func getCustomNetDialer() net.Dialer { } } +// customResolver is a net.Resolver that uses public DNS servers as fallback. +// On Android (CGO_ENABLED=0) the system resolver may not work, so we provide +// a Go-native resolver that queries multiple public DNS servers directly. +var customResolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + var d net.Dialer + dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"} + var lastErr error + for _, dns := range dnsServers { + conn, err := d.DialContext(ctx, "udp", dns) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr + }, +} + +// resolveUDPAddr resolves a host:port string to *net.UDPAddr. +// Works with both IP addresses and domain names. +// Uses the custom DNS resolver (public DNS fallback) so domain resolution +// works on Android where the system resolver may be unavailable. +func resolveUDPAddr(network, address string) (*net.UDPAddr, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("split host:port %q: %w", address, err) + } + + // If host is already an IP address, skip DNS resolution + if ip := net.ParseIP(host); ip != nil { + return net.ResolveUDPAddr(network, address) + } + + // Resolve domain name using custom resolver (public DNS fallback) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ips, err := customResolver.LookupIP(ctx, "ip4", host) + if err != nil || len(ips) == 0 { + // Fallback: try system resolver + return net.ResolveUDPAddr(network, address) + } + + resolved := net.JoinHostPort(ips[0].String(), port) + log.Printf("[DNS] resolved %s → %s", host, ips[0].String()) + return net.ResolveUDPAddr(network, resolved) +} + // endregion // region Automatic Captcha Solver & Authentication @@ -1715,6 +1765,7 @@ type turnParams struct { link string udp bool getCreds getCredsFunc + wrapKey []byte } func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, streamID int, c chan<- error) { @@ -1739,7 +1790,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } var turnServerAddr string turnServerAddr = net.JoinHostPort(urlhost, urlport) - turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr) + turnServerUDPAddr, err1 := resolveUDPAddr("udp", turnServerAddr) if err1 != nil { err = fmt.Errorf("failed to resolve TURN server address: %s", err1) return @@ -1845,9 +1896,24 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD }) var internalPipeAddr atomic.Value + var wc *wrapConn + if len(turnParams.wrapKey) == wrapKeyLen { + var wcErr error + wc, wcErr = newWrapConn(turnParams.wrapKey, false) + if wcErr != nil { + log.Printf("[STREAM %d] WRAP init failed: %v", streamID, wcErr) + turncancel() + return + } + } + go func() { defer turncancel() buf := make([]byte, 1600) + var wireBuf []byte + if wc != nil { + wireBuf = make([]byte, wrapMaxWire(len(buf))) + } for { if turnctx.Err() != nil { return @@ -1862,7 +1928,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD internalPipeAddr.Store(addr1) - _, err1 = relayConn.WriteTo(buf[:n], peer) + out := buf[:n] + if wc != nil { + written, wrapErr := wc.wrapInto(wireBuf, out) + if wrapErr != nil { + log.Printf("[STREAM %d] WRAP failed: %v", streamID, wrapErr) + return + } + out = wireBuf[:written] + } + + _, err1 = relayConn.WriteTo(out, peer) if err1 != nil { return } @@ -1872,7 +1948,12 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD go func() { defer wg.Done() defer turncancel() - buf := make([]byte, 1600) + readBufLen := 1600 + if wc != nil { + readBufLen = wrapMaxWire(1600) + } + buf := make([]byte, readBufLen) + plain := make([]byte, 1600) for { n, _, err1 := relayConn.ReadFrom(buf) if err1 != nil { @@ -1884,7 +1965,16 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } if addr, ok := addr1.(net.Addr); ok { - if _, err := conn2.WriteTo(buf[:n], addr); err != nil { + payload := buf[:n] + if wc != nil { + m, wrapErr := wc.unwrapPacket(payload, plain) + if wrapErr != nil { + log.Printf("[STREAM %d] UNWRAP failed: %v (n=%d)", streamID, wrapErr, n) + continue + } + payload = plain[:m] + } + if _, err := conn2.WriteTo(payload, addr); err != nil { return } } @@ -2006,11 +2096,23 @@ func main() { debugFlag := flag.Bool("debug", false, "enable debug logging") manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") captchaSolverFlag := flag.String("captcha-solver", "", "URL of Playwright-based captcha solver (e.g. http://127.0.0.1:8766). When set, captcha is solved via real headless Chromium, bypassing VK's bot detection. Requires captcha_solver.py running on the host.") + wrapMode := flag.Bool("wrap", false, "WRAP mode: SRTP-mimicry AEAD wrap DTLS packets. Peer server must use matching -wrap-key.") + wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)") + genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit") flag.Parse() + + if *genWrapKey { + key, err := genWrapKeyHex() + if err != nil { + log.Panicf("gen-wrap-key: %v", err) + } + fmt.Println(key) + return + } if *peerAddr == "" { log.Panicf("Need peer address!") } - peer, err := net.ResolveUDPAddr("udp", *peerAddr) + peer, err := resolveUDPAddr("udp", *peerAddr) if err != nil { panic(err) } @@ -2026,6 +2128,17 @@ func main() { log.Printf("[Captcha] Playwright solver enabled: %s (will be tried first, then fallback to auto/manual)", captchaSolverURL) } + if *wrapMode && *direct { + log.Panicf("-wrap requires DTLS; remove -no-dtls") + } + wrapKey, err := decodeWrapKey(*wrapMode, *wrapKeyHex) + if err != nil { + log.Panicf("%v", err) + } + if *wrapMode { + log.Printf("WRAP mode enabled: SRTP-mimicry AEAD. Peer server must use matching -wrap-key.") + } + var link string var getCreds getCredsFunc if *vklink != "" { @@ -2064,6 +2177,7 @@ func main() { link: link, udp: *udp, getCreds: getCreds, + wrapKey: wrapKey, } if *vlessMode { @@ -2356,7 +2470,7 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i urlport = tp.port } turnServerAddr := net.JoinHostPort(urlhost, urlport) - turnServerUDPAddr, err := net.ResolveUDPAddr("udp", turnServerAddr) + turnServerUDPAddr, err := resolveUDPAddr("udp", turnServerAddr) if err != nil { return nil, nil, fmt.Errorf("resolve TURN addr: %w", err) } @@ -2425,7 +2539,15 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i cleanup() return nil, nil, fmt.Errorf("generate cert: %w", err) } - dtlsPC := &relayPacketConn{relay: relayConn, peer: peer} + var relayWC *wrapConn + if len(tp.wrapKey) == wrapKeyLen { + relayWC, err = newWrapConn(tp.wrapKey, false) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("wrap init: %w", err) + } + } + dtlsPC := &relayPacketConn{relay: relayConn, peer: peer, wc: relayWC} dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer, dtls.WithCertificates(certificate), dtls.WithInsecureSkipVerify(true), @@ -2472,14 +2594,38 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i type relayPacketConn struct { relay net.PacketConn peer net.Addr + wc *wrapConn } func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { - return r.relay.ReadFrom(b) + if r.wc == nil { + return r.relay.ReadFrom(b) + } + buf := make([]byte, wrapMaxWire(len(b))) + n, addr, err := r.relay.ReadFrom(buf) + if err != nil { + return 0, addr, err + } + m, err := r.wc.unwrapPacket(buf[:n], b) + if err != nil { + return 0, addr, err + } + return m, addr, nil } func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { - return r.relay.WriteTo(b, r.peer) + if r.wc == nil { + return r.relay.WriteTo(b, r.peer) + } + out := make([]byte, wrapMaxWire(len(b))) + n, err := r.wc.wrapInto(out, b) + if err != nil { + return 0, err + } + if _, err = r.relay.WriteTo(out[:n], r.peer); err != nil { + return 0, err + } + return len(b), nil } func (r *relayPacketConn) Close() error { return r.relay.Close() } diff --git a/client/main_test.go b/client/main_test.go old mode 100644 new mode 100755 diff --git a/client/manual_captcha.go b/client/manual_captcha.go old mode 100644 new mode 100755 index 2726d68..916043b --- a/client/manual_captcha.go +++ b/client/manual_captcha.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -343,6 +344,12 @@ func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport { TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ForceAttemptHTTP2: false, + // Use the same CA bundle as the main tls-client (system pool → + // embedded Mozilla bundle). Without this, the captcha proxy fails + // on Android with "x509: certificate signed by unknown authority" + // because HARICA TLS RSA Root CA 2021 is not in the empty Android + // system cert pool. + TLSClientConfig: &tls.Config{RootCAs: loadCABundle()}, } if dialer != nil { transport.DialContext = dialer.DialContext diff --git a/client/manual_captcha_test.go b/client/manual_captcha_test.go old mode 100644 new mode 100755 diff --git a/client/namegen.go b/client/namegen.go old mode 100644 new mode 100755 diff --git a/client/profiles.go b/client/profiles.go old mode 100644 new mode 100755 diff --git a/client/slider_captcha.go b/client/slider_captcha.go old mode 100644 new mode 100755 diff --git a/client/slider_captcha_test.go b/client/slider_captcha_test.go old mode 100644 new mode 100755 diff --git a/client/wrap.go b/client/wrap.go new file mode 100755 index 0000000..d8efe80 --- /dev/null +++ b/client/wrap.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "sync/atomic" + + "golang.org/x/crypto/chacha20poly1305" +) + +// Wire format — SRTP-like mimicry: +// +// [12B RTP header | 12B explicit nonce | AEAD ciphertext | 16B tag] +// +// RTP header (RFC 3550): +// +// byte 0: 0x80 V=2, P=0, X=0, CC=0 +// byte 1: 0x6F M=0, PT=111 (opus, typical voice PT) +// byte 2-3: seq16 BE monotonic, init random +// byte 4-7: ts32 BE monotonic, init random, increments by 960 (20ms @ 48kHz) +// byte 8-11: SSRC random per conn, MSB encodes direction +// +// 12B explicit nonce = 4B sessionID || 8B counter (BE). sessionID MSB +// matches SSRC MSB (direction bit). counter starts at a random uint64. +// AAD = first 24 bytes (RTP header || nonce). +// +// VK TURN appears to forward SRTP-shaped ChannelData on a fast path and +// drop anomalous payloads. AEAD ciphertext + 16B tag is plausible as +// AES-GCM SRTP per RFC 7714. + +const ( + wrapKeyLen = 32 + wrapRTPHdrLen = 12 + wrapNonceLen = 12 + wrapTagLen = 16 + wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen // 24 + wrapOverhead = wrapHeaderLen + wrapTagLen // 40 + wrapRTPVersion = 0x80 // V=2, P=0, X=0, CC=0 + wrapRTPPT = 0x6F // M=0, PT=111 (opus) + wrapTSStep = 960 // 20ms @ 48kHz +) + +type wrapConn struct { + aead cipher.AEAD + sessionID [4]byte // 4B prefix for nonce; MSB encodes direction + ssrc [4]byte // SSRC for RTP header; MSB encodes direction + counter atomic.Uint64 + seq atomic.Uint32 // RTP sequence (used as uint16) + timestamp atomic.Uint32 // RTP timestamp +} + +func newWrapConn(key []byte, isServer bool) (*wrapConn, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, fmt.Errorf("wrap: aead init: %w", err) + } + w := &wrapConn{aead: aead} + + var rnd [16]byte + if _, err := rand.Read(rnd[:]); err != nil { + return nil, fmt.Errorf("wrap: rand init: %w", err) + } + copy(w.sessionID[:], rnd[0:4]) + copy(w.ssrc[:], rnd[4:8]) + if isServer { + w.sessionID[0] |= 0x80 + w.ssrc[0] |= 0x80 + } else { + w.sessionID[0] &^= 0x80 + w.ssrc[0] &^= 0x80 + } + w.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10]))) + w.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14])) + + var cb [8]byte + if _, err := rand.Read(cb[:]); err != nil { + return nil, fmt.Errorf("wrap: counter rand: %w", err) + } + w.counter.Store(binary.BigEndian.Uint64(cb[:])) + return w, nil +} + +// wrapMaxWire returns max wire bytes for a given payload size. +func wrapMaxWire(payloadLen int) int { + return wrapOverhead + payloadLen +} + +func (w *wrapConn) wrapInto(dst, payload []byte) (int, error) { + wireLen := wrapOverhead + len(payload) + if len(dst) < wireLen { + return 0, errors.New("wrap: dst buffer too small") + } + + // RTP header. + dst[0] = wrapRTPVersion + dst[1] = wrapRTPPT + seq := uint16(w.seq.Add(1) - 1) + binary.BigEndian.PutUint16(dst[2:4], seq) + ts := w.timestamp.Add(wrapTSStep) - wrapTSStep + binary.BigEndian.PutUint32(dst[4:8], ts) + copy(dst[8:12], w.ssrc[:]) + + // Explicit nonce. + noncePos := wrapRTPHdrLen + copy(dst[noncePos:noncePos+4], w.sessionID[:]) + ctr := w.counter.Add(1) - 1 + binary.BigEndian.PutUint64(dst[noncePos+4:noncePos+wrapNonceLen], ctr) + + nonce := dst[noncePos : noncePos+wrapNonceLen] + aad := dst[:wrapHeaderLen] + ctPos := wrapHeaderLen + copy(dst[ctPos:], payload) + w.aead.Seal(dst[ctPos:ctPos], nonce, dst[ctPos:ctPos+len(payload)], aad) + + return wireLen, nil +} + +func (w *wrapConn) unwrapPacket(wire, dst []byte) (int, error) { + if len(wire) < wrapOverhead { + return 0, errors.New("wrap: packet too short") + } + nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen] + aad := wire[:wrapHeaderLen] + ct := wire[wrapHeaderLen:] + + plain, err := w.aead.Open(ct[:0], nonce, ct, aad) + if err != nil { + return 0, fmt.Errorf("wrap: AEAD open: %w", err) + } + if len(plain) > len(dst) { + return 0, errors.New("wrap: dst buffer too small") + } + copy(dst[:len(plain)], plain) + return len(plain), nil +} + +// --- Helpers --- + +func genWrapKeyHex() (string, error) { + key := make([]byte, wrapKeyLen) + if _, err := rand.Read(key); err != nil { + return "", fmt.Errorf("wrap: key gen: %w", err) + } + return hex.EncodeToString(key), nil +} + +func decodeWrapKey(enabled bool, raw string) ([]byte, error) { + if !enabled { + return nil, nil + } + if raw == "" { + return nil, errors.New("-wrap requires -wrap-key") + } + key, err := hex.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("-wrap-key invalid hex: %w", err) + } + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(key)) + } + return key, nil +} diff --git a/client/wrap_test.go b/client/wrap_test.go new file mode 100755 index 0000000..a7a561b --- /dev/null +++ b/client/wrap_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" +) + +func TestWrapConnRoundTrip(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, wrapKeyLen) + payload := []byte("dtls record bytes") + + client, err := newWrapConn(key, false) + if err != nil { + t.Fatalf("newWrapConn(client): %v", err) + } + server, err := newWrapConn(key, true) + if err != nil { + t.Fatalf("newWrapConn(server): %v", err) + } + + wire := make([]byte, wrapMaxWire(len(payload))) + n, err := client.wrapInto(wire, payload) + if err != nil { + t.Fatalf("wrapInto: %v", err) + } + wire = wire[:n] + + if wire[0] != wrapRTPVersion { + t.Fatalf("RTP byte0 = 0x%02X, want 0x%02X", wire[0], wrapRTPVersion) + } + if wire[1] != wrapRTPPT { + t.Fatalf("RTP byte1 (PT) = 0x%02X, want 0x%02X", wire[1], wrapRTPPT) + } + if bytes.Contains(wire, payload) { + t.Fatalf("wrapped packet contains plaintext payload") + } + + dst := make([]byte, 1600) + m, err := server.unwrapPacket(wire, dst) + if err != nil { + t.Fatalf("unwrapPacket: %v", err) + } + if m != len(payload) { + t.Fatalf("unwrapped len = %d, want %d", m, len(payload)) + } + if !bytes.Equal(dst[:m], payload) { + t.Fatalf("round trip mismatch: got %q want %q", dst[:m], payload) + } + + // Server → Client + wire2 := make([]byte, wrapMaxWire(len(payload))) + n2, err := server.wrapInto(wire2, payload) + if err != nil { + t.Fatalf("server wrapInto: %v", err) + } + m2, err := client.unwrapPacket(wire2[:n2], dst) + if err != nil { + t.Fatalf("client unwrapPacket: %v", err) + } + if !bytes.Equal(dst[:m2], payload) { + t.Fatalf("server→client round trip mismatch") + } +} + +func TestWrapRTPHeaderProgression(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, wrapKeyLen) + wc, err := newWrapConn(key, false) + if err != nil { + t.Fatalf("newWrapConn: %v", err) + } + payload := []byte("x") + + wire1 := make([]byte, wrapMaxWire(len(payload))) + n1, err := wc.wrapInto(wire1, payload) + if err != nil { + t.Fatalf("wrapInto 1: %v", err) + } + wire2 := make([]byte, wrapMaxWire(len(payload))) + n2, err := wc.wrapInto(wire2, payload) + if err != nil { + t.Fatalf("wrapInto 2: %v", err) + } + if n1 != n2 { + t.Fatalf("wire size variance: %d vs %d", n1, n2) + } + + seq1 := binary.BigEndian.Uint16(wire1[2:4]) + seq2 := binary.BigEndian.Uint16(wire2[2:4]) + if seq2 != seq1+1 { + t.Fatalf("seq did not increment: %d → %d", seq1, seq2) + } + + ts1 := binary.BigEndian.Uint32(wire1[4:8]) + ts2 := binary.BigEndian.Uint32(wire2[4:8]) + if ts2-ts1 != wrapTSStep { + t.Fatalf("timestamp step = %d, want %d", ts2-ts1, wrapTSStep) + } + + // SSRC stable across packets. + if !bytes.Equal(wire1[8:12], wire2[8:12]) { + t.Fatalf("SSRC changed between packets") + } +} + +func TestWrapDirectionBit(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, wrapKeyLen) + client, err := newWrapConn(key, false) + if err != nil { + t.Fatalf("newWrapConn(client): %v", err) + } + server, err := newWrapConn(key, true) + if err != nil { + t.Fatalf("newWrapConn(server): %v", err) + } + + if client.sessionID[0]&0x80 != 0 { + t.Fatalf("client sessionID MSB should be 0, got 0x%02X", client.sessionID[0]) + } + if server.sessionID[0]&0x80 == 0 { + t.Fatalf("server sessionID MSB should be 1, got 0x%02X", server.sessionID[0]) + } + if client.ssrc[0]&0x80 != 0 { + t.Fatalf("client SSRC MSB should be 0, got 0x%02X", client.ssrc[0]) + } + if server.ssrc[0]&0x80 == 0 { + t.Fatalf("server SSRC MSB should be 1, got 0x%02X", server.ssrc[0]) + } +} + +func TestDecodeWrapKeyRequiresValidKeyWhenEnabled(t *testing.T) { + if key, err := decodeWrapKey(false, ""); err != nil || key != nil { + t.Fatalf("disabled decodeWrapKey = (%v, %v), want (nil, nil)", key, err) + } + + if _, err := decodeWrapKey(true, ""); err == nil { + t.Fatalf("decodeWrapKey accepted empty key") + } + + shortHex := strings.Repeat("ab", wrapKeyLen-1) + if _, err := decodeWrapKey(true, shortHex); err == nil { + t.Fatalf("decodeWrapKey accepted short key") + } + + fullHex := strings.Repeat("ab", wrapKeyLen) + key, err := decodeWrapKey(true, fullHex) + if err != nil { + t.Fatalf("decodeWrapKey returned error: %v", err) + } + if len(key) != wrapKeyLen { + t.Fatalf("decoded key len = %d, want %d", len(key), wrapKeyLen) + } +} + +func TestUnwrapRejectsShortPacket(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, wrapKeyLen) + wc, err := newWrapConn(key, false) + if err != nil { + t.Fatalf("newWrapConn: %v", err) + } + if _, err := wc.unwrapPacket([]byte("short"), make([]byte, 16)); err == nil { + t.Fatalf("unwrapPacket accepted short packet") + } +} + +func TestUnwrapRejectsTamperedPacket(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, wrapKeyLen) + client, err := newWrapConn(key, false) + if err != nil { + t.Fatalf("newWrapConn(client): %v", err) + } + server, err := newWrapConn(key, true) + if err != nil { + t.Fatalf("newWrapConn(server): %v", err) + } + + payload := []byte("integrity test") + wire := make([]byte, wrapMaxWire(len(payload))) + n, err := client.wrapInto(wire, payload) + if err != nil { + t.Fatalf("wrapInto: %v", err) + } + wire = wire[:n] + + // Flip a bit in the ciphertext. + wire[wrapHeaderLen+1] ^= 0xFF + + dst := make([]byte, 1600) + if _, err := server.unwrapPacket(wire, dst); err == nil { + t.Fatalf("unwrapPacket accepted tampered ciphertext") + } + + // Re-wrap and tamper RTP header (AAD). + n2, _ := client.wrapInto(wire, payload) + wire = wire[:n2] + wire[8] ^= 0x01 // flip a bit in SSRC + if _, err := server.unwrapPacket(wire, dst); err == nil { + t.Fatalf("unwrapPacket accepted tampered AAD") + } +} diff --git a/deploy/vk-captcha-solver.service b/deploy/vk-captcha-solver.service old mode 100644 new mode 100755 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh old mode 100644 new mode 100755 diff --git a/go.mod b/go.mod old mode 100644 new mode 100755 diff --git a/go.sum b/go.sum old mode 100644 new mode 100755 diff --git a/routes-macos.sh b/routes-macos.sh old mode 100644 new mode 100755 diff --git a/routes.ps1 b/routes.ps1 old mode 100644 new mode 100755 diff --git a/routes.sh b/routes.sh old mode 100644 new mode 100755 diff --git a/scripts/captcha_solver.py b/scripts/captcha_solver.py old mode 100644 new mode 100755 diff --git a/server/main.go b/server/main.go old mode 100644 new mode 100755 index cdf91d4..0e78be2 --- a/server/main.go +++ b/server/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "flag" "fmt" "io" @@ -19,12 +21,70 @@ import ( "github.com/xtaci/smux" ) +// customResolver uses public DNS servers as fallback for domain resolution. +var customResolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + var d net.Dialer + dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"} + var lastErr error + for _, dns := range dnsServers { + conn, err := d.DialContext(ctx, "udp", dns) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr + }, +} + +// resolveUDPAddr resolves a host:port string to *net.UDPAddr. +// Works with both IP addresses and domain names. +func resolveUDPAddr(network, address string) (*net.UDPAddr, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("split host:port %q: %w", address, err) + } + + // If host is already an IP address, skip DNS resolution + if ip := net.ParseIP(host); ip != nil { + return net.ResolveUDPAddr(network, address) + } + + // Resolve domain name using custom resolver (public DNS fallback) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ips, err := customResolver.LookupIP(ctx, "ip4", host) + if err != nil || len(ips) == 0 { + // Fallback: try system resolver + return net.ResolveUDPAddr(network, address) + } + + resolved := net.JoinHostPort(ips[0].String(), port) + log.Printf("[DNS] resolved %s → %s", host, ips[0].String()) + return net.ResolveUDPAddr(network, resolved) +} + func main() { listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port") connect := flag.String("connect", "", "connect to ip:port") vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") + wrapMode := flag.Bool("wrap", false, "WRAP mode: SRTP-mimicry AEAD wrap. Required when client uses -wrap.") + wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)") + genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit") flag.Parse() + if *genWrapKey { + key := make([]byte, wrapKeyLen) + if _, err := rand.Read(key); err != nil { + log.Panicf("gen-wrap-key: %v", err) + } + fmt.Println(hex.EncodeToString(key)) + return + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() signalChan := make(chan os.Signal, 1) @@ -37,32 +97,53 @@ func main() { log.Fatalf("Exit...\n") }() - addr, err := net.ResolveUDPAddr("udp", *listen) + addr, err := resolveUDPAddr("udp", *listen) if err != nil { panic(err) } if len(*connect) == 0 { log.Panicf("server address is required") } + var wrapKey []byte + if *wrapMode { + if *wrapKeyHex == "" { + log.Panicf("-wrap requires -wrap-key") + } + wrapKey, err = hex.DecodeString(*wrapKeyHex) + if err != nil { + log.Panicf("-wrap-key invalid hex: %v", err) + } + if len(wrapKey) != wrapKeyLen { + log.Panicf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(wrapKey)) + } + } + + log.Printf("Starting server listen=%s connect=%s vless=%t wrap=%t", *listen, *connect, *vlessMode, *wrapMode) + // Generate a certificate and private key to secure the connection certificate, genErr := selfsign.GenerateSelfSigned() if genErr != nil { panic(genErr) } - // - // Everything below is the pion-DTLS API! Thanks for using it ❤️. - // - - // Connect to a DTLS server - listener, err := dtls.ListenWithOptions( - "udp", - addr, + dtlsOpts := []dtls.ServerOption{ dtls.WithCertificates(certificate), dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret), dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)), - ) + } + + var listener net.Listener + if *wrapMode { + log.Printf("WRAP mode enabled: listener only accepts clients with matching -wrap-key") + wrapListener, werr := listenWrapped(addr, wrapKey) + if werr != nil { + panic(werr) + } + listener, err = dtls.NewListenerWithOptions(wrapListener, dtlsOpts...) + } else { + listener, err = dtls.ListenWithOptions("udp", addr, dtlsOpts...) + } if err != nil { panic(err) } diff --git a/server/wrap.go b/server/wrap.go new file mode 100755 index 0000000..8bf60c1 --- /dev/null +++ b/server/wrap.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "net" + "sync" + "sync/atomic" + "time" + + dtlsnet "github.com/pion/dtls/v3/pkg/net" + pionudp "github.com/pion/transport/v4/udp" + "golang.org/x/crypto/chacha20poly1305" +) + +// Wire format is identical to client — see client/wrap.go. Server sets the +// MSB of sessionID/SSRC; client clears it. RTP header fields are per-conn. + +const ( + wrapKeyLen = 32 + wrapRTPHdrLen = 12 + wrapNonceLen = 12 + wrapTagLen = 16 + wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen + wrapOverhead = wrapHeaderLen + wrapTagLen + wrapRTPVersion = 0x80 + wrapRTPPT = 0x6F + wrapTSStep = 960 +) + +// bufPool eliminates per-packet heap allocation on the hot read/write paths. +var bufPool = sync.Pool{ + New: func() any { + b := make([]byte, 1600+wrapOverhead) + return &b + }, +} + +// wrapState holds the AEAD instance shared by all connections under one key. +type wrapState struct { + aead cipher.AEAD +} + +func newWrapState(key []byte) (*wrapState, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, fmt.Errorf("wrap: aead init: %w", err) + } + return &wrapState{aead: aead}, nil +} + +// --- Listener --- + +func listenWrapped(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) { + ws, err := newWrapState(key) + if err != nil { + return nil, err + } + inner, err := pionudp.Listen("udp", addr) + if err != nil { + return nil, fmt.Errorf("wrap: udp listen: %w", err) + } + return &wrapPacketListener{ + inner: dtlsnet.PacketListenerFromListener(inner), + ws: ws, + }, nil +} + +type wrapPacketListener struct { + inner dtlsnet.PacketListener + ws *wrapState +} + +func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) { + pc, addr, err := l.inner.Accept() + if err != nil { + return pc, addr, err + } + c := &wrapPacketConn{inner: pc, ws: l.ws} + + var rnd [16]byte + if _, err := rand.Read(rnd[:]); err != nil { + return nil, addr, fmt.Errorf("wrap: rand init: %w", err) + } + copy(c.sessionID[:], rnd[0:4]) + copy(c.ssrc[:], rnd[4:8]) + c.sessionID[0] |= 0x80 + c.ssrc[0] |= 0x80 + c.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10]))) + c.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14])) + + var cb [8]byte + if _, err := rand.Read(cb[:]); err != nil { + return nil, addr, fmt.Errorf("wrap: counter rand: %w", err) + } + c.counter.Store(binary.BigEndian.Uint64(cb[:])) + return c, addr, nil +} + +func (l *wrapPacketListener) Close() error { return l.inner.Close() } +func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() } + +// --- Per-peer PacketConn --- + +type wrapPacketConn struct { + inner net.PacketConn + ws *wrapState + sessionID [4]byte + ssrc [4]byte + counter atomic.Uint64 + seq atomic.Uint32 + timestamp atomic.Uint32 +} + +func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte + buf := *bp + need := len(p) + wrapOverhead + if cap(buf) < need { + buf = make([]byte, need) + *bp = buf + } + defer bufPool.Put(bp) + + n, addr, err := c.inner.ReadFrom(buf[:cap(buf)]) + if err != nil { + return 0, addr, err + } + wire := buf[:n] + if len(wire) < wrapOverhead { + return 0, addr, errors.New("wrap: packet too short") + } + nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen] + aad := wire[:wrapHeaderLen] + ct := wire[wrapHeaderLen:] + + plain, err := c.ws.aead.Open(ct[:0], nonce, ct, aad) + if err != nil { + return 0, addr, fmt.Errorf("wrap: AEAD open: %w", err) + } + if len(plain) > len(p) { + return 0, addr, errors.New("wrap: dst buffer too small") + } + copy(p[:len(plain)], plain) + return len(plain), addr, nil +} + +func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + wireLen := wrapOverhead + len(p) + + bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte + out := *bp + if cap(out) < wireLen { + out = make([]byte, wireLen) + *bp = out + } + out = out[:wireLen] + defer bufPool.Put(bp) + + // RTP header. + out[0] = wrapRTPVersion + out[1] = wrapRTPPT + seq := uint16(c.seq.Add(1) - 1) + binary.BigEndian.PutUint16(out[2:4], seq) + ts := c.timestamp.Add(wrapTSStep) - wrapTSStep + binary.BigEndian.PutUint32(out[4:8], ts) + copy(out[8:12], c.ssrc[:]) + + noncePos := wrapRTPHdrLen + copy(out[noncePos:noncePos+4], c.sessionID[:]) + ctr := c.counter.Add(1) - 1 + binary.BigEndian.PutUint64(out[noncePos+4:noncePos+wrapNonceLen], ctr) + + nonce := out[noncePos : noncePos+wrapNonceLen] + aad := out[:wrapHeaderLen] + ctPos := wrapHeaderLen + copy(out[ctPos:], p) + c.ws.aead.Seal(out[ctPos:ctPos], nonce, out[ctPos:ctPos+len(p)], aad) + + if _, err := c.inner.WriteTo(out, addr); err != nil { + return 0, err + } + return len(p), nil +} + +func (c *wrapPacketConn) Close() error { return c.inner.Close() } +func (c *wrapPacketConn) LocalAddr() net.Addr { return c.inner.LocalAddr() } +func (c *wrapPacketConn) SetDeadline(t time.Time) error { return c.inner.SetDeadline(t) } +func (c *wrapPacketConn) SetReadDeadline(t time.Time) error { return c.inner.SetReadDeadline(t) } +func (c *wrapPacketConn) SetWriteDeadline(t time.Time) error { return c.inner.SetWriteDeadline(t) } diff --git a/tcputil/tcputil.go b/tcputil/tcputil.go old mode 100644 new mode 100755