From 987290ba480d3bb4cf57454decee3b338b1c8deb Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:03:24 +0800 Subject: [PATCH] Routing: `process` supports macOS as well (#6447) https://github.com/XTLS/Xray-core/pull/6434#issuecomment-4903384018 --- common/net/find_process_darwin.go | 348 +++++++++++++++++++++++++ common/net/find_process_darwin.s | 18 ++ common/net/find_process_darwin_test.go | 293 +++++++++++++++++++++ common/net/find_process_others.go | 2 +- 4 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 common/net/find_process_darwin.go create mode 100644 common/net/find_process_darwin.s create mode 100644 common/net/find_process_darwin_test.go diff --git a/common/net/find_process_darwin.go b/common/net/find_process_darwin.go new file mode 100644 index 000000000..dc0593c26 --- /dev/null +++ b/common/net/find_process_darwin.go @@ -0,0 +1,348 @@ +//go:build darwin + +package net + +import ( + "bytes" + "net" + "net/netip" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" + + "github.com/xtls/xray-core/common/errors" +) + +const ( + darwinProcPIDListFDs = 1 + darwinProcPIDFDSocketInfo = 3 + darwinProcFDTypeSocket = 2 + darwinProcFDInfoSize = 8 + darwinSocketFDInfoSize = 792 + darwinSocketFDInfoPSIOff = 24 + darwinSocketInfoProtoOff = darwinSocketFDInfoPSIOff + 156 + darwinSocketInfoFamilyOff = darwinSocketFDInfoPSIOff + 160 + darwinSocketInfoKindOff = darwinSocketFDInfoPSIOff + 232 + darwinSocketInfoInSockOff = darwinSocketFDInfoPSIOff + 240 + darwinInSockInfoFPortOff = darwinSocketInfoInSockOff + darwinInSockInfoLPortOff = darwinSocketInfoInSockOff + 4 + darwinInSockInfoVFlagOff = darwinSocketInfoInSockOff + 24 + darwinInSockInfoFAddrOff = darwinSocketInfoInSockOff + 32 + darwinInSockInfoLAddrOff = darwinSocketInfoInSockOff + 48 + darwinInSockInfoSize = 80 + darwinInSockInfoIPv4 = 0x1 + darwinInSockInfoIPv6 = 0x2 + darwinSockInfoIN = 1 + darwinSockInfoTCP = 2 +) + +type darwinSocketMatchLevel int + +const ( + darwinSocketNoMatch darwinSocketMatchLevel = iota + darwinSocketPortMatch + darwinSocketRemoteMatch + darwinSocketLocalMatch + darwinSocketExactMatch +) + +func FindProcess(network, srcIP string, srcPort uint16, destIP string, destPort uint16) (PID int, Name string, AbsolutePath string, err error) { + isLocal, err := IsLocal(net.ParseIP(srcIP)) + if err != nil { + return 0, "", "", errors.New("failed to determine if address is local: ", err) + } + if !isLocal { + return 0, "", "", ErrNotLocal + } + if network != "tcp" && network != "udp" { + panic("Unsupported network type for process lookup.") + } + + srcAddr, err := netip.ParseAddr(srcIP) + if err != nil { + return 0, "", "", errors.New("invalid source IP address: ", srcIP) + } + srcAddr = srcAddr.Unmap() + + var dstAddr netip.Addr + hasDstAddr := false + if destIP != "" && destPort != 0 { + dstAddr, err = netip.ParseAddr(destIP) + if err != nil { + return 0, "", "", errors.New("invalid destination IP address: ", destIP) + } + dstAddr = dstAddr.Unmap() + hasDstAddr = true + } + + processes, err := unix.SysctlKinfoProcSlice("kern.proc.all") + if err != nil { + return 0, "", "", errors.New("failed to list processes").Base(err) + } + + var bestPID int32 + bestLevel := darwinSocketNoMatch + ambiguousBest := false + + for _, process := range processes { + pid := process.Proc.P_pid + if pid <= 0 { + continue + } + + matchLevel, err := darwinProcessSocketMatchLevel(pid, network, srcAddr, srcPort, dstAddr, destPort, hasDstAddr) + if err != nil || matchLevel == darwinSocketNoMatch { + continue + } + if matchLevel == darwinSocketExactMatch { + bestPID = pid + bestLevel = matchLevel + ambiguousBest = false + break + } + if matchLevel > bestLevel { + bestPID = pid + bestLevel = matchLevel + ambiguousBest = false + continue + } + if matchLevel == bestLevel { + ambiguousBest = true + } + } + + if bestLevel == darwinSocketNoMatch { + return 0, "", "", errors.New("process not found for ", network, " connection from ", srcIP, ":", srcPort, " to ", destIP, ":", destPort) + } + if ambiguousBest { + return 0, "", "", errors.New("ambiguous process match for ", network, " connection from ", srcIP, ":", srcPort, " to ", destIP, ":", destPort) + } + + absPath, err := darwinProcessPath(bestPID) + if err != nil { + return 0, "", "", errors.New("could not get process path for PID ", bestPID, ": ", err) + } + + absPath = filepath.ToSlash(absPath) + return int(bestPID), filepath.Base(absPath), absPath, nil +} + +func darwinProcessSocketMatchLevel(pid int32, network string, srcAddr netip.Addr, srcPort uint16, dstAddr netip.Addr, dstPort uint16, hasDstAddr bool) (darwinSocketMatchLevel, error) { + fds, err := darwinProcessFDs(pid) + if err != nil { + return darwinSocketNoMatch, err + } + + bestLevel := darwinSocketNoMatch + info := make([]byte, darwinSocketFDInfoSize) + for fd := 0; fd+darwinProcFDInfoSize <= len(fds); fd += darwinProcFDInfoSize { + fdNumber := int32(darwinReadNativeUint32(fds[fd : fd+4])) + fdType := darwinReadNativeUint32(fds[fd+4 : fd+8]) + if fdType != darwinProcFDTypeSocket { + continue + } + + n, err := darwinProcPIDFDInfo(pid, fdNumber, darwinProcPIDFDSocketInfo, info) + if err != nil || n < darwinSocketInfoInSockOff+darwinInSockInfoSize { + continue + } + level := darwinSocketInfoMatchLevel(info[:n], network, srcAddr, srcPort, dstAddr, dstPort, hasDstAddr) + if level == darwinSocketExactMatch { + return level, nil + } + if level > bestLevel { + bestLevel = level + } + } + + return bestLevel, nil +} + +func darwinProcessFDs(pid int32) ([]byte, error) { + n, err := darwinProcPIDInfo(pid, darwinProcPIDListFDs, 0, nil) + if err != nil { + return nil, err + } + if n <= 0 { + return nil, nil + } + + buf := make([]byte, n) + n, err = darwinProcPIDInfo(pid, darwinProcPIDListFDs, 0, buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +func darwinSocketInfoMatchLevel(info []byte, network string, srcAddr netip.Addr, srcPort uint16, dstAddr netip.Addr, dstPort uint16, hasDstAddr bool) darwinSocketMatchLevel { + protocol := int(darwinReadNativeUint32(info[darwinSocketInfoProtoOff : darwinSocketInfoProtoOff+4])) + family := int(darwinReadNativeUint32(info[darwinSocketInfoFamilyOff : darwinSocketInfoFamilyOff+4])) + kind := int(darwinReadNativeUint32(info[darwinSocketInfoKindOff : darwinSocketInfoKindOff+4])) + + switch network { + case "tcp": + if protocol != unix.IPPROTO_TCP || kind != darwinSockInfoTCP { + return darwinSocketNoMatch + } + case "udp": + if protocol != unix.IPPROTO_UDP || kind != darwinSockInfoIN { + return darwinSocketNoMatch + } + default: + return darwinSocketNoMatch + } + + vflag := info[darwinInSockInfoVFlagOff] + if srcAddr.Is4() { + if family != unix.AF_INET || vflag&darwinInSockInfoIPv4 == 0 { + return darwinSocketNoMatch + } + } else { + if family != unix.AF_INET6 || vflag&darwinInSockInfoIPv6 == 0 { + return darwinSocketNoMatch + } + } + + localPort := int32(darwinReadNativeUint32(info[darwinInSockInfoLPortOff : darwinInSockInfoLPortOff+4])) + if !darwinPortMatches(localPort, srcPort) { + return darwinSocketNoMatch + } + localAddrMatches := darwinAddrMatchesOrUnspecified(info[darwinInSockInfoLAddrOff:darwinInSockInfoLAddrOff+16], srcAddr) + + foreignAddrRaw := info[darwinInSockInfoFAddrOff : darwinInSockInfoFAddrOff+16] + foreignPort := int32(darwinReadNativeUint32(info[darwinInSockInfoFPortOff : darwinInSockInfoFPortOff+4])) + + if !hasDstAddr { + if localAddrMatches { + return darwinSocketExactMatch + } + return darwinSocketNoMatch + } + + remoteMatches := darwinPortMatches(foreignPort, dstPort) && darwinAddrMatches(foreignAddrRaw, dstAddr) + if network == "udp" && darwinEndpointIsZero(foreignAddrRaw, foreignPort) && localAddrMatches { + return darwinSocketExactMatch + } + switch { + case localAddrMatches && remoteMatches: + return darwinSocketExactMatch + case localAddrMatches: + return darwinSocketLocalMatch + case remoteMatches: + return darwinSocketRemoteMatch + default: + return darwinSocketPortMatch + } +} + +func darwinPortMatches(value int32, port uint16) bool { + raw := uint16(value) + return raw == port || darwinNtohs(raw) == port +} + +func darwinNtohs(value uint16) uint16 { + return value<<8 | value>>8 +} + +func darwinAddrMatches(raw []byte, addr netip.Addr) bool { + if addr.Is4() { + ip := addr.As4() + return bytes.Equal(raw[12:16], ip[:]) + } + ip := addr.As16() + return bytes.Equal(raw, ip[:]) +} + +func darwinAddrMatchesOrUnspecified(raw []byte, addr netip.Addr) bool { + if darwinAddrMatches(raw, addr) { + return true + } + if addr.Is4() { + return darwinBytesAreZero(raw[12:16]) + } + return darwinBytesAreZero(raw) +} + +func darwinEndpointIsZero(rawAddr []byte, port int32) bool { + return uint32(port) == 0 && darwinBytesAreZero(rawAddr) +} + +func darwinBytesAreZero(raw []byte) bool { + for _, value := range raw { + if value != 0 { + return false + } + } + return true +} + +func darwinReadNativeUint32(b []byte) uint32 { + return *(*uint32)(unsafe.Pointer(&b[0])) +} + +func darwinProcessPath(pid int32) (string, error) { + buf := make([]byte, unix.PathMax) + n, err := darwinProcPIDPath(pid, buf) + if err != nil { + return "", err + } + if n <= 0 { + return "", errors.New("empty process path") + } + return strings.TrimRight(string(buf[:n]), "\x00"), nil +} + +func darwinProcPIDInfo(pid int32, flavor int, arg uint64, buf []byte) (int, error) { + var ptr unsafe.Pointer + if len(buf) > 0 { + ptr = unsafe.Pointer(&buf[0]) + } + + r0, _, errno := syscall_syscall6(libc_proc_pidinfo_trampoline_addr, uintptr(pid), uintptr(flavor), uintptr(arg), uintptr(ptr), uintptr(len(buf)), 0) + if errno != 0 { + return 0, errno + } + return int(r0), nil +} + +func darwinProcPIDFDInfo(pid int32, fd int32, flavor int, buf []byte) (int, error) { + var ptr unsafe.Pointer + if len(buf) > 0 { + ptr = unsafe.Pointer(&buf[0]) + } + + r0, _, errno := syscall_syscall6(libc_proc_pidfdinfo_trampoline_addr, uintptr(pid), uintptr(fd), uintptr(flavor), uintptr(ptr), uintptr(len(buf)), 0) + if errno != 0 { + return 0, errno + } + return int(r0), nil +} + +func darwinProcPIDPath(pid int32, buf []byte) (int, error) { + r0, _, errno := syscall_syscall6(libc_proc_pidpath_trampoline_addr, uintptr(pid), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)), 0, 0, 0) + if errno != 0 { + return 0, errno + } + return int(r0), nil +} + +var libc_proc_pidinfo_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_proc_pidinfo proc_pidinfo "/usr/lib/libproc.dylib" + +var libc_proc_pidfdinfo_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_proc_pidfdinfo proc_pidfdinfo "/usr/lib/libproc.dylib" + +var libc_proc_pidpath_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_proc_pidpath proc_pidpath "/usr/lib/libproc.dylib" + +// Implemented in the runtime package (runtime/sys_darwin.go). +func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) + +//go:linkname syscall_syscall6 syscall.syscall6 diff --git a/common/net/find_process_darwin.s b/common/net/find_process_darwin.s new file mode 100644 index 000000000..5e915476a --- /dev/null +++ b/common/net/find_process_darwin.s @@ -0,0 +1,18 @@ +//go:build darwin + +#include "textflag.h" + +TEXT libc_proc_pidinfo_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_proc_pidinfo(SB) +GLOBL ·libc_proc_pidinfo_trampoline_addr(SB), RODATA, $8 +DATA ·libc_proc_pidinfo_trampoline_addr(SB)/8, $libc_proc_pidinfo_trampoline<>(SB) + +TEXT libc_proc_pidfdinfo_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_proc_pidfdinfo(SB) +GLOBL ·libc_proc_pidfdinfo_trampoline_addr(SB), RODATA, $8 +DATA ·libc_proc_pidfdinfo_trampoline_addr(SB)/8, $libc_proc_pidfdinfo_trampoline<>(SB) + +TEXT libc_proc_pidpath_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_proc_pidpath(SB) +GLOBL ·libc_proc_pidpath_trampoline_addr(SB), RODATA, $8 +DATA ·libc_proc_pidpath_trampoline_addr(SB)/8, $libc_proc_pidpath_trampoline<>(SB) diff --git a/common/net/find_process_darwin_test.go b/common/net/find_process_darwin_test.go new file mode 100644 index 000000000..7eb8eb34d --- /dev/null +++ b/common/net/find_process_darwin_test.go @@ -0,0 +1,293 @@ +//go:build darwin + +package net + +import ( + stdnet "net" + "net/netip" + "os" + "testing" + "time" + "unsafe" + + "golang.org/x/sys/unix" +) + +func TestFindProcessDarwinTCP(t *testing.T) { + listener, err := stdnet.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + accepted := make(chan stdnet.Conn, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + return + } + close(accepted) + }() + + conn, err := stdnet.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + serverConn := <-accepted + if serverConn == nil { + t.Fatal("server did not accept tcp connection") + } + defer serverConn.Close() + + local := conn.LocalAddr().(*stdnet.TCPAddr) + remote := conn.RemoteAddr().(*stdnet.TCPAddr) + + pid, name, path, err := FindProcess("tcp", local.IP.String(), uint16(local.Port), remote.IP.String(), uint16(remote.Port)) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestFindProcessDarwinUDP(t *testing.T) { + conn, err := stdnet.ListenUDP("udp", &stdnet.UDPAddr{IP: stdnet.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + local := conn.LocalAddr().(*stdnet.UDPAddr) + + pid, name, path, err := FindProcess("udp", local.IP.String(), uint16(local.Port), "", 0) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestFindProcessDarwinNonLocal(t *testing.T) { + _, _, _, err := FindProcess("tcp", "203.0.113.1", 80, "", 0) + if err != ErrNotLocal { + t.Fatalf("expected ErrNotLocal, got %v", err) + } +} + +func TestFindProcessDarwinUnsupportedNetwork(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + + _, _, _, _ = FindProcess("icmp", "127.0.0.1", 0, "", 0) +} + +func assertCurrentProcess(t *testing.T, pid int, name string, path string) { + t.Helper() + + if pid != os.Getpid() { + t.Fatalf("expected pid %d, got %d (%s, %s)", os.Getpid(), pid, name, path) + } + + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if path == "" || name == "" { + t.Fatalf("expected process path and name, got name=%q path=%q", name, path) + } + if sameFile(executable, path) { + return + } + t.Fatalf("expected executable %q, got %q", executable, path) +} + +func sameFile(left string, right string) bool { + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + if leftErr != nil || rightErr != nil { + return false + } + return os.SameFile(leftInfo, rightInfo) +} + +func TestFindProcessDarwinTCPWithoutDestination(t *testing.T) { + listener, err := stdnet.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + accepted := make(chan stdnet.Conn, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + return + } + close(accepted) + }() + + conn, err := stdnet.DialTimeout("tcp", listener.Addr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + serverConn := <-accepted + if serverConn == nil { + t.Fatal("server did not accept tcp connection") + } + defer serverConn.Close() + + local := conn.LocalAddr().(*stdnet.TCPAddr) + pid, name, path, err := FindProcess("tcp", local.IP.String(), uint16(local.Port), "", 0) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestFindProcessDarwinTCPWithDifferentDestination(t *testing.T) { + listener, err := stdnet.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + accepted := make(chan stdnet.Conn, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + return + } + close(accepted) + }() + + conn, err := stdnet.DialTimeout("tcp", listener.Addr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + serverConn := <-accepted + if serverConn == nil { + t.Fatal("server did not accept tcp connection") + } + defer serverConn.Close() + + local := conn.LocalAddr().(*stdnet.TCPAddr) + pid, name, path, err := FindProcess("tcp", local.IP.String(), uint16(local.Port), "203.0.113.10", 443) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestDarwinSocketInfoMatchLevelFallbacks(t *testing.T) { + src := netip.MustParseAddr("198.18.0.2") + dst := netip.MustParseAddr("203.0.113.10") + otherLocal := netip.MustParseAddr("192.168.1.10") + otherRemote := netip.MustParseAddr("198.51.100.10") + unspecifiedLocal := netip.MustParseAddr("0.0.0.0") + + tests := []struct { + name string + local netip.Addr + remote netip.Addr + hasDst bool + wantLevel darwinSocketMatchLevel + }{ + { + name: "exact", + local: src, + remote: dst, + hasDst: true, + wantLevel: darwinSocketExactMatch, + }, + { + name: "unspecified local with matching remote", + local: unspecifiedLocal, + remote: dst, + hasDst: true, + wantLevel: darwinSocketExactMatch, + }, + { + name: "unspecified local without destination", + local: unspecifiedLocal, + remote: otherRemote, + hasDst: false, + wantLevel: darwinSocketExactMatch, + }, + { + name: "local match with different remote", + local: src, + remote: otherRemote, + hasDst: true, + wantLevel: darwinSocketLocalMatch, + }, + { + name: "remote match with different local", + local: otherLocal, + remote: dst, + hasDst: true, + wantLevel: darwinSocketRemoteMatch, + }, + { + name: "port only with destination", + local: otherLocal, + remote: otherRemote, + hasDst: true, + wantLevel: darwinSocketPortMatch, + }, + { + name: "different local without destination", + local: otherLocal, + remote: otherRemote, + hasDst: false, + wantLevel: darwinSocketNoMatch, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + info := newDarwinSocketInfo("tcp", test.local, 12345, test.remote, 443) + level := darwinSocketInfoMatchLevel(info, "tcp", src, 12345, dst, 443, test.hasDst) + if level != test.wantLevel { + t.Fatalf("unexpected match level: got %d, want %d", level, test.wantLevel) + } + }) + } +} + +func newDarwinSocketInfo(network string, local netip.Addr, localPort uint16, remote netip.Addr, remotePort uint16) []byte { + info := make([]byte, darwinSocketFDInfoSize) + switch network { + case "tcp": + writeDarwinNativeUint32(info, darwinSocketInfoProtoOff, uint32(unix.IPPROTO_TCP)) + writeDarwinNativeUint32(info, darwinSocketInfoKindOff, uint32(darwinSockInfoTCP)) + case "udp": + writeDarwinNativeUint32(info, darwinSocketInfoProtoOff, uint32(unix.IPPROTO_UDP)) + writeDarwinNativeUint32(info, darwinSocketInfoKindOff, uint32(darwinSockInfoIN)) + } + writeDarwinNativeUint32(info, darwinSocketInfoFamilyOff, uint32(unix.AF_INET)) + info[darwinInSockInfoVFlagOff] = darwinInSockInfoIPv4 + writeDarwinNativeUint32(info, darwinInSockInfoLPortOff, uint32(localPort)) + writeDarwinNativeUint32(info, darwinInSockInfoFPortOff, uint32(remotePort)) + copyDarwinIPv4(info[darwinInSockInfoLAddrOff:darwinInSockInfoLAddrOff+16], local) + copyDarwinIPv4(info[darwinInSockInfoFAddrOff:darwinInSockInfoFAddrOff+16], remote) + return info +} + +func writeDarwinNativeUint32(b []byte, offset int, value uint32) { + *(*uint32)(unsafe.Pointer(&b[offset])) = value +} + +func copyDarwinIPv4(dst []byte, addr netip.Addr) { + ip := addr.As4() + copy(dst[12:16], ip[:]) +} diff --git a/common/net/find_process_others.go b/common/net/find_process_others.go index 22e42bdc2..f4cdaf3e9 100644 --- a/common/net/find_process_others.go +++ b/common/net/find_process_others.go @@ -1,4 +1,4 @@ -//go:build !windows && !linux && !android +//go:build !windows && !linux && !android && !darwin package net