The section explains three ways to get a coordination server and then says nothing about running one afterwards, which is the part that decides whether self-hosting is a good idea. Officer's Headscale app is the answer to it, and it works against headscale and offscale alike. Written from what the app actually does rather than from the pitch: several servers registered and switched between, each PROBED rather than remembered — the comment in ServersView.tsx is explicit that a "not checked" dot is the one thing that list must never show — and, on the active one, nodes, users, pre-auth keys, invites and the ACL policy with an assistant, plus a console and diagnostics. Placed in FOR OFFICER rather than under offscale, because it is true of either self-hosted option and is the reason picking one is not a commitment to administering it over ssh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
228 lines
11 KiB
Bash
228 lines
11 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.
|
|
# ALLOW_ANY_ORIGIN defaults ON, and that is only defensible because the machine is
|
|
# not reachable from the open internet in the first place — a valid token plus the
|
|
# tailnet is the lock. An Officer install with no tailnet is an Officer install
|
|
# with one fewer layer than it was designed around.
|
|
#
|
|
# ── 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 — ALLOW_ANY_ORIGIN defaults on, and"
|
|
echo " that is only defensible because the machine is not reachable from"
|
|
echo " outside in the first place. A valid token plus the tailnet is the"
|
|
echo " lock; without the tailnet it is one layer short of its design."
|
|
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, here on this machine. The protocol"
|
|
echo " on the wire is Tailscale's and the encryption is WireGuard's;"
|
|
echo " offscale changes neither — it runs headscale's open-source code."
|
|
echo " What changes is the work: one command to install, certificates"
|
|
echo " included, managed from an app rather than a terminal, and"
|
|
echo " enrolling a device is a link and a tap."
|
|
echo " https://officer.dev/infrastructure/offscale.html"
|
|
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 " [?] 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 ""
|
|
echo " What it does that plain headscale does not:"
|
|
echo " · installs in one command, with the certificates handled"
|
|
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. ALLOW_ANY_ORIGIN defaults on, and that is only"
|
|
echo " defensible because the machine is not reachable from the open"
|
|
echo " internet in the first place. 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 }'
|
|
}
|
|
|
|
tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; }
|
|
|
|
# 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
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
# 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 }'
|
|
}
|