Browse Source

feat: add cf warp endpoint

pull/131/head
corviciuz 3 months ago
parent
commit
734cb47934
No known key found for this signature in database GPG Key ID: AB6A86440F9CD8C1
  1. 152
      client/main.go
  2. 208
      client/warp/api/cloudflare.go
  3. 200
      client/warp/api/masque.go
  4. 348
      client/warp/api/tunnel.go
  5. 120
      client/warp/config/config.go
  6. 23
      client/warp/internal/consts.go
  7. 195
      client/warp/internal/dns.go
  8. 318
      client/warp/internal/utils.go
  9. 65
      client/warp/internal/utils_windows.go
  10. 56
      client/warp/models/apierror.go
  11. 8
      client/warp/models/deviceupdate.go
  12. 102
      client/warp/models/register.go
  13. 305
      client/warp/proxy/mixed.go
  14. 256
      client/warp/runner.go
  15. 14
      go.mod
  16. 26
      go.sum

152
client/main.go

@ -33,6 +33,8 @@ import (
"github.com/bogdanfinn/tls-client/profiles"
"github.com/bschaatsbergen/dnsdialer"
"github.com/cacggghp/vk-turn-proxy/client/warp"
warpapi "github.com/cacggghp/vk-turn-proxy/client/warp/api"
"github.com/cacggghp/vk-turn-proxy/tcputil"
"github.com/cbeuw/connutil"
"github.com/google/uuid"
@ -1801,7 +1803,67 @@ func main() {
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
debugFlag := flag.Bool("debug", false, "enable debug logging")
manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
// Warp mode flags
warpMode := flag.Bool("warp", false, "enable Cloudflare Warp endpoint")
warpProxy := flag.String("proxy", "127.0.0.1:4080", "mixed SOCKS5/HTTP proxy listen address")
warpConfig := flag.String("config", "config.json", "path to config file (auto-registers if not found at default path)")
warpLocalDNS := flag.Bool("local-dns", false, "use system DNS instead of routing through the tunnel")
flag.Parse()
isDebug = *debugFlag
manualCaptcha = *manualCaptchaFlag
autoCaptchaSliderPOC = !manualCaptcha
// Extract call link early so it's available for both modes
var link string
if *vklink != "" {
parts := strings.Split(*vklink, "join/")
link = parts[len(parts)-1]
} else if *yalink != "" {
parts := strings.Split(*yalink, "j/")
link = parts[len(parts)-1]
}
if idx := strings.IndexAny(link, "/?#"); idx != -1 {
link = link[:idx]
}
// ── Warp mode ────────────────────────────────────────────────────────────────
if *warpMode {
warpCfg := warp.DefaultRunnerConfig()
warpCfg.ProxyAddr = *warpProxy
warpCfg.ConfigPath = *warpConfig
warpCfg.LocalDNS = *warpLocalDNS
warpCfg.Debug = isDebug
// If a VK link is provided, set up a relay getter so that QUIC traffic
// for the MASQUE tunnel goes through the VK TURN relay.
if *vklink != "" {
// Build a dnsdialer with hardcoded DNS servers for reliable VK API resolution.
dialer := dnsdialer.New(
dnsdialer.WithResolvers("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"),
dnsdialer.WithStrategy(dnsdialer.Fallback{}),
dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour),
)
warpCfg.GetRelayConn = func(relayCtx context.Context) (net.PacketConn, error) {
log.Printf("[Warp] Allocating VK TURN relay for MASQUE connection...")
user, pass, serverAddr, err := getVkCredsCached(relayCtx, link, 0, dialer)
if err != nil {
return nil, fmt.Errorf("get VK TURN creds: %w", err)
}
return allocateTurnRelayConn(relayCtx, user, pass, serverAddr, *host, *port, *udp)
}
}
if err := warp.Run(ctx, warpCfg); err != nil {
log.Fatalf("[Warp] Fatal error: %v", err)
}
return
}
// ── End Warp mode ─────────────────────────────────────────────────────────
if *peerAddr == "" {
log.Panicf("Need peer address!")
}
@ -1813,16 +1875,9 @@ func main() {
log.Panicf("Need either vk-link or yandex-link!")
}
isDebug = *debugFlag
manualCaptcha = *manualCaptchaFlag
autoCaptchaSliderPOC = !manualCaptcha
var link string
var getCreds getCredsFunc
if *vklink != "" {
parts := strings.Split(*vklink, "join/")
link = parts[len(parts)-1]
// Use hardcoded DNS servers for reliable VK API resolution.
dialer := dnsdialer.New(
dnsdialer.WithResolvers("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"),
dnsdialer.WithStrategy(dnsdialer.Fallback{}),
@ -1836,8 +1891,6 @@ func main() {
*n = 10
}
} else {
parts := strings.Split(*yalink, "j/")
link = parts[len(parts)-1]
getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) {
return getYandexCreds(s)
}
@ -2313,3 +2366,82 @@ func pipe(ctx context.Context, c1, c2 net.Conn) {
log.Printf("pipe: failed to reset deadline c2: %v", err)
}
}
// allocateTurnRelayConn allocates a TURN relay connection suitable for use as
// a baseConn for the Warp MASQUE QUIC session.
// It authenticates with the VK TURN server and returns a net.PacketConn that
// is already bound to the relay, pointed at the Cloudflare MASQUE endpoint.
func allocateTurnRelayConn(ctx context.Context, user, pass, serverAddr, hostOverride, portOverride string, useUDP bool) (net.PacketConn, error) {
urlhost, urlport, err := net.SplitHostPort(serverAddr)
if err != nil {
return nil, fmt.Errorf("parse TURN server address: %w", err)
}
if hostOverride != "" {
urlhost = hostOverride
}
if portOverride != "" {
urlport = portOverride
}
turnServerAddr := net.JoinHostPort(urlhost, urlport)
turnServerUDPAddr, err := net.ResolveUDPAddr("udp", turnServerAddr)
if err != nil {
return nil, fmt.Errorf("resolve TURN server address: %w", err)
}
ctx1, cancel1 := context.WithTimeout(ctx, 10*time.Second)
defer cancel1()
var turnConn net.PacketConn
if useUDP {
c, err := net.DialUDP("udp", nil, turnServerUDPAddr)
if err != nil {
return nil, fmt.Errorf("dial TURN (udp): %w", err)
}
turnConn = &connectedUDPConn{c}
} else {
var d net.Dialer
c, err := d.DialContext(ctx1, "tcp", turnServerAddr)
if err != nil {
return nil, fmt.Errorf("dial TURN (tcp): %w", err)
}
turnConn = turn.NewSTUNConn(c)
}
cfg := &turn.ClientConfig{
STUNServerAddr: turnServerAddr,
TURNServerAddr: turnServerAddr,
Conn: turnConn,
Net: newDirectNet(),
Username: user,
Password: pass,
// IPv4 allocation for Cloudflare endpoint
RequestedAddressFamily: turn.RequestedAddressFamilyIPv4,
LoggerFactory: logging.NewDefaultLoggerFactory(),
}
client, err := turn.NewClient(cfg)
if err != nil {
_ = turnConn.Close()
return nil, fmt.Errorf("create TURN client: %w", err)
}
if err := client.Listen(); err != nil {
client.Close()
_ = turnConn.Close()
return nil, fmt.Errorf("TURN listen: %w", err)
}
relayConn, err := client.Allocate()
if err != nil {
client.Close()
_ = turnConn.Close()
return nil, fmt.Errorf("TURN allocate: %w", err)
}
log.Printf("[Warp] TURN relay allocated: %s", relayConn.LocalAddr())
// Return the relay connection; the caller (MaintainTunnel) will use it as
// the base PacketConn for the QUIC dial.
return relayConn, nil
}
// Ensure the warpapi package is referenced to avoid unused import error.
// GetRelayConnFunc is used as the type of RunnerConfig.GetRelayConn.
var _ = warpapi.GetRelayConnFunc(nil)

208
client/warp/api/cloudflare.go

@ -0,0 +1,208 @@
package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"time"
fhttp "github.com/bogdanfinn/fhttp"
"github.com/cacggghp/vk-turn-proxy/client/warp/internal"
"github.com/cacggghp/vk-turn-proxy/client/warp/models"
)
// Register creates a new user account by registering a WireGuard public key and generating a random Android-like device identifier.
// The WireGuard private key isn't stored anywhere, therefore it won't be usable. It's sole purpose is to mimic the Android app's registration process.
//
// This function sends a POST request to the API to register a new user and returns the created account data.
//
// Parameters:
// - model: string - The device model string to register. (e.g., "PC")
// - locale: string - The user's locale. (e.g., "en-US")
// - jwt: string - Team token to register.
// - acceptTos: bool - Whether the user accepts the Terms of Service (TOS). If false, the user will be prompted to accept.
//
// Returns:
// - models.AccountData: The account data returned from the registration process.
// - error: An error if registration fails at any step.
//
// Example:
//
// account, err := Register("PC", "en-US", "", false)
// if err != nil {
// log.Fatalf("Registration failed: %v", err)
// }
func Register(model, locale, jwt string, acceptTos bool) (models.AccountData, error) {
wgKey, err := internal.GenerateRandomWgPubkey()
if err != nil {
return models.AccountData{}, fmt.Errorf("failed to generate wg key: %v", err)
}
serial, err := internal.GenerateRandomAndroidSerial()
if err != nil {
return models.AccountData{}, fmt.Errorf("failed to generate serial: %v", err)
}
if !acceptTos {
fmt.Print("You must accept the Terms of Service (https://www.cloudflare.com/application/terms/) to register. Do you agree? (y/n): ")
var response string
if _, err := fmt.Scanln(&response); err != nil {
return models.AccountData{}, fmt.Errorf("failed to read user input: %v", err)
}
if response != "y" {
return models.AccountData{}, fmt.Errorf("user did not accept TOS")
}
}
data := models.Registration{
Key: wgKey,
InstallID: "",
FcmToken: "",
Tos: internal.TimeAsCfString(time.Now()),
Model: model,
Serial: serial,
OsVersion: "",
KeyType: internal.KeyTypeWg,
TunType: internal.TunTypeWg,
Locale: locale,
}
jsonData, err := json.Marshal(data)
if err != nil {
return models.AccountData{}, fmt.Errorf("failed to marshal json: %v", err)
}
tr := &fhttp.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", "api.cloudflareclient.com")
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("DNS resolution failed for api.cloudflareclient.com")
}
return net.DialTimeout("tcp", net.JoinHostPort(ips[0].String(), "443"), 10*time.Second)
},
}
httpClient := &fhttp.Client{
Transport: tr,
Timeout: 60 * time.Second,
}
req, err := fhttp.NewRequest("POST", "https://consumer-masque.cloudflareclient.com/"+internal.ApiVersion+"/reg", bytes.NewBuffer(jsonData))
if err != nil {
return models.AccountData{}, fmt.Errorf("failed to create request: %v", err)
}
req.Host = "api.cloudflareclient.com"
for k, v := range internal.Headers {
req.Header.Set(k, v)
}
if jwt != "" {
req.Header.Set("CF-Access-Jwt-Assertion", jwt)
}
resp, err := httpClient.Do(req)
if err != nil {
return models.AccountData{}, fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fhttp.StatusOK {
return models.AccountData{}, fmt.Errorf("failed to register: %v", resp.Status)
}
var accountData models.AccountData
if err := json.NewDecoder(resp.Body).Decode(&accountData); err != nil {
return models.AccountData{}, fmt.Errorf("failed to decode response: %v", err)
}
return accountData, nil
}
// EnrollKey updates an existing user account with a new MASQUE public key.
//
// This function sends a PATCH request to update the user's account with a new key.
//
// Parameters:
// - accountData: models.AccountData - The account data of the user being updated.
// - pubKey: []byte - The new MASQUE public key in binary format.
// - deviceName: string - The name of the device to enroll. (optional)
//
// Returns:
// - models.AccountData: The updated account data.
// - error: An error if the update process fails.
//
// Example:
//
// updatedAccount, apiErr, err := EnrollKey(account, pubKey, "PC")
// if err != nil {
// log.Fatalf("Key enrollment failed: %v", err)
// }
func EnrollKey(accountData models.AccountData, pubKey []byte, deviceName string) (models.AccountData, *models.APIError, error) {
deviceUpdate := models.DeviceUpdate{
Key: base64.StdEncoding.EncodeToString(pubKey),
KeyType: internal.KeyTypeMasque,
TunType: internal.TunTypeMasque,
}
if deviceName != "" {
deviceUpdate.Name = deviceName
}
jsonData, err := json.Marshal(deviceUpdate)
if err != nil {
return models.AccountData{}, nil, fmt.Errorf("failed to marshal json: %v", err)
}
tr := &fhttp.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", "api.cloudflareclient.com")
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("DNS resolution failed for api.cloudflareclient.com")
}
return net.DialTimeout("tcp", net.JoinHostPort(ips[0].String(), "443"), 10*time.Second)
},
}
httpClient := &fhttp.Client{
Transport: tr,
Timeout: 60 * time.Second,
}
req, err := fhttp.NewRequest("PATCH", "https://consumer-masque.cloudflareclient.com/"+internal.ApiVersion+"/reg/"+accountData.ID, bytes.NewBuffer(jsonData))
if err != nil {
return models.AccountData{}, nil, fmt.Errorf("failed to create request: %v", err)
}
req.Host = "api.cloudflareclient.com"
for k, v := range internal.Headers {
req.Header.Set(k, v)
}
req.Header.Set("Authorization", "Bearer "+accountData.Token)
resp, err := httpClient.Do(req)
if err != nil {
return models.AccountData{}, nil, fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return models.AccountData{}, nil, fmt.Errorf("failed to read response body: %v", err)
}
if resp.StatusCode != fhttp.StatusOK {
var apiErr models.APIError
if err := json.Unmarshal(body, &apiErr); err != nil {
return models.AccountData{}, nil, fmt.Errorf("failed to parse error response: %v", err)
}
return models.AccountData{}, &apiErr, fmt.Errorf("failed to update: %s", resp.Status)
}
if err := json.Unmarshal(body, &accountData); err != nil {
return models.AccountData{}, nil, fmt.Errorf("failed to decode response: %v", err)
}
return accountData, nil, nil
}

