From 0f244412ec37887bc9d58fab6c18057b30f1401c Mon Sep 17 00:00:00 2001 From: Moroka8 Date: Thu, 7 May 2026 14:47:00 +0700 Subject: [PATCH] feat: add TURN ChannelData obfuscation Port the WRAP layer. Obfuscate DTLS packets before they enter TURN ChannelData with per-packet ChaCha20-XOR, and fail fast on invalid WRAP keys. Co-Authored-By: Anton Monakhov --- Dockerfile | 2 +- client/main.go | 97 ++++++++++++++++++++++++++++++++++++++----- client/wrap.go | 78 ++++++++++++++++++++++++++++++++++ client/wrap_test.go | 66 +++++++++++++++++++++++++++++ docker-entrypoint.sh | 11 ++++- server/main.go | 47 ++++++++++++++++++--- server/wrap.go | 99 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 client/wrap.go create mode 100644 client/wrap_test.go create mode 100644 server/wrap.go diff --git a/Dockerfile b/Dockerfile index eaaec0c..9ba25b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ WORKDIR /app COPY docker-entrypoint.sh . COPY --from=builder /build/vk-turn-proxy . -RUN chmod +x docker-entrypoint.sh +RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh EXPOSE 56000/tcp EXPOSE 56000/udp diff --git a/client/main.go b/client/main.go index 7305f37..93147a3 100644 --- a/client/main.go +++ b/client/main.go @@ -801,9 +801,10 @@ const ( cacheSafetyMargin = 60 * time.Second maxCacheErrors = 3 errorWindow = 10 * time.Second - streamsPerCache = 10 ) +var streamsPerCache = 10 + func getCacheID(streamID int) int { return streamID / streamsPerCache } @@ -1742,6 +1743,7 @@ type turnParams struct { port string link string udp bool + wrapKey []byte getCreds getCredsFunc } @@ -1875,6 +1877,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD // Do not set conn2 deadline (conn2 can sometimes be listenConn if direct mode is used) }) var internalPipeAddr atomic.Value + useWrap := len(turnParams.wrapKey) == wrapKeyLen go func() { defer turncancel() @@ -1893,7 +1896,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD internalPipeAddr.Store(addr1) - written, err1 := relayConn.WriteTo(buf[:n], peer) + out := buf[:n] + if useWrap { + wrapped, wrapErr := wrapPacket(turnParams.wrapKey, out) + if wrapErr != nil { + log.Printf("[STREAM %d] WRAP failed: %v", streamID, wrapErr) + return + } + out = wrapped + } + + written, err1 := relayConn.WriteTo(out, peer) stats.addTx(written) if err1 != nil { return @@ -1904,7 +1917,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 useWrap { + readBufLen += wrapNonceLen + } + buf := make([]byte, readBufLen) + plain := make([]byte, 1600) for { n, _, err1 := relayConn.ReadFrom(buf) if err1 != nil { @@ -1916,8 +1934,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } if addr, ok := addr1.(net.Addr); ok { - stats.addRx(n) - if _, err := conn2.WriteTo(buf[:n], addr); err != nil { + payload := buf[:n] + if useWrap { + m, wrapErr := unwrapPacket(turnParams.wrapKey, payload, plain) + if wrapErr != nil { + log.Printf("[STREAM %d] UNWRAP failed: %v (n=%d)", streamID, wrapErr, n) + continue + } + payload = plain[:m] + } + stats.addRx(len(payload)) + if _, err := conn2.WriteTo(payload, addr); err != nil { return } } @@ -2054,10 +2081,22 @@ func main() { direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") vlessBond := flag.Bool("vless-bond", false, "bond one VLESS TCP connection across all active smux sessions") + wrapMode := flag.Bool("wrap", false, "WRAP mode: ChaCha20-XOR obfuscate DTLS packets before they reach TURN ChannelData") + 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") + streamsPerCredFlag := flag.Int("streams-per-cred", streamsPerCache, "number of TURN streams sharing one VK credential cache") 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", "v2", "auto captcha solver implementation: v1|v2") flag.Parse() + if *genWrapKey { + key, err := genWrapKeyHex() + if err != nil { + log.Panicf("%v", err) + } + fmt.Println(key) + return + } if *peerAddr == "" { log.Panicf("Need peer address!") } @@ -2068,6 +2107,20 @@ func main() { if (*vklink == "") == (*yalink == "") { log.Panicf("Need either vk-link or yandex-link!") } + 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: peer server must use matching -wrap-key") + } + if *streamsPerCredFlag <= 0 { + log.Panicf("-streams-per-cred must be positive") + } + streamsPerCache = *streamsPerCredFlag isDebug = *debugFlag manualCaptcha = *manualCaptchaFlag @@ -2114,6 +2167,7 @@ func main() { port: *port, link: link, udp: *udp, + wrapKey: wrapKey, getCreds: getCreds, } @@ -2856,7 +2910,7 @@ 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} + dtlsPC := &relayPacketConn{relay: relayConn, peer: peer, wrapKey: tp.wrapKey} dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer, dtls.WithCertificates(certificate), dtls.WithInsecureSkipVerify(true), @@ -2906,16 +2960,39 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i // relayPacketConn wraps a TURN relay PacketConn to direct all writes to the peer. type relayPacketConn struct { - relay net.PacketConn - peer net.Addr + relay net.PacketConn + peer net.Addr + wrapKey []byte } func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { - return r.relay.ReadFrom(b) + if len(r.wrapKey) != wrapKeyLen { + return r.relay.ReadFrom(b) + } + buf := make([]byte, len(b)+wrapNonceLen) + n, addr, err := r.relay.ReadFrom(buf) + if err != nil { + return 0, addr, err + } + m, err := unwrapPacket(r.wrapKey, 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 len(r.wrapKey) != wrapKeyLen { + return r.relay.WriteTo(b, r.peer) + } + out, err := wrapPacket(r.wrapKey, b) + if err != nil { + return 0, err + } + if _, err = r.relay.WriteTo(out, r.peer); err != nil { + return 0, err + } + return len(b), nil } func (r *relayPacketConn) Close() error { return r.relay.Close() } diff --git a/client/wrap.go b/client/wrap.go new file mode 100644 index 0000000..f271e13 --- /dev/null +++ b/client/wrap.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + "golang.org/x/crypto/chacha20" +) + +const ( + wrapNonceLen = 12 + wrapKeyLen = 32 +) + +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 +} + +func wrapPacket(key, payload []byte) ([]byte, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + out := make([]byte, wrapNonceLen+len(payload)) + if _, err := rand.Read(out[:wrapNonceLen]); err != nil { + return nil, fmt.Errorf("wrap: nonce gen: %w", err) + } + cipher, err := chacha20.NewUnauthenticatedCipher(key, out[:wrapNonceLen]) + if err != nil { + return nil, fmt.Errorf("wrap: cipher init: %w", err) + } + cipher.XORKeyStream(out[wrapNonceLen:], payload) + return out, nil +} + +func unwrapPacket(key, wire, dst []byte) (int, error) { + if len(key) != wrapKeyLen { + return 0, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + if len(wire) < wrapNonceLen { + return 0, errors.New("wrap: short packet (no nonce)") + } + nonce := wire[:wrapNonceLen] + ciphertext := wire[wrapNonceLen:] + if len(ciphertext) > len(dst) { + return 0, errors.New("wrap: dst buffer too small") + } + cipher, err := chacha20.NewUnauthenticatedCipher(key, nonce) + if err != nil { + return 0, fmt.Errorf("wrap: cipher init: %w", err) + } + cipher.XORKeyStream(dst[:len(ciphertext)], ciphertext) + return len(ciphertext), nil +} diff --git a/client/wrap_test.go b/client/wrap_test.go new file mode 100644 index 0000000..8a0162d --- /dev/null +++ b/client/wrap_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestWrapPacketRoundTrip(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, wrapKeyLen) + payload := []byte("dtls record bytes") + + wire, err := wrapPacket(key, payload) + if err != nil { + t.Fatalf("wrapPacket returned error: %v", err) + } + if len(wire) != len(payload)+wrapNonceLen { + t.Fatalf("wire len = %d, want %d", len(wire), len(payload)+wrapNonceLen) + } + if bytes.Contains(wire, payload) { + t.Fatalf("wrapped packet contains plaintext payload") + } + + dst := make([]byte, len(payload)) + n, err := unwrapPacket(key, wire, dst) + if err != nil { + t.Fatalf("unwrapPacket returned error: %v", err) + } + if n != len(payload) { + t.Fatalf("unwrapped len = %d, want %d", n, len(payload)) + } + if !bytes.Equal(dst[:n], payload) { + t.Fatalf("round trip mismatch: got %q want %q", dst[:n], payload) + } +} + +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 TestUnwrapPacketRejectsShortPacket(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, wrapKeyLen) + if _, err := unwrapPacket(key, []byte("short"), make([]byte, 16)); err == nil { + t.Fatalf("unwrapPacket accepted short packet") + } +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 86c3879..521dc00 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,6 +2,7 @@ set -e CONNECT="${CONNECT_ADDR:?CONNECT_ADDR is required}" +LISTEN="${LISTEN_ADDR:-0.0.0.0:56000}" VLESS_FLAG="" if [ "${VLESS_MODE}" = "true" ]; then @@ -13,4 +14,12 @@ if [ "${VLESS_BOND}" = "true" ]; then BOND_FLAG="-vless-bond" fi -exec ./vk-turn-proxy -listen 0.0.0.0:56000 -connect "$CONNECT" $VLESS_FLAG $BOND_FLAG +WRAP_FLAG="" +WRAP_KEY_FLAG="" +if [ "${WRAP_MODE}" = "true" ]; then + WRAP="${WRAP_KEY:?WRAP_KEY is required when WRAP_MODE=true}" + WRAP_FLAG="-wrap" + WRAP_KEY_FLAG="-wrap-key $WRAP" +fi + +exec ./vk-turn-proxy -listen "$LISTEN" -connect "$CONNECT" $VLESS_FLAG $BOND_FLAG $WRAP_FLAG $WRAP_KEY_FLAG diff --git a/server/main.go b/server/main.go index e7ecc80..704fa64 100644 --- a/server/main.go +++ b/server/main.go @@ -2,7 +2,9 @@ package main import ( "context" + "crypto/rand" "encoding/binary" + "encoding/hex" "flag" "fmt" "io" @@ -26,8 +28,20 @@ func main() { connect := flag.String("connect", "", "connect to ip:port") vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") vlessBond := flag.Bool("vless-bond", false, "bond one VLESS TCP connection across all active smux sessions") + wrapMode := flag.Bool("wrap", false, "WRAP mode: ChaCha20-XOR obfuscate DTLS packets before they reach TURN ChannelData") + 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: rand.Read: %v", err) + } + fmt.Println(hex.EncodeToString(key)) + return + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() signalChan := make(chan os.Signal, 1) @@ -47,7 +61,20 @@ func main() { if len(*connect) == 0 { log.Panicf("server address is required") } - log.Printf("Starting server listen=%s connect=%s vless=%t vless-bond=%t bond-autodetect=true", *listen, *connect, *vlessMode, *vlessBond) + 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 vless-bond=%t wrap=%t bond-autodetect=true", *listen, *connect, *vlessMode, *vlessBond, *wrapMode) // Generate a certificate and private key to secure the connection certificate, genErr := selfsign.GenerateSelfSigned() if genErr != nil { @@ -58,15 +85,23 @@ func main() { // 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 100644 index 0000000..be3d62a --- /dev/null +++ b/server/wrap.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "crypto/rand" + "errors" + "fmt" + "net" + "time" + + dtlsnet "github.com/pion/dtls/v3/pkg/net" + pionudp "github.com/pion/transport/v4/udp" + "golang.org/x/crypto/chacha20" +) + +const ( + wrapNonceLen = 12 + wrapKeyLen = 32 +) + +func listenWrapped(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + inner, err := pionudp.Listen("udp", addr) + if err != nil { + return nil, fmt.Errorf("wrap: udp listen: %w", err) + } + return &wrapPacketListener{ + inner: dtlsnet.PacketListenerFromListener(inner), + key: key, + }, nil +} + +type wrapPacketListener struct { + inner dtlsnet.PacketListener + key []byte +} + +func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) { + pc, addr, err := l.inner.Accept() + if err != nil { + return pc, addr, err + } + return &wrapPacketConn{inner: pc, key: l.key}, addr, nil +} + +func (l *wrapPacketListener) Close() error { return l.inner.Close() } +func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() } + +type wrapPacketConn struct { + inner net.PacketConn + key []byte +} + +func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + buf := make([]byte, len(p)+wrapNonceLen) + n, addr, err := c.inner.ReadFrom(buf) + if err != nil { + return 0, addr, err + } + if n < wrapNonceLen { + return 0, addr, errors.New("wrap: short packet (no nonce)") + } + nonce := buf[:wrapNonceLen] + ciphertext := buf[wrapNonceLen:n] + if len(ciphertext) > len(p) { + return 0, addr, errors.New("wrap: read buffer too small") + } + cipher, err := chacha20.NewUnauthenticatedCipher(c.key, nonce) + if err != nil { + return 0, addr, fmt.Errorf("wrap: cipher init: %w", err) + } + cipher.XORKeyStream(p[:len(ciphertext)], ciphertext) + return len(ciphertext), addr, nil +} + +func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + out := make([]byte, wrapNonceLen+len(p)) + if _, err := rand.Read(out[:wrapNonceLen]); err != nil { + return 0, fmt.Errorf("wrap: nonce gen: %w", err) + } + cipher, err := chacha20.NewUnauthenticatedCipher(c.key, out[:wrapNonceLen]) + if err != nil { + return 0, fmt.Errorf("wrap: cipher init: %w", err) + } + cipher.XORKeyStream(out[wrapNonceLen:], p) + 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) }