Ledger of wiring quirks and c-shared mechanics that make the three upstreams co-exist in one DLL on Windows. 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.
cgo cannot drive MSVC. Native builds need MSYS2's mingw-w64 or
llvm-mingw on PATH; cross builds from macOS/Linux use
x86_64-w64-mingw32-gcc (amd64) / aarch64-w64-mingw32-clang
(arm64). The DLL exports use the default C calling convention for the
target (effectively cdecl); C# must declare
CallingConvention.Cdecl.
Every char* the DLL returns is C.CString — C-heap memory the
caller owns. EvcoreFreeString is exported so the host frees with
the same allocator that malloc'd. Never Marshal.FreeHGlobal these.
On upstream bump. No action — these are cgo mechanics.
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/Android frameworks' 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 — for a
c-shared build that means the panic fires while the Go runtime boots
on the host's first P/Invoke call.
Both build scripts rewrite 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>/. - 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.
The replace directives are dropped afterwards (EXIT trap in build.sh,
finally in build.ps1) so go.mod stays clean in version control.
.patched/ is gitignored. Keep build.sh and build.ps1 in sync —
build.sh is the authoritative copy the release workflow runs.
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>/.
These are not patches but call-site requirements that the wrappers in
go/ already encode. Listed here so they survive a future rewrite.
The iOS/Android editions inject an OS-created tun fd into each core.
Windows has no such handoff — each core creates the adapter itself
from its config, which is why StartCore here takes no fd/mtu and
why the host must run elevated:
- Xray-core:
proxy/tun/tun_windows.goopens/creates a WinTUN adapter named by the tun inbound'ssettings.name(defaultxray0) and drives it through gVisor. It usesgolang.zx2c4.com/wintun, which loads an externalwintun.dll— ship the signed DLL from wintun.net next to the host exe. Note Xray does not configure addresses/routes on the adapter and takes WinTUN's default MTU (there's no iphlpapi plumbing in its Windows path); the host service is responsible for addressing/routing the adapter, e.g. vianetsh/iphlpapi after start. - sing-box: the tun inbound via sagernet/sing-tun, which
embeds wintun.dll (memmod loader — nothing on disk), assigns
the configured
address, appliesauto_route, and watches the default route through winipcfg. Runs exactly like the standalone sing-box.exe — noadapter.PlatformInterfaceis installed, unlike the iOS/Android editions. - mihomo: same story through metacubex/sing-tun (also embedded
wintun.dll):
tun.devicenames the adapter,auto-route/auto-detect-interfacework natively.listener/sing_tun/ server_windows.goretries adapter creation for the "file already exists" race and forces bind-to-interface on pre-Win10.
The iOS edition feeds NWPathMonitor changes in, and the Android edition additionally protects every socket via VpnService.protect. Windows needs neither: there is no protect() concept (auto_route excludes the core's own traffic via routing rules and bind-to-interface), and sing-box/mihomo watch route changes natively (sing-box also registers a winpowrprof power listener). The exported Suspend/Resume remain useful for deterministic service-driven suspend handling; for sing-box they double-tap the same pause manager its power listener uses, which is harmless.
Same tag set as the iOS/Android frameworks — the Windows service is just as client-only:
with_clash_api with_grpc with_gvisor with_quic with_tailscale with_utls with_wireguard
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. cronet ships
windows_amd64/arm64 libs, so with_naive_outbound could be enabled
here; 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 start fails immediately.
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.
iOS kills the NE process right after stopTunnel and Android tears the
fd out from under the cores, so those editions detach teardown. A
Windows service process hosts many start/stop cycles; StopAll only
returns once the core has released its WinTUN adapter, routes, and
ports, so an immediate StartCore cannot collide with the previous
instance. See go/core.go for the latency bounds.
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 rewrite (preferred for tiny, line-level fixes). Copy
the module-cache source into go/.patched/<vendor>-<repo>@<version>/,
apply the rewrite in both build scripts, and add a transient
replace directive dropped after the build. See the tailscale-forks
entry 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.