200
client/warp/api/masque.go

@ -0,0 +1,200 @@
package api
import (
"context"
"crypto/ecdsa"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
connectip "github.com/Diniboy1123/connect-ip-go"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"github.com/yosida95/uritemplate/v3"
)
// fixedPeerConn wraps a net.PacketConn and makes it behave like a point-to-point
// connection to a fixed peer (e.g. the Cloudflare MASQUE endpoint).
// This is critical when using a TURN relay as the QUIC transport: the relay
// conn knows how to send/receive via TURN indications, but quic-go needs
// the connection to look like a direct pipe to the remote.
// Matches the fixedPeerConn from the working vk-turn-usque-old implementation.
type fixedPeerConn struct {
net.PacketConn
peer net.Addr
}
func (c *fixedPeerConn) Write(p []byte) (n int, err error) {
return c.PacketConn.WriteTo(p, c.peer)
}
func (c *fixedPeerConn) Read(p []byte) (n int, err error) {
n, _, err = c.PacketConn.ReadFrom(p)
return n, err
}
func (c *fixedPeerConn) RemoteAddr() net.Addr {
return c.peer
}
// PrepareTlsConfig creates a TLS configuration using the provided certificate and SNI (Server Name Indication).
// It also verifies the peer's public key against the provided public key.
//
// Parameters:
// - privKey: *ecdsa.PrivateKey - The private key to use for TLS authentication.
// - peerPubKey: *ecdsa.PublicKey - The endpoint's public key to pin to.
// - cert: [][]byte - The certificate chain to use for TLS authentication.
// - sni: string - The Server Name Indication (SNI) to use.
//
// Returns:
// - *tls.Config: A TLS configuration for secure communication.
// - error: An error if TLS setup fails.
func PrepareTlsConfig(privKey *ecdsa.PrivateKey, peerPubKey *ecdsa.PublicKey, cert [][]byte, sni string) (*tls.Config, error) {
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{
{
Certificate: cert,
PrivateKey: privKey,
},
},
ServerName: sni,
NextProtos: []string{http3.NextProtoH3},
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
},
CipherSuites: []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
},
// WARN: SNI is usually not for the endpoint, so we must skip verification
InsecureSkipVerify: true,
// we pin to the endpoint public key
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return nil
}
cert, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return err
}
if _, ok := cert.PublicKey.(*ecdsa.PublicKey); !ok {
// we only support ECDSA
// TODO: don't hardcode cert type in the future
// as backend can start using different cert types
return x509.ErrUnsupportedAlgorithm
}
if !cert.PublicKey.(*ecdsa.PublicKey).Equal(peerPubKey) {
// reason is incorrect, but the best I could figure
// detail explains the actual reason
//10 is NoValidChains, but we support go1.22 where it's not defined
return x509.CertificateInvalidError{Cert: cert, Reason: 10, Detail: "remote endpoint has a different public key than what we trust in config.json"}
}
return nil
},
}
return tlsConfig, nil
}
// ConnectTunnel establishes a QUIC connection and sets up a Connect-IP tunnel with the provided endpoint.
// Endpoint address is used to check whether the authentication/connection is successful or not.
// Requires modified connect-ip-go for now to support Cloudflare's non RFC compliant implementation.
//
// Parameters:
// - ctx: context.Context - The QUIC TLS context.
// - tlsConfig: *tls.Config - The TLS configuration for secure communication.
// - quicConfig: *quic.Config - The QUIC configuration settings.
// - connectUri: string - The URI template for the Connect-IP request.
// - endpoint: *net.UDPAddr - The UDP address of the QUIC server.
// - baseConn: net.PacketConn - Optional pre-allocated connection (e.g. from VK TURN relay). If nil, a new UDP socket is created.
//
// Returns:
// - net.PacketConn: The packet connection used for the QUIC session.
// - *http3.Transport: The HTTP/3 transport used for initial request.
// - *connectip.Conn: The Connect-IP connection instance.
// - *http.Response: The response from the Connect-IP handshake.
// - error: An error if the connection setup fails.
func ConnectTunnel(ctx context.Context, tlsConfig *tls.Config, quicConfig *quic.Config, connectUri string, endpoint *net.UDPAddr, baseConn net.PacketConn) (net.PacketConn, *http3.Transport, *connectip.Conn, *http.Response, error) {
var conn net.PacketConn
var err error
if baseConn != nil {
// Wrap the TURN relay conn in fixedPeerConn so quic-go sees it as a
// point-to-point connection to the Cloudflare endpoint.
// Without this wrapping, some QUIC packet flows don't survive the
// TURN relay hop (e.g. keepalives and connect-ip IP packets time out).
conn = &fixedPeerConn{PacketConn: baseConn, peer: endpoint}
} else {
// Create a new UDP socket for direct connection to the Cloudflare MASQUE endpoint
var udpConn *net.UDPConn
if endpoint.IP.To4() == nil {
udpConn, err = net.ListenUDP("udp", &net.UDPAddr{
IP: net.IPv6zero,
Port: 0,
})
} else {
udpConn, err = net.ListenUDP("udp", &net.UDPAddr{
IP: net.IPv4zero,
Port: 0,
})
}
if err != nil {
return nil, nil, nil, nil, err
}
conn = udpConn
}
qconn, err := quic.Dial(
ctx,
conn,
endpoint,
tlsConfig,
quicConfig,
)
if err != nil {
return conn, nil, nil, nil, err
}
tr := &http3.Transport{
EnableDatagrams: true,
AdditionalSettings: map[uint64]uint64{
// SETTINGS_H3_DATAGRAM (current IETF RFC 9297) - required by Cloudflare
0x33: 1,
// SETTINGS_H3_DATAGRAM_00 (deprecated draft, but official client still sends it)
0x276: 1,
},
DisableCompression: true,
}
hconn := tr.NewClientConn(qconn)
additionalHeaders := http.Header{
"User-Agent": []string{""},
}
template := uritemplate.MustNew(connectUri)
ipConn, rsp, err := connectip.Dial(ctx, hconn, template, "cf-connect-ip", additionalHeaders, true)
if err != nil {
if err.Error() == "CRYPTO_ERROR 0x131 (remote): tls: access denied" {
return conn, nil, nil, nil, errors.New("login failed! Please double-check if your tls key and cert is enrolled in the Cloudflare Access service")
}
return conn, nil, nil, nil, fmt.Errorf("failed to dial connect-ip: %v", err)
}
return conn, tr, ipConn, rsp, nil
}

