Files
platform/scripts/setup/machine-setup/lib/tailscale.sh
T
pastilhas 681df1c8bc make machine-setup.sh and officer-setup.sh actually run on macOS
Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.

The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.

Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.

Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:

  - grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
    table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
    jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
    dependency elsewhere in this script, so that's a correctness fix, not just a port.
  - ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
    enough for NPM's container to reach) — both silently mis-reported on macOS, since the
    "command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
    New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
  - getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
    its full 30-minute timeout regardless of whether the record actually resolved. macOS
    branch uses dscacheutil.
  - stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
    option -- c"), and the failure was VISIBLE and made every layout directory report as
    wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
  - apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
    installers (one literally named install_update_linux.sh, the other hardcoding
    "_Linux_" into the release asset URL) — each gets a brew branch, formula names
    verified against the live Homebrew catalog rather than assumed.
  - sudo -u "$USERNAME" to become the SAME account the process already is — real on
    Linux, where this whole script runs as root and dropping to the owner needs no
    password; on macOS the process never escalates in the first place, so re-invoking
    sudo for yourself asks for a real password on every call, or fails outright without
    sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
    opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
    setup's own as_owner() (already the shared entry point there) got the same branch,
    and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
    were rerouted through it too.

Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).

Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.

Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
2026-08-26 21:10:08 +01:00

326 lines
16 KiB
Bash

