Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the homelab or VPS case, so the role is not asked for there — it is `dev`, and every section that exists to make a machine a good server is skipped. Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh with a reason each, rather than an `if macos` threaded through each section. Most would simply fail — no systemd, no ufw, no netplan, no useradd, no /etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse: stopping a laptop from sleeping, or freezing the address of a machine that moves between networks daily. Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker, Neovim, JavaScript runtimes, Agent CLIs. The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to run as root and says so, so the whole script under sudo would have failed at the first brew install having already taken a password. It is now required on Linux and refused on macOS, which works precisely because the macOS path skips everything that needed it. Docker is checked, not installed. Docker Desktop is a GUI app that wants opening, permissions and a running window — not a shell script's business — and colima and lima both cost an evening the first time something does not resolve. So the step reports whether the daemon answers and points at the download otherwise. The group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM owned by whoever is logged in, so there is no group to join. Added the Xcode command line tools as a macOS-only step, before anything that builds. node-pty ships no prebuilt binary on any platform and always falls through to node-gyp, so `bun install` cannot finish without a compiler — and it fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select --install` opens a dialogue and returns immediately, so the step says to come back rather than pretending to have waited. Tailscale takes the cask, not install.sh — that script is a Linux package-manager wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and does not. Not run on a Mac. There isn't one here, so this is read from the code and from what each tool documents, not observed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
311 lines
16 KiB
Bash
311 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 Headscale app 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.
|
|
enable_ip_forwarding() {
|
|
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() {
|
|
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 }'
|
|
}
|