infra/bootstrap.sh
Tommy Rantti d52d1060ce Improve Forgejo API error reporting and add token instructions
curl -f collapsed auth failures, wrong paths, and real network errors
into the same vague "could not reach" message. Now reports the actual
HTTP status and response body, and prompts print exactly where to
generate a Forgejo access token before asking for it.

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

367 lines
11 KiB
Bash
Executable file

#!/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 <<EOF
$begin
Host *.$suffix
User $user
IdentityFile ~/.ssh/id_ed25519_${KEY_NAME}
EOF
)
while read -r name dns; do
[ -z "$name" ] && continue
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."
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