Skip to content

Fix audit blockers: ship patches on all architectures, authenticate SOCKS5, repair CI releases - #10

Merged
bropines merged 10 commits into
mainfrom
fix/audit-blockers
Sep 7, 2026
Merged

Fix audit blockers: ship patches on all architectures, authenticate SOCKS5, repair CI releases#10
bropines merged 10 commits into
mainfrom
fix/audit-blockers

Conversation

@bropines

@bropines bropines commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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, 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. Verified on the released binaries: aarch64 had the patch strings and wlynxg/anet references, 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=android everywhere does not work — Go requires external (cgo) linking for every Android target except arm64. The patches are now tagged android || linux instead, so they compile into the GOOS=linux targets. -buildmode=pie is also dropped 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.

verify_patched() now greps each freshly built tailscaled for the netmon, anet and 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:1055 gave any app holding only INTERNET egress into the tailnet with this node's identity. socks5.Server already has Username/Password upstream; cmd/tailscaled just never set them. Credentials are now generated on first start, stored 0600 in ~/.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 curl exits 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 Release was skipped, and the job still reported success. Last actual release was 2026-08-02 while the cron kept running nightly. Also, upstream v1.102.3 was compared against this repo's v1.100.0-10, which can never be equal, so four architectures were cross-compiled and discarded every night.

Also fixed

  • One source of daemon flags. The service run now execs tailscaled-start --foreground. ~/.tailscale/.env was previously read only by the manual path, so every documented variable silently did nothing for the service — which is what postinst starts.
  • Scoped process matching. pkill -f tailscaled matched this project's own runsv/svlogd/tail processes; pgrep -f "tailscaled.*$STATE_DIR" matched the helper's own cmdline. Verified on a host also running a system tailscaled: the old pattern matched 3 processes, the new stop leaves it untouched.
  • Install on a clean Termux. termux-services is a declared dependency but was never a prerequisite; dpkg -i exits 1 on an unmet dependency and set -eu killed the script before the apt install -f meant to repair it.
  • tailscaled-stop does sv down first; tailscale-test reads the live daemon's address and says so when there is no proxy; tailscaled-log follows the svlogd directory; the packaged log/run regains its mkdir.
  • TS_VERBOSE/TS_NO_LOGS are mapped to the names tailscaled actually reads; both were documented but wired to nothing.
  • Bounds-checked --socks5-server lookup, quote-preserving TS_EXTRA_ARGS, version-stamped source and binary caches, pipefail, real exit-code collection from parallel builds, SHA256SUMS on releases.

Supply chain, licensing, packaging

  • The upstream tarball is now checksummed before anything is unpacked, compiled or executed. build.sh used wget | tar -xz with no verification, then ran that code on the build machine — which for install.sh is the user's phone. TS_REQUIRE_CHECKSUM=1 makes an unpinned version fatal.
  • Shell completions reuse the binary just built instead of triggering a second host compile of upstream code, which on a Termux install is the same machine anyway.
  • LICENSE claimed "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 ships share/doc/tailscale-termux/copyright as clause 2 requires.
  • Pacman packages (closes termux pacman support #9). Termux's pacman variant had no package at all. The same staged payload now emits a .pkg.tar.xz alongside the .deb, remote-install.sh detects which package manager is present, and CI publishes both. Verified: .PKGINFO/.INSTALL first in the archive, no DEBIAN directory, correct per-arch metadata.
  • The deb postinst and the pacman .INSTALL now call one on-device post-install.sh instead of two copies of the same logic.

CI on pull requests

There was no PR check of any kind — only tag pushes, workflow_dispatch and 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-cli repeats it once, since package-manager output scrolls past. TS_SOCKS5_NO_AUTH=1 restores 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

bropines and others added 10 commits September 7, 2026 16:09
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>
@bropines
bropines merged commit ac7f95f into main Sep 7, 2026
6 checks passed
@bropines
bropines deleted the fix/audit-blockers branch September 7, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

termux pacman support

1 participant