348
client/warp/api/tunnel.go

@ -0,0 +1,348 @@
package api
import (
"context"
"crypto/rand"
"crypto/tls"
"errors"
"fmt"
"log"
"math/big"
"net"
"sync"
"sync/atomic"
"time"
connectip "github.com/Diniboy1123/connect-ip-go"
"github.com/cacggghp/vk-turn-proxy/client/warp/internal"
"github.com/songgao/water"
"golang.zx2c4.com/wireguard/tun"
)
// Verbose controls whether diagnostic logs like tunnel stats are printed.
var Verbose bool
// NetBuffer is a pool of byte slices with a fixed capacity.
// Helps to reduce memory allocations and improve performance.
// It uses a sync.Pool to manage the byte slices.
// The capacity of the byte slices is set when the pool is created.
type NetBuffer struct {
capacity int
buf sync.Pool
}
// Get returns a byte slice from the pool.
func (n *NetBuffer) Get() []byte {
return *(n.buf.Get().(*[]byte))
}
// Put places a byte slice back into the pool.
// It checks if the capacity of the byte slice matches the pool's capacity.
// If it doesn't match, the byte slice is not returned to the pool.
func (n *NetBuffer) Put(buf []byte) {
if cap(buf) != n.capacity {
return
}
n.buf.Put(&buf)
}
// NewNetBuffer creates a new NetBuffer with the specified capacity.
// The capacity must be greater than 0.
func NewNetBuffer(capacity int) *NetBuffer {
if capacity <= 0 {
panic("capacity must be greater than 0")
}
return &NetBuffer{
capacity: capacity,
buf: sync.Pool{
New: func() interface{} {
b := make([]byte, capacity)
return &b
},
},
}
}
// TunnelDevice abstracts a TUN device so that we can use the same tunnel-maintenance code
// regardless of the underlying implementation.
type TunnelDevice interface {
// ReadPacket reads a packet from the device (using the given mtu) and returns its contents.
ReadPacket(buf []byte) (int, error)
// WritePacket writes a packet to the device.
WritePacket(pkt []byte) error
}
// NetstackAdapter wraps a tun.Device (e.g. from netstack) to satisfy TunnelDevice.
type NetstackAdapter struct {
dev tun.Device
tunnelBufPool sync.Pool
tunnelSizesPool sync.Pool
}
func (n *NetstackAdapter) ReadPacket(buf []byte) (int, error) {
packetBufsPtr := n.tunnelBufPool.Get().(*[][]byte)
sizesPtr := n.tunnelSizesPool.Get().(*[]int)
defer func() {
(*packetBufsPtr)[0] = nil
n.tunnelBufPool.Put(packetBufsPtr)
n.tunnelSizesPool.Put(sizesPtr)
}()
(*packetBufsPtr)[0] = buf
(*sizesPtr)[0] = 0
_, err := n.dev.Read(*packetBufsPtr, *sizesPtr, 0)
if err != nil {
return 0, err
}
return (*sizesPtr)[0], nil
}
func (n *NetstackAdapter) WritePacket(pkt []byte) error {
// Write expects a slice of packet buffers.
_, err := n.dev.Write([][]byte{pkt}, 0)
return err
}
// NewNetstackAdapter creates a new NetstackAdapter.
func NewNetstackAdapter(dev tun.Device) TunnelDevice {
return &NetstackAdapter{
dev: dev,
tunnelBufPool: sync.Pool{
New: func() interface{} {
buf := make([][]byte, 1)
return &buf
},
},
tunnelSizesPool: sync.Pool{
New: func() interface{} {
sizes := make([]int, 1)
return &sizes
},
},
}
}
// WaterAdapter wraps a *water.Interface so it satisfies TunnelDevice.
type WaterAdapter struct {
iface *water.Interface
}
func (w *WaterAdapter) ReadPacket(buf []byte) (int, error) {
n, err := w.iface.Read(buf)
if err != nil {
return 0, err
}
return n, nil
}
func (w *WaterAdapter) WritePacket(pkt []byte) error {
_, err := w.iface.Write(pkt)
return err
}
// NewWaterAdapter creates a new WaterAdapter.
func NewWaterAdapter(iface *water.Interface) TunnelDevice {
return &WaterAdapter{iface: iface}
}
// GetRelayConnFunc is a function type that returns a pre-allocated packet connection
// for use as a TURN relay (e.g. from VK TURN). If nil is provided, a direct UDP
// connection to the MASQUE endpoint will be created.
type GetRelayConnFunc func(ctx context.Context) (net.PacketConn, error)
// MaintainTunnel continuously connects to the MASQUE server, then starts two
// forwarding goroutines: one forwarding from the device to the IP connection (and handling
// any ICMP reply), and the other forwarding from the IP connection to the device.
// If an error occurs in either loop, the connection is closed and a reconnect is attempted.
//
// Parameters:
// - ctx: context.Context - The context for the connection.
// - tlsConfig: *tls.Config - The TLS configuration for secure communication.
// - keepalivePeriod: time.Duration - The keepalive period for the QUIC connection.
// - initialPacketSize: uint16 - The initial packet size for the QUIC connection.
// - endpoint: *net.UDPAddr - The UDP address of the MASQUE server.
// - device: TunnelDevice - The TUN device to forward packets to and from.
// - mtu: int - The MTU of the TUN device.
// - reconnectDelay: time.Duration - The delay between reconnect attempts.
// - getRelayConn: GetRelayConnFunc - Optional function to obtain a TURN relay connection.
// If nil, a direct UDP connection to the endpoint is used.
// - onReady: func(bool) - Optional callback fired with true when connected, and false when disconnected.
func MaintainTunnel(ctx context.Context, tlsConfig *tls.Config, keepalivePeriod time.Duration, initialPacketSize uint16, endpoint *net.UDPAddr, device TunnelDevice, mtu int, reconnectDelay time.Duration, getRelayConn GetRelayConnFunc, onReady func(bool)) {
packetBufferPool := NewNetBuffer(mtu)
for {
// Check if context is done before attempting connection
select {
case <-ctx.Done():
return
default:
}
log.Printf("Establishing MASQUE connection to %s:%d", endpoint.IP, endpoint.Port)
// Optionally obtain a TURN relay packet connection
var baseConn net.PacketConn
if getRelayConn != nil {
var err error
baseConn, err = getRelayConn(ctx)
if err != nil {
log.Printf("Failed to obtain TURN relay connection: %v", err)
select {
case <-ctx.Done():
return
case <-time.After(reconnectDelay):
}
continue
}
}
udpConn, tr, ipConn, rsp, err := ConnectTunnel(
ctx,
tlsConfig,
internal.DefaultQuicConfig(keepalivePeriod, initialPacketSize),
internal.ConnectURI,
endpoint,
baseConn,
)
if err != nil {
log.Printf("Failed to connect tunnel: %v", err)
if udpConn != nil {
udpConn.Close()
}
select {
case <-ctx.Done():
return
case <-time.After(reconnectDelay):
}
continue
}
if rsp.StatusCode != 200 {
log.Printf("Tunnel connection failed: %s", rsp.Status)
ipConn.Close()
if udpConn != nil {
udpConn.Close()
}
if tr != nil {
tr.Close()
}
select {
case <-ctx.Done():
return
case <-time.After(reconnectDelay):
}
continue
}
log.Println("Connected to MASQUE server")
if onReady != nil {
onReady(true)
}
errChan := make(chan error, 2)
// Packet counters for diagnostics
var txPkts, rxPkts atomic.Int64
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if Verbose {
log.Printf("[Warp] Tunnel stats: TX=%d pkts, RX=%d pkts", txPkts.Load(), rxPkts.Load())
}
case <-ctx.Done():
return
}
}
}()
go func() {
for {
buf := packetBufferPool.Get()
n, err := device.ReadPacket(buf)
if err != nil {
packetBufferPool.Put(buf)
errChan <- fmt.Errorf("failed to read from TUN device: %v", err)
return
}
txPkts.Add(1)
paddedSize := n
if n < mtu-100 {
randOffset, _ := rand.Int(rand.Reader, big.NewInt(64))
paddedSize = n + int(randOffset.Int64())
if paddedSize > mtu {
paddedSize = mtu
}
if paddedSize > n {
_, _ = rand.Read(buf[n:paddedSize])
}
}
icmp, err := ipConn.WritePacket(buf[:paddedSize])
if err != nil {
packetBufferPool.Put(buf)
if errors.As(err, new(*connectip.CloseError)) {
errChan <- fmt.Errorf("connection closed while writing to IP connection: %v", err)
return
}
log.Printf("Error writing to IP connection: %v, continuing...", err)
continue
}
packetBufferPool.Put(buf)
if len(icmp) > 0 {
if err := device.WritePacket(icmp); err != nil {
if errors.As(err, new(*connectip.CloseError)) {
errChan <- fmt.Errorf("connection closed while writing ICMP to TUN device: %v", err)
return
}
log.Printf("Error writing ICMP to TUN device: %v, continuing...", err)
}
}
}
}()
go func() {
buf := packetBufferPool.Get()
defer packetBufferPool.Put(buf)
for {
n, err := ipConn.ReadPacket(buf, true)
if err != nil {
if errors.As(err, new(*connectip.CloseError)) {
errChan <- fmt.Errorf("connection closed while reading from IP connection: %v", err)
return
}
log.Printf("Error reading from IP connection: %v, continuing...", err)
continue
}
rxPkts.Add(1)
if err := device.WritePacket(buf[:n]); err != nil {
errChan <- fmt.Errorf("failed to write to TUN device: %v", err)
return
}
}
}()
err = <-errChan
if onReady != nil {
onReady(false)
}
log.Printf("Tunnel connection lost: %v. Reconnecting...", err)
ipConn.Close()
if udpConn != nil {
udpConn.Close()
}
if tr != nil {
tr.Close()
}
select {
case <-ctx.Done():
return
case <-time.After(reconnectDelay):
}
}
}

