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>
367 lines
11 KiB
Bash
Executable file
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
|