From 1bddb438ea5db530a0b2e97343b1e94311a0bc2e Mon Sep 17 00:00:00 2001 From: Tommy Rantti Date: Sat, 26 Sep 2026 19:29:15 +0300 Subject: [PATCH] Add travel laptop bootstrap script Idempotent setup for a disposable Debian travel laptop: base packages (vim, tmux, mosh, tailscale, uv, VS Code + Remote-SSH, firefox-esr), tailscale join, SSH config regenerated from live tailnet peers (wildcard + per-peer entries), fresh SSH key registered to Forgejo via API, and Firefox hardened to forced-incognito with no history and forced uBlock Origin. No repos cloned by default - workflow starts as pure Remote-SSH to the home machine, local clones added opportunistically per project. Co-Authored-By: Claude Sonnet 5 --- bootstrap.sh | 346 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100755 bootstrap.sh diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..34ace24 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,346 @@ +#!/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}') + + local token + read -rsp "Forgejo access token (write:user scope, used once, not stored): " token + echo + + local existing + if ! existing=$(curl -fsS -H "Authorization: token $token" "$FORGEJO_URL/api/v1/user/keys" 2>/dev/null); then + step_fail "forgejo ssh key" "could not reach $FORGEJO_URL/api/v1/user/keys" + unset token + return + fi + + 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}') + + if curl -fsS -X POST \ + -H "Authorization: token $token" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "$FORGEJO_URL/api/v1/user/keys" >/dev/null; then + step_ok "forgejo ssh key" "registered ($fingerprint)" + else + step_fail "forgejo ssh key" "POST to $FORGEJO_URL/api/v1/user/keys failed" + fi + 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