120
client/warp/config/config.go

@ -0,0 +1,120 @@
package config
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"os"
)
// Config represents the application configuration structure, containing essential details such as keys, endpoints, and access tokens.
type Config struct {
PrivateKey string `json:"private_key"` // Base64-encoded ECDSA private key
EndpointV4 string `json:"endpoint_v4"` // IPv4 address of the endpoint
EndpointV6 string `json:"endpoint_v6"` // IPv6 address of the endpoint
EndpointPubKey string `json:"endpoint_pub_key"` // PEM-encoded ECDSA public key of the endpoint to verify against
License string `json:"license"` // Application license key
ID string `json:"id"` // Device unique identifier
AccessToken string `json:"access_token"` // Authentication token for API access
IPv4 string `json:"ipv4"` // Assigned IPv4 address
IPv6 string `json:"ipv6"` // Assigned IPv6 address
}
// AppConfig holds the global application configuration.
var AppConfig Config
// ConfigLoaded indicates whether the configuration has been successfully loaded.
var ConfigLoaded bool
// LoadConfig loads the application configuration from a JSON file.
//
// Parameters:
// - configPath: string - The path to the configuration JSON file.
//
// Returns:
// - error: An error if the configuration file cannot be loaded or parsed.
func LoadConfig(configPath string) error {
file, err := os.Open(configPath)
if err != nil {
return fmt.Errorf("failed to open config file: %v", err)
}
defer file.Close()
decoder := json.NewDecoder(file)
if err := decoder.Decode(&AppConfig); err != nil {
return fmt.Errorf("failed to decode config file: %v", err)
}
ConfigLoaded = true
return nil
}
// SaveConfig writes the current application configuration to a prettified JSON file.
//
// Parameters:
// - configPath: string - The path to save the configuration JSON file.
//
// Returns:
// - error: An error if the configuration file cannot be written.
func (*Config) SaveConfig(configPath string) error {
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %v", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(AppConfig); err != nil {
return fmt.Errorf("failed to encode config file: %v", err)
}
return nil
}
// GetEcPrivateKey retrieves the ECDSA private key from the stored Base64-encoded string.
//
// Returns:
// - *ecdsa.PrivateKey: The parsed ECDSA private key.
// - error: An error if decoding or parsing the private key fails.
func (*Config) GetEcPrivateKey() (*ecdsa.PrivateKey, error) {
privKeyB64, err := base64.StdEncoding.DecodeString(AppConfig.PrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to decode private key: %v", err)
}
privKey, err := x509.ParseECPrivateKey(privKeyB64)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %v", err)
}
return privKey, nil
}
// GetEcEndpointPublicKey retrieves the ECDSA public key from the stored PEM-encoded string.
//
// Returns:
// - *ecdsa.PublicKey: The parsed ECDSA public key.
// - error: An error if decoding or parsing the public key fails.
func (*Config) GetEcEndpointPublicKey() (*ecdsa.PublicKey, error) {
endpointPubKeyB64, _ := pem.Decode([]byte(AppConfig.EndpointPubKey))
if endpointPubKeyB64 == nil {
return nil, fmt.Errorf("failed to decode endpoint public key")
}
pubKey, err := x509.ParsePKIXPublicKey(endpointPubKeyB64.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %v", err)
}
ecPubKey, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("failed to assert public key as ECDSA")
}
return ecPubKey, nil
}

23
client/warp/internal/consts.go

@ -0,0 +1,23 @@
package internal
const (
ApiUrl = "https://api.cloudflareclient.com"
ApiVersion = "v0a4471"
ConnectSNI = "consumer-masque.cloudflareclient.com"
// unused for now
ZeroTierSNI = "zt-masque.cloudflareclient.com"
ConnectURI = "https://cloudflareaccess.com"
DefaultModel = "PC"
KeyTypeWg = "curve25519"
TunTypeWg = "wireguard"
KeyTypeMasque = "secp256r1"
TunTypeMasque = "masque"
DefaultLocale = "en_US"
)
var Headers = map[string]string{
"User-Agent": "WARP for Android",
"CF-Client-Version": "a-6.35-4471",
"Content-Type": "application/json; charset=UTF-8",
"Connection": "Keep-Alive",
}

195
client/warp/internal/dns.go

@ -0,0 +1,195 @@
package internal
import (
"context"
"fmt"
"net"
"net/netip"
"time"
"golang.zx2c4.com/wireguard/tun/netstack"
)
// TunnelDNSResolver implements a DNS resolver that uses the provided DNS servers
// either inside a MASQUE tunnel (if TunNet is set) or over the system network (if TunNet is nil).
type TunnelDNSResolver struct {
// TunNet is the network stack for the tunnel you want to use for DNS resolution.
// If nil, DNS queries are sent over the system network.
TunNet *netstack.Net
// DNSAddrs is the list of DNS servers to use for resolution.
DNSAddrs []netip.Addr
// Timeout is the timeout for DNS queries on a specific server before trying the next one.
Timeout time.Duration
}
// Resolve performs a DNS lookup using the provided DNS resolvers.
// It tries each resolver in order until one succeeds, sending queries either through the tunnel
// or over the system network depending on TunNet.
//
// Parameters:
// - ctx: context.Context - The context for the DNS lookup.
// - name: string - The domain name to resolve.
//
// Returns:
// - context.Context: The original context for the DNS lookup.
// - net.IP: The resolved IP address.
// - error: An error if the lookup fails.
func (r TunnelDNSResolver) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) {
if len(r.DNSAddrs) == 0 {
return ctx, nil, fmt.Errorf("no DNS servers configured")
}
var queryCtx context.Context = ctx
var cancel context.CancelFunc
if r.Timeout > 0 {
queryCtx, cancel = context.WithTimeout(ctx, r.Timeout)
defer cancel()
}
type result struct {
ip net.IP
err error
}
results := make(chan result, len(r.DNSAddrs))
for _, dnsAddr := range r.DNSAddrs {
dnsHost := net.JoinHostPort(dnsAddr.String(), "53")
go func(dnsHost string) {
var dialFunc func(context.Context, string, string) (net.Conn, error)
if r.TunNet != nil {
dialFunc = func(ctx context.Context, network, address string) (net.Conn, error) {
return r.TunNet.DialContext(ctx, "udp", dnsHost)
}
} else {
dialFunc = func(ctx context.Context, network, address string) (net.Conn, error) {
return net.Dial("udp", dnsHost)
}
}
resolver := &net.Resolver{
PreferGo: true,
Dial: dialFunc,
}
ips, err := resolver.LookupIP(queryCtx, "ip", name)
if err == nil && len(ips) > 0 {
results <- result{ip: ips[0], err: nil}
} else {
results <- result{ip: nil, err: err}
}
}(dnsHost)
}
var lastErr error
for i := 0; i < len(r.DNSAddrs); i++ {
res := <-results
if res.err == nil && res.ip != nil {
if cancel != nil {
cancel()
}
return ctx, res.ip, nil
}
lastErr = res.err
}
return ctx, nil, fmt.Errorf("all DNS servers failed: %v", lastErr)
}
// NewNetstackResolver returns a *net.Resolver that uses the tunnel network stack
// and provided DNS servers for DNS queries.
//
// Parameters:
// - tunNet: *netstack.Net - The tunnel network stack.
// - dnsAddrs: []netip.Addr - DNS server addresses.
//
// Returns:
// - *net.Resolver - A resolver that routes queries through the tunnel.
func NewNetstackResolver(tunNet *netstack.Net, dnsAddrs []netip.Addr) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
if len(dnsAddrs) == 0 {
return nil, fmt.Errorf("no DNS servers configured")
}
if len(dnsAddrs) > 1 {
return raceDial(ctx, tunNet, dnsAddrs)
}
dnsHost := net.JoinHostPort(dnsAddrs[0].String(), "53")
return tunNet.DialContext(ctx, "udp", dnsHost)
},
}
}
func raceDial(ctx context.Context, tunNet *netstack.Net, addrs []netip.Addr) (net.Conn, error) {
type result struct {
conn net.Conn
err error
}
resChan := make(chan result, len(addrs))
childCtx, cancel := context.WithCancel(ctx)
defer cancel()
for _, addr := range addrs {
go func(a netip.Addr) {
dnsHost := net.JoinHostPort(a.String(), "53")
conn, err := tunNet.DialContext(childCtx, "udp", dnsHost)
if err == nil {
select {
case resChan <- result{conn: conn}:
case <-ctx.Done():
conn.Close()
}
} else {
select {
case resChan <- result{err: err}:
case <-ctx.Done():
}
}
}(addr)
}
var lastErr error
for i := 0; i < len(addrs); i++ {
res := <-resChan
if res.err == nil {
return res.conn, nil
}
lastErr = res.err
}
return nil, fmt.Errorf("all DNS race dials failed: %w", lastErr)
}
// NewStaticResolver returns a *net.Resolver that uses the provided DNS servers
// for lookups over the system network.
func NewStaticResolver(dnsAddrs []netip.Addr) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
if len(dnsAddrs) == 0 {
return nil, fmt.Errorf("no DNS servers configured")
}
dnsHost := net.JoinHostPort(dnsAddrs[0].String(), "53")
return net.Dial("udp", dnsHost)
},
}
}
// GetProxyResolver returns the appropriate *net.Resolver for proxy use
// based on the localDNS flag.
//
// Parameters:
// - localDNS: bool - Whether to use the system network for DNS.
// - tunNet: *netstack.Net - The tunnel network stack (if localDNS is false).
// - dnsAddrs: []netip.Addr - DNS server addresses.
// - timeout: time.Duration - Timeout for DNS queries.
//
// Returns:
// - *net.Resolver - A resolver suitable for use with proxy connections.
func GetProxyResolver(localDNS bool, tunNet *netstack.Net, dnsAddrs []netip.Addr, timeout time.Duration) *net.Resolver {
if localDNS {
return NewStaticResolver(dnsAddrs)
}
return NewNetstackResolver(tunNet, dnsAddrs)
}

