#!/usr/bin/env bash { set +x; } 2>/dev/null set -Eeuo pipefail usage() { cat <<'EOF' Quicksh tailscale 0.1.0 — private-key Tailscale setup Usage: bash tailscale.sh [--help|-h|--version] bash tailscale.sh --setup [--auth-key-file PATH] [--yes] Default: report client/service state without changing anything. --setup Install a missing client, enable tailscaled at boot, and join. --auth-key-file Private auth-key file; default: ~/.config/quicksh/tailscale-auth.key --yes Explicitly approve the displayed changes without confirmation. sudo may still require your local password. Supports genuine Arch Linux and Ubuntu with systemd. New Ubuntu installs support jammy, noble, resolute, and stonking; curl and CA certificates required. Arch uses pacman -S --needed, without refreshing databases or upgrading all. Ubuntu adds Tailscale's signed stable apt repository, updates indexes, then installs tailscale. Existing conflicting repository files are never replaced. The key file must be nonempty, owned by you or root, and mode 600 or 400. Keep its directory private. Supply it separately on each target device; no credential is embedded in this public script or fetched from Quicksh. The key is read by Tailscale via file:, never passed as a command argument. No browser login is needed. Invalid/expired/used keys fail; device approval or Tailnet Lock signing may still be required by your tailnet policy. Existing authenticated devices are left untouched, even on another tailnet. This helper does not force reauthentication or reset existing preferences. Setup creates Tailscale networking/firewall state. It keeps local DNS, does not accept subnet routes, and does not enable SSH or advertise routes. Review your tailnet access rules before joining. Package/service changes can remain after an enrollment failure; there is no automatic rollback. EOF } fail() { printf 'quicksh tailscale: %s\n' "$1" >&2 return 1 } backend_state() { local status if ! status=$("$@" tailscale status --json 2>/dev/null); then return 1 fi # Extract only this top-level field; never print IPs, peers, or login URLs. sed -n 's/^ "BackendState": "\([A-Za-z]*\)",\{0,1\}$/\1/p' <<< "$status" } install_ubuntu() ( local codename=$1 workdir target local keyring=/usr/share/keyrings/tailscale-archive-keyring.gpg local source=/etc/apt/sources.list.d/tailscale.list if ! workdir=$(mktemp -d); then fail 'could not create temporary directory.'; return 1; fi trap 'rm -rf -- "$workdir"' EXIT if ! curl --proto '=https' --tlsv1.2 -fsS --connect-timeout 15 --max-time 60 \ "https://pkgs.tailscale.com/stable/ubuntu/$codename.noarmor.gpg" -o "$workdir/keyring"; then fail 'could not download the official apt signing key; no repository files written.'; return 1 fi if [[ ! -s "$workdir/keyring" ]]; then fail 'downloaded signing key is empty.'; return 1; fi printf 'deb [signed-by=%s] https://pkgs.tailscale.com/stable/ubuntu %s main\n' \ "$keyring" "$codename" > "$workdir/source" # Compare before writing either file. Never overwrite an existing setup. for target in "$keyring" "$source"; do if [[ -L "$target" || ( -e "$target" && ! -f "$target" ) ]]; then fail 'unexpected apt repository path; review the existing Tailscale setup.'; return 1 fi done if [[ -e "$keyring" ]] && ! cmp -s -- "$workdir/keyring" "$keyring"; then fail 'existing Tailscale signing key differs; review it manually.'; return 1 fi # Permit the comment supplied by the official manual-install instructions. if [[ -e "$source" ]] && ! cmp -s -- "$workdir/source" <(sed '/^#/d; /^[[:space:]]*$/d' "$source"); then fail 'existing tailscale.list differs; review it manually.'; return 1 fi if ! "${elevate[@]}" install -d -m 755 /usr/share/keyrings /etc/apt/sources.list.d; then fail 'could not prepare apt directories.'; return 1 fi if [[ ! -e "$keyring" ]] && ! "${elevate[@]}" install -m 644 -- "$workdir/keyring" "$keyring"; then fail 'could not install the apt signing key.'; return 1 fi if [[ ! -e "$source" ]] && ! "${elevate[@]}" install -m 644 -- "$workdir/source" "$source"; then fail 'could not install the apt source list.'; return 1 fi if ! "${elevate[@]}" apt-get update; then fail 'apt index refresh failed.'; return 1; fi local -a confirm=() if (( approved )); then confirm=(-y); fi if ! "${elevate[@]}" apt-get install "${confirm[@]}" tailscale; then fail 'apt installation failed or was cancelled.'; return 1 fi ) main() { local setup=0 approved=0 key_option=0 local key_file=${HOME:-}/.config/quicksh/tailscale-auth.key while (( $# )); do case "$1" in --help|-h) usage; return ;; --version) printf 'Quicksh tailscale 0.1.0\n'; return ;; --setup) setup=1; shift ;; --yes) approved=1; shift ;; --auth-key-file) if (( $# < 2 )) || [[ -z "$2" || "$2" == --* ]]; then printf 'quicksh tailscale: --auth-key-file requires a path.\n' >&2; return 2 fi key_file=$2; key_option=1; shift 2 ;; *) printf 'quicksh tailscale: unsupported option; use --help.\n' >&2; return 2 ;; esac done if (( ! setup && (approved || key_option) )); then printf 'quicksh tailscale: --yes and --auth-key-file require --setup.\n' >&2; return 2 fi local ID=unknown VERSION_CODENAME=unknown if [[ ! -r /etc/os-release ]]; then fail 'cannot identify the OS.'; return 1; fi # shellcheck disable=SC1091 if ! . /etc/os-release; then fail 'could not read OS identification.'; return 1; fi case "$ID" in arch|ubuntu) ;; *) fail 'unsupported OS; requires genuine Arch Linux or Ubuntu. No changes made.'; return 1 ;; esac printf 'Quicksh tailscale 0.1.0\nOS: %s\n' "$ID" local installed=0 state=Unavailable if command -v tailscale >/dev/null 2>&1; then installed=1 if ! tailscale version; then fail 'installed client could not report its version.'; return 1; fi state=$(backend_state) || state=Unavailable fi case "$state" in Running|Stopped|NeedsLogin|NeedsMachineAuth|Starting|NoState|InUseOtherUser|Unavailable) ;; *) state=Unknown ;; esac printf 'Client installed: %s\nBackend: %s\n' "$installed" "$state" if (( ! setup )); then printf 'No changes made. Use --help for key-file setup.\n'; return 0 fi case "$state" in Running) printf 'Already connected. Existing identity and settings left untouched.\n'; return 0 ;; Stopped|NeedsMachineAuth|InUseOtherUser) fail 'existing enrollment needs reconnection/approval; review tailscale status locally. No changes made.'; return 1 ;; esac local tool metadata owner mode links size for tool in systemctl stat realpath sed; do if ! command -v "$tool" >/dev/null 2>&1; then fail "required tool missing: $tool"; return 1; fi done if [[ ! -d /run/systemd/system ]]; then fail 'requires a running systemd system. No changes made.'; return 1; fi if [[ -z "${HOME:-}" ]] && (( ! key_option )); then fail 'HOME is unset; supply --auth-key-file explicitly. No changes made.'; return 1 fi if [[ -L "$key_file" || ! -f "$key_file" || ! -r "$key_file" ]]; then fail 'auth-key file must be a readable regular file, not a symlink. No changes made.'; return 1 fi if ! key_file=$(realpath -e -- "$key_file") || ! metadata=$(stat -c '%u %a %h %s' -- "$key_file"); then fail 'could not inspect auth-key file. No changes made.'; return 1 fi read -r owner mode links size <<< "$metadata" if [[ ( "$owner" != "$EUID" && "$owner" != 0 ) || ( "$mode" != 600 && "$mode" != 400 ) || "$links" != 1 ]] || (( size < 1 || size > 4096 )); then fail 'key must be owned by you/root, mode 600/400, nonempty, <=4096 bytes, with no hard links.'; return 1 fi if ! metadata=$(stat -c '%u %a' -- "${key_file%/*}"); then fail 'could not inspect key directory.'; return 1; fi read -r owner mode <<< "$metadata" if [[ "$owner" != "$EUID" && "$owner" != 0 ]] || (( (8#$mode & 0022) != 0 )); then fail 'key directory must be owned by you/root and not writable by group/others.'; return 1 fi local -a elevate=() if (( EUID != 0 )); then if ! command -v sudo >/dev/null 2>&1; then fail 'sudo is required for setup as a non-root user.'; return 1; fi elevate=(sudo --) fi if (( ! installed )); then case "$ID" in arch) if ! command -v pacman >/dev/null 2>&1; then fail 'pacman is missing.'; return 1; fi printf 'Install: pacman -S --needed tailscale (no refresh/full upgrade).\n' printf 'If databases are stale, cancel and review a full pacman -Syu separately.\n' ;; ubuntu) case "$VERSION_CODENAME" in jammy|noble|resolute|stonking) ;; *) fail 'automatic installation supports Ubuntu jammy/noble/resolute/stonking only.'; return 1 ;; esac for tool in apt-get curl cmp install mktemp; do if ! command -v "$tool" >/dev/null 2>&1; then fail "required installation tool missing: $tool"; return 1; fi done printf 'Install: official Tailscale stable signed-by apt source/key for %s,\n' "$VERSION_CODENAME" printf 'then apt-get update and apt-get install tailscale.\n' ;; esac fi printf 'Enable/start: systemctl enable --now tailscaled\n' printf 'Join using private key file; keep local DNS and reject subnet routes.\n' printf 'Standard Tailscale interface/firewall rules apply; no SSH/exit-node/route advertising.\n' if (( ! approved )); then if [[ ! -t 0 || ! -t 1 ]]; then fail 'setup requires a terminal or explicit --yes. No changes made.'; return 1; fi local answer printf 'Apply these changes? Type yes: ' if ! IFS= read -r answer; then fail 'confirmation could not be read.'; return 1; fi if [[ "$answer" != yes ]]; then printf 'Cancelled; no changes made.\n'; return 0; fi fi if (( ! installed )); then if [[ "$ID" == arch ]]; then local -a confirm=() if (( approved )); then confirm=(--noconfirm); fi if ! "${elevate[@]}" pacman -S --needed "${confirm[@]}" tailscale; then fail 'pacman installation failed or was cancelled.'; return 1 fi elif ! install_ubuntu "$VERSION_CODENAME"; then return 1; fi if ! command -v tailscale >/dev/null 2>&1; then fail 'installed client is not in PATH.'; return 1; fi fi if ! "${elevate[@]}" systemctl enable --now tailscaled; then fail 'could not enable/start tailscaled.'; return 1; fi state=$(backend_state "${elevate[@]}") || state=Unavailable if [[ "$state" == Running ]]; then printf 'Already connected; no identity/settings changed.\n'; return 0; fi if [[ "$state" != NeedsLogin ]]; then fail 'daemon is not in NeedsLogin state; existing enrollment was not changed. Review tailscale status.'; return 1 fi # Suppress auth output: even error diagnostics must not disclose the key/login URL. if ! "${elevate[@]}" tailscale up --auth-key="file:$key_file" --timeout=60s \ --accept-dns=false --accept-routes=false >/dev/null 2>&1; then fail 'enrollment failed/timed out. Check key, network, approval/Tailnet Lock, and existing nondefault preferences; no reset/forced reauth attempted.' return 1 fi state=$(backend_state "${elevate[@]}") || state=Unavailable if [[ "$state" != Running ]]; then fail 'enrollment did not reach Running; check device approval/Tailnet Lock.'; return 1; fi printf 'Connected using the supplied key. Confirm the device in your Tailscale admin console.\n' printf 'The local key file was retained; remove it when no longer needed.\n' } main "$@"