#!/bin/bash
# =============================================================================
# machine-setup — Tailscale
# =============================================================================
#
# Definitions only, like the other lib/ files.
#
# ── Why this runs early ──
#
# It is a second way into the machine. The section that can lock you out is SSH
# hardening, and everything after this one can break networking in some smaller
# way; having the tailnet up first means a mistake is recoverable rather than a
# trip to a rescue console.
#
# ── Why it matters to Officer specifically ──
#
# The platform's CLAUDE.md is explicit: the perimeter IS the tailnet. Origin
# checking was removed outright on 2026-08-13 because the tailnet stands in its
# place, so a valid token plus the tailnet IS the lock — not one layer of two.
# An Officer install with no tailnet is missing the half the design assumes.
#
# ── Why the original hung ──
#
# It passed --authkey unconditionally, and its prompt accepted an empty answer.
# `tailscale up --authkey ""` falls back to interactive login: it prints a URL and
# blocks, with no timeout, forever. Nothing here passes an empty key, every call
# has a timeout, and the state is read before anything is run.
[[ -n "${MACHINE_SETUP_TAILSCALE_LOADED:-}" ]] && return 0
MACHINE_SETUP_TAILSCALE_LOADED=1
TS_EXIT_SYSCTL=/etc/sysctl.d/99-tailscale-exit.conf
TS_DISPATCHER=/etc/networkd-dispatcher/routable.d/50-tailscale-exit
# Printed only when asked for. The section leads with the question rather than
# with ten lines of explanation: somebody who runs Tailscale already does not need
# to be told what it is, and somebody who does not can type ?.
tailscale_help() {
echo " Tailscale is a private network between your own machines, over"
echo " WireGuard. Every device you enrol gets a stable 100.x address and"
echo " can reach every other, wherever they are — through NAT, across"
echo " providers, without either end having a public address."
echo ""
echo " Nothing is published to the open internet to make that work: no"
echo " port forwarding, no exposed ports, no holes in the firewall."
echo ""
echo " For Officer it is not a convenience. The platform is built assuming"
echo " the tailnet IS the perimeter, and there is no origin checking behind"
echo " it — a valid token plus the tailnet is the whole lock. Without the"
echo " tailnet you are running with half of it missing."
echo ""
echo " It is installed at this point in the run, before anything that can"
echo " lock you out of the machine, so there is always a second way in."
}
# The menu itself, in a function because it is shown twice — once to ask, and
# again after ? has printed the long answer, so the reader is not dropped back at
# a bare prompt having forgotten what the options were.
tailscale_network_menu() {
info "Which network should this machine join?"
echo ""
echo " [1] set up your own network — offscale"
echo " Your own coordination server. The protocol on the wire is"
echo " Tailscale's and the encryption is WireGuard's; offscale changes"
echo " neither — it runs headscale's open-source code. What changes is"
echo " the work: managed from an app rather than a terminal, and"
echo " enrolling a device is a link and a tap."
echo " offscale — just like headscale, and just like Tailscale's own"
echo " service — needs to run on a publicly reachable server of its"
echo " own. A small VPS is enough. Not this machine, not behind a home"
echo " router: every device that joins has to find it, including phones"
echo " on mobile data. The only difference from option 3 is who runs"
echo " that server."
echo " Follow that setup through first, then come back here with its"
echo " address and a key."
echo " https://officer.dev/infrastructure/offscale.html#install"
echo ""
echo " [2] use a network you already run — headscale or offscale"
echo " You already have a coordination server somewhere. Point this"
echo " machine at it and it joins that network alongside the rest."
echo ""
echo " [3] the easy route — tailscale.com"
echo " Tailscale runs the coordination for you. Nothing to host and"
echo " nothing to maintain, free for personal use; the trade is that"
echo " the list of your machines lives with them."
echo ""
echo " [4] no private network at all"
echo " This machine is reached over the open internet, or not at all."
echo " Everything the tailnet was doing becomes yours to do."
echo ""
echo " [?] what are tailscale, headscale and offscale?"
echo ""
}
# The long answer, printed when somebody types ?. Covers all three names,
# because the menu offers all three and two of them are not words anyone outside
# this project would know.
tailscale_networks_help() {
echo " Tailscale, headscale and offscale are three answers to one question:"
echo " who keeps the list of your machines and hands out the keys they use"
echo " to find each other."
echo ""
echo " The network itself is the same in all three cases. Machines talk"
echo " directly to each other over WireGuard, encrypted end to end. What"
echo " differs is only the coordination server — the thing that knows which"
echo " machines are yours. It never carries your traffic."
echo ""
echo " TAILSCALE"
echo " The company's own coordination server. Nothing to run, nothing to"
echo " maintain, free for personal use. You sign in with an existing"
echo " identity and your machines appear in their admin console."
echo " The trade is that the list of your machines lives with them."
echo ""
echo " HEADSCALE"
echo " An open-source coordination server you run yourself. The same"
echo " Tailscale clients connect to it, so the machines behave identically;"
echo " the difference is that nobody else holds the list. The cost is that"
echo " it is now a service you host, and it needs to be reachable."
echo ""
echo " OFFSCALE"
echo " Our own distribution of headscale, which is to say: headscale. The"
echo " protocol on the wire is Tailscale's and the encryption is"
echo " WireGuard's, and offscale changes neither — it runs the same"
echo " open-source project. A machine on an offscale network behaves"
echo " exactly as it would on either of the other two. There is no offscale"
echo " protocol to be locked into, because there is no offscale protocol."
echo ""
echo " Clients: stock Tailscale on computers. On iPhone, iPad and Android"
echo " there is our own app — the Tailscale client, our branding, and one"
echo " real difference: it takes an invite from the server directly. That"
echo " is the part of running headscale people give up at, because the"
echo " official app has to be talked into using a server that is not"
echo " Tailscale's. Desktop apps of our own are not there yet; on a"
echo " computer you point the official client at your own server."
echo ""
# Where it runs matters more than how it installs, and is the thing people
# get wrong: a coordination server at home is unreachable from exactly the
# devices a private network exists to reach.
echo " Where it runs: on a publicly reachable server of its own — a small"
echo " VPS is enough. Not on this machine, and not behind a home router."
echo " Every device that joins has to find it, including phones on mobile"
echo " data and laptops in other buildings, so it needs an address that"
echo " resolves from anywhere."
echo ""
echo " This is not something offscale asks for and the others do not. It"
echo " is true of headscale, and it is true of Tailscale — their"
echo " coordination server is publicly reachable too, they simply run it"
echo " for you. That is the whole of the difference between choosing"
echo " option 3 and choosing to host it yourself."
echo ""
echo " What it does that plain headscale does not:"
echo " · installs in one command on that server, certificates included"
echo " · health, logs, restarts and access policies from the app,"
echo " instead of a config file and a CLI"
echo " · enrolling a device is a link and a tap — the key is minted"
echo " and handed over for you"
echo " · several networks at once, and services reachable across them"
echo ""
echo " https://officer.dev/infrastructure/offscale.html"
echo ""
echo " FOR OFFICER"
echo " Whichever you pick, the tailnet is what Officer treats as its"
echo " perimeter, and it is not one layer of two — there is no origin"
echo " checking behind it. Installed at this point in the run,"
echo " before anything that can lock you out, so there is always a second"
echo " way in."
echo ""
echo " Officer also administers it. Its Offscale plugin talks to headscale"
echo " and offscale servers alike: register as many as you run, see which"
echo " are actually up — each is probed, not remembered — and switch"
echo " between them. On whichever is active you get the nodes, the users,"
echo " the pre-auth keys, the invites and the ACL policy, with an"
echo " assistant for writing it, plus a console and diagnostics. So the"
echo " server this section sets up is managed from the same place as"
echo " everything else on this machine, rather than over ssh and a CLI."
}
tailscale_is_installed() { command -v tailscale &>/dev/null; }
# NeedsLogin, Running, Stopped, NoState… Read before acting, because the original's
# failure was running `up` blindly against a node that was already up.
tailscale_state() {
tailscale status --json 2>/dev/null | awk -F'"' '/"BackendState"/ { print $4; exit }'
}
tailscale_ip() { tailscale ip -4 2>/dev/null | head -1; }
# Which control plane this node is talking to. Empty means Tailscale's own.
tailscale_control_url() {
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
}
# The official install.sh is a Linux package-manager script. macOS gets the same
# daemon wrapped in a GUI app, and the cask is the version with a CLI at
# /Applications/Tailscale.app/Contents/MacOS/Tailscale — the Mac App Store build
# is sandboxed and ships no usable `tailscale` binary, which is the difference
# that matters to a script.
tailscale_install() {
if [[ "${OS:-}" == "macos" ]]; then
brew install --cask tailscale
return
fi
curl -fsSL https://tailscale.com/install.sh | sh
}
# Tailscale's own coordination server, spelled out.
#
# Passed explicitly even when it is the default, because `tailscale up` with no
# --login-server keeps whatever ControlURL is already stored. On a node already
# pointed at a self-hosted server, choosing "the easy route" would otherwise
# leave it exactly where it was — no error, no message, wrong answer.
TS_DEFAULT_CONTROL_URL="https://controlplane.tailscale.com"
# Moving a node between coordination servers is not something `up` will do while
# it is logged in to one. Logging out first is the documented way, and doing it
# unasked would be worse than saying so.
# What choosing "no private network" actually hands you, said before it is
# chosen rather than discovered afterwards.
tailscale_none_warning() {
echo " Without a tailnet, everything it was doing becomes yours:"
echo ""
echo " · Anything you want to reach remotely has to be published to the"
echo " open internet deliberately, and kept closed otherwise."
echo " · TLS certificates are yours to obtain and to keep renewed."
echo " · Every exposed service needs its own authentication, because"
echo " there is no longer a network boundary in front of it."
echo " · This machine will be found. Anything listening on a public"
echo " address is scanned within minutes and attacked continuously."
echo ""
echo " For Officer specifically, this removes a layer that cannot be put"
echo " back from a setting:"
echo ""
echo " There is no origin checking in the platform. It was removed"
echo " because the tailnet is the perimeter, so a valid token plus the"
echo " tailnet is the entire lock. With no tailnet, the token is the"
echo " only thing left. Put an HTTPS reverse proxy in front of the"
echo " platform and restrict who can reach it at the network layer."
}
tailscale_needs_logout() {
local current="$1" target="$2"
[[ -n "$current" && -n "$target" && "$current" != "$target" ]]
}
# Routing has to be on before this machine can forward anyone else's packets,
# whether as an exit node or as a subnet router. Written as a drop-in so it is
# visible as this script's doing.
# Both this and install_exit_node_tuning below are Linux-only — sysctl.d, systemd's
# networkd-dispatcher, ethtool NIC offload flags, none of which macOS has an equivalent
# concept for. Tailscale itself DOES support acting as an exit node from macOS (that's the
# `--advertise-exit-node` flag on `tailscale up`, untouched by this guard) — what's
# unavailable is specifically this forwarding-throughput tuning, which is also a much
# narrower loss on a Mac: the file's own MACOS_SKIP already treats a laptop advertising as
# an exit node as an edge case, not the common path a homelab box is.
enable_ip_forwarding() {
if [[ "${OS:-}" == "macos" ]]; then
warn "IP-forwarding tuning is Linux-only (sysctl.d) — skipping; the exit node still works, just untuned"
return 0
fi
cat >"$TS_EXIT_SYSCTL" <<'EOF'
# Written by machine-setup: required to forward traffic for other tailnet nodes,
# as an exit node or as a subnet router.
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sysctl --system >/dev/null 2>&1
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
}
# UDP GRO forwarding, which Tailscale documents as roughly doubling throughput on
# a node that forwards for others. Applied on every routable event rather than
# once, because the settings are per-interface and do not survive the link going
# down and back up.
install_exit_node_tuning() {
if [[ "${OS:-}" == "macos" ]]; then
warn "NIC offload tuning is Linux-only (networkd-dispatcher, ethtool) — skipping"
return 0
fi
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
mkdir -p "$(dirname "$TS_DISPATCHER")"
cat >"$TS_DISPATCHER" <<'EOF'
#!/usr/bin/env bash
# Written by machine-setup. NIC offload settings for a Tailscale exit node or
# subnet router — Tailscale's own recommendation for forwarding throughput.
set -Eeuo pipefail
IF="${IFACE:-}"
if [[ -z "${IF}" ]]; then
IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')"
fi
[[ -n "${IF}" ]] || exit 0
command -v ethtool >/dev/null 2>&1 || exit 0
ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true
ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true
ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true
exit 0
EOF
chmod 755 "$TS_DISPATCHER"
systemctl enable --now networkd-dispatcher >/dev/null 2>&1 || true
# And once now, for the interface that is already up.
IFACE="$(default_iface)" bash "$TS_DISPATCHER" >/dev/null 2>&1 || true
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
}
# The LAN this machine sits on, as a CIDR — the useful default for a subnet
# router, and the number nobody remembers offhand.
lan_cidr() {
local iface
iface="$(default_iface)"
ip -4 route show dev "$iface" 2>/dev/null |
awk '$1 ~ /\// && $1 !~ /^default/ { print $1; exit }'
}