Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 11 additions & 18 deletions android_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
}
32 changes: 23 additions & 9 deletions controller/controller_android.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
})
Expand Down
223 changes: 223 additions & 0 deletions controller/proc_resolver_android.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading