From 8f1f32b00de74a68088083b84409f2a2fe24134a Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:02:48 -0400 Subject: [PATCH 1/4] feat: add Android fd control for resolver sockets Add opt-in Unix socket fd protection for upstream client sockets so Android hosts can call VpnService.protect before resolver traffic is used. --- client_config.toml.simple | 6 + internal/client/async_runtime.go | 2 +- internal/client/dns_listener.go | 1 + internal/client/mtu.go | 6 +- internal/client/protected_socket.go | 85 ++++++++ internal/client/protected_socket_unix_test.go | 200 ++++++++++++++++++ internal/client/socks_manager.go | 1 + internal/client/tcp_listener.go | 1 + internal/client/tunnel_runtime.go | 15 +- internal/config/client.go | 6 + internal/config/client_test.go | 89 ++++++++ internal/sockprotect/cloexec_flag_fallback.go | 5 + .../sockprotect/cloexec_flag_supported.go | 7 + internal/sockprotect/protect_other.go | 16 ++ internal/sockprotect/protect_unix.go | 58 +++++ internal/sockprotect/protect_unix_test.go | 166 +++++++++++++++ 16 files changed, 648 insertions(+), 16 deletions(-) create mode 100644 internal/client/protected_socket.go create mode 100644 internal/client/protected_socket_unix_test.go create mode 100644 internal/sockprotect/cloexec_flag_fallback.go create mode 100644 internal/sockprotect/cloexec_flag_supported.go create mode 100644 internal/sockprotect/protect_other.go create mode 100644 internal/sockprotect/protect_unix.go create mode 100644 internal/sockprotect/protect_unix_test.go diff --git a/client_config.toml.simple b/client_config.toml.simple index 3e18d17f..73e5f993 100644 --- a/client_config.toml.simple +++ b/client_config.toml.simple @@ -149,6 +149,12 @@ AUTO_DISABLE_TIMEOUT_SERVERS = true # Clamped to [1s, 86400s]. AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS = 30.0 +# Android VpnService integrations only. +# When set, every upstream resolver/carrier socket fd is sent to this Unix socket +# for VpnService.protect(fd) before connect/bind. +# MASTERDNSVPN_PROTECT_PATH overrides this TOML value. +# FD_CONTROL_UNIX_SOCKET = "" + # If true, payload labels are base-encoded before tunneling. # Usually keep false unless a specific resolver path behaves better with it. BASE_ENCODE_DATA = false diff --git a/internal/client/async_runtime.go b/internal/client/async_runtime.go index e27fbf12..bec3de36 100644 --- a/internal/client/async_runtime.go +++ b/internal/client/async_runtime.go @@ -313,7 +313,7 @@ func (c *Client) StartAsyncRuntime(parentCtx context.Context) error { // 3. Open dedicated UDP sockets for each RX/TX worker. conns := make([]*net.UDPConn, 0, c.tunnelRX_TX_Workers) for i := 0; i < c.tunnelRX_TX_Workers; i++ { - conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + conn, err := c.listenUDPProtected(runtimeCtx, &net.UDPAddr{IP: net.IPv4zero, Port: 0}) if err != nil { for _, opened := range conns { _ = opened.Close() diff --git a/internal/client/dns_listener.go b/internal/client/dns_listener.go index 06fbc99a..68cedaee 100644 --- a/internal/client/dns_listener.go +++ b/internal/client/dns_listener.go @@ -47,6 +47,7 @@ func (l *DNSListener) Start(ctx context.Context, ip string, port int) error { if err != nil { return err } + // Local app-facing DNS listener; upstream resolver sockets are protected elsewhere. conn, err := net.ListenUDP("udp", addr) if err != nil { return err diff --git a/internal/client/mtu.go b/internal/client/mtu.go index 85b4298d..5d419c8f 100644 --- a/internal/client/mtu.go +++ b/internal/client/mtu.go @@ -612,7 +612,7 @@ func (c *Client) recheckInactiveResolver(ctx context.Context, conn Connection) { return } - transport, err := newUDPQueryTransport(conn.ResolverLabel) + transport, err := c.newUDPQueryTransport(conn.ResolverLabel) if err != nil { return } @@ -734,7 +734,7 @@ func (c *Client) confirmResolverDown(conn *Connection, window time.Duration) boo return true } - transport, err := newUDPQueryTransport(conn.ResolverLabel) + transport, err := c.newUDPQueryTransport(conn.ResolverLabel) if err != nil { return true } @@ -890,7 +890,7 @@ func (c *Client) runConnectionMTUTest(ctx context.Context, conn Connection, serv func (c *Client) probeConnectionMTU(ctx context.Context, conn Connection, maxUploadPayload int) (mtuConnectionProbeResult, mtuRejectReason) { var result mtuConnectionProbeResult - probeTransport, err := newUDPQueryTransport(conn.ResolverLabel) + probeTransport, err := c.newUDPQueryTransport(conn.ResolverLabel) if err != nil { return result, mtuRejectUpload } diff --git a/internal/client/protected_socket.go b/internal/client/protected_socket.go new file mode 100644 index 00000000..bf71dba1 --- /dev/null +++ b/internal/client/protected_socket.go @@ -0,0 +1,85 @@ +package client + +import ( + "context" + "fmt" + "net" + "strings" + "syscall" + + "masterdnsvpn-go/internal/sockprotect" +) + +func (c *Client) protectPath() string { + if c == nil { + return "" + } + return strings.TrimSpace(c.cfg.FDControlUnixSocket) +} + +func (c *Client) protectControl(network, address string, rc syscall.RawConn) error { + path := c.protectPath() + if path == "" { + return nil + } + + var protectErr error + if err := rc.Control(func(fd uintptr) { + protectErr = sockprotect.ProtectFD(path, fd) + }); err != nil { + return fmt.Errorf("socket control failed for %s %s: %w", network, address, err) + } + if protectErr != nil { + return fmt.Errorf("failed to protect upstream socket for %s %s: %w", network, address, protectErr) + } + return nil +} + +func (c *Client) dialUDPResolver(resolverLabel string) (*net.UDPConn, error) { + if c.protectPath() == "" { + addr, err := net.ResolveUDPAddr("udp", resolverLabel) + if err != nil { + return nil, err + } + return net.DialUDP("udp", nil, addr) + } + + dialer := net.Dialer{ + Control: c.protectControl, + } + conn, err := dialer.DialContext(context.Background(), "udp", resolverLabel) + if err != nil { + return nil, err + } + + udpConn, ok := conn.(*net.UDPConn) + if !ok { + _ = conn.Close() + return nil, fmt.Errorf("unexpected udp resolver connection type %T", conn) + } + return udpConn, nil +} + +func (c *Client) listenUDPProtected(ctx context.Context, addr *net.UDPAddr) (*net.UDPConn, error) { + if c.protectPath() == "" { + return net.ListenUDP("udp", addr) + } + + if addr == nil { + addr = &net.UDPAddr{} + } + lc := net.ListenConfig{ + Control: c.protectControl, + } + packetConn, err := lc.ListenPacket(ctx, "udp", addr.String()) + if err != nil { + return nil, err + } + + udpConn, ok := packetConn.(*net.UDPConn) + if !ok { + _ = packetConn.Close() + return nil, fmt.Errorf("unexpected udp listener connection type %T", packetConn) + } + return udpConn, nil +} diff --git a/internal/client/protected_socket_unix_test.go b/internal/client/protected_socket_unix_test.go new file mode 100644 index 00000000..bb47a50e --- /dev/null +++ b/internal/client/protected_socket_unix_test.go @@ -0,0 +1,200 @@ +//go:build unix + +package client + +import ( + "context" + "fmt" + "net" + "os" + "syscall" + "testing" + "time" + + "golang.org/x/sys/unix" + + "masterdnsvpn-go/internal/config" +) + +type clientProtectServerResult struct { + fdCount int + err error +} + +func startClientStubProtectServer(t *testing.T, status byte) (string, <-chan clientProtectServerResult) { + t.Helper() + + path := fmt.Sprintf("/tmp/masterdnsvpn-client-protect-%d.sock", time.Now().UnixNano()) + _ = os.Remove(path) + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("Listen unix failed: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + _ = os.Remove(path) + }) + + resultCh := make(chan clientProtectServerResult, 4) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go handleClientProtectConn(conn, status, resultCh) + } + }() + + return path, resultCh +} + +func handleClientProtectConn(conn net.Conn, status byte, resultCh chan<- clientProtectServerResult) { + defer conn.Close() + + unixConn, ok := conn.(*net.UnixConn) + if !ok { + resultCh <- clientProtectServerResult{err: syscall.EINVAL} + return + } + + rawConn, err := unixConn.SyscallConn() + if err != nil { + resultCh <- clientProtectServerResult{err: err} + return + } + + var ( + payload [1]byte + oobn int + readErr error + ) + oob := make([]byte, unix.CmsgSpace(4)) + if err := rawConn.Read(func(fd uintptr) bool { + _, oobn, _, _, readErr = unix.Recvmsg(int(fd), payload[:], oob, 0) + return true + }); err != nil { + resultCh <- clientProtectServerResult{err: err} + return + } + if readErr != nil { + resultCh <- clientProtectServerResult{err: readErr} + return + } + + messages, err := unix.ParseSocketControlMessage(oob[:oobn]) + if err != nil { + resultCh <- clientProtectServerResult{err: err} + return + } + + fdCount := 0 + for _, msg := range messages { + fds, err := unix.ParseUnixRights(&msg) + if err != nil { + resultCh <- clientProtectServerResult{err: err} + return + } + fdCount += len(fds) + for _, receivedFD := range fds { + _ = unix.Close(receivedFD) + } + } + + _, err = conn.Write([]byte{status}) + resultCh <- clientProtectServerResult{fdCount: fdCount, err: err} +} + +func requireClientProtectResult(t *testing.T, resultCh <-chan clientProtectServerResult) clientProtectServerResult { + t.Helper() + + select { + case result := <-resultCh: + if result.err != nil { + t.Fatalf("protect server failed: %v", result.err) + } + return result + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for protect server") + return clientProtectServerResult{} + } +} + +func TestProtectedResolverQuerySendsFDToProtectServer(t *testing.T) { + resolverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatalf("ListenUDP resolver failed: %v", err) + } + defer resolverConn.Close() + + protectPath, protectResults := startClientStubProtectServer(t, 0x01) + c := New(config.ClientConfig{ + FDControlUnixSocket: protectPath, + PacketDuplicationCount: 1, + SetupPacketDuplicationCount: 1, + RX_TX_Workers: 1, + }, nil, nil) + + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 512) + n, addr, err := resolverConn.ReadFromUDP(buf) + if err != nil || n < 2 { + return + } + _, _ = resolverConn.WriteToUDP([]byte{buf[0], buf[1], 0x81, 0x00}, addr) + }() + + udpConn, err := c.getUDPConn(resolverConn.LocalAddr().String()) + if err != nil { + t.Fatalf("getUDPConn returned error: %v", err) + } + defer udpConn.Close() + + response, err := c.exchangeUDPQueryWithConn(udpConn, []byte{0x12, 0x34, 0x01}, time.Second) + if err != nil { + t.Fatalf("exchangeUDPQueryWithConn returned error: %v", err) + } + if len(response) < 2 || response[0] != 0x12 || response[1] != 0x34 { + t.Fatalf("unexpected resolver response: %v", response) + } + + result := requireClientProtectResult(t, protectResults) + if result.fdCount != 1 { + t.Fatalf("expected one protected resolver fd, got %d", result.fdCount) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for resolver traffic") + } +} + +func TestListenUDPProtectedSendsFDToProtectServer(t *testing.T) { + protectPath, protectResults := startClientStubProtectServer(t, 0x01) + c := New(config.ClientConfig{FDControlUnixSocket: protectPath}, nil, nil) + + conn, err := c.listenUDPProtected(context.Background(), &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + t.Fatalf("listenUDPProtected returned error: %v", err) + } + defer conn.Close() + + result := requireClientProtectResult(t, protectResults) + if result.fdCount != 1 { + t.Fatalf("expected one protected listener fd, got %d", result.fdCount) + } +} + +func TestListenUDPProtectedFailsOnProtectFailure(t *testing.T) { + protectPath, _ := startClientStubProtectServer(t, 0x00) + c := New(config.ClientConfig{FDControlUnixSocket: protectPath}, nil, nil) + + conn, err := c.listenUDPProtected(context.Background(), &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err == nil { + _ = conn.Close() + t.Fatal("expected listenUDPProtected to fail on protect failure") + } +} diff --git a/internal/client/socks_manager.go b/internal/client/socks_manager.go index 3ed56916..92d6e4d5 100644 --- a/internal/client/socks_manager.go +++ b/internal/client/socks_manager.go @@ -593,6 +593,7 @@ func (c *Client) handleSocksUDPAssociate(ctx context.Context, conn net.Conn, cli IP: net.IPv4zero, Port: 0, } + // Local SOCKS5 UDP ASSOCIATE relay socket; do not protect app-facing listeners. udpConn, err := net.ListenUDP("udp", bindAddr) if err != nil { _ = c.sendSocksReply(conn, SOCKS5_REPLY_GENERAL_FAILURE, SOCKS5_ATYP_IPV4, net.IPv4zero, 0) diff --git a/internal/client/tcp_listener.go b/internal/client/tcp_listener.go index 3c513eae..b7ffe169 100644 --- a/internal/client/tcp_listener.go +++ b/internal/client/tcp_listener.go @@ -43,6 +43,7 @@ func (l *TCPListener) Start(ctx context.Context, ip string, port int) error { listeners := make([]net.Listener, 0, len(addrs)) for i, addr := range addrs { + // Local app-facing TCP/SOCKS listener; upstream resolver sockets are protected elsewhere. listener, err := net.Listen("tcp", addr) if err != nil { if i == 0 { diff --git a/internal/client/tunnel_runtime.go b/internal/client/tunnel_runtime.go index f1947349..b9ae0fe5 100644 --- a/internal/client/tunnel_runtime.go +++ b/internal/client/tunnel_runtime.go @@ -155,7 +155,7 @@ func (c *Client) getUDPConn(resolverLabel string) (*net.UDPConn, error) { } return pc.conn, nil default: - return dialUDPResolver(resolverLabel) + return c.dialUDPResolver(resolverLabel) } } } @@ -235,15 +235,6 @@ func (c *Client) putRuntimeUDPBuffer(buf []byte) { c.udpBufferPool.Put(buf[:RuntimeUDPReadBufferSize]) } -// dialUDPResolver resolves the resolver address and establishes a new UDP connection. -func dialUDPResolver(resolverLabel string) (*net.UDPConn, error) { - addr, err := net.ResolveUDPAddr("udp", resolverLabel) - if err != nil { - return nil, err - } - return net.DialUDP("udp", nil, addr) -} - // normalizeTimeout ensures the timeout is positive, falling back to a default if necessary. func normalizeTimeout(timeout time.Duration, fallback time.Duration) time.Duration { if timeout <= 0 { @@ -258,8 +249,8 @@ type udpQueryTransport struct { } // newUDPQueryTransport creates a new transport for UDP queries to the specified resolver. -func newUDPQueryTransport(resolverLabel string) (*udpQueryTransport, error) { - conn, err := dialUDPResolver(resolverLabel) +func (c *Client) newUDPQueryTransport(resolverLabel string) (*udpQueryTransport, error) { + conn, err := c.dialUDPResolver(resolverLabel) if err != nil { return nil, err } diff --git a/internal/config/client.go b/internal/config/client.go index 7f96791a..149996a0 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -52,6 +52,7 @@ type ClientConfig struct { RecheckInactiveServersEnabled bool `toml:"RECHECK_INACTIVE_SERVERS_ENABLED"` AutoDisableTimeoutServers bool `toml:"AUTO_DISABLE_TIMEOUT_SERVERS"` AutoDisableTimeoutWindowSeconds float64 `toml:"AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS"` + FDControlUnixSocket string `toml:"FD_CONTROL_UNIX_SOCKET" json:"fd_control_unix_socket,omitempty"` BaseEncodeData bool `toml:"BASE_ENCODE_DATA"` UploadCompressionType int `toml:"UPLOAD_COMPRESSION_TYPE"` DownloadCompressionType int `toml:"DOWNLOAD_COMPRESSION_TYPE"` @@ -154,6 +155,7 @@ func defaultClientConfig() ClientConfig { RecheckInactiveServersEnabled: true, AutoDisableTimeoutServers: true, AutoDisableTimeoutWindowSeconds: 30.0, + FDControlUnixSocket: "", BaseEncodeData: false, UploadCompressionType: compression.TypeOff, DownloadCompressionType: compression.TypeOff, @@ -334,6 +336,10 @@ func finalizeClientConfig(cfg ClientConfig) (ClientConfig, error) { if cfg.LogLevel == "" { cfg.LogLevel = "INFO" } + cfg.FDControlUnixSocket = strings.TrimSpace(cfg.FDControlUnixSocket) + if envProtectPath := strings.TrimSpace(os.Getenv("MASTERDNSVPN_PROTECT_PATH")); envProtectPath != "" { + cfg.FDControlUnixSocket = envProtectPath + } switch cfg.ProtocolType { case "", "SOCKS5": diff --git a/internal/config/client_test.go b/internal/config/client_test.go index f25069c0..7cd2706c 100644 --- a/internal/config/client_test.go +++ b/internal/config/client_test.go @@ -76,6 +76,95 @@ MTU_TEST_TIMEOUT = 1.5 } } +func TestLoadClientConfigLoadsFDControlUnixSocket(t *testing.T) { + t.Setenv("MASTERDNSVPN_PROTECT_PATH", "") + dir := t.TempDir() + + configPath := filepath.Join(dir, "client_config.toml") + resolversPath := filepath.Join(dir, "client_resolvers.txt") + + if err := os.WriteFile(configPath, []byte(` +PROTOCOL_TYPE = "SOCKS5" +DOMAINS = ["v.domain.com"] +DATA_ENCRYPTION_METHOD = 1 +ENCRYPTION_KEY = "secret" +FD_CONTROL_UNIX_SOCKET = " /tmp/masterdnsvpn-protect.sock " +`), 0o644); err != nil { + t.Fatalf("WriteFile config failed: %v", err) + } + if err := os.WriteFile(resolversPath, []byte("8.8.8.8\n"), 0o644); err != nil { + t.Fatalf("WriteFile resolvers failed: %v", err) + } + + cfg, err := LoadClientConfig(configPath) + if err != nil { + t.Fatalf("LoadClientConfig returned error: %v", err) + } + + if cfg.FDControlUnixSocket != "/tmp/masterdnsvpn-protect.sock" { + t.Fatalf("unexpected fd control path: got=%q", cfg.FDControlUnixSocket) + } +} + +func TestLoadClientConfigFDControlEnvOverridesTOML(t *testing.T) { + t.Setenv("MASTERDNSVPN_PROTECT_PATH", "/tmp/env-protect.sock") + dir := t.TempDir() + + configPath := filepath.Join(dir, "client_config.toml") + resolversPath := filepath.Join(dir, "client_resolvers.txt") + + if err := os.WriteFile(configPath, []byte(` +PROTOCOL_TYPE = "SOCKS5" +DOMAINS = ["v.domain.com"] +DATA_ENCRYPTION_METHOD = 1 +ENCRYPTION_KEY = "secret" +FD_CONTROL_UNIX_SOCKET = "/tmp/toml-protect.sock" +`), 0o644); err != nil { + t.Fatalf("WriteFile config failed: %v", err) + } + if err := os.WriteFile(resolversPath, []byte("8.8.8.8\n"), 0o644); err != nil { + t.Fatalf("WriteFile resolvers failed: %v", err) + } + + cfg, err := LoadClientConfig(configPath) + if err != nil { + t.Fatalf("LoadClientConfig returned error: %v", err) + } + + if cfg.FDControlUnixSocket != "/tmp/env-protect.sock" { + t.Fatalf("expected env protect path override, got=%q", cfg.FDControlUnixSocket) + } +} + +func TestLoadClientConfigFDControlDefaultEmpty(t *testing.T) { + t.Setenv("MASTERDNSVPN_PROTECT_PATH", "") + dir := t.TempDir() + + configPath := filepath.Join(dir, "client_config.toml") + resolversPath := filepath.Join(dir, "client_resolvers.txt") + + if err := os.WriteFile(configPath, []byte(` +PROTOCOL_TYPE = "SOCKS5" +DOMAINS = ["v.domain.com"] +DATA_ENCRYPTION_METHOD = 1 +ENCRYPTION_KEY = "secret" +`), 0o644); err != nil { + t.Fatalf("WriteFile config failed: %v", err) + } + if err := os.WriteFile(resolversPath, []byte("8.8.8.8\n"), 0o644); err != nil { + t.Fatalf("WriteFile resolvers failed: %v", err) + } + + cfg, err := LoadClientConfig(configPath) + if err != nil { + t.Fatalf("LoadClientConfig returned error: %v", err) + } + + if cfg.FDControlUnixSocket != "" { + t.Fatalf("expected empty fd control path, got=%q", cfg.FDControlUnixSocket) + } +} + func TestLoadClientConfigRejectsInvalidProtocol(t *testing.T) { dir := t.TempDir() diff --git a/internal/sockprotect/cloexec_flag_fallback.go b/internal/sockprotect/cloexec_flag_fallback.go new file mode 100644 index 00000000..41d80028 --- /dev/null +++ b/internal/sockprotect/cloexec_flag_fallback.go @@ -0,0 +1,5 @@ +//go:build !android && !dragonfly && !freebsd && !linux && !netbsd && !openbsd + +package sockprotect + +const socketCloseOnExecFlag = 0 diff --git a/internal/sockprotect/cloexec_flag_supported.go b/internal/sockprotect/cloexec_flag_supported.go new file mode 100644 index 00000000..7102866e --- /dev/null +++ b/internal/sockprotect/cloexec_flag_supported.go @@ -0,0 +1,7 @@ +//go:build android || dragonfly || freebsd || linux || netbsd || openbsd + +package sockprotect + +import "golang.org/x/sys/unix" + +const socketCloseOnExecFlag = unix.SOCK_CLOEXEC diff --git a/internal/sockprotect/protect_other.go b/internal/sockprotect/protect_other.go new file mode 100644 index 00000000..bc3e584a --- /dev/null +++ b/internal/sockprotect/protect_other.go @@ -0,0 +1,16 @@ +//go:build !unix + +package sockprotect + +import ( + "fmt" + "strings" +) + +// ProtectFD is unsupported on platforms without Unix-domain fd passing. +func ProtectFD(path string, fd uintptr) error { + if strings.TrimSpace(path) == "" { + return nil + } + return fmt.Errorf("fd protection is unsupported on this platform") +} diff --git a/internal/sockprotect/protect_unix.go b/internal/sockprotect/protect_unix.go new file mode 100644 index 00000000..68d3757a --- /dev/null +++ b/internal/sockprotect/protect_unix.go @@ -0,0 +1,58 @@ +//go:build unix + +package sockprotect + +import ( + "fmt" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +const protectStatusReadTimeout = 5 * time.Second + +// ProtectFD sends fd to an Android/NekoBox-style protect server. +// The server is expected to reply with 0x01 on success. +func ProtectFD(path string, fd uintptr) error { + path = strings.TrimSpace(path) + if path == "" { + return nil + } + + connFD, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|socketCloseOnExecFlag, 0) + if err != nil { + return fmt.Errorf("open protect socket: %w", err) + } + defer unix.Close(connFD) + if socketCloseOnExecFlag == 0 { + unix.CloseOnExec(connFD) + } + + if err := unix.Connect(connFD, &unix.SockaddrUnix{Name: path}); err != nil { + return fmt.Errorf("connect protect socket: %w", err) + } + + if err := unix.Sendmsg(connFD, []byte{0x01}, unix.UnixRights(int(fd)), nil, 0); err != nil { + return fmt.Errorf("send fd to protect socket: %w", err) + } + + timeout := unix.NsecToTimeval(protectStatusReadTimeout.Nanoseconds()) + if err := unix.SetsockoptTimeval(connFD, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &timeout); err != nil { + return fmt.Errorf("set protect status timeout: %w", err) + } + + var status [1]byte + n, err := unix.Read(connFD, status[:]) + if err != nil { + return fmt.Errorf("read protect status: %w", err) + } + if n != 1 { + return fmt.Errorf("read protect status: short read %d", n) + } + if status[0] != 0x01 { + return fmt.Errorf("protect server returned failure") + } + + return nil +} diff --git a/internal/sockprotect/protect_unix_test.go b/internal/sockprotect/protect_unix_test.go new file mode 100644 index 00000000..41b02195 --- /dev/null +++ b/internal/sockprotect/protect_unix_test.go @@ -0,0 +1,166 @@ +//go:build unix + +package sockprotect + +import ( + "fmt" + "net" + "os" + "syscall" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +type protectServerResult struct { + fdCount int + err error +} + +func startStubProtectServer(t *testing.T, status byte) (string, <-chan protectServerResult) { + t.Helper() + + path := fmt.Sprintf("/tmp/masterdnsvpn-protect-%d.sock", time.Now().UnixNano()) + _ = os.Remove(path) + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("Listen unix failed: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + _ = os.Remove(path) + }) + + resultCh := make(chan protectServerResult, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + resultCh <- protectServerResult{err: err} + return + } + defer conn.Close() + + unixConn, ok := conn.(*net.UnixConn) + if !ok { + resultCh <- protectServerResult{err: syscall.EINVAL} + return + } + + rawConn, err := unixConn.SyscallConn() + if err != nil { + resultCh <- protectServerResult{err: err} + return + } + + var ( + payload [1]byte + oobn int + readErr error + ) + oob := make([]byte, unix.CmsgSpace(4)) + if err := rawConn.Read(func(fd uintptr) bool { + _, oobn, _, _, readErr = unix.Recvmsg(int(fd), payload[:], oob, 0) + return true + }); err != nil { + resultCh <- protectServerResult{err: err} + return + } + if readErr != nil { + resultCh <- protectServerResult{err: readErr} + return + } + + messages, err := unix.ParseSocketControlMessage(oob[:oobn]) + if err != nil { + resultCh <- protectServerResult{err: err} + return + } + + fdCount := 0 + for _, msg := range messages { + fds, err := unix.ParseUnixRights(&msg) + if err != nil { + resultCh <- protectServerResult{err: err} + return + } + fdCount += len(fds) + for _, receivedFD := range fds { + _ = unix.Close(receivedFD) + } + } + + _, err = conn.Write([]byte{status}) + resultCh <- protectServerResult{fdCount: fdCount, err: err} + }() + + return path, resultCh +} + +func callProtectWithUDPSocket(t *testing.T, path string) error { + t.Helper() + + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("ListenPacket udp failed: %v", err) + } + defer conn.Close() + + sysConn, ok := conn.(syscall.Conn) + if !ok { + t.Fatal("udp socket does not expose SyscallConn") + } + rawConn, err := sysConn.SyscallConn() + if err != nil { + t.Fatalf("SyscallConn failed: %v", err) + } + + var protectErr error + if err := rawConn.Control(func(fd uintptr) { + protectErr = ProtectFD(path, fd) + }); err != nil { + return err + } + return protectErr +} + +func requireProtectServerResult(t *testing.T, resultCh <-chan protectServerResult) protectServerResult { + t.Helper() + + select { + case result := <-resultCh: + if result.err != nil { + t.Fatalf("protect server failed: %v", result.err) + } + return result + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for protect server") + return protectServerResult{} + } +} + +func TestProtectFDSendsExactlyOneFD(t *testing.T) { + path, resultCh := startStubProtectServer(t, 0x01) + + if err := callProtectWithUDPSocket(t, path); err != nil { + t.Fatalf("ProtectFD returned error: %v", err) + } + + result := requireProtectServerResult(t, resultCh) + if result.fdCount != 1 { + t.Fatalf("expected one fd, got %d", result.fdCount) + } +} + +func TestProtectFDReturnsErrorOnFailureStatus(t *testing.T) { + path, resultCh := startStubProtectServer(t, 0x00) + + if err := callProtectWithUDPSocket(t, path); err == nil { + t.Fatal("expected ProtectFD to fail on 0x00 status") + } + + result := requireProtectServerResult(t, resultCh) + if result.fdCount != 1 { + t.Fatalf("expected one fd, got %d", result.fdCount) + } +} From a33daa9cff4340ed5265b0159aef2a4ca4b9fbf5 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:03:01 -0400 Subject: [PATCH 2/4] fix(client): only require ENCRYPTION_KEY when encryption is enabled DATA_ENCRYPTION_METHOD=0 (None) is valid no-crypto, but finalizeClientConfig unconditionally rejected an empty ENCRYPTION_KEY, so a None-encryption client (for example a profile that defaults to method 0) failed to start. Require the key only when the method is not 0. Add tests for both cases. --- internal/config/client.go | 4 +-- internal/config/client_test.go | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/config/client.go b/internal/config/client.go index 149996a0..646c0afc 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -474,8 +474,8 @@ func finalizeClientConfig(cfg ClientConfig) (ClientConfig, error) { cfg.MTUReactiveAddedServerLogFormat = strings.TrimSpace(cfg.MTUReactiveAddedServerLogFormat) cfg.EncryptionKey = strings.TrimSpace(cfg.EncryptionKey) - if cfg.EncryptionKey == "" { - return cfg, fmt.Errorf("ENCRYPTION_KEY is required in client config") + if cfg.DataEncryptionMethod != 0 && cfg.EncryptionKey == "" { + return cfg, fmt.Errorf("ENCRYPTION_KEY is required when DATA_ENCRYPTION_METHOD is not 0 (None)") } cfg.Domains = normalizeClientDomains(cfg.Domains) diff --git a/internal/config/client_test.go b/internal/config/client_test.go index 7cd2706c..ee38e804 100644 --- a/internal/config/client_test.go +++ b/internal/config/client_test.go @@ -106,6 +106,52 @@ FD_CONTROL_UNIX_SOCKET = " /tmp/masterdnsvpn-protect.sock " } } +func TestLoadClientConfigAllowsEmptyKeyWhenEncryptionNone(t *testing.T) { + t.Setenv("MASTERDNSVPN_PROTECT_PATH", "") + dir := t.TempDir() + configPath := filepath.Join(dir, "client_config.toml") + resolversPath := filepath.Join(dir, "client_resolvers.txt") + + // DATA_ENCRYPTION_METHOD = 0 (None) with no key must be accepted. + if err := os.WriteFile(configPath, []byte(` +PROTOCOL_TYPE = "SOCKS5" +DOMAINS = ["v.domain.com"] +DATA_ENCRYPTION_METHOD = 0 +ENCRYPTION_KEY = "" +`), 0o644); err != nil { + t.Fatalf("WriteFile config failed: %v", err) + } + if err := os.WriteFile(resolversPath, []byte("8.8.8.8\n"), 0o644); err != nil { + t.Fatalf("WriteFile resolvers failed: %v", err) + } + if _, err := LoadClientConfig(configPath); err != nil { + t.Fatalf("expected empty key to be allowed with method 0, got: %v", err) + } +} + +func TestLoadClientConfigRejectsEmptyKeyWhenEncryptionEnabled(t *testing.T) { + t.Setenv("MASTERDNSVPN_PROTECT_PATH", "") + dir := t.TempDir() + configPath := filepath.Join(dir, "client_config.toml") + resolversPath := filepath.Join(dir, "client_resolvers.txt") + + // DATA_ENCRYPTION_METHOD != 0 with an empty key must still be rejected. + if err := os.WriteFile(configPath, []byte(` +PROTOCOL_TYPE = "SOCKS5" +DOMAINS = ["v.domain.com"] +DATA_ENCRYPTION_METHOD = 1 +ENCRYPTION_KEY = "" +`), 0o644); err != nil { + t.Fatalf("WriteFile config failed: %v", err) + } + if err := os.WriteFile(resolversPath, []byte("8.8.8.8\n"), 0o644); err != nil { + t.Fatalf("WriteFile resolvers failed: %v", err) + } + if _, err := LoadClientConfig(configPath); err == nil { + t.Fatalf("expected empty key to be rejected when encryption is enabled") + } +} + func TestLoadClientConfigFDControlEnvOverridesTOML(t *testing.T) { t.Setenv("MASTERDNSVPN_PROTECT_PATH", "/tmp/env-protect.sock") dir := t.TempDir() From 7750c774600452756d5ae3f2a27f2b51bdfbca88 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:17:46 -0400 Subject: [PATCH 3/4] fix(sockprotect): bound protect handshake with connect/send timeouts ProtectFD ran the full handshake inside RawConn.Control with only a receive timeout, so a protect server that accepted but stalled during connect or send could block upstream socket creation indefinitely. Perform a non-blocking connect bounded by a deadline (poll for writability, then check SO_ERROR) and set SO_SNDTIMEO/SO_RCVTIMEO so every blocking step is time-bounded. --- internal/sockprotect/protect_unix.go | 69 ++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/internal/sockprotect/protect_unix.go b/internal/sockprotect/protect_unix.go index 68d3757a..5a6bf63f 100644 --- a/internal/sockprotect/protect_unix.go +++ b/internal/sockprotect/protect_unix.go @@ -10,9 +10,12 @@ import ( "golang.org/x/sys/unix" ) -const protectStatusReadTimeout = 5 * time.Second +// protectTimeout bounds every blocking step of the protect handshake (connect, +// send, and status read) so a stalled or unresponsive protect server can never +// block upstream socket creation indefinitely. +const protectTimeout = 5 * time.Second -// ProtectFD sends fd to an Android/NekoBox-style protect server. +// ProtectFD sends fd to an Android-style protect server (matsuri/libneko framing). // The server is expected to reply with 0x01 on success. func ProtectFD(path string, fd uintptr) error { path = strings.TrimSpace(path) @@ -29,7 +32,16 @@ func ProtectFD(path string, fd uintptr) error { unix.CloseOnExec(connFD) } - if err := unix.Connect(connFD, &unix.SockaddrUnix{Name: path}); err != nil { + // Bound send and receive with timeouts so neither can block forever. + tv := unix.NsecToTimeval(protectTimeout.Nanoseconds()) + if err := unix.SetsockoptTimeval(connFD, unix.SOL_SOCKET, unix.SO_SNDTIMEO, &tv); err != nil { + return fmt.Errorf("set protect send timeout: %w", err) + } + if err := unix.SetsockoptTimeval(connFD, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil { + return fmt.Errorf("set protect status timeout: %w", err) + } + + if err := connectWithTimeout(connFD, &unix.SockaddrUnix{Name: path}, protectTimeout); err != nil { return fmt.Errorf("connect protect socket: %w", err) } @@ -37,11 +49,6 @@ func ProtectFD(path string, fd uintptr) error { return fmt.Errorf("send fd to protect socket: %w", err) } - timeout := unix.NsecToTimeval(protectStatusReadTimeout.Nanoseconds()) - if err := unix.SetsockoptTimeval(connFD, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &timeout); err != nil { - return fmt.Errorf("set protect status timeout: %w", err) - } - var status [1]byte n, err := unix.Read(connFD, status[:]) if err != nil { @@ -56,3 +63,49 @@ func ProtectFD(path string, fd uintptr) error { return nil } + +// connectWithTimeout performs a non-blocking connect bounded by timeout, so a +// protect server that accepts slowly (or never) cannot stall the caller. +func connectWithTimeout(connFD int, sa unix.Sockaddr, timeout time.Duration) error { + if err := unix.SetNonblock(connFD, true); err != nil { + return fmt.Errorf("set nonblocking: %w", err) + } + // Restore blocking mode so the subsequent send/read honor SO_SNDTIMEO/SO_RCVTIMEO. + defer func() { _ = unix.SetNonblock(connFD, false) }() + + err := unix.Connect(connFD, sa) + if err == nil { + return nil + } + if err != unix.EINPROGRESS && err != unix.EINTR { + return err + } + + deadline := time.Now().Add(timeout) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return unix.ETIMEDOUT + } + fds := []unix.PollFd{{Fd: int32(connFD), Events: unix.POLLOUT}} + n, perr := unix.Poll(fds, int(remaining.Milliseconds())) + if perr != nil { + if perr == unix.EINTR { + continue + } + return perr + } + if n == 0 { + return unix.ETIMEDOUT + } + // Connect finished; surface any pending socket error. + soErr, gerr := unix.GetsockoptInt(connFD, unix.SOL_SOCKET, unix.SO_ERROR) + if gerr != nil { + return gerr + } + if soErr != 0 { + return unix.Errno(soErr) + } + return nil + } +} From d481d72d4b86783a87d536c214d2c68cc4e9320e Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:32:55 -0400 Subject: [PATCH 4/4] client: propagate context through protected UDP resolver dial dialUDPResolver hardcoded context.Background(), so callers holding a live context (probeConnectionMTU, recheckInactiveResolver) could not cancel or time out socket creation. In the protected path a cancelled probe would hold a goroutine for up to protectTimeout (~15s total) after cancellation. Thread ctx through dialUDPResolver -> getUDPConn / newUDPQueryTransport and wire the live context from the MTU probe/recheck paths. Callers without a context (sendOneWayDNSQuery, exchangeDNSOverConnection, confirmResolverDown) pass context.Background() (no behavior change). The unprotected path now also uses DialContext so it honors cancellation too. Addresses Greptile review (PR #2). go build/vet/test clean. --- internal/client/mtu.go | 6 +++--- internal/client/protected_socket.go | 17 +++++++++++++---- internal/client/protected_socket_unix_test.go | 2 +- internal/client/tunnel_runtime.go | 13 +++++++------ 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/internal/client/mtu.go b/internal/client/mtu.go index 5d419c8f..fa91d5a5 100644 --- a/internal/client/mtu.go +++ b/internal/client/mtu.go @@ -612,7 +612,7 @@ func (c *Client) recheckInactiveResolver(ctx context.Context, conn Connection) { return } - transport, err := c.newUDPQueryTransport(conn.ResolverLabel) + transport, err := c.newUDPQueryTransport(ctx, conn.ResolverLabel) if err != nil { return } @@ -734,7 +734,7 @@ func (c *Client) confirmResolverDown(conn *Connection, window time.Duration) boo return true } - transport, err := c.newUDPQueryTransport(conn.ResolverLabel) + transport, err := c.newUDPQueryTransport(context.Background(), conn.ResolverLabel) if err != nil { return true } @@ -890,7 +890,7 @@ func (c *Client) runConnectionMTUTest(ctx context.Context, conn Connection, serv func (c *Client) probeConnectionMTU(ctx context.Context, conn Connection, maxUploadPayload int) (mtuConnectionProbeResult, mtuRejectReason) { var result mtuConnectionProbeResult - probeTransport, err := c.newUDPQueryTransport(conn.ResolverLabel) + probeTransport, err := c.newUDPQueryTransport(ctx, conn.ResolverLabel) if err != nil { return result, mtuRejectUpload } diff --git a/internal/client/protected_socket.go b/internal/client/protected_socket.go index bf71dba1..bf24bb18 100644 --- a/internal/client/protected_socket.go +++ b/internal/client/protected_socket.go @@ -35,19 +35,28 @@ func (c *Client) protectControl(network, address string, rc syscall.RawConn) err return nil } -func (c *Client) dialUDPResolver(resolverLabel string) (*net.UDPConn, error) { +func (c *Client) dialUDPResolver(ctx context.Context, resolverLabel string) (*net.UDPConn, error) { + if ctx == nil { + ctx = context.Background() + } if c.protectPath() == "" { - addr, err := net.ResolveUDPAddr("udp", resolverLabel) + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "udp", resolverLabel) if err != nil { return nil, err } - return net.DialUDP("udp", nil, addr) + udpConn, ok := conn.(*net.UDPConn) + if !ok { + _ = conn.Close() + return nil, fmt.Errorf("unexpected udp resolver connection type %T", conn) + } + return udpConn, nil } dialer := net.Dialer{ Control: c.protectControl, } - conn, err := dialer.DialContext(context.Background(), "udp", resolverLabel) + conn, err := dialer.DialContext(ctx, "udp", resolverLabel) if err != nil { return nil, err } diff --git a/internal/client/protected_socket_unix_test.go b/internal/client/protected_socket_unix_test.go index bb47a50e..af08c1e9 100644 --- a/internal/client/protected_socket_unix_test.go +++ b/internal/client/protected_socket_unix_test.go @@ -146,7 +146,7 @@ func TestProtectedResolverQuerySendsFDToProtectServer(t *testing.T) { _, _ = resolverConn.WriteToUDP([]byte{buf[0], buf[1], 0x81, 0x00}, addr) }() - udpConn, err := c.getUDPConn(resolverConn.LocalAddr().String()) + udpConn, err := c.getUDPConn(context.Background(), resolverConn.LocalAddr().String()) if err != nil { t.Fatalf("getUDPConn returned error: %v", err) } diff --git a/internal/client/tunnel_runtime.go b/internal/client/tunnel_runtime.go index b9ae0fe5..4e707af7 100644 --- a/internal/client/tunnel_runtime.go +++ b/internal/client/tunnel_runtime.go @@ -12,6 +12,7 @@ package client import ( + "context" "encoding/binary" "errors" "net" @@ -115,7 +116,7 @@ func (c *Client) exchangeUDPQueryWithConn(conn *net.UDPConn, packet []byte, time } func (c *Client) sendOneWayDNSQuery(resolver Connection, packet []byte, deadline time.Time) error { - udpConn, err := c.getUDPConn(resolver.ResolverLabel) + udpConn, err := c.getUDPConn(context.Background(), resolver.ResolverLabel) if err != nil { return err } @@ -136,7 +137,7 @@ func (c *Client) sendOneWayDNSQuery(resolver Connection, packet []byte, deadline // getUDPConn retrieves a UDP connection from the pool for the specified resolver. // If no connection is available in the pool, it dials a new one. -func (c *Client) getUDPConn(resolverLabel string) (*net.UDPConn, error) { +func (c *Client) getUDPConn(ctx context.Context, resolverLabel string) (*net.UDPConn, error) { c.resolverConnsMu.Lock() pool, ok := c.resolverConns[resolverLabel] if !ok { @@ -155,7 +156,7 @@ func (c *Client) getUDPConn(resolverLabel string) (*net.UDPConn, error) { } return pc.conn, nil default: - return c.dialUDPResolver(resolverLabel) + return c.dialUDPResolver(ctx, resolverLabel) } } } @@ -249,8 +250,8 @@ type udpQueryTransport struct { } // newUDPQueryTransport creates a new transport for UDP queries to the specified resolver. -func (c *Client) newUDPQueryTransport(resolverLabel string) (*udpQueryTransport, error) { - conn, err := c.dialUDPResolver(resolverLabel) +func (c *Client) newUDPQueryTransport(ctx context.Context, resolverLabel string) (*udpQueryTransport, error) { + conn, err := c.dialUDPResolver(ctx, resolverLabel) if err != nil { return nil, err } @@ -271,7 +272,7 @@ func (c *Client) exchangeUDPQuery(transport *udpQueryTransport, packet []byte, t // exchangeDNSOverConnection sends a DNS query and returns the extracted VPN packet. func (c *Client) exchangeDNSOverConnection(conn Connection, query []byte, timeout time.Duration) (VpnProto.Packet, error) { - udpConn, err := c.getUDPConn(conn.ResolverLabel) + udpConn, err := c.getUDPConn(context.Background(), conn.ResolverLabel) if err != nil { return VpnProto.Packet{}, err }