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.
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.
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.
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 whenwith_tailscaleis 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:
- Resolve each fork's version via
go list -m. - Copy the module-cache source into
go/.patched/<vendor>-tailscale@<version>/. - Sed-rewrite the five
expvar.Publish(...)calls intsweb/varz/varz.goto prefix the published name with the vendor. go mod edit -replaceso 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.
These are not patches but call-site requirements that the wrappers in
go/ already encode. Listed here so they survive a future rewrite.
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.fdenv var (seeproxy/tun/tun_android.go, which feeds it to a gvisor fdbased endpoint).go/xray.gosets it beforecore.StartInstance. - sing-box: read via an
adapter.PlatformInterfacewhoseOpenInterfaceis invoked by the tun inbound. The interface is registered on thebox.Options.Contextviaservice.ContextWith[adapter.PlatformInterface]. Seego/singbox.go. - mihomo: written into
cfg.General.Tun.FileDescriptorbetweenexecutor.ParseWithBytesandhub.ApplyConfig. The wire-level YAML key istun.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.
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 acontrol.Functo the system dialer. The list is append-only — registration happens under async.Onceand the installed controller re-reads the current Protector on every dial. - sing-box:
UsePlatformAutoDetectInterfaceControl() == trueroutes every outbound throughAutoDetectInterfaceControl(fd). This replaces bind-to-interface-by-name, which also sidestepsnet.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'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.
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 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.
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'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.
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.