#!/usr/bin/env bash set -uo pipefail STEP_NAMES=() STEP_STATUS=() STEP_DETAIL=() step_ok() { STEP_NAMES+=("$1"); STEP_STATUS+=("OK"); STEP_DETAIL+=("${2:-}"); echo "[OK] $1"; } step_skip() { STEP_NAMES+=("$1"); STEP_STATUS+=("SKIPPED"); STEP_DETAIL+=("${2:-}"); echo "[SKIPPED] $1 - ${2:-}"; } step_fail() { STEP_NAMES+=("$1"); STEP_STATUS+=("FAILED"); STEP_DETAIL+=("${2:-}"); echo "[FAILED] $1 - ${2:-}"; } section() { echo; echo "== $1 =="; } print_summary() { echo echo "===================== summary =====================" local any_failed=0 for i in "${!STEP_NAMES[@]}"; do printf '[%-8s] %s\n' "${STEP_STATUS[$i]}" "${STEP_NAMES[$i]}" [ -n "${STEP_DETAIL[$i]}" ] && printf ' %s\n' "${STEP_DETAIL[$i]}" [ "${STEP_STATUS[$i]}" = "FAILED" ] && any_failed=1 done echo "=====================================================" if [ "$any_failed" = 1 ]; then echo "One or more steps failed. Fix the issue above and re-run this script -" echo "already-completed steps are safe to skip and will not be repeated." exit 1 fi echo "All steps OK or already satisfied." } # ---------- phase 0: environment ---------- preflight_env() { section "preflight: environment" if [ "$(id -u)" = "0" ]; then step_fail "environment check" "do not run as root - script uses sudo for the specific commands that need it" print_summary exit 1 fi if ! grep -qi '^ID=debian' /etc/os-release 2>/dev/null; then step_fail "environment check" "this script targets Debian; /etc/os-release did not report ID=debian" print_summary exit 1 fi step_ok "environment check" } # ---------- phase 0.1: config ---------- gather_config() { section "config" if [ -z "${FORGEJO_URL:-}" ]; then read -rp "Forgejo instance URL (e.g. https://git.example.com): " FORGEJO_URL fi FORGEJO_URL="${FORGEJO_URL%/}" if [ -z "${KEY_NAME:-}" ]; then read -rp "Label for this device's SSH key [travel-laptop]: " KEY_NAME fi KEY_NAME="${KEY_NAME:-travel-laptop}" KEYFILE="$HOME/.ssh/id_ed25519_${KEY_NAME}" step_ok "config gathered" "FORGEJO_URL=$FORGEJO_URL KEY_NAME=$KEY_NAME" } # ---------- phase 0.2: connectivity preflight ---------- check_url() { local name="$1" url="$2" if curl -fsS --max-time 5 -o /dev/null "$url"; then step_ok "reachable: $name" else step_fail "reachable: $name" "curl could not reach $url" fi } preflight_connectivity() { section "preflight: connectivity" check_url "Debian mirrors" "https://deb.debian.org" check_url "Tailscale install" "https://pkgs.tailscale.com" check_url "uv installer" "https://astral.sh" check_url "Firefox extensions" "https://addons.mozilla.org" check_url "VS Code repo" "https://packages.microsoft.com" check_url "Forgejo instance" "$FORGEJO_URL" for i in "${!STEP_NAMES[@]}"; do if [ "${STEP_STATUS[$i]}" = "FAILED" ]; then echo echo "Connectivity check failed for one or more dependencies (see above)." echo "Fix connectivity and re-run before continuing." print_summary exit 1 fi done } # ---------- phase 1: base packages ---------- install_packages() { section "base packages" if command -v tailscale >/dev/null 2>&1; then step_skip "tailscale package" "already installed" else if curl -fsSL https://tailscale.com/install.sh | sh >/tmp/tailscale-install.log 2>&1; then step_ok "tailscale package" else step_fail "tailscale package" "see /tmp/tailscale-install.log" fi fi if [ ! -f /etc/apt/keyrings/microsoft.gpg ]; then sudo mkdir -p /etc/apt/keyrings curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ | gpg --dearmor \ | sudo tee /etc/apt/keyrings/microsoft.gpg >/dev/null fi local vscode_repo_line='deb [arch=amd64 signed-by=/etc/apt/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/code stable main' if [ -f /etc/apt/sources.list.d/vscode.list ] && grep -qF "$vscode_repo_line" /etc/apt/sources.list.d/vscode.list; then step_skip "vscode apt repo" "already configured" else echo "$vscode_repo_line" | sudo tee /etc/apt/sources.list.d/vscode.list >/dev/null step_ok "vscode apt repo" fi if sudo apt-get update -qq && sudo apt-get install -y -qq \ vim tmux mosh git curl ca-certificates jq gnupg firefox-esr code >/tmp/apt-install.log 2>&1; then step_ok "apt packages" else step_fail "apt packages" "see /tmp/apt-install.log" fi if command -v uv >/dev/null 2>&1; then step_skip "uv" "already installed" else if curl -fsSL https://astral.sh/uv/install.sh | sh >/tmp/uv-install.log 2>&1; then step_ok "uv" else step_fail "uv" "see /tmp/uv-install.log" fi fi if code --list-extensions 2>/dev/null | grep -qx "ms-vscode-remote.remote-ssh"; then step_skip "vscode remote-ssh extension" "already installed" else if code --install-extension ms-vscode-remote.remote-ssh >/tmp/vscode-ext.log 2>&1; then step_ok "vscode remote-ssh extension" else step_fail "vscode remote-ssh extension" "see /tmp/vscode-ext.log" fi fi } # ---------- phase 2: tailscale join ---------- tailscale_join() { section "tailscale" if sudo tailscale status >/dev/null 2>&1; then step_skip "tailscale up" "already connected" else if sudo tailscale up; then step_ok "tailscale up" else step_fail "tailscale up" "tailscale up failed or was not approved" fi fi } # ---------- phase 3: ssh config from tailnet ---------- regen_ssh_config() { section "ssh config (tailnet hosts)" local sshconf="$HOME/.ssh/config" local begin="# BEGIN travel-bootstrap" local end="# END travel-bootstrap" mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" touch "$sshconf" chmod 600 "$sshconf" local status_json if ! status_json=$(tailscale status --json 2>/dev/null); then step_fail "ssh config (tailnet hosts)" "tailscale status --json failed" return fi local self_dns suffix user self_dns=$(echo "$status_json" | jq -r '.Self.DNSName' | sed 's/\.$//') suffix=${self_dns#*.} user=$(whoami) if [ -z "$suffix" ] || [ "$suffix" = "$self_dns" ]; then step_fail "ssh config (tailnet hosts)" "could not determine tailnet suffix from $self_dns" return fi local new_block new_block=$(cat < "$tmp" printf '%s\n' "$new_block" >> "$tmp" mv "$tmp" "$sshconf" chmod 600 "$sshconf" if [ -z "$old_block" ]; then step_ok "ssh config (tailnet hosts)" "wrote wildcard + $(echo "$status_json" | jq '.Peer | length') peer entries" else step_ok "ssh config (tailnet hosts)" "updated - peer list changed" fi } # ---------- phase 4: ssh keypair + forgejo registration ---------- forgejo_ssh_key() { section "forgejo ssh key" local keyfile="$KEYFILE" if [ ! -f "$keyfile" ]; then ssh-keygen -t ed25519 -f "$keyfile" -N "" -C "${KEY_NAME}-$(date +%Y%m%d)" >/dev/null fi local pubkey fingerprint pubkey=$(cat "${keyfile}.pub") fingerprint=$(ssh-keygen -lf "${keyfile}.pub" | awk '{print $2}') echo "Need a token: log into $FORGEJO_URL -> avatar (top right) -> Settings ->" echo "Applications tab -> Manage Access Tokens -> name it, grant 'write:user'" echo "scope (or tick 'user' read+write) -> Generate Token -> copy it now, it's" echo "only shown once." local token read -rsp "Forgejo access token (used once, not stored): " token echo local tmpbody status tmpbody=$(mktemp) if ! status=$(curl -sS -o "$tmpbody" -w '%{http_code}' -H "Authorization: token $token" "$FORGEJO_URL/api/v1/user/keys"); then step_fail "forgejo ssh key" "GET /api/v1/user/keys - curl could not connect at all (DNS/TLS/network)" unset token; rm -f "$tmpbody" return fi if [ "$status" != "200" ]; then step_fail "forgejo ssh key" "GET /api/v1/user/keys - HTTP $status: $(cat "$tmpbody") (401=bad token, 403=missing write:user scope, 404=check FORGEJO_URL)" unset token; rm -f "$tmpbody" return fi local existing existing=$(cat "$tmpbody") rm -f "$tmpbody" if echo "$existing" | jq -e --arg fp "$fingerprint" '.[] | select(.fingerprint == $fp)' >/dev/null 2>&1; then step_skip "forgejo ssh key" "already registered ($fingerprint)" unset token return fi local payload payload=$(jq -n --arg title "${KEY_NAME}-$(hostname)-$(date +%Y%m%d)" --arg key "$pubkey" \ '{title: $title, key: $key}') tmpbody=$(mktemp) if ! status=$(curl -sS -o "$tmpbody" -w '%{http_code}' -X POST \ -H "Authorization: token $token" \ -H "Content-Type: application/json" \ -d "$payload" \ "$FORGEJO_URL/api/v1/user/keys"); then step_fail "forgejo ssh key" "POST /api/v1/user/keys - curl could not connect at all (DNS/TLS/network)" unset token; rm -f "$tmpbody" return fi if [ "$status" = "200" ] || [ "$status" = "201" ]; then step_ok "forgejo ssh key" "registered ($fingerprint)" else step_fail "forgejo ssh key" "POST /api/v1/user/keys - HTTP $status: $(cat "$tmpbody") (401=bad token, 403=missing write:user scope, 404=check FORGEJO_URL)" fi rm -f "$tmpbody" unset token } # ---------- phase 5: browser hardening ---------- harden_firefox() { section "firefox hardening" local policy_dir="/etc/firefox/policies" local policy_file="$policy_dir/policies.json" local desired desired=$(cat <<'EOF' { "policies": { "DisableTelemetry": true, "DisableFirefoxAccounts": true, "OfferToSaveLogins": false, "NoDefaultBookmarks": true, "DisableFormHistory": true, "DisablePocket": true, "Preferences": { "browser.privatebrowsing.autostart": { "Value": true, "Status": "locked" }, "places.history.enabled": { "Value": false, "Status": "locked" }, "browser.cache.disk.enable": { "Value": false, "Status": "locked" } }, "ExtensionSettings": { "uBlock0@raymondhill.net": { "installation_mode": "force_installed", "install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi" } } } } EOF ) if [ -f "$policy_file" ] && [ "$(cat "$policy_file")" = "$desired" ]; then step_skip "firefox policy" "already up to date" return fi sudo mkdir -p "$policy_dir" echo "$desired" | sudo tee "$policy_file" >/dev/null step_ok "firefox policy" "wrote $policy_file - VERIFY: private-browsing-autostart + forced uBlock Origin behave as expected on first launch" } main() { preflight_env gather_config preflight_connectivity install_packages tailscale_join regen_ssh_config forgejo_ssh_key harden_firefox print_summary } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi