#!/usr/bin/env bash # name: tailscale # summary: Install Tailscale and join the tailnet # # curl -fsSL https://install.xaritomi.xyz/tailscale | sudo bash # # No auth key is embedded here, because this repo is public. Credentials come # from one of these, in order of preference: # # 1. 1Password (installs the CLI if missing, signs in if needed): # ... | sudo -E TS_AUTHKEY_OP=op://mimir/tailscale/authkey bash # ... | sudo -E bash -s -- --from-op # 2. A Tailscale OAuth client, which mints a short-lived key per run: # ... | sudo -E TS_OAUTH_CLIENT_ID=... TS_OAUTH_CLIENT_SECRET=... TS_TAGS=tag:server bash # 3. A pre-made auth key: # ... | sudo -E TS_AUTHKEY=tskey-auth-... bash # 4. Nothing - authenticate interactively in a browser (the default, and the # fewest steps on a machine that has never been set up). # # Everything is wrapped in main() and only invoked on the last line, so a # truncated download cannot half-execute. set -euo pipefail TS_AUTHKEY="${TS_AUTHKEY:-}" TS_AUTHKEY_OP="${TS_AUTHKEY_OP:-}" TS_OAUTH_CLIENT_ID="${TS_OAUTH_CLIENT_ID:-}" TS_OAUTH_CLIENT_SECRET="${TS_OAUTH_CLIENT_SECRET:-}" TS_HOSTNAME="${TS_HOSTNAME:-}" TS_SSH="${TS_SSH:-false}" TS_TAGS="${TS_TAGS:-}" OP_ACCOUNT="${OP_ACCOUNT:-}" OP_INSTALLER="${OP_INSTALLER:-https://install.xaritomi.xyz/op}" DEFAULT_OP_REF="op://mimir/tailscale/authkey" log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } usage() { sed -n '2,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//; /^set -euo/d'; exit 0; } require_root() { [ "$(id -u)" -eq 0 ] || die "must run as root (pipe to 'sudo bash', or 'sudo -E bash' to pass TS_*/OP_* through)"; } install_tailscale() { if command -v tailscale >/dev/null 2>&1; then log "tailscale already installed ($(tailscale version | head -1))" return fi command -v curl >/dev/null 2>&1 || die "curl is required" log "installing tailscale" curl -fsSL https://tailscale.com/install.sh | sh } # --- 1Password ------------------------------------------------------------- # Every `op` call takes /dev/null 2>&1; } ensure_op_installed() { command -v op >/dev/null 2>&1 && return 0 log "1Password CLI not present; installing it" curl -fsSL "$OP_INSTALLER" | bash command -v op >/dev/null 2>&1 || die "could not install the 1Password CLI" } ensure_op_signed_in() { op_ready && { log "1Password already authenticated"; return 0; } if [ -n "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]; then op_ready || die "OP_SERVICE_ACCOUNT_TOKEN is set but op rejected it" return 0 fi # Prompts must come from the terminal, not from the pipe feeding this script. [ -e /dev/tty ] || die "1Password is not signed in and there is no terminal to sign in with; set OP_SERVICE_ACCOUNT_TOKEN instead" log "signing in to 1Password (interactive)" local creds args=() [ -n "$OP_ACCOUNT" ] && args+=(--account "$OP_ACCOUNT") creds=$(op signin "${args[@]}" /dev/tty) \ || die "op signin failed. On a fresh machine you must add the account first: op account add --address .1password.com --email " eval "$creds" op_ready || die "signed in, but op still cannot read the vault" } key_from_op() { local ref="$1" ensure_op_installed ensure_op_signed_in log "reading the auth key from ${ref}" op read "$ref" /dev/null || die "could not read ${ref} (does the item exist, and does this account have access?)" } # --- Tailscale OAuth ------------------------------------------------------- mint_ephemeral_key() { local token tags_json token=$(curl -fsSL -u "${TS_OAUTH_CLIENT_ID}:${TS_OAUTH_CLIENT_SECRET}" \ -d 'grant_type=client_credentials' https://api.tailscale.com/api/v2/oauth/token \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') [ -n "$token" ] || die "could not get an OAuth token; check TS_OAUTH_CLIENT_ID/SECRET" [ -n "$TS_TAGS" ] || die "TS_TAGS is required with an OAuth client (e.g. TS_TAGS=tag:server)" tags_json=$(printf '%s' "$TS_TAGS" | awk -F, '{for(i=1;i<=NF;i++) printf "%s\"%s\"", (i>1?",":""), $i}') curl -fsSL -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' \ -d "{\"capabilities\":{\"devices\":{\"create\":{\"reusable\":false,\"ephemeral\":false,\"preauthorized\":true,\"tags\":[${tags_json}]}}},\"expirySeconds\":600}" \ https://api.tailscale.com/api/v2/tailnet/-/keys \ | sed -n 's/.*"key":"\([^"]*\)".*/\1/p' } resolve_key() { if [ -n "$TS_AUTHKEY" ]; then printf '%s' "$TS_AUTHKEY"; return; fi if [ -n "$TS_AUTHKEY_OP" ]; then key_from_op "$TS_AUTHKEY_OP"; return; fi if [ -n "$TS_OAUTH_CLIENT_ID" ] && [ -n "$TS_OAUTH_CLIENT_SECRET" ]; then log "minting a short-lived auth key from the OAuth client" mint_ephemeral_key; return fi printf '' } join_tailnet() { if tailscale status >/dev/null 2>&1; then log "already joined the tailnet" return fi local -a args=(--ssh="${TS_SSH}") [ -n "$TS_HOSTNAME" ] && args+=(--hostname="${TS_HOSTNAME}") [ -n "$TS_TAGS" ] && args+=(--advertise-tags="${TS_TAGS}") local key; key=$(resolve_key) if [ -n "$key" ]; then log "joining the tailnet with a supplied key" tailscale up --authkey="$key" "${args[@]}" else warn "no credential given; falling back to interactive browser login" tailscale up "${args[@]}" fi } main() { while [ $# -gt 0 ]; do case "$1" in -h|--help) usage ;; --from-op) TS_AUTHKEY_OP="${TS_AUTHKEY_OP:-$DEFAULT_OP_REF}" ;; --op-ref) TS_AUTHKEY_OP="${2:?--op-ref needs an op:// reference}"; shift ;; *) die "unknown argument: $1 (try --help)" ;; esac shift done require_root install_tailscale systemctl enable --now tailscaled 2>/dev/null || true join_tailnet log "done: $(tailscale ip -4 2>/dev/null | head -1) ($(hostname))" } main "$@"