318
client/warp/internal/utils.go

@ -0,0 +1,318 @@
package internal
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"errors"
"log"
"math/big"
"net"
"strconv"
"strings"
"time"
"github.com/quic-go/quic-go"
)
// PortMapping represents a network port forwarding rule.
type PortMapping struct {
BindAddress string // The address to bind the local port.
LocalPort int // The local port number.
RemoteIP string // The remote destination IP address.
RemotePort int // The remote destination port number.
}
// GenerateRandomAndroidSerial generates a random 8-byte Android-like device identifier
// and returns it as a hexadecimal string.
//
// Returns:
// - string: A randomly generated 16-character hexadecimal serial number.
// - error: An error if random data generation fails.
func GenerateRandomAndroidSerial() (string, error) {
serial := make([]byte, 8)
if _, err := rand.Read(serial); err != nil {
return "", err
}
return hex.EncodeToString(serial), nil
}
// GenerateRandomWgPubkey generates a random 32-byte WireGuard like public key
// and returns it as a base64-encoded string.
//
// Returns:
// - string: A randomly generated WireGuard like public key in base64 format.
// - error: An error if random data generation fails.
func GenerateRandomWgPubkey() (string, error) {
publicKey := make([]byte, 32)
if _, err := rand.Read(publicKey); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(publicKey), nil
}
// TimeAsCfString formats a given time.Time into a Cloudflare-compatible string format.
//
// The format follows the standard: "YYYY-MM-DDTHH:MM:SS.sss-07:00".
//
// Parameters:
// - t: time.Time to format.
//
// Returns:
// - string: The formatted time string.
func TimeAsCfString(t time.Time) string {
return t.Format("2006-01-02T15:04:05.000-07:00")
}
// GenerateEcKeyPair generates a new ECDSA key pair using the P-256 curve.
//
// Returns:
// - []byte: The marshalled private key in ASN.1 DER format.
// - []byte: The marshalled public key in PKIX format.
// - error: An error if key generation or marshalling fails.
func GenerateEcKeyPair() ([]byte, []byte, error) {
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
marshalledPrivKey, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
return nil, nil, err
}
marshalledPubKey, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
if err != nil {
return nil, nil, err
}
return marshalledPrivKey, marshalledPubKey, nil
}
// GenerateCert creates a self-signed certificate using the provided ECDSA private and public keys.
//
// The certificate is valid for 24 hours.
//
// Parameters:
// - privKey: *ecdsa.PrivateKey - The private key to sign the certificate.
// - pubKey: *ecdsa.PublicKey - The public key to include in the certificate.
//
// Returns:
// - [][]byte: A slice containing the certificate in DER format.
// - error: An error if certificate generation fails.
func GenerateCert(privKey *ecdsa.PrivateKey, pubKey *ecdsa.PublicKey) ([][]byte, error) {
cert, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(0),
NotBefore: time.Now(),
NotAfter: time.Now().Add(1 * 24 * time.Hour),
}, &x509.Certificate{}, &privKey.PublicKey, privKey)
if err != nil {
return nil, err
}
return [][]byte{cert}, nil
}
// DefaultQuicConfig returns a MASQUE compatible default QUIC configuration with specified keep-alive period and initial packet size.
//
// Parameters:
// - keepalivePeriod: time.Duration - The duration for sending QUIC keep-alive packets.
// - initialPacketSize: uint16 - The initial size of QUIC packets. (1242 seems used by the original implementation)
//
// Returns:
// - *quic.Config: A pointer to a configured QUIC configuration object.
func DefaultQuicConfig(keepalivePeriod time.Duration, initialPacketSize uint16) *quic.Config {
return &quic.Config{
EnableDatagrams: true,
InitialPacketSize: initialPacketSize,
KeepAlivePeriod: keepalivePeriod,
}
}
// parsePortMapping is an internal helper function that parses a port mapping string into its components.
//
// It handles IPv6 addresses enclosed in brackets and various format edge cases.
//
// Parameters:
// - port: string - The port mapping string.
//
// Returns:
// - string: The bind address.
// - int: The local port.
// - string: The remote hostname/IP.
// - int: The remote port.
// - error: An error if parsing fails.
func parsePortMapping(port string) (bindAddress string, localPort int, remoteHost string, remotePort int, err error) {
parts := strings.Split(port, ":")
// Handle IPv6 addresses (which are enclosed in brackets)
if len(parts) >= 4 && strings.HasPrefix(parts[0], "[") && strings.Contains(parts[0], "]") {
bindAddress = parts[0]
parts = parts[1:] // Shift parts forward
} else if len(parts) == 3 {
bindAddress = "localhost" // Default to localhost
} else if len(parts) == 4 {
bindAddress = parts[0]
parts = parts[1:] // Shift forward
} else {
return "", 0, "", 0, errors.New("invalid port mapping format (expected format: [bind_address:]local_port:remote_host:remote_port)")
}
// Parse local port
localPort, err = strconv.Atoi(parts[0])
if err != nil || localPort <= 0 || localPort > 65535 {
return "", 0, "", 0, errors.New("invalid local port")
}
// Validate remote host (allow both hostnames and IPs)
remoteHost = parts[1]
if net.ParseIP(remoteHost) == nil && !isValidHostname(remoteHost) {
return "", 0, "", 0, errors.New("invalid remote hostname/IP")
}
// Parse remote port
remotePort, err = strconv.Atoi(parts[2])
if err != nil || remotePort <= 0 || remotePort > 65535 {
return "", 0, "", 0, errors.New("invalid remote port")
}
// If bindAddress is an IPv6 address, remove brackets for proper binding
if strings.HasPrefix(bindAddress, "[") && strings.HasSuffix(bindAddress, "]") {
bindAddress = strings.Trim(bindAddress, "[]")
}
// Convert "localhost" or hostnames to actual addresses
if bindAddress == "*" {
bindAddress = "0.0.0.0" // Allow all interfaces
}
// Validate bind address (support both IPs and hostnames)
bindAddress, err = resolveBindAddress(bindAddress)
if err != nil {
return "", 0, "", 0, errors.New("invalid local address: " + err.Error())
}
remoteHost, err = resolveBindAddress(remoteHost)
if err != nil {
return "", 0, "", 0, errors.New("invalid remote address: " + err.Error())
}
return bindAddress, localPort, remoteHost, remotePort, nil
}
// ParsePortMapping parses a port mapping string into a structured PortMapping.
//
// The expected format is: `[bind_address:]local_port:remote_host:remote_port`.
//
// Parameters:
// - port: string - The port mapping string.
//
// Returns:
// - PortMapping: A structured representation of the parsed port mapping.
// - error: An error if the parsing fails.
func ParsePortMapping(port string) (PortMapping, error) {
bindAddress, localPort, remoteHost, remotePort, err := parsePortMapping(port)
if err != nil {
return PortMapping{}, err
}
return PortMapping{
BindAddress: bindAddress,
LocalPort: localPort,
RemoteIP: remoteHost,
RemotePort: remotePort,
}, nil
}
// resolveBindAddress resolves a hostname or IP to its string representation.
//
// Parameters:
// - addr: string - The hostname or IP.
//
// Returns:
// - string: The resolved IP address.
// - error: An error if resolution fails.
func resolveBindAddress(addr string) (string, error) {
tcpAddr, err := net.ResolveTCPAddr("tcp", addr+":0") // Resolve the address
if err != nil {
return "", err
}
return tcpAddr.IP.String(), nil // Return resolved IP
}
// isValidHostname checks if a given hostname is valid.
// Pretty ugly for now, needs to be refactored.
//
// Parameters:
// - hostname: string - The hostname to validate.
//
// Returns:
// - bool: True if valid, false otherwise.
func isValidHostname(hostname string) bool {
// Must contain at least one dot (.) unless it's "localhost"
if hostname == "localhost" {
return true
}
return strings.Contains(hostname, ".")
}
// LoginToBase64 encodes a username and password into a base64-encoded string in "username:password" format.
// This is commonly used for HTTP Basic Authentication.
//
// Parameters:
// - username: string - The username to encode.
// - password: string - The password to encode.
//
// Returns:
// - string: The base64-encoded "username:password" string.
func LoginToBase64(username, password string) string {
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}
// CheckIfname validates a network interface name according to the following rules:
// - Must not be empty.
// - Should not exceed 15 characters (warning if it does).
// - Should not contain non-ASCII characters (warning if it does).
// - Should not contain invalid characters: '/', whitespace, or control characters.
//
// Parameters:
// - name: string - The interface name to validate.
//
// Returns:
// - error: An error if the name is invalid, or nil if valid.
func CheckIfname(name string) error {
if name == "" {
return errors.New("interface name cannot be empty")
}
if len(name) >= 16 {
log.Printf("Warning: interface name '%s' is longer than %d characters", name, 16-1)
}
var invalidChar bool
var hasWhitespace bool
for _, r := range name {
if r > 127 {
invalidChar = true
break
}
if r == '/' || r == ' ' || strings.ContainsRune("\t\n\v\f\r", r) {
hasWhitespace = true
break
}
}
if invalidChar {
log.Printf("Warning: interface name contains non-ASCII character")
}
if hasWhitespace {
return errors.New("interface name contains invalid character: '/' or whitespace")
}
return nil
}

