Browse Source

feat: domain name resolution for peer, TURN server, and listen address

Replace net.ResolveUDPAddr with a custom resolveUDPAddr() that:
1. Detects if host is an IP address → uses it directly
2. If it's a domain name → resolves via custom DNS resolver
   (public DNS servers: 77.88.8.8, 8.8.8.8, 1.1.1.1, etc.)
3. Falls back to system resolver if custom resolver fails
4. Logs: [DNS] resolved example.com → 1.2.3.4

This enables using domain names instead of IP addresses for:
- -peer (client): e.g. -peer myserver.com:56000
- -listen (server): e.g. -listen myserver.com:56000
- TURN server (resolved from VK API response)

Critical for Android (CGO_ENABLED=0) where the system Go resolver
may not work — the custom resolver queries public DNS servers directly.
pull/183/head
Z User 2 days ago
parent
commit
b8c4ffab11
  1. 0
      .github/workflows/.golangci.yml
  2. 0
      .github/workflows/ci.yml
  3. 0
      .github/workflows/issues.yml
  4. 0
      .github/workflows/release.yml
  5. 0
      Dockerfile
  6. 0
      LICENSE
  7. 0
      README.md
  8. 0
      client/ca_bundle.go
  9. 0
      client/captcha_parse_test.go
  10. 0
      client/certs/cacert.pem
  11. 160
      client/main.go
  12. 0
      client/main_test.go
  13. 7
      client/manual_captcha.go
  14. 0
      client/manual_captcha_test.go
  15. 0
      client/namegen.go
  16. 0
      client/profiles.go
  17. 0
      client/slider_captcha.go
  18. 0
      client/slider_captcha_test.go
  19. 171
      client/wrap.go
  20. 201
      client/wrap_test.go
  21. 0
      deploy/vk-captcha-solver.service
  22. 0
      docker-entrypoint.sh
  23. 0
      go.mod
  24. 0
      go.sum
  25. 0
      routes-macos.sh
  26. 0
      routes.ps1
  27. 0
      routes.sh
  28. 0
      scripts/captcha_solver.py
  29. 101
      server/main.go
  30. 198
      server/wrap.go
  31. 0
      tcputil/tcputil.go

0
.github/workflows/.golangci.yml

0
.github/workflows/ci.yml

0
.github/workflows/issues.yml

0
.github/workflows/release.yml

0
Dockerfile

0
LICENSE

0
README.md

0
client/ca_bundle.go

0
client/captcha_parse_test.go

0
client/certs/cacert.pem

