Contributing
Read this once before your first patch. It is short, and everything in it is specific to this repository.
What you are working on
Section titled “What you are working on”TailSocks is an Android client for Tailscale that runs the real tailscaled in
userspace, so the app works without Android’s VPN permission at all. The UI is
Kotlin/Compose, the bridge between the app and the daemon is a Gomobile module
(appctr/), and the daemon itself is upstream Tailscale plus a handful of local
patches. Optional TUN and Root modes are built on top of that core, not instead
of it.
The one rule that explains most of the codebase: the Go daemon is a separate
process, not a library inside the app. libtailscale.so is a
position-independent executable (PIE) that the app fork/execs from
jniLibs — built with upstream’s ts_include_cli tag, it is the CLI as well
when invoked as tailscale; the app then talks to the running process over the
unix socket files/tailscaled.sock using LocalAPI v0. Daemon management never
wraps the CLI binary, and the Kotlin side never links the daemon in. If you find
yourself wanting to call a Go function from Kotlin directly, the answer is
almost always a LocalAPI call or a new method on the appctr bridge — not a new
linkage.
Building
Section titled “Building”Two stages, in this order. Gradle only packages the Go and C artifacts; it does not build them.
git clone --recurse-submodules https://github.com/bropines/tailsocks.gitcd tailsocks
# Stage 1 — the Go core and the JNI bridge (needs ANDROID_NDK_HOME)cd appctr && bash build.sh && cd ..
# Stage 2 — the APK./gradlew app:assembleDebugappctr/build.sh downloads the Tailscale version pinned in
appctr/TAILSCALE_VERSION (currently v1.104.1), applies every patch in
appctr/patches/, cross-compiles the PIE binary for arm64-v8a,
armeabi-v7a, x86 and x86_64 into app/src/main/jniLibs/, and builds
appctr/tmp/appctr.aar.
Release builds need a real keystore and all four variables; since 4.0.0 the build refuses to fall back to the debug key (a release APK signed with a throwaway key can never be updated by a properly signed one — Android rejects the certificate change, and the only way out is uninstalling and losing the app state):
KEYSTORE_FILE="$PWD/tailsocks.jks" KEYSTORE_PASSWORD=... \KEY_ALIAS=... KEY_PASSWORD=... ./gradlew app:assembleReleaseABI splits are on, so output is a universal APK plus one per ABI under
app/build/outputs/apk/<debug|release>/. The whole pipeline is on the Building page.
Do not commit generated or private inputs: the .so binaries, appctr/tmp/,
appctr/tailscale_src/, appctr/orig/ and keystores are all git-ignored, and
should stay that way. English and Russian UI text is maintained by hand in
this repository: add the string to app/src/main/res/values/strings.xml (or the
screen’s own strings_<area>.xml) and its translation to values-ru/ in
the same commit; an untranslated Russian UI is treated as a defect, not as
pending work. Every other language comes from Hosted Weblate as pull requests —
see Translating.
Daemon changes are patches, not edits
Section titled “Daemon changes are patches, not edits”appctr/tailscale_src/ is generated and git-ignored: pristine upstream
Tailscale with appctr/patches/*.patch applied in alphabetical order
(01-enable-socks-android … 23-android-loopback-guard, twenty-three today).
appctr/orig/ holds the pristine copy the patches are diffed against. The
source of truth is the patch files; the tree is scratch space.
Working loop:
- Read the upstream file first. Design your change around the pattern Tailscale
already uses — the pristine copy is right there in
appctr/orig/. - Edit under
appctr/tailscale_src/, build, iterate. - Regenerate the patch set:
./appctr/patches/recreate_patches.sh. It deletespatches/*.patchand re-diffstailscale_srcagainstorig— per file, or per directory for the taildrop/taildrive/net stanzas. - Verify from scratch:
cd appctr && bash build.sh --clean. Without--clean,build.shsees an existingtailscale_src/and skips patching entirely, so a broken patch will not be noticed until someone else builds.
Two constraints on a patch:
- It must apply to pristine sources with no fuzz.
build.shrunspatch -p1 --batch --forward -F0, so a version bump that shifts context fails the build instead of landing the hunk in the wrong place. - It must not be empty.
build.shaborts on a zero-byte patch, because GNUpatchaccepts one and exits 0 — a lost fix would otherwise ship silently.
If you add a change to a file recreate_patches.sh does not already diff, add
the stanza for it in the same commit. Otherwise your work disappears at the next
clean build.
Bumping Tailscale means editing appctr/TAILSCALE_VERSION; build.sh notices
the change and forces a clean re-download and re-patch by itself.
The three tunnel modes
Section titled “The three tunnel modes”Settings → Tunnel mode picks exactly one:
| Mode | How traffic gets in | What it needs |
|---|---|---|
| Proxy (default) | Apps are pointed at the local SOCKS5 / HTTP proxy and the local DNS server. Nothing is captured. | Nothing — no VPN permission, no root |
| VPN (TUN) | Android’s VpnService device, with two engines. Native (the default): the bridge relaunches tailscaled with --tun=android-vpn on a duplicate of the fd, and the daemon reads and writes it itself. Compatibility: hev-socks5-tunnel (app/src/main/jni/) reads the device and forwards into that same local SOCKS5 proxy. |
The system VPN permission |
| Root | The daemon runs through su on a real kernel interface tailscale0, with policy routing in table 53 and the TAILSOCKS_MARK / TAILSOCKS_DNS iptables chains. Android’s VPN slot stays free. |
Magisk / KernelSU / APatch |
Concretely:
- Proxy mode must keep working with no VPN permission and no root. It is the
reason this app exists; do not make any shared path depend on a
VpnServiceor onsu. - Root Mode routing lives in
core/RootUtils.kt, and almost all of it is policy routing written withip rule/ip route, not iptables: the tailnet rules into table53at priority 100, the catch-all into the daemon’s table52at priority 200, the per-appuidrange … gotoexclusions at priority 190, and the LAN and foreign-CGNATthrowroutes. iptables carries only marking and DNAT, and only insideTAILSOCKS_MARK/TAILSOCKS_DNS: never write a bare--set-mark,OUTPUTcarries only the guarded jumps into those chains, andFORWARDonly thetailscale0ACCEPT pair. Do not reintroduce a mangle-mark bypass chain — 4.0 deleted the oldTAILSOCKS_BYPASSbecause a mark set in mangleOUTPUTarrives after the route and the source address have already been chosen. Its rules are tiered — T1 (tailnet reachability) coexists with anything, T2 (default-route capture) and T3 (device-wide DNS) claim the whole device and therefore have exactly one owner. If you touch T2 or T3, read Routing around tailscale0 and Next to another VPN client in Root Mode internals first: the coexistence logic there is what keeps another VPN client’s apps from being stolen. - TUN mode has two engines, chosen in Settings → TUN (
tun_engine, stored only when the user picks one). The native engine is the default:core/TunVpnService.ktestablishes the device with what the daemon’s no-op router would install — the node’s addresses, the tailnet ranges, accepted subnet routes (core/TunRoutes.kt), the default route behind an exit node — and Android fixes those atestablish(), so a change rebuilds the device and relaunches the daemon.hev-socks5-tunnelstays as the compatibility engine; keep both working. The fd swap,CallbackRouterandSCM_RIGHTSindocs/NATIVE_TUN_PLAN.mdare kept there for the record and are not wanted: switching engines restarts the daemon by design.
State the modes you tested in the pull request. “Proxy only” is a fine answer; silently untested is not.
Commits and the changelog
Section titled “Commits and the changelog”Subjects are conventional-commit style, in English, as used in git log:
feat(root): health-gate the DNS redirect, guard foreign CGNAT, leave netd's table spacefix(ui): stop the DNS test button from covering its own titleperf(root): react to another tunnel at once instead of on the next tickdocs: hand off what is left — Root Mode device checks and the CLI binary- Prefix:
feat,fix,docs,refactor,perf,style,chore. - Scope in parentheses, naming the area —
(root),(ui),(core),(dns),(tun),(build),(appctr),(security),(patches). Drop it only when the change really is repo-wide (docs:). - The subject says what the behaviour now is, not which file moved. Lowercase after the colon, no trailing period, and it may run to ~90 characters — a readable sentence beats a truncated label.
- Use the body for why, and for what breaks without it. Not needed for trivia; expected for anything subtle. Commit messages, code, comments and logs are English regardless of the language of the discussion.
Anything a user would notice goes into CHANGELOG.md in the same commit that
changes the behaviour — not in a follow-up, not at release time. It goes under
the topmost heading, ## [X.Y.Z] - Unreleased, in the Added / Changed /
Fixed / Security group that fits. Never edit a heading that already has a
date: released sections are history.
Changelog entries are for users and for whoever cuts the release, and they
are short: one line per change, saying what changed — about twenty words at
most, no class names, file paths, constants, resource ids or API payloads.
The why and the details go into the commit message body, where anyone who
wants them can read them. CI copies the tagged section into the GitHub release
notes verbatim. Calibrate against the [4.1.1] section.
The version is written in one file. version.properties holds
VERSION_NAME and VERSION_CODE (major×1000000 + minor×10000 + patch×100);
Gradle and F-Droid both read it. A release commit bumps both, renames the
Unreleased heading to ## [X.Y.Z] - YYYY-MM-DD, and is tagged vX.Y.Z — CI
refuses a tag that disagrees with the file. That rename is load-bearing: CI builds
the GitHub release notes by matching ## [<tag without the v>] in
CHANGELOG.md, and a tag with no matching heading ships an empty release.
Tagging is the maintainer’s job — send a pull request against main and leave
the tag alone.
Testing
Section titled “Testing”What you can check without a device:
cd appctr && go test ./...— the Go bridge’s own tests (currentlyextraargs_test.go).gofmtoverappctr/— CI fails on unformatted Go.appctr/build.shrunsgofmt -w *.gofor you, andgit config core.hooksPath .githooksenables a pre-commit hook that does the same../gradlew app:assembleDebug— it compiles, and that is genuinely most of what the Kotlin side can be checked for automatically.- If you add an
external fun, build a release and letverifyReleaseNativeMethodsrun: it fails when R8 shrinks a native method away, which otherwise crashesSystem.loadLibraryat runtime. New native methods need a plain-keepfornative <methods>inapp/proguard-rules.pro.
What genuinely needs a device:
- Anything JNI or native — the TUN engine and ByeDPI are only exercised at runtime, and R8 behaviour differs between debug and release.
- The foreground-service lifecycle, Quick Settings tile, widgets, SAF.
- All of Root Mode. Policy routing, the iptables chains and the coexistence
logic with another VPN client cannot be verified anywhere but a rooted phone;
the 4.0.0 coexistence work was checked on a rooted Redmi (APatch) including
across a real reboot. Tools for that: Settings → Diagnostics & developer →
Check Routing, the ROOT tab in the Logs screen, and
tools/root-debug.shrun undersuon the device for a full snapshot.
Where to ask
Section titled “Where to ask”- Architecture — how the daemon, the bridge and the app fit together, and why each workaround exists.
- Root Mode guide — the complete Root Mode reference: the three rule tiers, coexistence with other VPN clients, the boot script, diagnostics.
agents.md— the full engineering mandate this guide summarises.- Building — build pipeline details.
If none of those answer it, open an issue and say which mode, which Android version, and whether the device is rooted.