65
client/warp/internal/utils_windows.go

@ -0,0 +1,65 @@
//go:build windows
package internal
import (
"fmt"
"log"
"os/exec"
)
func SetIPv4Address(ifaceName, ipAddr, mask string) error {
cmd := exec.Command("netsh", "interface", "ipv4", "set", "address",
fmt.Sprintf("name=\"%s\"", ifaceName),
"static", ipAddr, mask)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", output)
}
log.Println("IPv4 address set successfully:", ipAddr)
return nil
}
func SetIPv6Address(ifaceName, ipAddr, mask string) error {
cmd := exec.Command("netsh", "interface", "ipv6", "set", "address",
fmt.Sprintf("interface=\"%s\"", ifaceName),
ipAddr+"/"+mask)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", output)
}
log.Println("IPv6 address set successfully:", ipAddr)
return nil
}
func SetIPv4MTU(ifaceName string, mtu int) error {
cmd := exec.Command("netsh", "interface", "ipv4", "set", "subinterface",
fmt.Sprintf("\"%s\"", ifaceName),
fmt.Sprintf("mtu=%d", mtu))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", output)
}
log.Println("IPv4 MTU set successfully:", mtu)
return nil
}
func SetIPv6MTU(ifaceName string, mtu int) error {
cmd := exec.Command("netsh", "interface", "ipv6", "set", "subinterface",
fmt.Sprintf("\"%s\"", ifaceName),
fmt.Sprintf("mtu=%d", mtu))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", output)
}
log.Println("IPv6 MTU set successfully:", mtu)
return nil
}

56
client/warp/models/apierror.go

@ -0,0 +1,56 @@
package models
// Known error messages from the API
const (
InvalidPublicKey = "Invalid public key"
)
type APIError struct {
// not sure what type this is, so we will settle for interface{}
// for now
Result interface{} `json:"result"`
Success bool `json:"success"`
Errors []ErrorInfo `json:"errors"`
Messages []string `json:"messages"`
}
type ErrorInfo struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ErrorsAsString returns a string representation of the errors in the APIError.
// It concatenates the error messages into a single string, separated by semicolons.
//
// Parameters:
// - separator: string - The string to use as a separator between error messages.
//
// Returns:
// - string: A string containing all error messages, separated by the specified separator.
func (e *APIError) ErrorsAsString(separator string) string {
var result string
for _, err := range e.Errors {
result += err.Message + separator
}
if len(result) > 0 {
return result[:len(result)-len(separator)]
}
return result
}
// HasErrorMessage checks if the APIError contains a specific error message.
// It returns true if the error message is found, otherwise false.
//
// Parameters:
// - message: string - The error message to check for.
//
// Returns:
// - bool: true if the error message is found, otherwise false.
func (e *APIError) HasErrorMessage(message string) bool {
for _, err := range e.Errors {
if err.Message == message {
return true
}
}
return false
}

8
client/warp/models/deviceupdate.go

@ -0,0 +1,8 @@
package models
type DeviceUpdate struct {
Key string `json:"key"`
KeyType string `json:"key_type"`
TunType string `json:"tunnel_type"`
Name string `json:"name,omitempty"`
}

102
client/warp/models/register.go

@ -0,0 +1,102 @@
package models
type Registration struct {
Key string `json:"key"`
InstallID string `json:"install_id"`
FcmToken string `json:"fcm_token"`
Tos string `json:"tos"`
Model string `json:"model"`
Serial string `json:"serial_number"`
OsVersion string `json:"os_version"`
KeyType string `json:"key_type"`
TunType string `json:"tunnel_type"`
Locale string `json:"locale"`
}
type AccountData struct {
ID string `json:"id"`
Type string `json:"type"`
Model string `json:"model"`
Name string `json:"name"`
Key string `json:"key"`
KeyType string `json:"key_type"`
TunType string `json:"tunnel_type"`
Account Account `json:"account"`
Config Config `json:"config"`
// WarpEnabled not set for ZeroTier
WarpEnabled bool `json:"warp_enabled,omitempty"`
// Waitlist not set for ZeroTier
Waitlist bool `json:"waitlist_enabled,omitempty"`
Created string `json:"created"`
Updated string `json:"updated"`
// Tos not set for ZeroTier
Tos string `json:"tos,omitempty"`
// Place not set for ZeroTier
Place int `json:"place,omitempty"`
Locale string `json:"locale"`
// Enabled not set for ZeroTier
Enabled bool `json:"enabled,omitempty"`
InstallID string `json:"install_id"`
// Token only set for /reg call
Token string `json:"token,omitempty"`
FcmToken string `json:"fcm_token"`
// SerialNumber not set for ZeroTier
SerialNumber string `json:"serial_number,omitempty"`
Policy Policy `json:"policy"`
}
type Account struct {
ID string `json:"id"`
AccountType string `json:"account_type"`
// Created not set for ZeroTier
Created string `json:"created,omitempty"`
// Updated not set for ZeroTier
Updated string `json:"updated,omitempty"`
// Managed only set for ZeroTier
Managed string `json:"managed,omitempty"`
// Organization only set for ZeroTier
Organization string `json:"organization,omitempty"`
// PremiumData not set for ZeroTier
PremiumData int `json:"premium_data,omitempty"`
// Quota not set for ZeroTier
Quota int `json:"quota,omitempty"`
// WarpPlus not set for ZeroTier
WarpPlus bool `json:"warp_plus,omitempty"`
// ReferralCode not set for ZeroTier
ReferralCount int `json:"referral_count,omitempty"`
// ReferralRenewalCount not set for ZeroTier
ReferralRenewalCount int `json:"referral_renewal_countdown,omitempty"`
// Role not set for ZeroTier
Role string `json:"role,omitempty"`
// License not set for ZeroTier
License string `json:"license,omitempty"`
}
type Config struct {
ClientID string `json:"client_id"`
Peers []Peer `json:"peers"`
Interface struct {
Addresses struct {
V4 string `json:"v4"`
V6 string `json:"v6"`
} `json:"addresses"`
} `json:"interface"`
Services struct {
HTTPProxy string `json:"http_proxy"`
} `json:"services"`
}
type Peer struct {
PublicKey string `json:"public_key"`
Endpoint struct {
V4 string `json:"v4"`
V6 string `json:"v6"`
Host string `json:"host"`
Ports []int `json:"ports"`
} `json:"endpoint"`
}
type Policy struct {
TunnelProtocol string `json:"tunnel_protocol"`
// TODO: add ZeroTier fields
}

305
client/warp/proxy/mixed.go

