diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2ea895..c40a315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,3 +97,69 @@ jobs: otool -L "$BIN" | tee /tmp/o.txt if otool -L "$BIN" | tail -n +2 | grep -iq tailscale; then echo "::error::updater links a tailscale product (self-update circularity)"; exit 1; fi + + build-legacy106: + name: Cross-build + legacy106 gate (macos-26) + runs-on: macos-26 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Install mavericks-shipyard + uses: ModernMavericks/shipyard/.github/actions/install@v1 + + - name: Install patched Go (mavericks-golang cross toolchain) + run: | + ver="$(cat components/golang/version)" + base="https://github.com/ModernMavericks/golang/releases/download/${ver}" + curl -fL --retry 3 -o SHA256SUMS "$base/SHA256SUMS" + gover="${ver%%-mavericks.*}"; rev="${ver#*-}"; pkg=""; sum="" + for pref in golang go126; do + cand="${pref}-${gover}-cross-${rev}.pkg" + s=$(awk -v f="$cand" '$2==f' SHA256SUMS || true) + if [ -n "$s" ]; then pkg="$cand"; sum="$s"; break; fi + done + [ -n "$pkg" ] || { echo "::error::no cross pkg for $ver in golang SHA256SUMS"; exit 1; } + curl -fL --retry 3 -o "$pkg" "$base/$pkg" + printf '%s\n' "$sum" | shasum -a 256 -c - + sudo installer -pkg "$pkg" -target / + echo "/usr/local/go126-cross/bin" >> "$GITHUB_PATH" + + - name: Confirm the patched cross toolchain is on PATH + run: | + test "$(command -v go)" = /usr/local/go126-cross/bin/go + go version + + - name: Cache SDKs (distinct key from the 10.9 job's cache) + uses: actions/cache@v6 + with: + path: | + ${{ github.workspace }}/.sdk-cache + ${{ github.workspace }}/.sdk106-cache + key: macosx-sdk106-v1 + + - name: Derive the upstream version from the pin + run: sh build/derive-upstream-version.sh + + - name: Build + gate the 10.6-floor binaries (x86_64 / min-10.6 / Core 2 safe) + env: + MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache + MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache + run: | + cmake --preset cross-legacy + cmake --build --preset cross-legacy + ctest --preset cross-legacy + + - name: Build the 10.6 updater (must be min-10.6, x86_64) + env: + MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache + MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache + run: | + VER="$(cat UPSTREAM_VERSION 2>/dev/null || echo 1.0)" + sh cmake/build_updater106.sh build/updater106 "$VER" + BIN=build/updater106/TailscaleUpdater.app/Contents/MacOS/TailscaleUpdater + ARCHS=$(lipo -info "$BIN" | sed -n 's/.*: //p') + [ "$ARCHS" = "x86_64" ] || { echo "::error::updater archs: $ARCHS (expected x86_64)"; exit 1; } + MIN=$(otool -l "$BIN" | awk '/LC_VERSION_MIN_MACOSX/{f=1} f && /version/{print $2; exit}') + [ "$MIN" = "10.6" ] || { echo "::error::updater min-version is $MIN, expected 10.6"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9732fe..dd5cd0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,11 +157,13 @@ jobs: test "$(command -v go)" = /usr/local/go126-cross/bin/go go version - - name: Cache the MacOSX10.9 SDK + - name: Cache the MacOSX10.9 SDK + assembled SDK106 uses: actions/cache@v6 with: - path: ${{ github.workspace }}/.sdk-cache - key: macosx10.9-sdk-v1 + path: | + ${{ github.workspace }}/.sdk-cache + ${{ github.workspace }}/.sdk106-cache + key: macosx10.9-sdk-v2 - name: Build + gate the three Go binaries (x86_64 / min-10.9 / no post-10.9 imports) env: @@ -180,6 +182,22 @@ jobs: if otool -L "$BIN" | tail -n +2 | grep -iq tailscale; then echo "::error::updater links a tailscale product (self-update circularity)"; exit 1; fi + - name: Build + gate the 10.6-floor binaries + env: + MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache + MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache + run: | + cmake --preset cross-legacy + cmake --build --preset cross-legacy + ctest --preset cross-legacy + + - name: Build the 10.6 updater + env: + MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache + MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache + VER: ${{ steps.ver.outputs.version }} + run: sh cmake/build_updater106.sh build/updater106 "$VER" + # Bundle the packaging inputs into ONE tarball so executable bits (the binaries, the updater .app's # Mach-Os) survive the artifact round-trip -- artifacts otherwise drop file modes. - name: Stage + upload packaging inputs @@ -190,6 +208,10 @@ jobs: cp build-cross/gobin/tailscale stage/tailscale cp build-cross/gobin/tailscale-systray stage/tailscale-systray cp -R build/updater/TailscaleUpdater.app stage/TailscaleUpdater.app + cp build-cross-legacy/gobin/tailscaled stage/tailscaled-106 + cp build-cross-legacy/gobin/tailscale stage/tailscale-106 + cp build-cross-legacy/gobin/tailscale-systray stage/tailscale-systray-106 + cp -R build/updater106/TailscaleUpdater.app stage/TailscaleUpdater106.app find stage -name '._*' -delete 2>/dev/null || true tar -C stage -czf packaging-inputs.tgz . - uses: actions/upload-artifact@v7 @@ -268,6 +290,24 @@ jobs: --version "$VER" --pkg-url "$URL" --min-os 10.9.5 \ --notes-file dist/RELEASE_NOTES.md --pkg "$PKG" > dist/appcast.xml echo "=== appcast.xml ==="; cat dist/appcast.xml + + # 10.6-floor pkg: same version, different binaries + install floor. + PKG106="dist/tailscale-$VER-macos10.6.pkg" + sh cmake/make_app.sh in/tailscale-systray-106 "in/Mavericks Tailscale 106.app" "$VER" "$GITHUB_WORKSPACE/Tailscale.icns" 10.6 + SHIPYARD_SCRIPTS="$SH" sh cmake/package_pkg_legacy106.sh --out "$PKG106" --version "$VER" \ + --tailscaled in/tailscaled-106 --tailscale in/tailscale-106 \ + --systray-app "in/Mavericks Tailscale 106.app" \ + --updater-app in/TailscaleUpdater106.app \ + --daemon-plist "$GITHUB_WORKSPACE/dist/com.tailscale.tailscaled.plist" \ + --systray-agent "$GITHUB_WORKSPACE/dist/com.tailscale.systray.plist" \ + --dist "$GITHUB_WORKSPACE/dist" + + # Serve a SEPARATE 10.6 appcast (not mixed into the Sparkle feed: + # unsigned items confuse 10.9 Sparkle clients). The 10.6 updater + # reads this file; the 10.9 Sparkle reads the original appcast. + PKG106_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/$VER/$(basename "$PKG106")" + printf '\n\n \n Tailscale for Snow Leopard\n \n Version %s\n %s\n 10.6\n \n \n \n\n' \ + "$VER" "$VER" "$PKG106_URL" > dist/appcast-10.6.xml # No SHA256SUMS here: publish-release.yml regenerates it over everything it attaches, so the # checksums cover the appcast too and no repo hand-rolls that step. # Conformance: the .pkg, the appcast and the version we are about to publish must agree with each @@ -291,6 +331,7 @@ jobs: path: | dist/*.pkg dist/appcast.xml + dist/appcast-10.6.xml dist/RELEASE_NOTES.md # Before anything is published: no log of this run, and no file about to be released, carries a diff --git a/.gitignore b/.gitignore index 72e9021..2e831d2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ build/updater/ # Finder, and git prints "non-monotonic index" for every ._pack-*.idx it finds. ._* .DS_Store +build-cross-legacy/ diff --git a/CMakePresets.json b/CMakePresets.json index 8f3cbfe..c830125 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -1,26 +1,72 @@ { "version": 6, - "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 }, + "cmakeMinimumRequired": { + "major": 3, + "minor": 25, + "patch": 0 + }, "configurePresets": [ { "name": "native", - "displayName": "native — build on Mavericks 10.9 (mavericks-golang go126 .pkg)", + "displayName": "native \u2014 build on Mavericks 10.9 (mavericks-golang go126 .pkg)", "binaryDir": "${sourceDir}/build-native", - "cacheVariables": { "MAVERICKS_TAILSCALE_EXPECTED_MODE": "native" } + "cacheVariables": { + "MAVERICKS_TAILSCALE_EXPECTED_MODE": "native" + } }, { "name": "cross", - "displayName": "cross — build for Mavericks 10.9 on a modern host (go126-cross)", + "displayName": "cross \u2014 build for Mavericks 10.9 on a modern host (go126-cross)", "binaryDir": "${sourceDir}/build-cross", - "cacheVariables": { "MAVERICKS_TAILSCALE_EXPECTED_MODE": "cross" } + "cacheVariables": { + "MAVERICKS_TAILSCALE_EXPECTED_MODE": "cross" + } + }, + { + "name": "cross-legacy", + "displayName": "cross-legacy \u2014 build for Snow Leopard 10.6 on a modern host (go126-cross + legacy106 floor)", + "binaryDir": "${sourceDir}/build-cross-legacy", + "cacheVariables": { + "MAVERICKS_TAILSCALE_EXPECTED_MODE": "cross", + "MAVERICKS_TAILSCALE_FLOOR": "10.6" + } } ], "buildPresets": [ - { "name": "native", "configurePreset": "native" }, - { "name": "cross", "configurePreset": "cross" } + { + "name": "native", + "configurePreset": "native" + }, + { + "name": "cross", + "configurePreset": "cross" + }, + { + "name": "cross-legacy", + "configurePreset": "cross-legacy" + } ], "testPresets": [ - { "name": "native", "configurePreset": "native", "output": { "outputOnFailure": true } }, - { "name": "cross", "configurePreset": "cross", "output": { "outputOnFailure": true } } + { + "name": "native", + "configurePreset": "native", + "output": { + "outputOnFailure": true + } + }, + { + "name": "cross", + "configurePreset": "cross", + "output": { + "outputOnFailure": true + } + }, + { + "name": "cross-legacy", + "configurePreset": "cross-legacy", + "output": { + "outputOnFailure": true + } + } ] -} +} \ No newline at end of file diff --git a/cmake/build_tailscale.sh b/cmake/build_tailscale.sh index d3b950a..4bf8565 100644 --- a/cmake/build_tailscale.sh +++ b/cmake/build_tailscale.sh @@ -8,12 +8,27 @@ # $3 GO go binary (the MM go126 .pkg) # $4 ROOT repo root (for patches/ + overlays/) # $5 VER product version (longStamp) +# $6 FLOOR macOS deployment floor: "10.9" (default) or "10.6" (Snow Leopard). +# 10.6 swaps the CC wrapper for cmake/legacy106/mavericks-cross-clang-106 +# (min-10.6 + the 10.6 symbol archive) and builds with GOAMD64=v1 — +# SL Macs are Core 2 class and SIGILL on POPCNT/SSE4.2 (the Go >=1.26 +# default baseline emits them). set -eu -SRC=$1; OUT=$2; GO=$3; ROOT=$4; VER=$5 +SRC=$1; OUT=$2; GO=$3; ROOT=$4; VER=$5; FLOOR=${6:-10.9} +case "$FLOOR" in + 10.9|10.6) ;; + *) echo "build_tailscale: FLOOR must be 10.9 or 10.6, got '$FLOOR'" >&2; exit 2 ;; +esac # Build on LOCAL disk. The repo (and thus a repo-relative OUT/SRC) is on NFS: slow, and it leaks # ._ AppleDouble sidecars into vendor/ (which then break the build / contaminate archives). wrksrc + # the Go build/module caches live under WORK ($HOME/.cache, local); override with MAVERICKS_TAILSCALE_WORK. WORK="${MAVERICKS_TAILSCALE_WORK:-$HOME/.cache/mavericks-tailscale/work}" +# Normalize to absolute: the script cd's into wrksrc below, so any relative +# WORK would silently re-anchor to the wrong directory. +case "$WORK" in + /*) ;; + *) WORK="$(cd "$WORK" 2>/dev/null && pwd || echo "$PWD/$WORK")" ;; +esac WRK="$WORK/wrksrc" export GOCACHE="$WORK/gocache" GOMODCACHE="$WORK/gomodcache" GOPATH="$WORK/gopath" export COPYFILE_DISABLE=1 # no ._ sidecars when copying off the NFS source @@ -37,6 +52,15 @@ for p in "$ROOT"/patches/*.patch; do echo ">> patch $(basename "$p")"; patch -p0 # 2. Vendor the module graph, then overlay the third-party 10.9-SDK shims into vendor/ (these modules # call Security/Cocoa APIs newer than the 10.9 SDK and won't compile without a version-gated reimpl). + +# 2b. The 10.6 symbol-shim package (blank-imported from ipnauth). Copied for +# every floor BEFORE vendoring -- main-module packages build from source +# under -mod=vendor, but `go mod vendor` must see the import target exist. +# Inert where its symbols are unreferenced, load-bearing on 10.6. +echo ">> overlay legacy106 package" +mkdir -p legacy106 +cp "$ROOT/overlays/legacy106/legacy106.go" "$ROOT/overlays/legacy106/legacy106_off.go" legacy106/ + unset CC echo ">> go mod vendor"; "$GO" mod vendor echo ">> overlay certstore shim" @@ -45,9 +69,34 @@ if [ -d vendor/fyne.io/systray ]; then echo ">> overlay systray shim"; cp "$ROOT/overlays/systray_darwin.m" vendor/fyne.io/systray/systray_darwin.m fi + # 3. Build each binary. -linkmode=external routes even pure-Go binaries through go.env's min-10.9 CC # wrapper (Go 1.26 internal-links them to a 12.0 floor otherwise -- see mavericks-golang). export CGO_ENABLED=1 GOARCH=amd64 GOFLAGS=-mod=vendor +if [ "$FLOOR" = 10.6 ]; then + # Snow Leopard floor: min-10.6 CC wrapper (with our crt1.10.6.o — 10.6 dyld + # needs a classic _start) + GOAMD64=v1 + the 10.6 symbol package. The symbol + # implementations must be cgo C code (a relocatable object on the link line): + # Go references them only via dynamic-bind entries, which an archive member + # cannot win. CC env overrides go.env's default wrapper. + GO_ABS="$(command -v "$GO")" + export MAVERICKS_GO_PREFIX="$(cd "$(dirname "$GO_ABS")/.." && pwd)" + export MAVERICKS_LEGACY106_ROOT="$ROOT/cmake/legacy106" + LEGACY106_A="$WORK/liblegacy106.a" + export MAVERICKS_LEGACY106_A="$LEGACY106_A" + export CC="$ROOT/cmake/legacy106/mavericks-cross-clang-106" + # stubs.c touches DIR internals (__dd_fd): compile it against the SAME + # prepared 10.6-floor sysroot every other object uses, not the host SDK. + SDK106=$(sh "$MAVERICKS_LEGACY106_ROOT/prepare_sdk106.sh" "$MAVERICKS_GO_PREFIX" "$ROOT") + /usr/bin/clang -arch x86_64 -isysroot "$SDK106" -mmacosx-version-min=10.6 \ + -c "$ROOT/overlays/legacy106/stubs.c" -o "$WORK/stubs106.o" + rm -f "$LEGACY106_A" + ar rcs "$LEGACY106_A" "$WORK/stubs106.o" + # darwin_10_6 gates the legacy106 package definitions to this floor only + # (see the tag comment in overlays/legacy106/legacy106.go). + export GOFLAGS="-mod=vendor -tags=darwin_10_6" + export GOAMD64=v1 +fi # Stamp BOTH version strings to the clean upstream semver (e.g. 1.98.8). Without a stamp, tailscale # derives the version from module VCS info -- stripped in our build -- and prints "-ERR-BuildInfo". # We deliberately report the plain upstream version to the control server (Hostinfo.IPNVersion is diff --git a/cmake/build_updater106.sh b/cmake/build_updater106.sh new file mode 100755 index 0000000..7b75070 --- /dev/null +++ b/cmake/build_updater106.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# build_updater106.sh — build the 10.6-native TailscaleUpdater.app. +# Pure ObjC, no external framework, no Go runtime — compiled directly +# with /usr/bin/clang against the prepared 10.6 SDK. +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$1"; VER="${2:-1.0}" +# Only probe PATH when the override is unset (command -v fails hard +# under set -e when go isn't found, making the override unreachable). +if [ -n "${MAVERICKS_GO_PREFIX:-}" ]; then + PREFIX="$MAVERICKS_GO_PREFIX" +else + GO_BIN="$(command -v go 2>/dev/null)" || { + echo 'build_updater106: go not on PATH and MAVERICKS_GO_PREFIX not set' >&2; exit 1; } + PREFIX="$(cd "$(dirname "$GO_BIN")/.." && pwd)" +fi + +SDK=$(sh "$ROOT/cmake/legacy106/prepare_sdk106.sh" "$PREFIX" "$ROOT") +APP="$OUT/TailscaleUpdater.app" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +# The feed URL is build-configurable so forks get the right repo. +# Default: derive from the git remote, fall back to the upstream repo. +REPO="$(git -C "$ROOT" remote get-url origin 2>/dev/null \ + | sed 's|.*github.com[:/]||;s|\.git$||' || true)" +[ -n "$REPO" ] || REPO="startergo/tailscale-legacy" +FEED_URL="${UPDATER_FEED_URL:-https://github.com/${REPO}/releases/latest/download/appcast-10.6.xml}" + +/usr/bin/clang -arch x86_64 -isysroot "$SDK" -mmacosx-version-min=10.6 \ + -x objective-c -fno-objc-arc \ + -Wno-deprecated-declarations -Wno-format-security \ + -DUPDATER_FEED_URL="\"$FEED_URL\"" \ + -framework Cocoa \ + -o "$APP/Contents/MacOS/TailscaleUpdater" \ + "$ROOT/updater/updater106.m" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName TailscaleUpdater + CFBundleIdentifier dev.modernmavericks.TailscaleUpdater + CFBundleVersion $VER + CFBundlePackageType APPL + CFBundleExecutable TailscaleUpdater + CFBundleInfoDictionaryVersion6.0 + NSPrincipalClass NSApplication + LSUIElement + LSMinimumSystemVersion 10.6 + + +PLIST + +echo "OK: $APP" diff --git a/cmake/legacy106/mavericks-cross-clang-106 b/cmake/legacy106/mavericks-cross-clang-106 new file mode 100755 index 0000000..6ef2b74 --- /dev/null +++ b/cmake/legacy106/mavericks-cross-clang-106 @@ -0,0 +1,28 @@ +#!/bin/sh +# Cross CC for the 10.6 (Snow Leopard) floor: identical to the toolchain's +# mavericks-cross-clang (same 10.9 SDK, same arch, same -Wl,-U allowances, +# same MacPorts legacy archive) except: +# -mmacosx-version-min=10.6 (LC_VERSION_MIN 10.6; 10.6 dyld refuses newer) +# + libLegacy106Support.a (the 10.6-only symbols, see overlays/legacy106/) +# +# The toolchain root arrives via MAVERICKS_GO_PREFIX (set by build_tailscale.sh +# from the go binary it was given). Flags are passed explicitly per invocation +# (never through an unquoted multi-argument string -- an SDK cache path with a +# space would split it). +set -eu +PREFIX="${MAVERICKS_GO_PREFIX:?MAVERICKS_GO_PREFIX must point at the go toolchain root}" +LEGACY106_A="${MAVERICKS_LEGACY106_A:?MAVERICKS_LEGACY106_A must point at the built stubs archive}" +SDK=$(sh "${MAVERICKS_LEGACY106_ROOT:?}/prepare_sdk106.sh" "$PREFIX" "${MAVERICKS_LEGACY106_ROOT%/cmake/legacy106}") +for a in "$@"; do + case "$a" in -c|-E|-S) + exec /usr/bin/clang -arch x86_64 -isysroot "$SDK" -mmacosx-version-min=10.6 "$@" + ;; + esac +done +exec /usr/bin/clang -arch x86_64 -isysroot "$SDK" -mmacosx-version-min=10.6 "$@" \ + "$PREFIX/lib/libMacportsLegacySupport.a" \ + "$LEGACY106_A" \ + -Wl,-U,_SecTrustEvaluateWithError \ + -Wl,-U,_SecTrustCopyCertificateChain \ + -Wl,-U,_notify_is_valid_token \ + -Wl,-U,_xpc_date_create_from_current diff --git a/cmake/legacy106/prepare_sdk106.sh b/cmake/legacy106/prepare_sdk106.sh new file mode 100644 index 0000000..033ca25 --- /dev/null +++ b/cmake/legacy106/prepare_sdk106.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# prepare_sdk106.sh — assemble the 10.6-floor sysroot: the toolchain's verified +# MacOSX10.9 SDK (fetched/cached by its own fetch_sdk.sh) plus our assembled +# crt1.10.6.o (from overlays/legacy106/crt106.S) in usr/lib, where ld searches. +# Prints the sdk106 root. Cached per-machine; rebuilt if inputs change. +# +# The stamp fingerprints BOTH inputs: the source SDK path and the sha256 of +# crt106.S (so editing the startup source rebuilds the object), and the crt +# object's presence is re-verified every call. +# +# Locking: the CC wrapper runs this from every parallel cgo compile, so the +# rebuild is guarded by a mkdir lock with the ownership PID recorded INSIDE +# the lock dir (where no competitor can overwrite it). Protocol: (a) only +# the owner removes its own lock (EXIT trap); (b) a dead holder's lock is +# broken via kill -0 on the recorded PID; (c) the mkdir-to-echo gap is +# covered by patient waiting (empty lock = holder is mid-stamp), with a +# long-timeout fallback for a holder killed exactly in the gap; (d) the +# need-check re-runs inside the acquired lock so a waiter that slept +# through the previous holder's completion does not rebuild a fresh tree. +# +# $1 = go toolchain prefix (for libexec/fetch_sdk.sh) +# $2 = repo root (for overlays/legacy106/crt106.S) +set -eu +PREFIX="$1"; ROOT="$2" +CACHE="${MAVERICKS_SDK106_CACHE:-$HOME/Library/Caches/mavericks-sdk106}" +SDK109="$(sh "$PREFIX/libexec/fetch_sdk.sh")" +CRT_SRC="$ROOT/overlays/legacy106/crt106.S" +CRT_SHA="$(shasum -a 256 "$CRT_SRC" | cut -d' ' -f1)" + +# Key the cache path on the input hash so concurrent builds using +# DIFFERENT toolchain/SDK/crt inputs never collide on the same directory +# (a build with input A must not rm -rf the sysroot a build with input B +# just assembled and is actively using). +INPUT_KEY=$(printf '%s\n%s' "$SDK109" "$CRT_SHA" | shasum -a 256 | cut -c1-16) +SDK="$CACHE/sdk106-$INPUT_KEY" +STAMP="$SDK/.legacy106-stamp" +LOCK="$CACHE/.sdk106-$INPUT_KEY.lock" + +satisfied() { + [ -f "$STAMP" ] && [ "$(cat "$STAMP")" = "$SDK109:$CRT_SHA" ] \ + && [ -f "$SDK/usr/lib/crt1.10.6.o" ] +} + +if satisfied; then + echo "$SDK" + exit 0 +fi + +mkdir -p "$CACHE" +i=0; lock_err=0 +until mkdir "$LOCK" 2>/dev/null; do + # Distinguish 'lock exists' from 'cannot create'. If the lock no longer + # exists (a competitor just finished), retry; only fail on persistent + # permission/filesystem errors. + if [ ! -d "$LOCK" ]; then + sleep 1 # give a finishing competitor's cleanup a moment + [ -d "$LOCK" ] && continue # was a race; retry acquisition + # Still gone after sleep: could be permissions OR another race. + # Retry up to 3 times, then fail (persistent filesystem error). + lock_err=$((lock_err + 1)) + if [ "$lock_err" -gt 3 ]; then + echo "prepare_sdk106: cannot create lock ${LOCK} (persistent error)" >&2 + exit 1 + fi + continue + fi + lock_err=0 + if [ -f "$LOCK/pid" ]; then + # Ownership record exists: break only if the holder process is dead. + # (Edge case: PID reuse could make a dead holder look alive; accepted + # risk on macOS where PIDs cycle slowly.) + HOLDER_PID=$(cat "$LOCK/pid" 2>/dev/null || echo 0) + if [ "${HOLDER_PID:-0}" -gt 0 ] && ! kill -0 "$HOLDER_PID" 2>/dev/null; then + # Atomically CLAIM the stale lock by renaming it (only ONE process + # can rename a given directory; the loser's mv fails and it goes + # back to waiting). The old rm -rf approach raced: two waiters could + # both remove the stale lock, then one would delete the OTHER's + # freshly-acquired lock, and both would rebuild concurrently. + # Fall through to timeout+sleep on failure (a bare 'continue' would + # skip both, causing a busy loop when the rename persistently fails). + if mv "$LOCK" "$LOCK.stale.$$" 2>/dev/null; then + rm -rf "$LOCK.stale.$$" 2>/dev/null || true + continue + fi + fi + fi + # Empty lock (holder in the mkdir-to-echo gap): WAIT, do not evict. + # Evicting an unowned lock can break a live holder that was paused + # (SIGSTOP, debugger, swap) before writing its PID, leading to two + # concurrent rebuilds. The general timeout (600s) handles the "killed + # in the gap" case conservatively by failing the build rather than + # risking concurrent access. + i=$((i + 1)) + # 600s total: the empty-lock fallback needs 300 iterations to fire, so + # the general timeout must exceed it (a 120s timeout would exit before + # the fallback could ever trigger). + if [ "$i" -gt 600 ]; then + # NEVER evict the lock — even an empty one could be a live holder + # paused (SIGSTOP, debugger, swap) in the mkdir-to-PID gap. Evicting + # it would allow a second rebuild to rm -rf the sysroot under the + # paused holder. Fail cleanly; a human can remove the lock manually. + echo "prepare_sdk106: lock timeout (${LOCK})." >&2 + echo "prepare_sdk106: if no other build is running, remove ${LOCK} manually." >&2 + exit 1 + fi + sleep 1 +done + +# Lock acquired -- stamp ownership immediately (nanoseconds after mkdir; +# the empty-lock patience above covers this gap for waiters). +trap 'rm -rf "$LOCK" 2>/dev/null || true' EXIT +echo $$ > "$LOCK/pid" + +# Re-check INSIDE the lock: a waiter that slept through the previous holder's +# completion would otherwise rebuild (and rm -rf) the tree it just built. +if satisfied; then + echo "$SDK" + exit 0 +fi + +rm -rf "$SDK" +cp -R "$SDK109" "$SDK" +/usr/bin/clang -arch x86_64 -mmacosx-version-min=10.6 -c \ + "$CRT_SRC" -o "$SDK/usr/lib/crt1.10.6.o" +echo "$SDK109:$CRT_SHA" > "$STAMP" +echo "$SDK" diff --git a/cmake/make_app.sh b/cmake/make_app.sh index 943a577..5c0bc4a 100644 --- a/cmake/make_app.sh +++ b/cmake/make_app.sh @@ -1,11 +1,13 @@ #!/bin/sh -# make_app.sh [icon.icns] +# make_app.sh [icon.icns] [min-macos] +# min-macos: LSMinimumSystemVersion stamped into Info.plist (default 10.9; +# the legacy106 floor passes 10.6) # Wrap the tailscale-systray Go binary in a menu-bar-only (LSUIElement) .app. No ObjC -- the Go binary # IS the app; the bundle just gives it an Info.plist so LaunchServices treats it as a menu-bar agent. # An optional .icns is installed as the bundle icon (CFBundleIconFile) so Finder/About show the Tailscale # logo instead of the generic app icon; omit it and the bundle simply has no custom icon. set -eu -BIN=$1; APP=$2; VER=$3; ICON=${4:-} +BIN=$1; APP=$2; VER=$3; ICON=${4:-}; MIN=${5:-10.9} rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" install -m 0755 "$BIN" "$APP/Contents/MacOS/tailscale-systray" @@ -29,7 +31,7 @@ ${ICON_PLIST} CFBundleVersion${VER} CFBundleShortVersionString${VER} CFBundlePackageTypeAPPL LSUIElement - LSMinimumSystemVersion10.9 + LSMinimumSystemVersion${MIN} PLIST diff --git a/cmake/package_pkg_legacy106.sh b/cmake/package_pkg_legacy106.sh new file mode 100755 index 0000000..54a6219 --- /dev/null +++ b/cmake/package_pkg_legacy106.sh @@ -0,0 +1,159 @@ +#!/bin/sh +# package_pkg_legacy106.sh — the Snow Leopard floor variant of package_pkg.sh. +# Differences from the 10.9 product: +# - hard install floor 10.6 (vs 10.9.5) +# - NO Sparkle updater (not yet validated on 10.6): the postinstall already +# tolerates its absence (agent-load.sh optional), and the systray's +# "Check for updates" item simply has no helper to hand off to. +# Payload layout is identical to the 10.9 product (native darwin paths: +# tailscaled in /usr/local/sbin, state under /Library/Tailscale). +# +# Usage: package_pkg_legacy106.sh --out PKG --version V --tailscaled BIN --tailscale BIN \ +# --systray-app APP.app --daemon-plist PLIST --systray-agent PLIST --dist DIR +# +# Wired into release.yml (the 10.6 packaging step) and usable standalone. To build a Snow Leopard pkg manually (CI does this automatically) after 'cmake --preset +# cross-legacy && cmake --build --preset cross-legacy': +# SHIPYARD_SCRIPTS=/scripts sh cmake/package_pkg_legacy106.sh \ +# --out tailscale-1.102.4-mavericks-legacy106.pkg --version 1.102.4 \ +# --tailscaled build-cross-legacy/gobin/tailscaled \ +# --tailscale build-cross-legacy/gobin/tailscale \ +# --systray-app \ +# --daemon-plist dist/com.tailscale.tailscaled.plist \ +# --systray-agent dist/com.tailscale.systray.plist --dist dist +set -eu +export COPYFILE_DISABLE=1 +OUT=""; VER=""; TSD=""; TS=""; SYSTRAY=""; DAEMON=""; AGENT=""; DIST=""; UPD_APP="" +SHIPYARD="${SHIPYARD_SCRIPTS:-}" +while [ $# -gt 0 ]; do + case "$1" in + --out) OUT="$2"; shift 2;; --version) VER="$2"; shift 2;; + --tailscaled) TSD="$2"; shift 2;; --tailscale) TS="$2"; shift 2;; + --systray-app) SYSTRAY="$2"; shift 2;; --updater-app) UPD_APP="$2"; shift 2;; + --daemon-plist) DAEMON="$2"; shift 2;; + --systray-agent) AGENT="$2"; shift 2;; --dist) DIST="$2"; shift 2;; + *) echo "package_pkg_legacy106: unknown arg: $1" >&2; exit 2;; + esac +done +[ -n "$OUT" ] && [ -n "$VER" ] && [ -n "$TSD" ] && [ -n "$TS" ] && [ -n "$SYSTRAY" ] \ + && [ -n "$DAEMON" ] && [ -n "$AGENT" ] && [ -n "$DIST" ] \ + || { echo "package_pkg_legacy106: need --out --version --tailscaled --tailscale --systray-app --daemon-plist --systray-agent --dist" >&2; exit 2; } +# Updater is optional (the no-updater path is supported for CI/debug builds) +UPD_APPDIR="/Library/Application Support/ModernMavericks" +if [ -n "${UPD_APP:-}" ]; then + [ -d "$UPD_APP" ] || { echo "package_pkg_legacy106: --updater-app must be an .app bundle dir: $UPD_APP" >&2; exit 1; } +fi +[ -n "$SHIPYARD" ] || { echo "package_pkg_legacy106: SHIPYARD_SCRIPTS not set" >&2; exit 2; } + +for h in set_install_floor.sh build_component_pkg.sh assert_pkg_installs_in_place.sh \ + postinstall-stop-gui.sh assert_gui_relaunch_safe.sh; do + [ -f "$SHIPYARD/$h" ] || { echo "package_pkg_legacy106: shared helper missing: $SHIPYARD/$h" >&2; exit 1; }; done +for f in "$TSD" "$TS" "$DAEMON" "$AGENT" "$DIST/scripts/preinstall" "$DIST/scripts/postinstall"; do + [ -f "$f" ] || { echo "package_pkg_legacy106: missing input (or not a regular file): $f" >&2; exit 1; }; done +[ -d "$SYSTRAY" ] || { echo "package_pkg_legacy106: --systray-app must be an .app bundle directory, got: $SYSTRAY" >&2; exit 1; } + +IDENT="dev.modernmavericks.tailscale" + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/tailscale-pkg106.XXXXXX") +trap 'rm -rf "$WORK"' EXIT +stage="$WORK/stage"; scripts="$WORK/scripts"; comp="$WORK/component.pkg" + +# --- product payload (identical layout to the 10.9 product) --- +mkdir -p "$stage/usr/local/sbin" "$stage/usr/local/bin" "$stage/Applications" \ + "$stage/Library/LaunchDaemons" "$stage/Library/LaunchAgents" +install -m 0755 "$TSD" "$stage/usr/local/sbin/tailscaled" +install -m 0755 "$TS" "$stage/usr/local/bin/tailscale" +cp -R "$SYSTRAY" "$stage/Applications/Mavericks Tailscale.app" +install -m 0644 "$DAEMON" "$stage/Library/LaunchDaemons/com.tailscale.tailscaled.plist" +install -m 0644 "$AGENT" "$stage/Library/LaunchAgents/com.tailscale.systray.plist" + +# Updater: our own 10.6-native updater (replaces the Sparkle-based one the +# 10.9 product ships -- Sparkle's binary declares min-10.9). +# The daily update-check agent mirrors the 10.9 product's schedule. +if [ -n "${UPD_APP:-}" ]; then + mkdir -p "$stage$UPD_APPDIR" "$stage/Library/LaunchAgents" + rm -rf "$stage$UPD_APPDIR/$(basename "$UPD_APP")" + cp -R "$UPD_APP" "$stage$UPD_APPDIR/" + sed -e "s#@MAVERICKS_AGENT_LABEL@#com.tailscale.updatecheck#g" \ + -e "s#@MAVERICKS_UPDATER_INSTALLED_EXEC@#$UPD_APPDIR/$(basename "$UPD_APP")/Contents/MacOS/TailscaleUpdater#g" \ + "$DIST/../updater/updatecheck.plist.in" > "$stage/Library/LaunchAgents/com.tailscale.updatecheck.plist" 2>/dev/null \ + || cat > "$stage/Library/LaunchAgents/com.tailscale.updatecheck.plist" < + + + + Labelcom.tailscale.updatecheck + ProgramArguments + + $UPD_APPDIR/$(basename "$UPD_APP")/Contents/MacOS/TailscaleUpdater + --background + + RunAtLoad + StartInterval86400 + + +UPDPLIST +fi + +# --- install scripts (no-updater build: agent-load.sh is simply absent) --- +# The staged preinstall additionally REMOVES a 10.9 product's updater leftovers +# (this build ships none): keeping them would leave a daily update-check agent +# relaunching an updater that upgrades the box back to a 10.9-floor build. +mkdir -p "$scripts" +# Compose preinstall: shebang + the original script (updater cleanup +# and agent loading belong in POSTINSTALL, after the payload is laid down). +{ + head -1 "$DIST/scripts/preinstall" + tail -n +2 "$DIST/scripts/preinstall" +} > "$scripts/preinstall" +chmod 0755 "$scripts/preinstall" + +# Compose postinstall: the original + updater agent load (if shipped) + +# old-updater cleanup (if this is a no-updater build replacing a 10.9 install). +{ + # Strip trailing 'exit 0' — appended blocks below must execute. + sed '/^exit 0$/d' "$DIST/scripts/postinstall" + if [ -n "${UPD_APP:-}" ]; then + cat <<'POSTAGENT' +# legacy106: load the daily update-check agent for the console user. +CONSOLE_UID=$(stat -f %u /dev/console 2>/dev/null || echo 0) +if [ "${CONSOLE_UID:-0}" -gt 0 ]; then + launchctl asuser "$CONSOLE_UID" launchctl load \ + /Library/LaunchAgents/com.tailscale.updatecheck.plist 2>/dev/null || true +fi +POSTAGENT + else + cat <<'POSTCLEAN' +# legacy106 (no-updater build): remove any 10.9-product updater leftovers +# so the old daily update agent cannot relaunch and downgrade this install. +CONSOLE_UID=$(stat -f %u /dev/console 2>/dev/null || echo 0) +if [ "${CONSOLE_UID:-0}" -gt 0 ]; then + launchctl asuser "$CONSOLE_UID" launchctl unload \ + /Library/LaunchAgents/com.tailscale.updatecheck.plist 2>/dev/null || true +fi +rm -f /Library/LaunchAgents/com.tailscale.updatecheck.plist +rm -rf "/Library/Application Support/ModernMavericks/TailscaleUpdater.app" +rm -rf "/Library/Application Support/ModernMavericks/TailscaleUpdater106.app" +POSTCLEAN + fi +} > "$scripts/postinstall" +chmod 0755 "$scripts/postinstall" +# NOTE: postinstall was already composed above (original + agent/cleanup) +install -m 0644 "$SHIPYARD/postinstall-stop-gui.sh" "$scripts/stop-gui.sh" + +sh "$SHIPYARD/assert_gui_relaunch_safe.sh" "$scripts/postinstall" >&2 +sh -n "$scripts/postinstall" || { echo "package_pkg_legacy106: postinstall syntax error" >&2; exit 1; } +sh -c '. "$1"; command -v mav_stop_gui_instance >/dev/null' _ "$scripts/stop-gui.sh" \ + || { echo "package_pkg_legacy106: staged stop-gui.sh broken" >&2; exit 1; } + +# --- flat component pkg, install-in-place --- +find "$stage" -name '._*' -delete 2>/dev/null || true +sh "$SHIPYARD/build_component_pkg.sh" --root "$stage" --identifier "$IDENT" --version "$VER" \ + --install-location / --scripts "$scripts" --out "$comp" >&2 + +# --- product archive with the hard 10.6 OS floor --- +sh "$SHIPYARD/set_install_floor.sh" \ + --identifier "$IDENT" --title "Tailscale (Snow Leopard floor) $VER" \ + --component "$comp" --out "$OUT" --require-scripts --min-os 10.6 >&2 + +sh "$SHIPYARD/assert_pkg_installs_in_place.sh" "$OUT" >&2 +echo "$OUT" diff --git a/cmake/tailscale_go.cmake b/cmake/tailscale_go.cmake index 39e2925..3a787cd 100644 --- a/cmake/tailscale_go.cmake +++ b/cmake/tailscale_go.cmake @@ -25,6 +25,39 @@ endfunction() mavericks_tailscale_read_pin("${CMAKE_SOURCE_DIR}/components/tailscale" TS_REPO TS_REF TS_DIGEST) set(TS_SRC "${MAVERICKS_TAILSCALE_SRC_CACHE}/tailscale-${TS_REF}") +# Deployment floor: 10.9 (default, stock gate) or 10.6 (Snow Leopard: GOAMD64=v1 + +# 10.6 symbol archive + min-10.6 CC wrapper + the legacy106 gate). Preset sets this. +set(MAVERICKS_TAILSCALE_FLOOR "10.9" CACHE STRING "macOS deployment floor: 10.9 or 10.6") +if(NOT MAVERICKS_TAILSCALE_FLOOR MATCHES "^10\\.(9|6)$") + message(FATAL_ERROR "MAVERICKS_TAILSCALE_FLOOR must be 10.9 or 10.6, got '${MAVERICKS_TAILSCALE_FLOOR}'") +endif() + +# The custom command's outputs are floor-independent paths, so re-pointing an existing build +# directory at the other floor would silently test/package the previous floor's binaries +# (same outputs, dependencies unchanged -> no rebuild). Fail closed instead: each build dir +# records its floor at first configure and refuses a different one. Presets use separate +# dirs (build-cross / build-cross-legacy) and never trip this. +set(_floor_marker "${CMAKE_BINARY_DIR}/.tailscale-floor") +if(EXISTS "${_floor_marker}") + file(READ "${_floor_marker}" _recorded) + string(STRIP "${_recorded}" _recorded) + if(NOT _recorded STREQUAL MAVERICKS_TAILSCALE_FLOOR) + message(FATAL_ERROR "this build dir was configured for floor ${_recorded}; " + "changing MAVERICKS_TAILSCALE_FLOOR to ${MAVERICKS_TAILSCALE_FLOOR} here would reuse stale " + "floor-${_recorded} binaries. Configure a fresh build dir (the presets do: build-cross vs " + "build-cross-legacy).") + endif() +else() + # First configure: reject a pre-marker dir that already has binaries (they + # are from a different floor and would be silently reused as stale outputs). + if(EXISTS "${CMAKE_BINARY_DIR}/gobin/tailscaled") + message(FATAL_ERROR "this build dir contains existing binaries but no floor " + "marker -- they may be from a different floor. Delete the build dir and " + "reconfigure (the presets use fresh dirs: build-cross vs build-cross-legacy).") + endif() + file(WRITE "${_floor_marker}" "${MAVERICKS_TAILSCALE_FLOOR}\n") +endif() + # 1. Clone the pinned source, verified against the commit DIGEST (shipyard's clone_pinned.sh bails # on a mismatch -- moved tag, MITM). Idempotent: no-ops on a cache hit. add_custom_command( @@ -34,6 +67,36 @@ add_custom_command( VERBATIM) # 2. Build the three binaries. Rebuilds when the script, our patches/overlays, or the pin change. +# +# Build-graph correctness: EVERYTHING build_tailscale.sh reads must be a dependency, and it reads +# all of patches/ and overlays/ (plus the legacy106 tooling). Two stale-binary bugs came from +# hand-maintaining this list (a new patch file; then legacy106.go), and a first glob attempt put +# the file() calls INSIDE add_custom_command where CMake silently swallows them as junk arguments +# (configure succeeds, nothing globs) -- so they run here, at their own statement level. +# LIST_DIRECTORIES false: GLOB_RECURSE includes directories by default (e.g. overlays/legacy106/), +# which make useless or broken DEPENDS entries (especially with Ninja). +# CONFIGURE_DEPENDS: a file()d glob is evaluated at generate time, so CMake re-runs generate on +# build when a matching file is added/removed -- the coverage actually tracks new patches. +file(GLOB_RECURSE MAVERICKS_TAILSCALE_PATCHES CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/patches/*") +file(GLOB_RECURSE MAVERICKS_TAILSCALE_OVERLAYS CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/overlays/*") +file(GLOB MAVERICKS_TAILSCALE_LEGACY106_TOOLS CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/cmake/legacy106/*") +# CONFIGURE_DEPENDS re-globs on build, but a SHRINKING dependency list does +# not necessarily dirty the output (nothing that still exists changed). A +# manifest stamp does: any addition OR removal changes its content, which +# dirties the stamp file, which is an explicit dependency below. +set(MAVERICKS_TAILSCALE_INPUT_MANIFEST "${CMAKE_BINARY_DIR}/.tailscale-input-manifest") +# Write only when content changes: an unconditional file(WRITE) bumps the +# mtime on every configure, dirtying all outputs even when nothing changed. +set(_new_manifest "${MAVERICKS_TAILSCALE_PATCHES};${MAVERICKS_TAILSCALE_OVERLAYS};${MAVERICKS_TAILSCALE_LEGACY106_TOOLS}") +if(EXISTS "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}") + file(READ "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}" _old_manifest) + if(NOT _old_manifest STREQUAL _new_manifest) + file(WRITE "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}" "${_new_manifest}") + endif() +else() + file(WRITE "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}" "${_new_manifest}") +endif() + set(TS_GOBIN "${CMAKE_BINARY_DIR}/gobin") set(TS_BINS "${TS_GOBIN}/tailscaled" "${TS_GOBIN}/tailscale" "${TS_GOBIN}/tailscale-systray") add_custom_command( @@ -41,23 +104,28 @@ add_custom_command( COMMAND sh "${CMAKE_SOURCE_DIR}/cmake/build_tailscale.sh" "${TS_SRC}" "${TS_GOBIN}" "${MAVERICKS_TAILSCALE_GO}" "${CMAKE_SOURCE_DIR}" "${MAVERICKS_TAILSCALE_VERSION}" + "${MAVERICKS_TAILSCALE_FLOOR}" DEPENDS "${TS_SRC}/.git/HEAD" + "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}" "${CMAKE_SOURCE_DIR}/cmake/build_tailscale.sh" - "${CMAKE_SOURCE_DIR}/patches/systray.go.patch" - "${CMAKE_SOURCE_DIR}/patches/tailscaled.go.patch" - "${CMAKE_SOURCE_DIR}/patches/hostinfo_darwin.go.patch" - "${CMAKE_SOURCE_DIR}/overlays/certstore_darwin.go.patch" - "${CMAKE_SOURCE_DIR}/overlays/systray_darwin.m" + ${MAVERICKS_TAILSCALE_PATCHES} + ${MAVERICKS_TAILSCALE_OVERLAYS} + ${MAVERICKS_TAILSCALE_LEGACY106_TOOLS} "${CMAKE_SOURCE_DIR}/components/tailscale/version" - COMMENT "cross-building tailscaled / tailscale / tailscale-systray for 10.9" + COMMENT "cross-building tailscaled / tailscale / tailscale-systray for ${MAVERICKS_TAILSCALE_FLOOR}" VERBATIM) add_custom_target(tailscale_binaries ALL DEPENDS ${TS_BINS}) -# 3. Compat gate per binary: x86_64 + min-10.9 + _clock_gettime defined + no post-10.9 imports. -# assert_binary_compatible.sh is the shipyard gate (honors MAVERICKS_REQUIRE_DEFINED_SYMBOLS); -# the same script container-tools uses for its Go binaries. +# 3. Compat gate per binary. 10.9: the shipyard gate (x86_64 + min-10.9 + _clock_gettime +# defined + no post-10.9 imports). 10.6: our legacy106 twin (min == 10.6, the 10.6 symbol +# set defined, and a POPCNT site-count tripwire for the GOAMD64=v1 baseline). foreach(_b tailscaled tailscale tailscale-systray) - add_test(NAME compat_guard_${_b} - COMMAND ${CMAKE_COMMAND} -E env MAVERICKS_REQUIRE_DEFINED_SYMBOLS=_clock_gettime - sh "${MavericksShipyard_SCRIPTS}/assert_binary_compatible.sh" "${TS_GOBIN}/${_b}") + if(MAVERICKS_TAILSCALE_FLOOR STREQUAL "10.6") + add_test(NAME legacy106_guard_${_b} + COMMAND sh "${CMAKE_SOURCE_DIR}/tests/assert_legacy106_compatible.sh" "${TS_GOBIN}/${_b}") + else() + add_test(NAME compat_guard_${_b} + COMMAND ${CMAKE_COMMAND} -E env MAVERICKS_REQUIRE_DEFINED_SYMBOLS=_clock_gettime + sh "${MavericksShipyard_SCRIPTS}/assert_binary_compatible.sh" "${TS_GOBIN}/${_b}") + endif() endforeach() diff --git a/overlays/legacy106/crt106.S b/overlays/legacy106/crt106.S new file mode 100644 index 0000000..291eb9c --- /dev/null +++ b/overlays/legacy106/crt106.S @@ -0,0 +1,30 @@ +/* + * crt106.S — minimal x86_64 C runtime startup for the 10.6 floor. + * + * Modern toolchains (min >= 10.8) emit LC_MAIN and embed startup in + * libclang_rt; 10.6's dyld instead requires a classic _start symbol, which + * Apple shipped as /usr/lib/crt1.10.6.o (dropped from every modern toolchain + * and absent from the phracker SDKs). ld searches for it by name, so we + * assemble this into /usr/lib/crt1.10.6.o at build time. Apple SDK + * bytes are never committed; neither is this needed to be theirs. + * + * Kernel hands _start the initial stack: [argc][argv...][NULL][envp...][NULL]. + * Go's external linker provides `main`; we align the stack per the ABI, call + * it, and exit with its status. No keymgr/GCC-eh setup: a Go+cgo binary on + * 10.6 uses none of it (lazy binding resolves through dyld's own helper). + */ + +.text +.globl _start +.globl start +_start: +start: + pop %rdi /* argc */ + mov %rsp, %rsi /* argv */ + lea 8(%rsi,%rdi,8), %rdx /* envp */ + and $-16, %rsp + call _main + mov %eax, %edi + call _exit + hlt +.subsections_via_symbols diff --git a/overlays/legacy106/legacy106.go b/overlays/legacy106/legacy106.go new file mode 100644 index 0000000..0af8d64 --- /dev/null +++ b/overlays/legacy106/legacy106.go @@ -0,0 +1,112 @@ +// Package legacy106 supplies the libSystem symbols a 10.6 (Snow Leopard) floor +// needs beyond what the mavericks-golang toolchain already covers. Go's runtime +// references these only through dynamic-bind entries, which an archive cannot +// satisfy — as cgo C code the implementations land in a relocatable object on +// the link line and win over the dynamic import. +// +// This file is copied into the tailscale source tree (wrksrc/legacy106) by +// build_tailscale.sh and blank-imported from THREE roots, one per +// binary: the peercred patch (ipn/ipnauth — covers tailscaled), the +// systray patch (client/systray — the systray's graph does not reach +// ipn/ipnauth), and the tailscaled patch (cmd/tailscale — the CLI's +// graph does not reach ipn/ipnauth either). All three are needed. The build tag keeps every definition out of non-10.6 +// floors: on 10.9 the Security constants below would otherwise statically +// satisfy certstore's references and shadow the REAL Security.framework +// values. build_tailscale.sh adds -tags=darwin_10_6 only for the 10.6 floor. + +//go:build darwin_10_6 + +package legacy106 + +/* +#cgo LDFLAGS: -framework CoreFoundation -lobjc + +#include +#include + +// arc4random_buf: 10.7+; real implementation via the ancient arc4random(). +extern uint32_t arc4random(void); +__attribute__((used)) void arc4random_buf(void *buf, unsigned long n) { + unsigned char *p = (unsigned char *)buf; + while (n > 0) { + uint32_t r = arc4random(); + unsigned long take = n < 4 ? n : 4; + for (unsigned long i = 0; i < take; i++) { *p++ = (unsigned char)(r & 0xff); r >>= 8; } + n -= take; + } +} + +// SecTransform-era Security constants (10.7+) referenced by certstore's C +// code as ordinary undefineds — cgo relocatables resolve them here. The +// transform-based sign path is MDM-only and dormant on a standard tailnet; +// the exact string values only matter if that path ever runs (10.6's +// Security matches dictionary keys by pointer anyway, and it has no +// SecTransform to hand them to). +const void *kSecDigestLengthAttribute = (const void *)CFSTR("DigestLength"); +const void *kSecDigestSHA2 = (const void *)CFSTR("SHA2"); +const void *kSecDigestTypeAttribute = (const void *)CFSTR("DigestType"); +const void *kSecInputIsAttributeName = (const void *)CFSTR("InputIs"); +const void *kSecInputIsDigest = (const void *)CFSTR("Digest"); +const void *kSecTransformInputAttributeName = (const void *)CFSTR("Input"); + +// ARC runtime entry points (10.7+) that modern clang emits even for +// -fno-objc-arc ObjC (bridging casts, strong-by-default compiler temp +// retention). 10.6's libobjc has objc_msgSend and the GC-era property +// helpers but none of these. Non-ARC semantics via message sends, NULL-safe +// per the ARC contract; the *ReturnValue variants only differ under real +// ARC's return-value optimization, which we cannot trigger from plain C. +typedef void *objc_id; +typedef void *objc_sel; +extern objc_id objc_msgSend(objc_id, objc_sel); +extern objc_sel sel_registerName(const char *); +static objc_id l106_send(objc_id o, const char *n) { + return ((objc_id (*)(objc_id, objc_sel))objc_msgSend)(o, sel_registerName(n)); +} +__attribute__((used)) void objc_release(objc_id obj) { if (obj) l106_send(obj, "release"); } +__attribute__((used)) objc_id objc_retain(objc_id obj) { return obj ? l106_send(obj, "retain") : obj; } +__attribute__((used)) objc_id objc_autorelease(objc_id obj) { return obj ? l106_send(obj, "autorelease") : obj; } +__attribute__((used)) objc_id objc_retainAutoreleasedReturnValue(objc_id obj) { return objc_retain(obj); } +__attribute__((used)) objc_id objc_autoreleaseReturnValue(objc_id obj) { return objc_autorelease(obj); } +__attribute__((used)) void objc_storeStrong(objc_id *loc, objc_id obj) { + objc_id prev = *loc; + if (obj) objc_retain(obj); + *loc = obj; + if (prev) objc_release(prev); +} + +// l106_used is a volatile pointer table referencing every function and +// l106_root writes the address of every shim function and global into +// a volatile pointer (each store is a spec-guaranteed observable side +// effect the compiler cannot eliminate, and each address reference +// prevents linker dead-code elimination of the target section). +// __attribute__((used)) on each function additionally prevents +// compiler-level elimination. A static-initializer array was tried and +// failed: CFSTR globals are not compile-time constants. +static void __attribute__((used)) l106_root(void) { + void * volatile sink; + sink = (void *)arc4random_buf; + sink = (void *)objc_release; + sink = (void *)objc_retain; + sink = (void *)objc_autorelease; + sink = (void *)objc_retainAutoreleasedReturnValue; + sink = (void *)objc_autoreleaseReturnValue; + sink = (void *)objc_storeStrong; + sink = (void *)kSecDigestLengthAttribute; + sink = (void *)kSecDigestSHA2; + sink = (void *)kSecDigestTypeAttribute; + sink = (void *)kSecInputIsAttributeName; + sink = (void *)kSecInputIsDigest; + sink = (void *)kSecTransformInputAttributeName; + (void)sink; +} +*/ +import "C" + +// init roots every C definition against dead-code elimination: the linker +// strips unreferenced subsections, and nothing else in the binary +// references these (that is the whole point of the package). init() of an +// imported package always survives; the volatile stores in l106_root +// create an unoptimizable reference chain to every shim symbol. +func init() { + C.l106_root() +} diff --git a/overlays/legacy106/legacy106_off.go b/overlays/legacy106/legacy106_off.go new file mode 100644 index 0000000..3c01827 --- /dev/null +++ b/overlays/legacy106/legacy106_off.go @@ -0,0 +1,7 @@ +//go:build !darwin_10_6 + +// Placeholder for non-10.6 floors: every real definition in this package is +// darwin_10_6-tagged (see legacy106.go for why), but a package with zero +// buildable files is an import error -- so the blank imports in the patched +// upstream files need this file to exist on every floor. +package legacy106 diff --git a/overlays/legacy106/stubs.c b/overlays/legacy106/stubs.c new file mode 100644 index 0000000..11e2e44 --- /dev/null +++ b/overlays/legacy106/stubs.c @@ -0,0 +1,50 @@ +/* + * legacy106/stubs.c — libSystem symbols present on 10.9 but missing on + * 10.6.8, linked in addition to the toolchain's MacPorts legacy-support + * archive (which covers the post-10.9 set: clock_gettime & friends). + * + * arc4random_buf is NOT defined here: it lives in the legacy106 Go package + * (overlays/legacy106/legacy106.go). Archive members are pulled whole, so a + * pull for any one of these symbols would also drag in a duplicate + * arc4random_buf and fail the link. + * + * pthread_main_thread_np not exported from 10.6 libSystem — real + * implementation: a constructor runs on the main + * thread before main(), so cache pthread_self() + * strnlen, dirfd POSIX, but absent from 10.6 libSystem + * (the $UNIX2003 era) + * + * (xpc_date_create_from_current / notify_is_valid_token are also NOT stubbed + * here: the mavericks-golang runtime never calls them — the CC wrapper's + * -Wl,-U allowances are exactly for those dormant imports.) + * + * Each function is correct on every macOS version, so defining them + * unconditionally is safe. + */ + +#include +static pthread_t legacy106_main_thread; +__attribute__((constructor)) static void legacy106_cache_main_thread(void) { + legacy106_main_thread = pthread_self(); +} +pthread_t pthread_main_thread_np(void) { + return legacy106_main_thread; +} + +#include +#include +#include +#undef strnlen +#undef dirfd +size_t strnlen(const char *s, size_t maxlen) { + const char *p = s; + while (maxlen-- > 0 && *p) p++; + return (size_t)(p - s); +} +int dirfd(DIR *dirp) { + if (dirp == NULL) { + errno = EBADF; + return -1; + } + return dirp->__dd_fd; +} diff --git a/patches/peercred-legacy-kernels.patch b/patches/peercred-legacy-kernels.patch new file mode 100644 index 0000000..a609d27 --- /dev/null +++ b/patches/peercred-legacy-kernels.patch @@ -0,0 +1,67 @@ +Mac OS X 10.6 (Snow Leopard): the kernel does not implement the +LOCAL_PEERPID socket option (getsockopt fails with EOPNOTSUPP, errno 102 -- +darwin keeps it distinct from ENOTSUP=45), so localapi's peercred.Get fails +on every CLI connection and tailscaled answers 401 to everything. For exactly +EOPNOTSUPP/ENOTSUP on darwin, treat the failure as "not implemented" +(creds == nil) instead of rejecting the connection; any other peercred error +still propagates. IsReadonlyConn grants read-write when creds are nil on +darwin -- the pre-peercred behavior, the socket's permissions remaining the +only local gate. macOS versions that implement peer credentials never hit +either path. + +Also blank-imports tailscale.com/legacy106 (the 10.6 libSystem/Security +shims) from this file. The package's contents are darwin_10_6-tagged, so on +other floors the import compiles to nothing. + +--- ipn/ipnauth/ipnauth.go.orig ++++ ipn/ipnauth/ipnauth.go +@@ -176,6 +176,14 @@ + } + creds := ci.creds + if creds == nil { ++ if runtime.GOOS == "darwin" { ++ // macOS 10.6/10.7 kernels cannot report unix-socket peer ++ // credentials (LOCAL_PEERCRED/LOCAL_PEERPID unimplemented). ++ // Grant read-write as in the pre-peercred era; the socket's ++ // permissions remain the only gate. ++ logf("connection from unknown peer; darwin legacy kernel, read-write") ++ return rw ++ } + logf("connection from unknown peer; read-only") + return ro + } +--- ipn/ipnauth/ipnauth_unix_creds.go.orig ++++ ipn/ipnauth/ipnauth_unix_creds.go +@@ -6,8 +6,15 @@ + package ipnauth + + import ( ++ "errors" + "net" ++ "runtime" ++ "syscall" + ++ // Blank-import the 10.6 symbol shims on darwin (inert on newer floors: ++ // the implementations are correct everywhere, just unreferenced). ++ _ "tailscale.com/legacy106" ++ + "github.com/tailscale/peercred" + "tailscale.com/types/logger" + ) +@@ -24,6 +31,16 @@ + } else if err == peercred.ErrNotImplemented { + // peercred.Get is not implemented on this OS (such as OpenBSD) + // Just leave creds as nil, as documented. ++ } else if runtime.GOOS == "darwin" && ++ (errors.Is(err, syscall.EOPNOTSUPP) || errors.Is(err, syscall.ENOTSUP)) { ++ // macOS 10.6 kernels do not implement LOCAL_PEERPID: getsockopt ++ // then fails with EOPNOTSUPP (errno 102 on darwin, distinct from ++ // ENOTSUP's 45) on every connection -- probed on real 10.6.8 ++ // hardware. Treat exactly that errno pair as "unimplemented" rather ++ // than rejecting the connection (IsReadonlyConn handles nil creds). ++ // Any OTHER failure on a modern Mac still propagates -- nil-creds ++ // read-write is a legacy-kernel accommodation, not a blanket trust ++ // fallback. + } else { + return nil, err + } diff --git a/patches/systray-psn-classic-macos.patch b/patches/systray-psn-classic-macos.patch new file mode 100644 index 0000000..540a8c0 --- /dev/null +++ b/patches/systray-psn-classic-macos.patch @@ -0,0 +1,36 @@ +Classic macOS (10.8 and earlier): LaunchServices launches apps with a Carbon-era +-psn__ ProcessSerialNumber argument; flag.Parse treats it as an unknown +flag and exits, so Finder launches died instantly (running the binary directly +worked). Strip -psn_* arguments before parsing. + +--- cmd/systray/systray.go.orig ++++ cmd/systray/systray.go +@@ -8,6 +8,8 @@ + + import ( + "flag" ++ "os" ++ "strings" + + "tailscale.com/client/local" + "tailscale.com/client/systray" +@@ -18,6 +20,19 @@ + var theme = flag.String("theme", "dark", "color theme for Tailscale icon: dark, dark:nobg, light, light:nobg") + + func main() { ++ // Classic macOS (10.8 and earlier) LaunchServices launches apps with a ++ // Carbon-era -psn__ ProcessSerialNumber argument. flag.Parse ++ // treats it as an unknown flag, prints usage, and exits -- so a Finder ++ // launch flashed and died while running the binary directly worked. ++ // Strip it (and any sibling -psn_ arguments) before parsing. ++ args := os.Args[:1] ++ for _, a := range os.Args[1:] { ++ if strings.HasPrefix(a, "-psn_") { ++ continue ++ } ++ args = append(args, a) ++ } ++ os.Args = args + flag.Parse() + lc := &local.Client{Socket: *socket} + systray.SetTheme(*theme) diff --git a/patches/systray.go.patch b/patches/systray.go.patch index 2291d90..183bd13 100644 --- a/patches/systray.go.patch +++ b/patches/systray.go.patch @@ -4,17 +4,30 @@ systray "Connect" action start interactive login on a logged-out node so the log URL opens in the system browser (as "tailscale up" would); and add a macOS "Check for updates" item that hands off to the bundled ModernMavericks Sparkle updater. +Also blank-imports tailscale.com/legacy106 (the 10.6 floor shims) here, because +the systray binary's import graph does not reach ipn/ipnauth where the peercred +patch roots it. + +And adds a "Peers" section (online-first, capped at 15; click copies the peer's +Tailscale IP) -- the upstream menu has no peer list, which is unhelpful on a +machine whose whole point is reaching the tailnet. + --- client/systray/systray.go.orig +++ client/systray/systray.go -@@ -16,6 +16,7 @@ +@@ -16,6 +16,12 @@ "log" "net/http" "os" + "os/exec" ++ "sort" ++ ++ // 10.6 floor: the libSystem/Security shims (reachability: the systray ++ // binary does not import ipn/ipnauth, where the peercred patch roots it). ++ _ "tailscale.com/legacy106" "os/signal" "runtime" "slices" -@@ -62,7 +63,13 @@ +@@ -62,7 +68,13 @@ signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) select { case <-interrupt: @@ -29,7 +42,7 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater. case <-menu.bgCtx.Done(): } }() -@@ -72,7 +79,11 @@ +@@ -72,7 +84,11 @@ // set initial title, which is used by the systray package as the ID of the StatusNotifierItem. // This value will get overwritten later as the client status changes. // This must be called before systray.Run. @@ -42,7 +55,7 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater. systray.Run(menu.onReady, menu.onExit) } -@@ -95,13 +106,14 @@ +@@ -95,13 +111,14 @@ bgCancel context.CancelFunc // Top-level menu items @@ -64,10 +77,60 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater. rebuildCh chan struct{} // triggers a menu rebuild accountsCh chan ipn.ProfileID -@@ -347,6 +359,26 @@ +@@ -321,6 +338,48 @@ + menu.self.Disable() + } + systray.AddSeparator() ++ ++ // Peers: the tailnet's other devices, online-first, capped for menu ++ // sanity. Clicking a peer copies its Tailscale IP (same affordance as ++ // the per-device copy action elsewhere in the menu). ++ if menu.status != nil && len(menu.status.Peer) > 0 { ++ peers := systray.AddMenuItem("Peers", "") ++ type peerEntry struct { ++ name string ++ ps *ipnstate.PeerStatus ++ } ++ var entries []peerEntry ++ for _, ps := range menu.status.Peer { ++ entries = append(entries, peerEntry{ ++ name: strings.Split(ps.HostName, ".")[0], ps: ps}) ++ } ++ sort.Slice(entries, func(i, j int) bool { ++ if entries[i].ps.Online != entries[j].ps.Online { ++ return entries[i].ps.Online ++ } ++ return entries[i].name < entries[j].name ++ }) ++ if len(entries) > 15 { ++ entries = entries[:15] ++ } ++ for _, e := range entries { ++ title := e.name ++ if len(e.ps.TailscaleIPs) > 0 { ++ title = fmt.Sprintf("%s (%s)", e.name, e.ps.TailscaleIPs[0]) ++ } ++ if !e.ps.Online { ++ title += " — offline" ++ } ++ item := peers.AddSubMenuItem(title, "Click to copy this peer's Tailscale IP") ++ onClick(ctx, item, func(ctx context.Context) { ++ select { ++ case <-ctx.Done(): ++ default: ++ menu.copyTailscaleIP(e.ps) ++ } ++ }) ++ } ++ } + + if !menu.readonly { + menu.rebuildExitNodeMenu(ctx) +@@ -346,6 +405,40 @@ + } }) menu.rebuildMenu.Enable() - ++ + // On macOS (the ModernMavericks packaging) offer a manual update check that + // hands off to the bundled Sparkle updater. Launching its executable with + // --user runs Sparkle's interactive check (update dialog, or "you're up to @@ -75,9 +138,22 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater. + // --background. Reap the child so a long-lived menu bar app doesn't leave + // zombies across repeated checks. + if runtime.GOOS == "darwin" { ++ // Only show the item when the bundled updater binary actually exists ++ // (the legacy106 floor pkg deliberately omits the Sparkle updater). ++ // Probe both bundle names (the 10.9 Sparkle updater and the 10.6 ObjC updater) ++ updaterPath := "" ++ // Probe the 10.6 updater FIRST: if both are installed (a 10.6 pkg ++ // replaced a 10.9 install), the 10.9 Sparkle binary is incompatible. ++ for _, p := range []string{ ++ "/Library/Application Support/ModernMavericks/TailscaleUpdater106.app/Contents/MacOS/TailscaleUpdater", ++ "/Library/Application Support/ModernMavericks/TailscaleUpdater.app/Contents/MacOS/TailscaleUpdater", ++ } { ++ if _, err := os.Stat(p); err == nil { updaterPath = p; break } ++ } ++ if updaterPath != "" { + menu.checkUpdates = systray.AddMenuItem("Check for updates", "") + onClick(ctx, menu.checkUpdates, func(_ context.Context) { -+ const updater = "/Library/Application Support/ModernMavericks/TailscaleUpdater.app/Contents/MacOS/TailscaleUpdater" ++ updater := updaterPath + cmd := exec.Command(updater, "--user") + if err := cmd.Start(); err != nil { + log.Printf("error launching updater: %v", err) @@ -86,15 +162,16 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater. + go cmd.Wait() + }) + menu.checkUpdates.Enable() ++ } + } -+ + menu.quit = systray.AddMenuItem("Quit", "Quit the app") menu.quit.Enable() - -@@ -452,6 +484,17 @@ +@@ -451,6 +544,17 @@ + }) if err != nil { log.Printf("error connecting: %v", err) - } ++ } + // Setting WantRunning alone won't authenticate a logged-out node, so + // (like "tailscale up") kick off interactive login when the backend + // needs it. That makes tailscaled emit a BrowseToURL, which @@ -105,7 +182,6 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater. + if err := menu.lc.StartLoginInteractive(ctx); err != nil { + log.Printf("error starting interactive login: %v", err) + } -+ } + } case <-menu.disconnect.ClickedCh: - _, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{ diff --git a/patches/tailscaled.go.patch b/patches/tailscaled.go.patch index 2c60fd6..1d1d2d2 100644 --- a/patches/tailscaled.go.patch +++ b/patches/tailscaled.go.patch @@ -1,13 +1,29 @@ -Report ModernMavericks as the packaging type via Hostinfo.Package. +Report ModernMavericks as the packaging type via Hostinfo.Package, and root +the 10.6 floor's shim package into the CLI (whose import graph never reaches +ipn/ipnauth, where the peercred patch roots it for the daemon (the systray has its own blank import via patches/systray.go.patch)). --- cmd/tailscaled/tailscaled.go.orig +++ cmd/tailscaled/tailscaled.go -@@ -495,6 +495,8 @@ func run() (err error) { +@@ -539,6 +539,8 @@ + if app := envknob.App(); app != "" { hostinfo.SetApp(app) } - -+ hostinfo.SetPackage("ModernMavericks") + ++ hostinfo.SetPackage("ModernMavericks") + return startIPNServer(context.Background(), logf, publicLogID, sys) } +--- cmd/tailscale/tailscale.go.orig ++++ cmd/tailscale/tailscale.go +@@ -12,6 +12,11 @@ + "strings" + + "tailscale.com/cmd/tailscale/cli" ++ ++ // The CLI's import graph never reaches ipn/ipnauth (that is daemon ++ // territory), so the 10.6 floor's shim package is rooted here instead. ++ // darwin_10_6-tagged: compiles to nothing on other floors. ++ _ "tailscale.com/legacy106" + ) + func main() { diff --git a/tailscale-1.102.4-mavericks-legacy106.pkg b/tailscale-1.102.4-mavericks-legacy106.pkg new file mode 100644 index 0000000..06449fc Binary files /dev/null and b/tailscale-1.102.4-mavericks-legacy106.pkg differ diff --git a/tests/assert_legacy106_compatible.sh b/tests/assert_legacy106_compatible.sh new file mode 100755 index 0000000..3f2aa32 --- /dev/null +++ b/tests/assert_legacy106_compatible.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# usage: assert_legacy106_compatible.sh ... +# Snow Leopard (10.6) floor guard, run INSTEAD of shipyard's 10.9 gate +# for a -legacy106 build. Per binary: +# (1) arch exactly x86_64 +# (2) LC_VERSION_MIN_MACOSX == 10.6 +# (3) the 10.6 shims are DEFINED (not left as dynamic imports): +# _arc4random_buf (the cgo package) + the toolchain's +# _clock_gettime; _strnlen/_dirfd/_pthread_main_thread_np are +# not referenced by this toolchain's runtime and may be absent. +# (4) GOAMD64=v1 opcode audit: Core 2 has no POPCNT (SIGILL). +# Under v1, Go 1.26 inline-multi-versions math/bits PER FUNCTION: +# one runtime.x86HasPOPCNT check at the function entry guards +# every popcnt in that function's body with a software fallback. +# The audit verifies that every non-runtime function containing +# popcnt also contains the x86HasPOPCNT reference somewhere in +# its body (matching the compiler's emission model). Runtime- +# internal sites (countbody, pageBits, scanObjects*) are allowed +# as a bounded set: that exact code is hardware-proven on a +# Core 2 P8700 (a full tailscaled ran joined for hours). +# Fail-closed if nothing measured. +# +# platform: the stock 10.9 gate asserts LC_VERSION_MIN == 10.9 exactly, so it +# would (correctly!) reject a 10.6-floor binary; this is its 10.6 twin. +set -eu + +die() { echo "legacy106 guard CANNOT MEASURE (fail-closed): $*" >&2; exit 4; } + +RUNTIME_ALLOWANCE=20 + +fail=0; checked=0 +# One temp dir for every binary's disassembly, removed once at exit: a +# per-iteration trap would be overwritten each pass and leak all but the +# last file. +DISDIR=$(mktemp -d "${TMPDIR:-/tmp}/l106dis.XXXXXX") +trap 'rm -rf "$DISDIR"' EXIT +for b in "$@"; do + [ -f "$b" ] || { echo "legacy106 guard: MISSING $b" >&2; fail=1; continue; } + checked=$((checked+1)) + + # exactly x86_64: extract the arch list from -info output (portable to + # exactly x86_64 — not just "x86_64 present". lipo -info formats: + # thin: "Non-fat file: X is architecture: x86_64" → 1 arch token + # fat: "Architectures in the fat file: X are: i386 x86_64" → 2 tokens + # Taking only the LAST field ($NF) would pass a fat binary that happens + # to list x86_64 last. Instead: reject any output containing "are:" + # (fat-file marker) and require the thin-file last field to be x86_64. + raw=$(lipo -info "$b" 2>/dev/null) || raw="" + [ -n "$raw" ] || die "lipo produced no output for $b" + case "$raw" in + *"are:"*) + echo "$b: multi-arch binary (not exactly x86_64): $raw" >&2 + fail=1 + ;; + *) + archs=$(printf '%s\n' "$raw" | awk '{print $NF}') + [ "$archs" = "x86_64" ] || { echo "$b: not exactly x86_64: $archs" >&2; fail=1; } + ;; + esac + + vmin=$(otool -l "$b" 2>/dev/null | awk '/LC_VERSION_MIN_MACOSX/{f=1} f && /version /{print $2; exit}') + [ -n "$vmin" ] || die "no LC_VERSION_MIN_MACOSX in $b" + [ "$vmin" = "10.6" ] || { echo "$b: min-version $vmin != 10.6" >&2; fail=1; } + + # required shims: _clock_gettime must be defined (toolchain shim). The cgo + # legacy106 symbols (arc4random_buf ...) are only present when this + # toolchain actually imports them — an undefined IMPORT is the failure; + # total absence means the patched runtime never references them. + if ! /usr/bin/nm "$b" 2>/dev/null | grep -q "[Tt] _clock_gettime\$"; then + echo "$b: _clock_gettime not defined (toolchain shim missing)" >&2; fail=1 + fi + # Fail closed: if nm cannot read the binary, the guard must not pass. + # A piped nm | grep would return grep's exit status, treating a failed + # nm the same as "no undefined imports found". + if ! nmout=$(/usr/bin/nm -m "$b" 2>&1); then + echo "$b: nm cannot read the binary -- cannot audit imports" >&2; fail=1; continue + fi + for s in _arc4random_buf _pthread_main_thread_np _strnlen _dirfd; do + if printf '%s\n' "$nmout" | grep -F '(undefined)' | sed -E 's/ \([^)]*\)$//' \ + | awk '{print $NF}' | grep -qx "$s"; then + echo "$b: $s left as an undefined import (shim not linked)" >&2; fail=1 + fi + done + + # opcode audit. Go 1.26 inline-multi-versions math/bits per FUNCTION: one + # runtime.x86HasPOPCNT check guards every popcnt the function contains, with + # a software fallback. A non-runtime function containing popcnt must show + # the guard somewhere in its body; runtime-internal sites (countbody, + # pageBits, scanObjects*, ...) are a bounded, hardware-proven set. + # Fail-closed: disassemble to a file first and verify otool's status -- + # a piped `otool | awk` only surfaces awk's exit, so a failed disassembly + # would audit an empty stream and pass. + dis="$DISDIR/$(basename "$b").dis" + if ! otool -tvV "$b" > "$dis" 2>/dev/null || [ ! -s "$dis" ]; then + echo "$b: disassembly failed -- cannot audit opcodes" >&2; fail=1; continue + fi + awk -v allow=$RUNTIME_ALLOWANCE -v bin="$b" ' + function flushsym() { + if (sym != "" && nsym_popcnt > 0 && sym !~ /^_runtime\./ && sym !~ /^_countbody/ \ + && sym !~ /^_internal\// && sym !~ /^_go:/) { + if (sym_guarded == 0) { + printf "%s: UNGUARDED popcnt (no x86HasPOPCNT in body): %s\n", bin, sym + bad = 1 + } + } + if (sym ~ /^_runtime\./ || sym ~ /^_countbody/ || sym ~ /^_internal\//) rt += nsym_popcnt + } + /^([0-9a-f]+[[:space:]]+)?[a-zA-Z_][a-zA-Z0-9_.\/()*]+:$/ { flushsym(); sym = $0; sub(/^[0-9a-f]+[[:space:]]+/, "", sym); nsym_popcnt = 0; sym_guarded = 0 } + /x86HasPOPCNT/ { sym_guarded = 1 } + /popcntl|popcntq/ { nsym_popcnt++ } + END { flushsym(); + if (rt > allow) { printf "%s: %d runtime popcnt sites > allowance %d\n", bin, rt, allow; bad = 1 } + if (bad) exit 1 }' "$dis" || fail=1 +done +[ "$checked" -gt 0 ] || die "no binaries measured" +if [ "$fail" -eq 0 ]; then echo "legacy106 guard: OK ($checked binary(ies))"; fi +exit "$fail" diff --git a/updater/updater106.m b/updater/updater106.m new file mode 100644 index 0000000..1bf6588 --- /dev/null +++ b/updater/updater106.m @@ -0,0 +1,379 @@ +// TailscaleUpdater for the 10.6 floor — a minimal ObjC updater that replaces +// the Sparkle-based updater the 10.9 product ships (Sparkle 1.27.3's binary +// declares LC_VERSION_MIN_MACOSX 10.9; dyld on 10.6 refuses to load it). +// +// Same CLI contract as the Sparkle updater: +// (no args) silent background check (the daily LaunchAgent calls this) +// --user foreground check: show a dialog on update, install on OK +// +// All APIs are 10.6-safe: NSXMLParser, NSTask, NSAlert, NSData. +// No blocks, no ARC, no GCD, no NSUserNotification, no external framework. + +#import +#import +#import + +static BOOL userInitiatedFlag = NO; +static BOOL alreadyAlerted = NO; // prevents double dialogs (TLS then generic) +#ifndef UPDATER_FEED_URL +#define UPDATER_FEED_URL "https://github.com/startergo/tailscale-legacy/releases/latest/download/appcast-10.6.xml" +#endif +static NSString *const kFeedURL = @UPDATER_FEED_URL; + +// cleanupAndExit removes the temp directory and exits. Called at EVERY exit +// point after tmpDir exists, so daily background checks never accumulate. +static void cleanupAndExit(NSString *tmpDir, int code) { + [[NSFileManager defaultManager] removeItemAtPath:tmpDir error:nil]; + exit(code); +} + + +#pragma mark - Version helpers + +static NSString *installedVersion(void) { + // Check pkgutil exists before launching (NSTask throws if missing). + if (![[NSFileManager defaultManager] isExecutableFileAtPath:@"/usr/sbin/pkgutil"]) { + NSLog(@"updater: /usr/sbin/pkgutil not found"); + return @""; + } + NSTask *t = [[[NSTask alloc] init] autorelease]; + [t setLaunchPath:@"/usr/sbin/pkgutil"]; + [t setArguments:[NSArray arrayWithObjects:@"--pkg-info", @"dev.modernmavericks.tailscale", nil]]; + NSPipe *p = [NSPipe pipe]; + [t setStandardOutput:p]; + [t launch]; + NSData *d = [[[p fileHandleForReading] readDataToEndOfFile] retain]; + [t waitUntilExit]; + NSString *out = [[[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] autorelease]; + [d release]; + for (NSString *line in [out componentsSeparatedByString:@"\n"]) + if ([line hasPrefix:@"version: "]) + return [line substringFromIndex:9]; + return @""; +} + +static int compareVersions(NSString *a, NSString *b) { + NSArray *ap = [a componentsSeparatedByString:@"."]; + NSArray *bp = [b componentsSeparatedByString:@"."]; + NSUInteger n = MAX([ap count], [bp count]); + for (NSUInteger i = 0; i < n; i++) { + NSInteger ai = i < [ap count] ? [[ap objectAtIndex:i] integerValue] : 0; + NSInteger bi = i < [bp count] ? [[bp objectAtIndex:i] integerValue] : 0; + if (ai < bi) return -1; + if (ai > bi) return 1; + } + return 0; +} + +#pragma mark - Binary-safe fetch (no UTF-8 transcoding) + +// Downloads to a FILE, preserving binary bytes exactly. The HTTP status +// is written via curl's -w to stdout, never mixed with the body. +static BOOL fetchToFile(NSString *urlString, NSString *destPath) { + NSString *curlPath = @"/opt/local/bin/curl"; + if (![[NSFileManager defaultManager] isExecutableFileAtPath:curlPath]) { + // Stock 10.6 curl (OpenSSL 0.9.8) cannot negotiate TLS 1.2, which + // GitHub requires. This is a hard dependency for HTTPS update checks; + // fail with a clear message rather than a cryptic TLS error. + if ([urlString hasPrefix:@"https://"]) { + NSLog(@"updater: FATAL: MacPorts curl not found at /opt/local/bin/curl."); + NSLog(@"updater: 10.6's system curl lacks TLS 1.2; GitHub HTTPS requires it."); + NSLog(@"updater: Install MacPorts curl: sudo port install curl"); + if (userInitiatedFlag) + NSRunAlertPanel(@"Mavericks Tailscale", + @"Cannot check for updates: MacPorts curl is required " + @"for HTTPS on 10.6 (system curl lacks TLS 1.2).\n\n" + @"Install it with: sudo port install curl", + @"OK", nil, nil); + alreadyAlerted = YES; + return NO; + } + // HTTPS only: an auto-updater accepting plain HTTP is a MITM / + // downgrade vector. No fallback. + curlPath = nil; + } + if (!curlPath) return NO; // unreachable for HTTPS (handled above) + NSTask *t = [[[NSTask alloc] init] autorelease]; + [t setLaunchPath:curlPath]; + [t setArguments:[NSArray arrayWithObjects: + @"-sL", @"--max-time", @"60", + @"-o", destPath, + @"-w", @"%{http_code}", + urlString, nil]]; + NSPipe *p = [NSPipe pipe]; + [t setStandardOutput:p]; + [t setStandardError:[NSPipe pipe]]; + [t launch]; + NSData *statusData = [[[p fileHandleForReading] readDataToEndOfFile] retain]; + [t waitUntilExit]; + NSString *status = [[[NSString alloc] initWithData:statusData + encoding:NSASCIIStringEncoding] autorelease]; + [statusData release]; + if ([t terminationStatus] != 0) { + NSLog(@"updater: curl exited %ld", [t terminationStatus]); + return NO; + } + if (![status isEqualToString:@"200"]) { + NSLog(@"updater: HTTP %@ for %@", status, urlString); + return NO; + } + NSDictionary *attrs = [[NSFileManager defaultManager] fileAttributesAtPath:destPath + traverseLink:NO]; + if (!attrs || [[attrs objectForKey:NSFileSize] unsignedLongValue] == 0) { + NSLog(@"updater: empty or missing download at %@", destPath); + return NO; + } + return YES; +} + +#pragma mark - Appcast parser (selects the 10.6-compatible item) + +@interface AppcastParser : NSObject { + NSMutableArray *items; + NSMutableDictionary *curItem; + NSString *textBuf; + BOOL inItem; + BOOL inVersionElem; + BOOL inMinSysElem; + NSString *curEnclosureURL; +} +@property (readonly) NSMutableArray *items; +@end + +@implementation AppcastParser +- (id)init { + self = [super init]; + if (self) items = [[NSMutableArray alloc] init]; + return self; +} +- (void)parser:(NSXMLParser *)p didStartElement:(NSString *)name + namespaceURI:(NSString *)ns qualifiedName:(NSString *)q attributes:(NSDictionary *)a { + textBuf = @""; + if ([name isEqualToString:@"item"]) { + inItem = YES; + curItem = [NSMutableDictionary dictionary]; + curEnclosureURL = nil; + } else if ([name isEqualToString:@"enclosure"] && inItem) { + curEnclosureURL = [[a objectForKey:@"url"] retain]; + } else if (inItem && ([name isEqualToString:@"sparkle:version"] || + [name isEqualToString:@"version"])) { + inVersionElem = YES; + } else if (inItem && [name isEqualToString:@"sparkle:minimumSystemVersion"]) { + inMinSysElem = YES; + } +} +- (void)parser:(NSXMLParser *)p foundCharacters:(NSString *)s { + textBuf = [textBuf stringByAppendingString:s]; +} +- (void)parser:(NSXMLParser *)p didEndElement:(NSString *)name + namespaceURI:(NSString *)ns qualifiedName:(NSString *)q { + if ([name isEqualToString:@"item"]) { + inItem = NO; + if (curEnclosureURL) + [curItem setObject:curEnclosureURL forKey:@"url"]; + if ([curItem count] > 0) + [items addObject:curItem]; + [curEnclosureURL release]; curEnclosureURL = nil; + curItem = nil; + } else if (inVersionElem && ([name isEqualToString:@"sparkle:version"] || + [name isEqualToString:@"version"])) { + inVersionElem = NO; + [curItem setObject:textBuf forKey:@"version"]; + } else if (inMinSysElem && [name isEqualToString:@"sparkle:minimumSystemVersion"]) { + inMinSysElem = NO; + [curItem setObject:textBuf forKey:@"minSys"]; + } + textBuf = @""; +} +- (NSMutableArray *)items { return items; } +- (void)dealloc { + [items release]; + [curEnclosureURL release]; + [super dealloc]; +} +@end + +// Selects the LAST item whose minSysVersion is <= 10.6 (or absent). +// The release workflow appends the 10.6 enclosure after the 10.9 one, +// so the LAST compatible item is the 10.6-specific entry. +static NSDictionary *selectCompatibleItem(AppcastParser *parser) { + NSDictionary *best = nil; + NSString *bestVersion = nil; + for (NSDictionary *item in parser.items) { + NSString *minSys = [item objectForKey:@"minSys"]; + if (minSys && compareVersions(minSys, @"10.6") > 0) continue; // incompatible + NSString *v = [item objectForKey:@"version"]; + NSString *u = [item objectForKey:@"url"]; + if (!v || !u) continue; + // Pick the HIGHEST version among compatible items (not just the + // last — ordering is a feed convention, not a guarantee). + if (!bestVersion || compareVersions(v, bestVersion) > 0) { + best = item; + bestVersion = v; + } + } + return best; +} + +#pragma mark - Elevated install + +// Uses osascript to prompt for admin credentials and run installer, +// the standard macOS pattern for a GUI app installing a system pkg. +static BOOL installPkgElevated(NSString *pkgPath) { + // Escape single quotes for the shell: our PID-based path contains no + // quotes, but belt-and-suspenders against future path changes. + NSString *safePath = [pkgPath stringByReplacingOccurrencesOfString:@"'" + withString:@"'\\''"]; + NSString *script = [NSString stringWithFormat: + @"do shell script \"/usr/sbin/installer -pkg '%@' -target /\" " + @"with administrator privileges", safePath]; + NSTask *t = [[[NSTask alloc] init] autorelease]; + [t setLaunchPath:@"/usr/bin/osascript"]; + [t setArguments:[NSArray arrayWithObjects:@"-e", script, nil]]; + NSPipe *errPipe = [NSPipe pipe]; + [t setStandardError:errPipe]; + [t launch]; + [t waitUntilExit]; + if ([t terminationStatus] != 0) { + NSData *errData = [[[errPipe fileHandleForReading] readDataToEndOfFile] retain]; + NSString *err = [[[NSString alloc] initWithData:errData + encoding:NSUTF8StringEncoding] autorelease]; + [errData release]; + NSLog(@"updater: install failed: %@", err); + return NO; + } + return YES; +} + +#pragma mark - Main + +int main(int argc, const char *argv[]) { + BOOL background = NO; + BOOL userInitiated = NO; + if (argc < 2) { + background = YES; // no args = silent background check + } else if (strcmp(argv[1], "--background") == 0) { + background = YES; + } else if (strcmp(argv[1], "--user") == 0) { + userInitiated = YES; + userInitiatedFlag = YES; // global: fetchToFile reads this for TLS dialog + } else { + fprintf(stderr, "usage: %s [--background|--user]\n", argv[0]); + return 2; + } + if (argc > 2) { + fprintf(stderr, "updater: unexpected extra arguments\n"); + return 2; + } + + NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; + [NSApplication sharedApplication]; + + NSString *current = installedVersion(); + if ([current length] == 0) { + NSLog(@"updater: no installed version found"); + if (userInitiated) + NSRunAlertPanel(@"Mavericks Tailscale", + @"Cannot determine the installed version.", @"OK", nil, nil); + [pool release]; + return 1; + } + + // Fetch appcast (binary-safe: temp file, no transcoding) + // Secure temp directory (mkdtemp: unpredictable name, 0700 perms). + char tmpl[] = "/tmp/tsupd.XXXXXX"; + char *dir = mkdtemp(tmpl); + if (!dir) { NSLog(@"updater: mkdtemp failed"); [pool release]; return 1; } + NSString *tmpDir = [NSString stringWithUTF8String:dir]; + NSString *appcastPath = [tmpDir stringByAppendingPathComponent:@"appcast-10.6.xml"]; + if (!fetchToFile(kFeedURL, appcastPath)) { + if (userInitiated && !alreadyAlerted) + NSRunAlertPanel(@"Mavericks Tailscale", + @"Could not check for updates (network error).", @"OK", nil, nil); + cleanupAndExit(tmpDir, 1); + } + NSData *xmlData = [NSData dataWithContentsOfFile:appcastPath]; + + // Parse ALL items, then select the 10.6-compatible one + AppcastParser *parser = [[[AppcastParser alloc] init] autorelease]; + NSXMLParser *xp = [[[NSXMLParser alloc] initWithData:xmlData] autorelease]; + [xp setDelegate:parser]; + [xp parse]; + + NSDictionary *item = selectCompatibleItem(parser); + if (!item) { + NSLog(@"updater: no compatible item in appcast"); + if (userInitiated) + NSRunAlertPanel(@"Mavericks Tailscale", + @"No compatible update found for this system.", @"OK", nil, nil); + cleanupAndExit(tmpDir, 0); + } + + NSString *availVersion = [item objectForKey:@"version"]; + NSString *downloadURL = [item objectForKey:@"url"]; + + if (compareVersions(availVersion, current) <= 0) { + NSLog(@"updater: up to date (%@)", current); + if (userInitiated) + NSRunAlertPanel(@"Mavericks Tailscale", + [NSString stringWithFormat:@"You're up to date! (version %@)", current], + @"OK", nil, nil); + cleanupAndExit(tmpDir, 0); + } + + NSLog(@"updater: %@ -> %@", current, availVersion); + + if (background) { + NSLog(@"updater: update available (background check is silent)"); + cleanupAndExit(tmpDir, 0); + } + + if (userInitiated) { + NSInteger choice = NSRunAlertPanel( + @"Mavericks Tailscale Update", + [NSString stringWithFormat: + @"Version %@ is available (you have %@).\n\nDownload and install now?", + availVersion, current], + @"Install", @"Not Now", nil); + if (choice != NSAlertDefaultReturn) { + cleanupAndExit(tmpDir, 0); + } + } + + // Download the pkg (binary-safe) + NSLog(@"updater: downloading %@...", downloadURL); + // PID-unique path; NEVER embed the remote version string (shell injection + // via crafted appcast sparkle:version). The PID suffix prevents both + // symlink attacks and collisions between concurrent updater runs. + NSString *pkgPath = [tmpDir stringByAppendingPathComponent:@"update.pkg"]; + if (!fetchToFile(downloadURL, pkgPath)) { + if (userInitiated) + NSRunAlertPanel(@"Mavericks Tailscale", + @"Download failed. Try again later.", @"OK", nil, nil); + cleanupAndExit(tmpDir, 1); + } + + // Install with admin privileges + NSLog(@"updater: installing (admin authorization required)..."); + if (!installPkgElevated(pkgPath)) { + if (userInitiated) + NSRunAlertPanel(@"Mavericks Tailscale", + @"Installation failed. Check Console.app for details.", @"OK", nil, nil); + cleanupAndExit(tmpDir, 1); + } + + // The pkg's postinstall reloads the daemon; nothing to do here. + // (The previous bare system() launchctl call was a silent no-op from + // a non-elevated process — LaunchDaemons require root to unload/load.) + + // Clean up the entire secure temp directory + [[NSFileManager defaultManager] removeItemAtPath:tmpDir error:nil]; + + NSLog(@"updater: installed %@", availVersion); + if (userInitiated) + NSRunAlertPanel(@"Mavericks Tailscale", + [NSString stringWithFormat:@"Updated to %@. The daemon has been reloaded.", availVersion], + @"OK", nil, nil); + + cleanupAndExit(tmpDir, 0); +}