infra/bootstrap.sh
Tommy Rantti 0c742e6a2f Print concrete next steps after a successful run
The summary just said "all OK" and left you to figure out what to do.
Now it lists the tailnet hosts this run actually found and spells out
the exact VS Code Remote-SSH steps to reach one, instead of assuming
you remember from the design conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 20:21:37 +03:00

420 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
set -uo pipefail
STEP_NAMES=()
STEP_STATUS=()
STEP_DETAIL=()
KNOWN_HOSTS=()
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."
echo
echo "================== next steps ======================"
echo "1. Check you're actually on the tailnet:"
echo " tailscale status"
echo
if [ "${#KNOWN_HOSTS[@]}" -gt 0 ]; then
echo "2. Open VS Code, Ctrl+Shift+P -> 'Remote-SSH: Connect to Host...',"
echo " and pick one of the hosts this run found on your tailnet:"
for h in "${KNOWN_HOSTS[@]}"; do
echo " - $h"
done
echo " (a host you expect but don't see here just isn't on the tailnet"
echo " right now - check it's powered on and connected, then re-run"
echo " this script to refresh the list)"
else
echo "2. No other tailnet peers were found yet. Once your home machine is"
echo " online and joined to the tailnet, re-run this script to add it,"
echo " then use VS Code's 'Remote-SSH: Connect to Host...' to reach it."
fi
echo
echo "3. Once connected, File > Open Folder to browse that machine's"
echo " filesystem and get to your project - same as opening a folder"
echo " locally, just on the remote host."
echo "====================================================="
}
# ---------- 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"
local config_file="$HOME/.config/travel-bootstrap/config"
mkdir -p "$(dirname "$config_file")"
local DEFAULT_FORGEJO_URL="" DEFAULT_KEY_NAME=""
if [ -f "$config_file" ]; then
# shellcheck disable=SC1090
source "$config_file"
fi
if [ -z "${FORGEJO_URL:-}" ]; then
if [ -n "$DEFAULT_FORGEJO_URL" ]; then
read -rp "Forgejo instance URL [$DEFAULT_FORGEJO_URL]: " FORGEJO_URL
FORGEJO_URL="${FORGEJO_URL:-$DEFAULT_FORGEJO_URL}"
else
read -rp "Forgejo instance URL (e.g. https://git.example.com): " FORGEJO_URL
fi
fi
FORGEJO_URL="${FORGEJO_URL%/}"
if [ -z "${KEY_NAME:-}" ]; then
local key_default="${DEFAULT_KEY_NAME:-travel-laptop}"
read -rp "Label for this device's SSH key [$key_default]: " KEY_NAME
KEY_NAME="${KEY_NAME:-$key_default}"
fi
KEYFILE="$HOME/.ssh/id_ed25519_${KEY_NAME}"
cat > "$config_file" <<EOF
DEFAULT_FORGEJO_URL="$FORGEJO_URL"
DEFAULT_KEY_NAME="$KEY_NAME"
EOF
chmod 600 "$config_file"
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 <<EOF
$begin
Host *.$suffix
User $user
IdentityFile ~/.ssh/id_ed25519_${KEY_NAME}
EOF
)
while read -r name dns; do
[ -z "$name" ] && continue
KNOWN_HOSTS+=("$name")
new_block+=$(cat <<EOF
Host $name
HostName $dns
User $user
IdentityFile ~/.ssh/id_ed25519_${KEY_NAME}
EOF
)
done < <(echo "$status_json" | jq -r '.Peer[] | "\(.HostName) \(.DNSName | rtrimstr("."))"')
new_block+=$'\n'"$end"
local old_block
old_block=$(awk -v b="$begin" -v e="$end" '$0==b{f=1} f{print} $0==e{f=0}' "$sshconf")
if [ "$old_block" = "$new_block" ]; then
step_skip "ssh config (tailnet hosts)" "no change - tailnet peers unchanged"
return
fi
local tmp
tmp=$(mktemp)
awk -v b="$begin" -v e="$end" '$0==b{skip=1} !skip{print} $0==e{skip=0}' "$sshconf" > "$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."
echo "Paste the token below, then press Enter and then Ctrl-D to finish:"
local token
token=$(cat)
token="${token//[[:space:]]/}"
if [ -z "$token" ]; then
echo "Captured: empty (nothing came through - paste may not have worked in this terminal)"
else
echo "Captured: length=${#token} chars, ${token:0:4}...${token: -4}"
fi
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