@ -0,0 +1,305 @@
// Package proxy implements a mixed SOCKS5/HTTP proxy server that listens on a single
// port and automatically detects the protocol from the first byte of each connection.
package proxy
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"strings"
"sync/atomic"
"time"
"github.com/cacggghp/vk-turn-proxy/client/warp/internal"
"github.com/things-go/go-socks5"
"golang.zx2c4.com/wireguard/tun/netstack"
)
// netResolverAdapter wraps a *net.Resolver so it satisfies socks5.NameResolver.
// Using *net.Resolver (via NewNetstackResolver) instead of TunnelDNSResolver gives us
// Go's built-in UDP retry / exponential-backoff logic, which is far more resilient to
// the packet loss inherent in a UDP-over-MASQUE-over-VK-TURN chain.
type netResolverAdapter struct {
r *net.Resolver
}
func (a netResolverAdapter) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) {
dnsCtx, dnsCancel := context.WithTimeout(context.Background(), 45*time.Second)
defer dnsCancel()
ips, err := a.r.LookupIP(dnsCtx, "ip", name)
if err != nil || len(ips) == 0 {
log.Printf("[Warp] SOCKS5: DNS failed for %s: %v", name, err)
return ctx, nil, fmt.Errorf("DNS lookup %s: %w", name, err)
}
return ctx, ips[0], nil
}
// MixedProxy listens on a single address and routes incoming connections to either
// a SOCKS5 handler or an HTTP/CONNECT handler based on the first byte received.
// Both protocols resolve DNS through the MASQUE tunnel via *net.Resolver,
// which has built-in retry logic suitable for high-latency/lossy relay paths.
type MixedProxy struct {
addr string
tunNet *netstack.Net
resolver *net.Resolver // tunnel-aware resolver (used by both HTTP and SOCKS5)
socks netResolverAdapter // socks5.NameResolver adapter around the same resolver
ready atomic.Bool // whether the tunnel is fully connected
}
// NewMixedProxy creates a new MixedProxy.
//
// Parameters:
// - addr: The address to listen on (e.g. "127.0.0.1:4080").
// - tunNet: The netstack network (from the MASQUE tunnel).
// - dnsAddrs: DNS servers to use inside the tunnel (e.g. 162.159.36.1).
// - localDNS: if true, use the system resolver instead of routing DNS through the tunnel.
func NewMixedProxy(addr string, tunNet *netstack.Net, dnsAddrs []netip.Addr, localDNS bool) *MixedProxy {
var resolver *net.Resolver
if localDNS {
resolver = &net.Resolver{PreferGo: false}
log.Printf("[Warp] Using local (system) DNS resolver")
} else {
// Tunnel resolver — DNS goes through MASQUE to 162.159.36.1.
resolver = internal.NewNetstackResolver(tunNet, dnsAddrs)
}
return &MixedProxy{
addr: addr,
tunNet: tunNet,
resolver: resolver,
socks: netResolverAdapter{r: resolver},
}
}
// SetReady updates the tunnel connection state.
// When false, the proxy quickly rejects pending connections.
func (m *MixedProxy) SetReady(ready bool) {
m.ready.Store(ready)
}
// ListenAndServe starts the mixed proxy server and blocks until the context is cancelled.
func (m *MixedProxy) ListenAndServe(ctx context.Context) error {
listener, err := net.Listen("tcp", m.addr)
if err != nil {
return fmt.Errorf("mixed proxy: listen on %s: %w", m.addr, err)
}
defer listener.Close()
context.AfterFunc(ctx, func() { _ = listener.Close() })
log.Printf("[Warp] Mixed proxy (SOCKS5+HTTP) listening on %s", m.addr)
socksServer := socks5.NewServer(
socks5.WithLogger(socks5.NewLogger(log.New(io.Discard, "", 0))),
socks5.WithDial(func(sCtx context.Context, network, addr string) (net.Conn, error) {
return m.tunNet.DialContext(sCtx, network, addr)
}),
socks5.WithResolver(m.socks),
)
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
log.Printf("[Warp] Mixed proxy accept error: %v", err)
continue
}
}
go m.handleConn(ctx, conn, socksServer)
}
}
// handleConn peeks at the first byte to detect protocol: 0x05 = SOCKS5, else HTTP.
func (m *MixedProxy) handleConn(ctx context.Context, conn net.Conn, socksServer *socks5.Server) {
defer func() {
if r := recover(); r != nil {
log.Printf("[Warp] Mixed proxy panic: %v", r)
conn.Close()
}
}()
buf := make([]byte, 1)
if _, err := io.ReadFull(conn, buf); err != nil {
conn.Close()
return
}
peeked := &peekedConn{Conn: conn, buf: buf}
if buf[0] == 0x05 {
if !m.ready.Load() {
log.Printf("[Warp] Rejecting SOCKS5 from %s (tunnel not ready)", conn.RemoteAddr())
conn.Close()
return
}
if err := socksServer.ServeConn(peeked); err != nil {
log.Printf("[Warp] SOCKS5 error: %v", err)
}
return
}
if !m.ready.Load() {
conn.Close()
return
}
m.handleHTTP(ctx, peeked)
}
// handleHTTP serves a single HTTP/CONNECT connection.
func (m *MixedProxy) handleHTTP(ctx context.Context, conn net.Conn) {
defer conn.Close()
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
m.handleHTTPConnect(w, r)
} else {
m.handleHTTPPlain(w, r)
}
}),
}
_ = server.Serve(&oneConnListener{conn: conn})
}
func (m *MixedProxy) handleHTTPConnect(w http.ResponseWriter, r *http.Request) {
host, port, err := net.SplitHostPort(r.Host)
if err != nil {
http.Error(w, "invalid host", http.StatusBadRequest)
return
}
dnsCtx, dnsCancel := context.WithTimeout(context.Background(), 45*time.Second)
defer dnsCancel()
ips, err := m.resolver.LookupIP(dnsCtx, "ip", host)
if err != nil || len(ips) == 0 {
log.Printf("[Warp] HTTP CONNECT: DNS failed for %s: %v", host, err)
http.Error(w, fmt.Sprintf("DNS failed for %s: %v", host, err), http.StatusServiceUnavailable)
return
}
destAddr := net.JoinHostPort(ips[0].String(), port)
destConn, err := m.tunNet.DialContext(r.Context(), "tcp", destAddr)
if err != nil {
log.Printf("[Warp] HTTP CONNECT: tunnel dial failed for %s: %v", destAddr, err)
http.Error(w, fmt.Sprintf("tunnel dial failed: %v", err), http.StatusServiceUnavailable)
return
}
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
destConn.Close()
return
}
clientConn, _, err := hj.Hijack()
if err != nil {
destConn.Close()
return
}
_, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go func() {
defer destConn.Close()
defer clientConn.Close()
_, _ = io.Copy(destConn, clientConn)
}()
_, _ = io.Copy(clientConn, destConn)
}
// handleHTTPPlain handles plain HTTP proxy requests (GET, POST, etc.).
// Mirrors the working implementation from the old http-proxy.
func (m *MixedProxy) handleHTTPPlain(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.RequestURI, "http") {
http.Error(w, "only absolute URIs supported", http.StatusBadRequest)
return
}
client := &http.Client{
Transport: &http.Transport{
DialContext: func(dialCtx context.Context, network, addr string) (net.Conn, error) {
h, p, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("invalid address: %w", err)
}
dnsCtx, dnsCancel := context.WithTimeout(context.Background(), 45*time.Second)
defer dnsCancel()
ips, err := m.resolver.LookupIP(dnsCtx, "ip", h)
if err != nil || len(ips) == 0 {
log.Printf("[Warp] HTTP plain: DNS failed for %s: %v", h, err)
return nil, fmt.Errorf("DNS failed for %s: %w", h, err)
}
return m.tunNet.DialContext(dialCtx, network, net.JoinHostPort(ips[0].String(), p))
},
},
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
req.Header = r.Header.Clone()
resp, err := client.Do(req)
if err != nil {
log.Printf("[Warp] HTTP plain: upstream error for %s: %v", r.URL.Host, err)
http.Error(w, fmt.Sprintf("upstream error: %v", err), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// peekedConn wraps a net.Conn and re-injects already-read bytes into the stream.
type peekedConn struct {
net.Conn
buf []byte
offset int
}
func (p *peekedConn) Read(b []byte) (int, error) {
if p.offset < len(p.buf) {
n := copy(b, p.buf[p.offset:])
p.offset += n
return n, nil
}
return p.Conn.Read(b)
}
// oneConnListener serves a single pre-accepted connection to http.Server.Serve.
type oneConnListener struct {
conn net.Conn
done chan struct{}
}
func (l *oneConnListener) Accept() (net.Conn, error) {
if l.done == nil {
l.done = make(chan struct{})
return l.conn, nil
}
<-l.done
return nil, fmt.Errorf("oneConnListener: done")
}
func (l *oneConnListener) Close() error {
if l.done != nil {
close(l.done)
}
return nil
}
func (l *oneConnListener) Addr() net.Addr { return l.conn.LocalAddr() }

256
client/warp/runner.go

@ -0,0 +1,256 @@
// Package warp provides the Warp (MASQUE/Cloudflare) mode runner for the vk-turn-proxy client.
// It is activated by the -warp flag in the main binary and reuses existing vk-turn flags
// for VK TURN relay integration (-vk-link, -listen, etc.).
package warp
import (
"context"
"encoding/base64"
"fmt"
"log"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"time"
"github.com/cacggghp/vk-turn-proxy/client/warp/api"
"github.com/cacggghp/vk-turn-proxy/client/warp/config"
"github.com/cacggghp/vk-turn-proxy/client/warp/internal"
"github.com/cacggghp/vk-turn-proxy/client/warp/proxy"
"golang.zx2c4.com/wireguard/tun/netstack"
)
// RunnerConfig holds all parameters needed to start the Warp mode.
type RunnerConfig struct {
// ConfigPath is the path to the Warp config.json.
// If empty and the file is not found at the default path, registration is triggered.
ConfigPath string
// ProxyAddr is the address for the mixed SOCKS5/HTTP proxy (e.g. "127.0.0.1:4080").
ProxyAddr string
// GetRelayConn is an optional function that provides a pre-allocated TURN relay connection.
// If nil, Warp connects directly to Cloudflare's MASQUE endpoint.
GetRelayConn api.GetRelayConnFunc
// ConnectPort is the port for the MASQUE QUIC connection (default 443).
ConnectPort int
// UseIPv6 selects the IPv6 endpoint instead of IPv4 for the MASQUE connection.
UseIPv6 bool
// KeepalivePeriod is the QUIC keepalive interval.
KeepalivePeriod time.Duration
// InitialPacketSize is the initial QUIC packet size.
InitialPacketSize uint16
// ReconnectDelay is the delay between tunnel reconnect attempts.
ReconnectDelay time.Duration
// MTU is the MTU for the virtual TUN device.
MTU int
// LocalDNS skips tunnel DNS and uses the system resolver.
// Useful when 162.159.36.1 is unreachable over the TURN relay.
LocalDNS bool
// Debug enables verbose logging in the warp/api package.
Debug bool
}
// DefaultRunnerConfig returns a RunnerConfig with sensible defaults.
func DefaultRunnerConfig() RunnerConfig {
return RunnerConfig{
ConfigPath: "config.json",
ProxyAddr: "127.0.0.1:4080",
ConnectPort: 443,
UseIPv6: false,
KeepalivePeriod: 30 * time.Second,
InitialPacketSize: 1242,
ReconnectDelay: 1 * time.Second,
MTU: 1200, // Lowered to avoid fragmentation over TURN relay
}
}
// Run starts the Warp-in-VK-TURN mode.
// It handles config loading/registration, then starts the MASQUE tunnel and mixed proxy.
func Run(ctx context.Context, cfg RunnerConfig) error {
// 1. Resolve config path to absolute
cfgPath, err := resolveConfigPath(cfg.ConfigPath)
if err != nil {
return fmt.Errorf("warp: resolve config path: %w", err)
}
// 2. Try to load config
if err := config.LoadConfig(cfgPath); err != nil {
if cfg.ConfigPath != "" && cfg.ConfigPath != "config.json" {
// User explicitly specified a config path — error out
return fmt.Errorf("warp: config file not found at %s: %w", cfgPath, err)
}
// Default path not found — start interactive registration
log.Printf("[Warp] Config not found at %s. Starting registration...", cfgPath)
if err := runInteractiveRegistration(cfgPath); err != nil {
return fmt.Errorf("warp: registration failed: %w", err)
}
}
// 3. Prepare TLS keys from config
privKey, err := config.AppConfig.GetEcPrivateKey()
if err != nil {
return fmt.Errorf("warp: get private key: %w", err)
}
peerPubKey, err := config.AppConfig.GetEcEndpointPublicKey()
if err != nil {
return fmt.Errorf("warp: get peer public key: %w", err)
}
cert, err := internal.GenerateCert(privKey, &privKey.PublicKey)
if err != nil {
return fmt.Errorf("warp: generate cert: %w", err)
}
tlsConfig, err := api.PrepareTlsConfig(privKey, peerPubKey, cert, internal.ConnectSNI)
if err != nil {
return fmt.Errorf("warp: prepare TLS config: %w", err)
}
// 4. Determine MASQUE endpoint
connectPort := cfg.ConnectPort
if connectPort <= 0 {
connectPort = 443
}
var endpoint *net.UDPAddr
if cfg.UseIPv6 {
addr := net.JoinHostPort(config.AppConfig.EndpointV6, fmt.Sprint(connectPort))
endpoint, err = net.ResolveUDPAddr("udp", addr)
if err != nil {
return fmt.Errorf("warp: resolve IPv6 endpoint: %w", err)
}
} else {
addr := net.JoinHostPort(config.AppConfig.EndpointV4, fmt.Sprint(connectPort))
endpoint, err = net.ResolveUDPAddr("udp", addr)
if err != nil {
return fmt.Errorf("warp: resolve IPv4 endpoint: %w", err)
}
if ip4 := endpoint.IP.To4(); ip4 != nil {
endpoint.IP = ip4
}
}
// DNS addresses: Cloudflare WARP intentionally blocks/drops most regular UDP port 53 traffic
// over MASQUE tunnels to public servers (like 9.9.9.9 or 1.1.1.1) to enforce their DoH proxy.
// You MUST use their internal designated DNS forwarder: 162.159.36.1
dnsAddrs := []netip.Addr{
netip.MustParseAddr("162.159.36.1"),
netip.MustParseAddr("1.1.1.1"),
netip.MustParseAddr("1.0.0.1"),
}
var localAddresses []netip.Addr
parseInternalIP := func(s string) (netip.Addr, error) {
// Strip mask if present (e.g. 172.16.0.2/32)
if i := strings.Index(s, "/"); i != -1 {
s = s[:i]
}
return netip.ParseAddr(s)
}
if v4, err := parseInternalIP(config.AppConfig.IPv4); err == nil {
localAddresses = append(localAddresses, v4)
}
if v6, err := parseInternalIP(config.AppConfig.IPv6); err == nil {
localAddresses = append(localAddresses, v6)
}
api.Verbose = cfg.Debug
tunDev, tunNet, err := netstack.CreateNetTUN(localAddresses, dnsAddrs, cfg.MTU)
if err != nil {
return fmt.Errorf("warp: create virtual TUN: %w", err)
}
defer tunDev.Close()
// 6. Init mixed proxy so we can pass its SetReady callback
mp := proxy.NewMixedProxy(cfg.ProxyAddr, tunNet, dnsAddrs, cfg.LocalDNS)
// 7. Start tunnel maintenance in background
log.Printf("[Warp] Starting MASQUE tunnel to %s (via TURN: %v)", endpoint, cfg.GetRelayConn != nil)
go api.MaintainTunnel(
ctx,
tlsConfig,
cfg.KeepalivePeriod,
cfg.InitialPacketSize,
endpoint,
api.NewNetstackAdapter(tunDev),
cfg.MTU,
cfg.ReconnectDelay,
cfg.GetRelayConn,
mp.SetReady,
)
// 8. Start mixed proxy listener (blocks until cancelled)
// Both SOCKS5 and HTTP resolve DNS through the MASQUE tunnel via TunnelDNSResolver,
// then dial tunNet with the resolved IP — matching the working httpproxy.go pattern.
return mp.ListenAndServe(ctx)
}
// resolveConfigPath returns the absolute path for the config file.
// If the path is relative, it is resolved relative to the executable's directory.
func resolveConfigPath(cfgPath string) (string, error) {
if filepath.IsAbs(cfgPath) {
return cfgPath, nil
}
// Try CWD first
if _, err := os.Stat(cfgPath); err == nil {
abs, err := filepath.Abs(cfgPath)
if err != nil {
return "", err
}
return abs, nil
}
// Fall back to executable directory (useful on Android/embedded)
exePath, err := os.Executable()
if err != nil {
return cfgPath, nil //nolint:nilerr — best effort
}
return filepath.Join(filepath.Dir(exePath), cfgPath), nil
}
// runInteractiveRegistration runs the interactive Cloudflare WARP registration flow.
// It asks the user to accept TOS and choose a device name, then saves the config.
func runInteractiveRegistration(cfgPath string) error {
log.Printf("[Warp] === Cloudflare WARP Registration ===")
// Register (will prompt for TOS internally inside api.Register)
accountData, err := api.Register(internal.DefaultModel, internal.DefaultLocale, "", false /* acceptTos — prompt inside */)
if err != nil {
return fmt.Errorf("register: %w", err)
}
fmt.Print("[Warp] Enter device name (leave empty for default): ")
var deviceName string
_, _ = fmt.Scanln(&deviceName)
privKey, pubKey, err := internal.GenerateEcKeyPair()
if err != nil {
return fmt.Errorf("generate key pair: %w", err)
}
log.Printf("[Warp] Enrolling device key...")
updatedAccountData, apiErr, err := api.EnrollKey(accountData, pubKey, deviceName)
if err != nil {
if apiErr != nil {
return fmt.Errorf("enroll key: %v (API errors: %s)", err, apiErr.ErrorsAsString("; "))
}
return fmt.Errorf("enroll key: %w", err)
}
log.Printf("[Warp] Registration successful. Saving config to %s...", cfgPath)
config.AppConfig = config.Config{
PrivateKey: base64.StdEncoding.EncodeToString(privKey),
EndpointV4: updatedAccountData.Config.Peers[0].Endpoint.V4[:len(updatedAccountData.Config.Peers[0].Endpoint.V4)-2],
EndpointV6: updatedAccountData.Config.Peers[0].Endpoint.V6[1 : len(updatedAccountData.Config.Peers[0].Endpoint.V6)-3],
EndpointPubKey: updatedAccountData.Config.Peers[0].PublicKey,
License: updatedAccountData.Account.License,
ID: updatedAccountData.ID,
AccessToken: accountData.Token,
IPv4: updatedAccountData.Config.Interface.Addresses.V4,
IPv6: updatedAccountData.Config.Interface.Addresses.V6,
}
if err := config.AppConfig.SaveConfig(cfgPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
config.ConfigLoaded = true
log.Printf("[Warp] Config saved successfully.")
return nil
}

14
go.mod

@ -3,6 +3,7 @@ module github.com/cacggghp/vk-turn-proxy
go 1.25.5
require (
github.com/Diniboy1123/connect-ip-go v0.0.0-20251011145655-7be32d5976d9
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.14.0
github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45
@ -13,8 +14,13 @@ require (
github.com/pion/logging v0.2.4
github.com/pion/transport/v4 v4.0.1
github.com/pion/turn/v5 v5.0.3
github.com/quic-go/quic-go v0.59.0
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
github.com/things-go/go-socks5 v0.1.1
github.com/xtaci/kcp-go/v5 v5.6.18
github.com/xtaci/smux v1.5.34
github.com/yosida95/uritemplate/v3 v3.0.2
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
)
require (
@ -24,7 +30,8 @@ require (
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
@ -39,13 +46,18 @@ require (
github.com/templexxx/xorsimd v0.4.3 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 // indirect
)

26
go.sum

@ -1,5 +1,7 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Diniboy1123/connect-ip-go v0.0.0-20251011145655-7be32d5976d9 h1:+0wdi3fTeWM+XZH8s3mJ6RuG3tfx9yj9WFbEhupcA6k=
github.com/Diniboy1123/connect-ip-go v0.0.0-20251011145655-7be32d5976d9/go.mod h1:7N+URwxiIxNn21j8f67tXvG26tvxzw81lVhtLIb0ynE=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
@ -25,6 +27,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@ -39,6 +43,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -78,6 +84,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
@ -86,6 +96,8 @@ github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
github.com/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY=
github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
@ -98,14 +110,18 @@ github.com/xtaci/smux v1.5.34 h1:OUA9JaDFHJDT8ZT3ebwLWPAgEfE6sWo2LaTy3anXqwg=
github.com/xtaci/smux v1.5.34/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@ -150,6 +166,10 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@ -172,5 +192,7 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 h1:QyFROp5Ew7XZWKPtp8ap78z4gpY6xHpJIEdHgVA4bzA=
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

Loading…
Cancel
Save