Fix audit blockers: ship patches on all architectures, authenticate SOCKS5, repair CI releases - #10
Merged
Conversation
The netmon patch was tagged //go:build android, but build.sh only set GOOS=android for aarch64 -- PR #4 switched armv7 to GOOS=linux to make it compile, and i686/x86_64 followed. The tag then stopped matching, so three of the four published architectures shipped without the netmon patch that is the entire point of this project. Verified on the released binaries: aarch64 had the patch strings and wlynxg/anet references, arm/i686/x86_64 had zero. Setting GOOS=android everywhere does not work: Go requires external (cgo) linking for every Android target except arm64 ("android/amd64 requires external (cgo) linking, but cgo is not enabled"), which would mean shipping an NDK toolchain. Instead the patches are tagged `android || linux` so they are compiled into the GOOS=linux targets too. Also drop -buildmode=pie from x86_64. With CGO_ENABLED=0 it produced a dynamic ELF with .interp=/lib64/ld-linux-x86-64.so.2, a glibc loader Android does not have, so that binary could not start at all. arm and i686 already omitted it. verify_patched() greps each freshly built tailscaled for the netmon, anet and SOCKS5-auth markers and fails the build if any are missing. A build tag that stops matching produces no warning anywhere, so checking the artifact is the only way to catch the next one. Additionally: - fix_socks5_auth.go exposes the credential getters that build.sh injects into cmd/tailscaled/proxy.go. A failed injection is fatal rather than silently shipping an open proxy. - The DNS override is now configurable via TS_DNS_SERVER instead of being hardcoded to 8.8.8.8, and honours the caller's transport so Go's TCP retry for truncated answers is no longer forced back onto UDP. - The source cache is keyed on the version it was populated with; it used to reuse any existing tailscale_src regardless of the version requested. - Parallel builds collect exit codes. A bare `wait` always returns 0, so a failed cross-compile was reported as "Build complete!". - set -o pipefail, and check for git/wget/tar rather than only go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android's loopback is not isolated per app, so the unauthenticated SOCKS5 proxy on the fixed port 127.0.0.1:1055 -- enabled by the service run script and started by postinst -- gave any app holding only INTERNET permission egress into the tailnet with this node's identity. upstream's socks5.Server already has Username/Password fields; cmd/tailscaled simply never set them. tailscaled-start now generates a credential pair on first start, stores it 0600 in ~/.tailscale/socks5.env, and passes it through the environment rather than argv, where every process on the device could read it via /proc. `tailscale-socks5` prints the address, credentials and a ready-made proxy URL; --regenerate issues a new password. Verified end to end: without credentials curl exits 97 (rejected by the SOCKS5 server); with them the request completes. The service run script now execs `tailscaled-start --foreground` instead of repeating its own flag list. Previously ~/.tailscale/.env was read only by the manual path, so every documented variable silently did nothing for the service -- which is what postinst starts. That single source of flags is also what lets the credentials apply to both paths. Other fixes in the helpers, now sharing one libexec/common.sh: - daemon_pids() matches on process name plus state directory. `pgrep -f tailscaled` matched this project's own runsv/svlogd/tail processes, and `pgrep -f "tailscaled.*$STATE_DIR"` matched the helper's own cmdline, so tailscaled-start could report "already running" when nothing was. - tailscaled-stop does `sv down` first. It only ever sent a kill, which runit undid a second later, while pkill's exit 0 made it look like it worked. - tailscale-test reads the SOCKS5 address from the running daemon instead of a socks_addr file the service path never wrote, and says so explicitly when there is no proxy rather than silently skipping half the test. - tailscaled-log follows the svlogd directory when the service wrote there. - The packaged log/run recreates the mkdir it lost to the heredoc; without a log directory svlogd exits fatally and runsv loops. - Bounds-check the --socks5-server lookup (`tailscaled-start --socks5-server` used to die on an unbound variable) and keep quotes in TS_EXTRA_ARGS, so --hostname="my phone" stays one argument. - TS_VERBOSE and TS_NO_LOGS are mapped to the TS_LOG_VERBOSITY and TS_NO_LOGS_NO_SUPPORT names tailscaled actually reads; both were documented but wired to nothing. - Ship share/doc/tailscale-termux/copyright: the binaries are Tailscale's BSD-3 code, whose clause 2 requires the notice in binary distributions. - Stale binaries are detected by version stamp rather than mere existence, so a rebuild cannot package an old daemon under a new version number. On upgrade, postinst writes and prints ~/.tailscale/UPGRADE_NOTICE explaining that the proxy now needs a password and how to retrieve it; tailscale-cli repeats it once, since dpkg output scrolls past. TS_SOCKS5_NO_AUTH=1 restores the old open proxy for clients that cannot send credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The package declares Depends: termux-services, but the installer never checked for it. dpkg -i exits 1 on an unmet dependency after unpacking but not configuring, and under `set -eu` that killed the script before the very `apt install -f -y` meant to repair it -- leaving a half-configured package blocking apt. remote-install.sh then deleted its tmpdir on the way out, taking the downloaded .deb with it, so there was nothing left to retry by hand. Now termux-services is a prerequisite, dpkg -i is allowed to fail into apt install -f, the download is kept in ~/.cache/tailscale-termux, and the final banner is printed only after dpkg-query confirms the package is really configured. Both installers ran `pkill -f tailscaled`, which under the Termux uid also matches this project's own `runsv tailscaled`, `svlogd` and `tail -f ...tailscaled.log`. Killing runsv without `sv down` just makes runit restart the daemon a second later, in the middle of dpkg -i. Replaced with `sv down` plus a kill scoped to --statedir. Confirmed on a host that also runs a system tailscaled: the scoped stop leaves it untouched, where the old pattern matched three processes. Also: - `curl | grep -Po` assignments get `|| true`. Under `set -e` a failing grep (GitHub rate-limits unauthenticated API calls to 60/hour per IP, which carrier NAT reaches easily) aborted on the assignment, making the "No releases found" branch dead code and leaving the user with silence. - install.sh names the .deb it just built instead of `ls | head -n 1`, which sorts lexicographically and so picked the *oldest* package present. - An unsupported Go architecture is an error rather than a silent fallback to aarch64, which only bought a long build and an opaque dpkg error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y night
The release step was gated on `startsWith(github.ref, 'refs/tags/') ||
github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch'`.
A reusable workflow inherits the caller's event, so on the nightly
check-updates run github.event_name is "schedule" and github.ref is
"refs/heads/main" -- all three disjuncts false. Confirmed on run 34073832941:
all four builds succeeded, "Create Release" was skipped, and the job still
reported success, which is why it went unnoticed. The last actual release was
2026-08-02 while the cron kept running nightly.
Gate on inputs.ts_version instead, which is populated on both workflow_call
and workflow_dispatch and empty on a tag push, with the tag-ref check kept.
check-updates compared upstream's "v1.102.3" against this repo's release tag
"v1.100.0-10". Those can never be equal, so new_version was always true and
four architectures were cross-compiled and thrown away every single night.
Both sides are now normalised before comparison.
The upstream tag also went straight into a shell body via ${{ }} in a job
holding contents: write. It now travels through env, and the workflow's
default permission is contents: read with write granted only to the job that
publishes.
Releases now carry SHA256SUMS: remote-install.sh and tailscale-update download
these assets with nothing to verify them against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A section on the SOCKS5 proxy: why it needs a password on Android, where the generated credentials live, and how to read them. - The .env table now lists the variables that are really wired up. TS_VERBOSE was documented but read by nobody; TS_DEBUG and TS_NO_LOGS likewise. Added TS_SOCKS5, TS_DNS_SERVER and the credential overrides. - Say that .env applies to both start paths, which is now true. - "Auto-start on boot" was never implemented: termux-services starts its supervisor from profile.d, so the node is offline after a reboot until a Termux session opens. Reworded, with pointers to Termux:Boot and termux-wake-lock. - Disclose the two things the patches change about the node: DNS goes to 8.8.8.8 by default (why, and how to change it), and the daemon reports itself to the control plane as App=tailscale-cli, DeviceModel=Termux. - Note that only aarch64 is a GOOS=android build and why, and that the build verifies the patches are present in the binary. - Troubleshooting entry for a SOCKS5 client that breaks after this update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Source verification. build.sh downloaded Tailscale's tarball with `wget | tar -xz` and no verification of any kind, then compiled and (for shell completions) executed that code -- on the build machine, which for install.sh is the user's phone. The archive is now written to disk, checksummed, and compared against checksums/<version>.sha256 before anything is unpacked. An unpinned version is recorded and reported rather than silently trusted; TS_REQUIRE_CHECKSUM=1 makes it a hard failure. v1.98.3 is pinned to start. Completions no longer trigger a second host build of ./cmd/tailscale when the binary just built can run here -- which on a Termux install it always can, since target and host are the same machine. That removes both a redundant compile and one of the two places freshly downloaded upstream code was executed during packaging. Licensing. LICENSE claimed "All rights reserved" over what is, in the shipped artifacts, almost entirely Tailscale's BSD-3 code. It now states plainly which parts it covers and that the tailscale/tailscaled programs are Tailscale's, under the same terms. The patch files carry SPDX headers. Pacman packages (closes #9). Termux also ships a pacman-based variant, which had no package here at all. The same staged payload now emits a .pkg.tar.xz alongside the .deb: a tar archive with .PKGINFO and .INSTALL first, minus the Debian control directory. remote-install.sh detects which package manager is present and fetches the matching artifact, and CI publishes both. The postinst logic moved into libexec/tailscale-termux/post-install.sh so the deb's postinst and the pacman .INSTALL scriptlet run one implementation instead of two copies. The upgrade notice moved with it, into a packaged text file rather than a heredoc duplicated per format. Verified: the pacman archive has .PKGINFO/.INSTALL first and no DEBIAN directory; post-install.sh writes the notice on upgrade and not on a fresh install; tailscale-cli repeats it exactly once; completions are non-empty and came from the target binary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repository had no PR check at all -- the only triggers were tag pushes, workflow_dispatch and the nightly workflow_call. That is how three architectures shipped for months without the netmon patch. PRs now run the same four-architecture matrix, where build.sh's verify_patched turns that class of regression into a failed build. The publish gate already resolves to false without a tag or an explicit version, so PR runs upload artifacts and release nothing. TS_VERSION fell back to github.ref_name, which is a branch or PR ref on anything but a tag push -- feeding "10/merge" in as a Tailscale version makes the source download fail outright. It now falls back to empty, which build.sh already handles by resolving the latest upstream tag. build_deb.sh exports TS_VERSION so the version it stamps on the package and the source version build.sh downloads cannot disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caught by the PR check added in the previous commit. TS_VERSION doubles as this project's package version and as the upstream tag build.sh downloads, and those are not the same thing: with no tags in a shallow CI checkout, `git describe --tags --always` returns a bare commit hash. Exporting that verbatim made build.sh try to fetch a release tarball for tag "25b7c87". The package version now accepts anything, while the source version is only derived when TS_VERSION actually looks like an upstream release. Otherwise build.sh resolves the latest Tailscale tag itself, and binaries_are_current has no stamp to compare against, so it only checks the binaries exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second finding from the new PR check. With the source version separated out, the build itself passed and verify_patched confirmed all four architectures -- then dpkg-deb rejected the package: "'Version' field value 'bc4e2c2': version number does not start with digit". Both dpkg and pacman require a version starting with a digit, and `git describe --tags --always` returns a bare commit hash whenever the clone has no tags. A version that cannot be packaged is now rewritten to 0.0.0.g<hash>, and CI checks out full history so `git describe` finds real tags and produces a meaningful version in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four loose ends from a second pass over AUDIT.md. SHA256SUMS was published but nothing consumed it, which made it decoration. remote-install.sh now fetches the release's SHA256SUMS and verifies the package before installing, refusing to continue on a mismatch. Releases predating the file skip verification with a note rather than failing. tailscale-update fetched the installer from main, so anyone who installed a reviewed release later ran whatever happened to be on the default branch. It now pulls remote-install.sh from the release tag, falling back to main only if the tag has no installer. Verified both paths against the live repository. README claimed that invoking `tailscale` auto-starts the daemon. Only the tailscale-cli wrapper does that; the bare binary just gets --socket filled in by the patch. Corrected. Release assets now include LICENSE. The .deb and .pkg.tar.xz carry the copyright notice internally, but the loose binaries published alongside them did not travel with one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the findings from
AUDIT.md. Every claim below was verified against the code or the built artifacts, not taken on trust.Blockers
The netmon patch was missing from 3 of 4 published architectures. It is tagged
//go:build android, butbuild.shonly setGOOS=androidforaarch64— PR #4 switched armv7 toGOOS=linuxto make it compile, and i686/x86_64 followed. Verified on the released binaries: aarch64 had the patch strings andwlynxg/anetreferences, the other three had zero. Users on 32-bit ARM or x86 were getting a stock daemon with exactly the netlink limitation this project exists to work around.Setting
GOOS=androideverywhere does not work — Go requires external (cgo) linking for every Android target exceptarm64. The patches are now taggedandroid || linuxinstead, so they compile into theGOOS=linuxtargets.-buildmode=pieis also dropped from x86_64: withCGO_ENABLED=0it produced a dynamic ELF with.interp=/lib64/ld-linux-x86-64.so.2, a glibc loader Android does not have, so that binary could not start at all.verify_patched()now greps each freshly builttailscaledfor the netmon,anetand SOCKS5-auth markers and fails the build if any are missing. Confirmed on a full four-architecture build: all four pass.The SOCKS5 proxy was open to every app on the device. Android's loopback is not isolated per app, so the unauthenticated proxy on the fixed
127.0.0.1:1055gave any app holding onlyINTERNETegress into the tailnet with this node's identity.socks5.Serveralready hasUsername/Passwordupstream;cmd/tailscaledjust never set them. Credentials are now generated on first start, stored0600in~/.tailscale/socks5.env, and passed through the environment rather than argv, where every process can read them via/proc.Verified end to end against a real daemon: without credentials
curlexits 97 (rejected by the SOCKS5 server); with them the request completes.The nightly updater could never publish. The release step was gated on
github.event_name == 'workflow_call', but a reusable workflow inherits the caller's event —schedule. Confirmed on run 34073832941: all four builds succeeded,Create Releasewas skipped, and the job still reported success. Last actual release was 2026-08-02 while the cron kept running nightly. Also, upstreamv1.102.3was compared against this repo'sv1.100.0-10, which can never be equal, so four architectures were cross-compiled and discarded every night.Also fixed
runnow execstailscaled-start --foreground.~/.tailscale/.envwas previously read only by the manual path, so every documented variable silently did nothing for the service — which is whatpostinststarts.pkill -f tailscaledmatched this project's ownrunsv/svlogd/tailprocesses;pgrep -f "tailscaled.*$STATE_DIR"matched the helper's own cmdline. Verified on a host also running a systemtailscaled: the old pattern matched 3 processes, the new stop leaves it untouched.termux-servicesis a declared dependency but was never a prerequisite;dpkg -iexits 1 on an unmet dependency andset -eukilled the script before theapt install -fmeant to repair it.tailscaled-stopdoessv downfirst;tailscale-testreads the live daemon's address and says so when there is no proxy;tailscaled-logfollows the svlogd directory; the packagedlog/runregains itsmkdir.TS_VERBOSE/TS_NO_LOGSare mapped to the namestailscaledactually reads; both were documented but wired to nothing.--socks5-serverlookup, quote-preservingTS_EXTRA_ARGS, version-stamped source and binary caches,pipefail, real exit-code collection from parallel builds,SHA256SUMSon releases.Supply chain, licensing, packaging
build.shusedwget | tar -xzwith no verification, then ran that code on the build machine — which forinstall.shis the user's phone.TS_REQUIRE_CHECKSUM=1makes an unpinned version fatal.LICENSEclaimed "All rights reserved" over what is, in the shipped artifacts, almost entirely Tailscale's BSD-3 code. It now says which parts it covers; the package shipsshare/doc/tailscale-termux/copyrightas clause 2 requires..pkg.tar.xzalongside the.deb,remote-install.shdetects which package manager is present, and CI publishes both. Verified:.PKGINFO/.INSTALLfirst in the archive, noDEBIANdirectory, correct per-arch metadata.postinstand the pacman.INSTALLnow call one on-devicepost-install.shinstead of two copies of the same logic.CI on pull requests
There was no PR check of any kind — only tag pushes,
workflow_dispatchand the nightly call. That is how the unpatched architectures went unnoticed for months. PRs now run the same four-architecture matrix and publish nothing.Upgrade impact
Existing users' SOCKS5 clients will stop working. The post-install step distinguishes an upgrade from a fresh install and prints a notice explaining why and how to retrieve the credentials;
tailscale-clirepeats it once, since package-manager output scrolls past.TS_SOCKS5_NO_AUTH=1restores the old behaviour for clients that cannot send credentials. Verified: notice on upgrade, none on fresh install, shown exactly once.Note
With this merged the nightly workflow will start publishing releases again. Consider gating the publish job behind an Actions environment with a required reviewer.
🤖 Generated with Claude Code