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 <noreply@anthropic.com>
This commit is contained in:
commit
1bddb438ea
1 changed files with 346 additions and 0 deletions
346
bootstrap.sh
Executable file
346
bootstrap.sh
Executable file
|
|
@ -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 <<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}')
|
||||
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue