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..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 := 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 := 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 := 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 new file mode 100644 index 00000000..bf24bb18 --- /dev/null +++ b/internal/client/protected_socket.go @@ -0,0 +1,94 @@ +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(ctx context.Context, resolverLabel string) (*net.UDPConn, error) { + if ctx == nil { + ctx = context.Background() + } + if c.protectPath() == "" { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "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 + } + + dialer := net.Dialer{ + Control: c.protectControl, + } + conn, err := dialer.DialContext(ctx, "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..af08c1e9 --- /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(context.Background(), 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..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 dialUDPResolver(resolverLabel) + return c.dialUDPResolver(ctx, resolverLabel) } } } @@ -235,15 +236,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 +250,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(ctx context.Context, resolverLabel string) (*udpQueryTransport, error) { + conn, err := c.dialUDPResolver(ctx, resolverLabel) if err != nil { return nil, err } @@ -280,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 } diff --git a/internal/config/client.go b/internal/config/client.go index 7f96791a..646c0afc 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": @@ -468,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 f25069c0..ee38e804 100644 --- a/internal/config/client_test.go +++ b/internal/config/client_test.go @@ -76,6 +76,141 @@ 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 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() + + 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..5a6bf63f --- /dev/null +++ b/internal/sockprotect/protect_unix.go @@ -0,0 +1,111 @@ +//go:build unix + +package sockprotect + +import ( + "fmt" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +// 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-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) + 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) + } + + // 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) + } + + 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) + } + + 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 +} + +// 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 + } +} 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) + } +}