diff --git a/client/main.go b/client/main.go index 6186914..b41adf9 100644 --- a/client/main.go +++ b/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) diff --git a/client/warp/api/cloudflare.go b/client/warp/api/cloudflare.go new file mode 100644 index 0000000..7f3366d --- /dev/null +++ b/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 +} diff --git a/client/warp/api/masque.go b/client/warp/api/masque.go new file mode 100644 index 0000000..dd47c9f --- /dev/null +++ b/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 +} diff --git a/client/warp/api/tunnel.go b/client/warp/api/tunnel.go new file mode 100644 index 0000000..5dafd88 --- /dev/null +++ b/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): + } + } +} diff --git a/client/warp/config/config.go b/client/warp/config/config.go new file mode 100644 index 0000000..5813141 --- /dev/null +++ b/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 +} diff --git a/client/warp/internal/consts.go b/client/warp/internal/consts.go new file mode 100644 index 0000000..17f0225 --- /dev/null +++ b/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", +} diff --git a/client/warp/internal/dns.go b/client/warp/internal/dns.go new file mode 100644 index 0000000..0b42410 --- /dev/null +++ b/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) +} diff --git a/client/warp/internal/utils.go b/client/warp/internal/utils.go new file mode 100644 index 0000000..ee3736d --- /dev/null +++ b/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 +} diff --git a/client/warp/internal/utils_windows.go b/client/warp/internal/utils_windows.go new file mode 100644 index 0000000..1faacd0 --- /dev/null +++ b/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 +} diff --git a/client/warp/models/apierror.go b/client/warp/models/apierror.go new file mode 100644 index 0000000..7474ed8 --- /dev/null +++ b/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 +} diff --git a/client/warp/models/deviceupdate.go b/client/warp/models/deviceupdate.go new file mode 100644 index 0000000..59facf2 --- /dev/null +++ b/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"` +} diff --git a/client/warp/models/register.go b/client/warp/models/register.go new file mode 100644 index 0000000..2c3b53e --- /dev/null +++ b/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 +} diff --git a/client/warp/proxy/mixed.go b/client/warp/proxy/mixed.go new file mode 100644 index 0000000..64bace0 --- /dev/null +++ b/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() } diff --git a/client/warp/runner.go b/client/warp/runner.go new file mode 100644 index 0000000..79ddf8b --- /dev/null +++ b/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 +} diff --git a/go.mod b/go.mod index e6e5929..3f91975 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index aef2d97..bdf5414 100644 --- a/go.sum +++ b/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=