160
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 // endregion
// region Automatic Captcha Solver & Authentication // region Automatic Captcha Solver & Authentication
@ -1715,6 +1765,7 @@ type turnParams struct {
link string link string
udp bool udp bool
getCreds getCredsFunc getCreds getCredsFunc
wrapKey []byte
} }
func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, streamID int, c chan<- error) { 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 var turnServerAddr string
turnServerAddr = net.JoinHostPort(urlhost, urlport) turnServerAddr = net.JoinHostPort(urlhost, urlport)
turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr) turnServerUDPAddr, err1 := resolveUDPAddr("udp", turnServerAddr)
if err1 != nil { if err1 != nil {
err = fmt.Errorf("failed to resolve TURN server address: %s", err1) err = fmt.Errorf("failed to resolve TURN server address: %s", err1)
return return
@ -1845,9 +1896,24 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
}) })
var internalPipeAddr atomic.Value 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() { go func() {
defer turncancel() defer turncancel()
buf := make([]byte, 1600) buf := make([]byte, 1600)
var wireBuf []byte
if wc != nil {
wireBuf = make([]byte, wrapMaxWire(len(buf)))
}
for { for {
if turnctx.Err() != nil { if turnctx.Err() != nil {
return return
@ -1862,7 +1928,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
internalPipeAddr.Store(addr1) 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 { if err1 != nil {
return return
} }
@ -1872,7 +1948,12 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
go func() { go func() {
defer wg.Done() defer wg.Done()
defer turncancel() defer turncancel()
buf := make([]byte, 1600) readBufLen := 1600
if wc != nil {
readBufLen = wrapMaxWire(1600)
}
buf := make([]byte, readBufLen)
plain := make([]byte, 1600)
for { for {
n, _, err1 := relayConn.ReadFrom(buf) n, _, err1 := relayConn.ReadFrom(buf)
if err1 != nil { 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 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 return
} }
} }
@ -2006,11 +2096,23 @@ func main() {
debugFlag := flag.Bool("debug", false, "enable debug logging") debugFlag := flag.Bool("debug", false, "enable debug logging")
manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") 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.") 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() flag.Parse()
if *genWrapKey {
key, err := genWrapKeyHex()
if err != nil {
log.Panicf("gen-wrap-key: %v", err)
}
fmt.Println(key)
return
}
if *peerAddr == "" { if *peerAddr == "" {
log.Panicf("Need peer address!") log.Panicf("Need peer address!")
} }
peer, err := net.ResolveUDPAddr("udp", *peerAddr) peer, err := resolveUDPAddr("udp", *peerAddr)
if err != nil { if err != nil {
panic(err) 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) 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 link string
var getCreds getCredsFunc var getCreds getCredsFunc
if *vklink != "" { if *vklink != "" {
@ -2064,6 +2177,7 @@ func main() {
link: link, link: link,
udp: *udp, udp: *udp,
getCreds: getCreds, getCreds: getCreds,
wrapKey: wrapKey,
} }
if *vlessMode { if *vlessMode {
@ -2356,7 +2470,7 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
urlport = tp.port urlport = tp.port
} }
turnServerAddr := net.JoinHostPort(urlhost, urlport) turnServerAddr := net.JoinHostPort(urlhost, urlport)
turnServerUDPAddr, err := net.ResolveUDPAddr("udp", turnServerAddr) turnServerUDPAddr, err := resolveUDPAddr("udp", turnServerAddr)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("resolve TURN addr: %w", err) 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() cleanup()
return nil, nil, fmt.Errorf("generate cert: %w", err) 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, dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer,
dtls.WithCertificates(certificate), dtls.WithCertificates(certificate),
dtls.WithInsecureSkipVerify(true), dtls.WithInsecureSkipVerify(true),
@ -2472,14 +2594,38 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
type relayPacketConn struct { type relayPacketConn struct {
relay net.PacketConn relay net.PacketConn
peer net.Addr peer net.Addr
wc *wrapConn
} }
func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
if r.wc == nil {
return r.relay.ReadFrom(b) 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) { func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
if r.wc == nil {
return r.relay.WriteTo(b, r.peer) 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() } func (r *relayPacketConn) Close() error { return r.relay.Close() }

0
client/main_test.go

7
client/manual_captcha.go

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -343,6 +344,12 @@ func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport {
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: false, 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 { if dialer != nil {
transport.DialContext = dialer.DialContext transport.DialContext = dialer.DialContext

0
client/manual_captcha_test.go

0
client/namegen.go

0
client/profiles.go

0
client/slider_captcha.go

0
client/slider_captcha_test.go

171
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
}

201
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")
}
}

0
deploy/vk-captcha-solver.service

0
docker-entrypoint.sh

0
go.mod

0
go.sum

0
routes-macos.sh

0
routes.ps1

0
routes.sh

0
scripts/captcha_solver.py

101
server/main.go

@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -19,12 +21,70 @@ import (
"github.com/xtaci/smux" "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() { func main() {
listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port") listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port")
connect := flag.String("connect", "", "connect to 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") 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() 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()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
signalChan := make(chan os.Signal, 1) signalChan := make(chan os.Signal, 1)
@ -37,32 +97,53 @@ func main() {
log.Fatalf("Exit...\n") log.Fatalf("Exit...\n")
}() }()
addr, err := net.ResolveUDPAddr("udp", *listen) addr, err := resolveUDPAddr("udp", *listen)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if len(*connect) == 0 { if len(*connect) == 0 {
log.Panicf("server address is required") 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 // Generate a certificate and private key to secure the connection
certificate, genErr := selfsign.GenerateSelfSigned() certificate, genErr := selfsign.GenerateSelfSigned()
if genErr != nil { if genErr != nil {
panic(genErr) panic(genErr)
} }
// dtlsOpts := []dtls.ServerOption{
// Everything below is the pion-DTLS API! Thanks for using it ❤️.
//
// Connect to a DTLS server
listener, err := dtls.ListenWithOptions(
"udp",
addr,
dtls.WithCertificates(certificate), dtls.WithCertificates(certificate),
dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret), dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)), 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 { if err != nil {
panic(err) panic(err)
} }

198
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) }

0
tcputil/tcputil.go

Loading…
Cancel
Save