diff --git a/README.md b/README.md index fad94379..f6c2cca0 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,28 @@ getXrayState ## controller +### Socket protect + Used to solve the socket protect problem on Android. +### Process finder (per-app routing) + +`ConnectivityManager.getConnectionOwnerUid()` is API 30+. On older Android +libXray falls back to parsing `/proc/net/{tcp,udp}{,6}` in pure Go. + +Usage (Java/Kotlin): + +```java +ProcessFinder finder = new ProcessFinder() { + @Override + public long findProcessByConnection(String network, String srcIP, long srcPort, + String destIP, long destPort) { + return -1; // return UID or -1 + } +}; +LibXray.registerProcessFinder(finder, Build.VERSION.SDK_INT); +``` + ## geo ### count diff --git a/android_wrapper.go b/android_wrapper.go index 3000f2fc..88dfe24a 100644 --- a/android_wrapper.go +++ b/android_wrapper.go @@ -8,18 +8,10 @@ type DialerController interface { ProtectFd(int) bool } -// ProcessFinder is an interface for Android process finding functionality. -// Apps should implement FindProcessByConnection() -// and pass the implementation to RegisterProcessFinder() before starting the core. +// ProcessFinder -> implemented by apps to resolve UID from connection details. +// See RegisterProcessFinder. type ProcessFinder interface { - // FindProcessByConnection finds the UID of the process that owns the given connection. - // - // network: Protocol type: "tcp" or "udp" - // srcIP: Source IP address - // srcPort: Source port - // destIP: Destination IP address - // destPort: Destination port - // Returns the UID of the owning process, or -1 if not found. + // FindProcessByConnection -> returns UID owning the connection, or -1. FindProcessByConnection(network, srcIP string, srcPort int, destIP string, destPort int) int } @@ -35,17 +27,18 @@ func RegisterListenerController(controller DialerController) { }) } -// RegisterProcessFinder registers an Android process finder with Xray-core, -// enabling per-app routing based on UID. Must be called before starting the -// core for process-based routing rules to work. -// Pass nil to unregister a previously registered finder. -func RegisterProcessFinder(finder ProcessFinder) { +// RegisterProcessFinder -> registers process finder for per-app routing. +// Pass nil to unregister. +// sdkVersion = Build.VERSION.SDK_INT. +// On API < 30, /proc/net/* parsing is used automatically; the Java callback +// is only called on API 30+. +func RegisterProcessFinder(finder ProcessFinder, sdkVersion int) { if finder == nil { - c.RegisterProcessFinder(nil) + c.RegisterProcessFinder(nil, 0) return } c.RegisterProcessFinder(func(network, srcIP string, srcPort int, destIP string, destPort int) int { return finder.FindProcessByConnection(network, srcIP, srcPort, destIP, destPort) - }) + }, sdkVersion) } diff --git a/controller/controller_android.go b/controller/controller_android.go index 82dc0b67..19573767 100644 --- a/controller/controller_android.go +++ b/controller/controller_android.go @@ -10,27 +10,34 @@ import ( xinternet "github.com/xtls/xray-core/transport/internet" ) -// Give a callback before connection beginning. Useful for Android development. -// It depends on xray:api:beta +// RegisterDialerController -> callback before connection begins. +// Depends on xray:api:beta func RegisterDialerController(controller func(fd uintptr)) { xinternet.RegisterDialerController(func(network, address string, conn syscall.RawConn) error { return conn.Control(controller) }) } -// Give a callback before listener beginning. Useful for Android development. -// It depends on xray:api:beta +// RegisterListenerController -> callback before listener begins. +// Depends on xray:api:beta func RegisterListenerController(controller func(fd uintptr)) { xinternet.RegisterListenerController(func(network, address string, conn syscall.RawConn) error { return conn.Control(controller) }) } -// RegisterProcessFinder registers an Android process finder with Xray-core, -// enabling per-app routing based on UID. Must be called before starting the -// core for process-based routing rules to work. -// Pass nil to unregister a previously registered finder. -func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destIP string, destPort int) int) { +// sdkThresholdGetConnectionOwner -> min API level for getConnectionOwnerUid(). +// Below this we use /proc/net/* parsing. +const sdkThresholdGetConnectionOwner = 30 + +var androidSdkVersion int + +// RegisterProcessFinder -> registers process finder for per-app routing. +// Pass nil to unregister. sdkVersion = Build.VERSION.SDK_INT. +// When SDK < 30, falls back to /proc/net/* parsing (pure Go). +func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destIP string, destPort int) int, sdkVersion int) { + androidSdkVersion = sdkVersion + if finder == nil { corenet.RegisterAndroidProcessFinder(nil) return @@ -41,6 +48,13 @@ func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destI return 0, "", "", fmt.Errorf("process finder: no destination for %s %s:%d", network, srcIP, srcPort) } + if androidSdkVersion > 0 && androidSdkVersion < sdkThresholdGetConnectionOwner { + uid, err := resolveUidFromProc(network, srcIP, int(srcPort), destIP, int(destPort)) + if err == nil { + return uid, fmt.Sprintf("%d", uid), "", nil + } + } + uid := finder(network, srcIP, int(srcPort), destIP, int(destPort)) return uid, fmt.Sprintf("%d", uid), "", nil }) diff --git a/controller/proc_resolver_android.go b/controller/proc_resolver_android.go new file mode 100644 index 00000000..928c12f3 --- /dev/null +++ b/controller/proc_resolver_android.go @@ -0,0 +1,223 @@ +//go:build android + +package controller + +import ( + "bufio" + "encoding/hex" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +// /proc/net/* UID resolver. Used when getConnectionOwnerUid() (API 30+) is +// unavailable. UID is read directly from the connection line - no inode->PID +// chain needed. Matches PCAPdroid uid_resolver.c: +// +// https://github.com/emanuele-f/PCAPdroid/blob/master/app/src/main/jni/common/uid_resolver.c +// +// /proc/net/{tcp,udp} field layout (after strings.Fields): +// +// [0] sl [1] local_address [2] rem_address [3] st +// [4] tx_queue:rx_queue [5] tr:tm->when [6] retrnsmt +// [7] uid [8] timeout [9] inode + +// resolveUidFromProc -> tries IPv4 first, falls back to IPv4-mapped IPv6 +// (matching PCAPdroid get_uid_slow). +func resolveUidFromProc(network, srcIP string, srcPort int, destIP string, destPort int) (int, error) { + ip := net.ParseIP(srcIP) + if ip == nil { + return -1, fmt.Errorf("bad src IP %s", srcIP) + } + + if ip4 := ip.To4(); ip4 != nil { + uid, err := resolve4(network, ip4, srcPort, destIP, destPort) + if err == nil { + return uid, nil + } + // PCAPdroid: sprintf("0000000000000000FFFF0000%08X", ip4) + // Catches IPv4 conns shown in tcp6/udp6. + mapped := make([]byte, 16) + copy(mapped[10:], []byte{0xFF, 0xFF}) + copy(mapped[12:], ip4) + return resolve6(network, mapped, srcPort, destIP, destPort) + } + + ip16 := ip.To16() + if ip16 != nil { + return resolve6(network, ip16, srcPort, destIP, destPort) + } + + return -1, fmt.Errorf("bad IP family for %s", srcIP) +} + +func resolve4(network string, ip4 []byte, srcPort int, destIP string, destPort int) (int, error) { + procFile := procFilePath(network, false) + if procFile == "" { + return -1, fmt.Errorf("unsupported network %s", network) + } + targetHex, _ := formatHexAddr4(ip4, srcPort) + destHex, _ := formatDestHex(destIP, destPort, false) + return findUidInProcFile(procFile, targetHex, destHex) +} + +func resolve6(network string, ip16 []byte, srcPort int, destIP string, destPort int) (int, error) { + procFile := procFilePath(network, true) + if procFile == "" { + return -1, fmt.Errorf("unsupported network %s", network) + } + targetHex, _ := formatHexAddr6(ip16, srcPort) + destHex, _ := formatDestHex(destIP, destPort, true) + return findUidInProcFile(procFile, targetHex, destHex) +} + +func procFilePath(network string, v6 bool) string { + switch network { + case "tcp": + if v6 { + return "/proc/net/tcp6" + } + return "/proc/net/tcp" + case "udp": + if v6 { + return "/proc/net/udp6" + } + return "/proc/net/udp" + default: + return "" + } +} + +// formatDestHex -> format dest IP:port for matching. Returns "" if dest +// info is missing (match any remote). +func formatDestHex(destIP string, destPort int, v6 bool) (string, error) { + ip := net.ParseIP(destIP) + if ip == nil || destPort <= 0 { + return "", nil + } + if v6 { + if ip16 := ip.To16(); ip16 != nil { + return formatHexAddr6(ip16, destPort) + } + if ip4 := ip.To4(); ip4 != nil { + mapped := make([]byte, 16) + copy(mapped[10:], []byte{0xFF, 0xFF}) + copy(mapped[12:], ip4) + return formatHexAddr6(mapped, destPort) + } + return "", nil + } + if ip4 := ip.To4(); ip4 != nil { + return formatHexAddr4(ip4, destPort) + } + return "", nil +} + +// /proc/net/* stores IPs per 32-bit word in little-endian byte order. +// PCAPdroid casts in6_addr to uint32_t[4] on LE hardware; we replicate +// that here. + +func formatHexAddr4(ip4 []byte, port int) (string, error) { + b := make([]byte, 4) + copy(b, ip4) + for i, j := 0, 3; i < j; i, j = i+1, j-1 { + b[i], b[j] = b[j], b[i] + } + return fmt.Sprintf("%s:%04X", strings.ToUpper(hex.EncodeToString(b)), port), nil +} + +func formatHexAddr6(ip16 []byte, port int) (string, error) { + words := make([]string, 4) + for w := 0; w < 4; w++ { + off := w * 4 + b := []byte{ip16[off+3], ip16[off+2], ip16[off+1], ip16[off+0]} + words[w] = strings.ToUpper(hex.EncodeToString(b)) + } + return fmt.Sprintf("%s:%04X", strings.Join(words, ""), port), nil +} + +// allZeros -> true if s is all '0' characters. +func allZeros(s string) bool { + for _, c := range s { + if c != '0' { + return false + } + } + return len(s) > 0 +} + +// findUidInProcFile -> scans /proc/net/{tcp,udp,..} for a line matching +// (targetHex, destHex). Matching per PCAPdroid get_uid_proc: +// +// sport == src_port +// (dport == dst_port || dport == 0) +// (dhex == conn_dhex || dhex == zero) +// (shex == conn_shex || shex == zero) +// +// where shex/dhex/sport/dport are from /proc and +// conn_shex/conn_dhex/src_port/dst_port are our connection args. +func findUidInProcFile(filePath, targetHex, destHex string) (int, error) { + f, err := os.Open(filePath) + if err != nil { + return -1, err + } + defer f.Close() + + s := bufio.NewScanner(f) + first := true + for s.Scan() { + if first { + first = false + continue + } + + fields := strings.Fields(s.Text()) + if len(fields) < 8 { + continue + } + + // Split hex:port into parts for independent matching. + loc := strings.SplitN(fields[1], ":", 2) + tgt := strings.SplitN(targetHex, ":", 2) + if len(loc) != 2 || len(tgt) != 2 { + continue + } + + // sport == src_port + if loc[1] != tgt[1] { + continue + } + // shex == conn_shex || shex == zero + if loc[0] != tgt[0] && !allZeros(loc[0]) { + continue + } + + // Match remote when we have a dest filter. + if destHex != "" { + rem := strings.SplitN(fields[2], ":", 2) + dst := strings.SplitN(destHex, ":", 2) + if len(rem) != 2 || len(dst) != 2 { + continue + } + + // dport == dst_port || dport == 0 + if rem[1] != dst[1] && rem[1] != "0000" { + continue + } + // dhex == conn_dhex || dhex == zero + if rem[0] != dst[0] && !allZeros(rem[0]) { + continue + } + } + + uid, err := strconv.Atoi(fields[7]) + if err != nil { + continue + } + return uid, nil + } + + return -1, fmt.Errorf("not found in %s", filePath) +} diff --git a/go.mod b/go.mod index 09946bb2..3a71b73e 100644 --- a/go.mod +++ b/go.mod @@ -41,13 +41,14 @@ require ( go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mod v0.37.0 // indirect + golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 // indirect + golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect @@ -57,3 +58,5 @@ require ( gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) + +tool golang.org/x/mobile/cmd/gobind diff --git a/go.sum b/go.sum index 65df90a0..962ff921 100644 --- a/go.sum +++ b/go.sum @@ -96,8 +96,12 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -112,6 +116,8 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= 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=