Skip to content

Latest commit

 

History

History
197 lines (154 loc) · 9.11 KB

File metadata and controls

197 lines (154 loc) · 9.11 KB

Patches

Ledger of wiring quirks and gomobile mechanics that make the three upstreams co-exist in one Go module on Android. The cores themselves are unpatched — if that ever changes, see "Future source patches" at the bottom.

Pinned versions live in go/go.mod. The daily upstream-watch workflow keeps them current; see README.md for the release flow.

go.mod overrides (go/go.mod)

tools.go to anchor golang.org/x/mobile/bind

gomobile bind invokes gobind from a temporary directory, and gobind imports golang.org/x/mobile/bind. In module mode, that import has to appear somewhere in our module's import graph for go list to find it. go/tools.go does so under //go:build tools, so the package never compiles into the aar but go.mod keeps the require.

On upstream bump. No action — this is a gomobile mechanic.

Source patches

Xray-core, sing-box, and mihomo all build unmodified from their published Go-module tags. The only sources we modify are two transitive deps that fight over expvar.Publish.

tailscale forks: rename five expvar names to coexist

Identical to the iOS framework's patch — the collision is process-global and platform-independent. Two tailscale forks land in the dep graph and each runs its own init():

  • github.com/sagernet/tailscale — pulled in by sing-box's DERP service when with_tailscale is enabled.
  • github.com/metacubex/tailscale — pulled in by mihomo unconditionally.

Both forks ship a copy of tsweb/varz/varz.go whose init() calls expvar.Publish five times with hardcoded names (process_start_unix_time, version, go_version, counter_uptime_sec, gauge_goroutines). expvar.Publish panics on the duplicate registration from whichever init() runs second.

Scripts/build.sh rewrites both copies after go mod tidy:

  1. Resolve each fork's version via go list -m.
  2. Copy the module-cache source into go/.patched/<vendor>-tailscale@<version>/.
  3. Sed-rewrite the five expvar.Publish(...) calls in tsweb/varz/varz.go to prefix the published name with the vendor.
  4. go mod edit -replace so the build links the patched copies.

Patched directories are version-suffixed and cached across builds. An EXIT trap drops the replace directives so go.mod stays clean in version control. .patched/ is gitignored.

On upstream bump. The patch is keyed on the resolved module versions, so a new sing-box or mihomo tag that bumps either fork just regenerates .patched/<vendor>-tailscale@<new-version>/. If a fork ever drops or renames the five names, the sed produces no change and the duplicate-name panic reappears at first launch — which is the signal to revisit.

Wiring quirks per core

These are not patches but call-site requirements that the wrappers in go/ already encode. Listed here so they survive a future rewrite.

TUN inbound: each core consumes the VpnService fd directly

We don't ship a userland tun→socks bridge. Each core owns its own TUN inbound, with the FD plumbed differently:

  • Xray-core: read from the xray.tun.fd env var (see proxy/tun/tun_android.go, which feeds it to a gvisor fdbased endpoint). go/xray.go sets it before core.StartInstance.
  • sing-box: read via an adapter.PlatformInterface whose OpenInterface is invoked by the tun inbound. The interface is registered on the box.Options.Context via service.ContextWith[adapter.PlatformInterface]. See go/singbox.go.
  • mihomo: written into cfg.General.Tun.FileDescriptor between executor.ParseWithBytes and hub.ApplyConfig. The wire-level YAML key is tun.file-descriptor, but we keep the FD out of the config string so users can't accidentally pin a stale value.

For sing-box and mihomo we syscall.Dup the FD before handing it to sing-tun — its Close() always closes the wrapped os.File, so a non-dup'd path would tear down the VpnService's tun out from under the Kotlin side. Xray's Android tun path never closes the FD (its Close is a no-op), so we don't dup there.

Socket protection: three different hooks, one Protector

