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>
420 lines
13 KiB
Bash
Executable file
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
|