Android routes every socket the VPN process creates back into its own TUN unless VpnService.protect(fd) marks it. Each core exposes a different escape hatch, all wired to the single Protector registered via SetSocketProtector (go/protect.go):

  • Xray-core: internet.RegisterDialerController(fn) appends a control.Func to the system dialer. The list is append-only — registration happens under a sync.Once and the installed controller re-reads the current Protector on every dial.
  • sing-box: UsePlatformAutoDetectInterfaceControl() == true routes every outbound through AutoDetectInterfaceControl(fd). This replaces bind-to-interface-by-name, which also sidesteps net.Interfaces() being degraded on Android 11+ (SELinux denies netlink RTM_GETLINK dumps to app processes).
  • mihomo: dialer.DefaultSocketHook — the exact global CMFA uses; its presence also makes mihomo skip interface pinning and routing marks (component/dialer/dialer.go).

A nil Protector degrades to a no-op so the package stays usable in tests, but any real VpnService deployment must register one.

sing-box: platform monitor instead of netlink

sing-box's native linux default-interface monitor needs netlink, which Android's SELinux policy denies to apps on API 30+. The wrapper therefore reports UsePlatformDefaultInterfaceMonitor() == true and ships an externally-driven monitor (go/singbox.go), fed from a ConnectivityManager.NetworkCallback via Evcore.updateDefaultInterface — the same architecture libbox uses in sing-box's own Android app, and the same wrapper code as the iOS framework's NWPathMonitor feed.

sing-box: gomobile bind needs -tags=with_*

Same tag set as the iOS framework — a VpnService client has the same "client-only" profile as a Network Extension:

Tag Unlocks
with_clash_api clash REST/WebSocket API (yacd talks to this)
with_grpc full gRPC transport (vs. the lite HTTP/2 fallback)
with_gvisor gVisor + mixed TUN stack; also enables gVisor for wireguard endpoints
with_quic QUIC transports — Hysteria/Hysteria2/TUIC, QUIC/HTTP3 DNS
with_tailscale tailscale endpoint (joins a tailnet from inside the VpnService)
with_utls uTLS client fingerprinting and client-side REALITY
with_wireguard wireguard outbound endpoint

Excluded (same reasoning as iOS, see that repo's PATCHES.md for the full table): with_acme, with_ccm, with_dhcp, with_ech (deprecated), with_naive_outbound, with_ocm, with_reality_server (deprecated), with_v2ray_api. Of these only with_naive_outbound differs materially by platform — cronet ships Android .so libs, so it could be enabled here at the cost of ~30 MB per ABI; it stays off for parity until an Everywhere feature needs it.

sing-box: must pass include.Context(ctx) into box.New

sing-box 1.10+ requires the inbound/outbound/endpoint/DNS-transport/ service registries to be attached to the context that box.New is called with; include.Context(ctx) bundles them in one call. Without it box.New parses the JSON but cannot instantiate socks, direct, vmess, …, and the VpnService dies the instant the tunnel comes up.

On upstream bump. Verify include.Context is still the canonical entry point — the registry surface has been refactored a couple of times in 1.x.

mihomo: must call hub.ApplyConfig, not executor.ApplyConfig

executor.ApplyConfig alone does not start the external-controller HTTP/WS API server; hub.ApplyConfig wraps applyRoute (which boots it) plus executor.ApplyConfig. Without the hub call, yacd shows "cannot connect to 127.0.0.1:9090". hub.ApplyConfig returns no error — failures inside it are logged via mihomo's own logger.

mihomo: package-manager side quirks on Android

mihomo's listener/sing_tun/server_android.go (compiled because GOOS=android, non-cmfa) lazily boots a tun.PackageManager to resolve uid→package for process rules. Inside an app sandbox that lookup can fail; it is only exercised when a config actually uses Android package rules, so plain configs are unaffected. If Everywhere ever needs per-app rules, the CMFA route (build tag cmfa + a Kotlin-side resolver) is the escape hatch.

Future source patches

The Go module cache is read-only by design, so patching upstream sources in place is not an option. Two paths exist depending on the size of the change:

Build-time sed (preferred for tiny, line-level fixes). Copy the module-cache source into go/.patched/<vendor>-<repo>@<version>/, apply a sed rewrite, and add a transient replace directive that an EXIT trap drops afterwards. See the tailscale-forks entry under "Source patches" for the template.

GitHub fork (for anything bigger). Fork to github.com/NodePassProject/<repo>, add a replace directive to go/go.mod, document why, what file, and what the upstream-correct fix would be here, and update .github/workflows/upstream-watch.yml to watch the fork.