#!/usr/bin/env bash # Panama's front door: the one command a fresh Fedora machine needs. # # Download this file from the documented commit URL, verify its documented # SHA-256, then pass both immutable values as PANAMA_BOOT_REVISION and # PANAMA_BOOT_SHA256 when invoking it. # # Deliberately dumb, because a copy of this script leaves the repository the # moment somebody curls it -- nothing here can be fixed by re-running # ./install, so there is as little here as possible: get git, get the clone, # hand off. Everything with judgment in it lives in `install`, which is also # where re-runs and upgrades already work. # # The one exception to dumb is the root path below, which cannot live in # `install`: a fresh VPS hands you a root login and nothing else, and the user # that `install` needs to exist is exactly what has not been created yet. set -euo pipefail if [[ ! "${PANAMA_BOOT_REVISION:-}" =~ ^[0-9a-f]{40}$ ]]; then echo "boot: PANAMA_BOOT_REVISION must be a full lowercase commit" >&2 exit 1 fi if [[ ! "${PANAMA_BOOT_SHA256:-}" =~ ^[0-9a-f]{64}$ ]]; then echo "boot: PANAMA_BOOT_SHA256 must be a lowercase SHA-256" >&2 exit 1 fi actual_boot_sha="$(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)" if [[ "$actual_boot_sha" != "$PANAMA_BOOT_SHA256" ]]; then echo "boot: downloaded boot file does not match PANAMA_BOOT_SHA256" >&2 exit 1 fi REPO_URL="https://git.gbrown.org/gib/Panama.git" PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" export PANAMA_PATH BOOTSTRAP_USER="" checkout_command() { if [[ -n "$BOOTSTRAP_USER" ]]; then runuser -u "$BOOTSTRAP_USER" -- "$@" else "$@" fi } prepare_panama_checkout() { local checkout="$1" actual_head checkout_status if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then printf 'boot: %s exists but is not a Panama Git checkout\n' "$checkout" >&2 return 1 fi if [[ -d "$checkout/.git" ]]; then checkout_status="$(checkout_command git -C "$checkout" status --porcelain)" || { printf 'boot: could not inspect the existing checkout at %s\n' "$checkout" >&2 return 1 } if [[ -n "$checkout_status" ]]; then printf 'boot: existing checkout at %s has local changes\n' "$checkout" >&2 return 1 fi echo "Panama is already cloned at $checkout; fetching the verified revision" checkout_command git -C "$checkout" fetch origin "$PANAMA_BOOT_REVISION" || return 1 if ! checkout_command git -C "$checkout" merge-base --is-ancestor \ HEAD "$PANAMA_BOOT_REVISION"; then echo "boot: existing checkout cannot fast-forward to the verified revision" >&2 return 1 fi checkout_command git -C "$checkout" merge --ff-only "$PANAMA_BOOT_REVISION" || return 1 else checkout_command mkdir -p "$(dirname "$checkout")" || return 1 checkout_command git init "$checkout" || return 1 checkout_command git -C "$checkout" remote add origin "$REPO_URL" || return 1 checkout_command git -C "$checkout" fetch --depth=1 origin \ "$PANAMA_BOOT_REVISION" || return 1 checkout_command git -C "$checkout" checkout --detach \ "$PANAMA_BOOT_REVISION" || return 1 actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1 if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then echo "boot: fetched checkout does not match PANAMA_BOOT_REVISION" >&2 return 1 fi checkout_command git -C "$checkout" checkout -b main || return 1 checkout_command git -C "$checkout" config branch.main.remote origin || return 1 checkout_command git -C "$checkout" config branch.main.merge refs/heads/main || return 1 fi actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1 if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then echo "boot: checkout HEAD does not match PANAMA_BOOT_REVISION" >&2 return 1 fi } SERVER=0 INSTALL_ARGS=() for arg in "$@"; do case "$arg" in --server) SERVER=1; INSTALL_ARGS+=(--server) ;; *) printf 'boot: unknown argument: %s\n' "$arg" >&2 printf 'usage: boot [--server]\n' >&2 exit 2 ;; esac done # The public bootstrap contract runs this branch as an ordinary user with a # stubbed root identity. Keep its filesystem adapter unavailable to a real root # shell so it cannot redirect a real installation by accident. BOOT_ROOT="${PANAMA_BOOT_FIXTURE_ROOT:-}" if [[ -n "$BOOT_ROOT" && "$EUID" -eq 0 ]]; then echo "boot: PANAMA_BOOT_FIXTURE_ROOT is test-only" >&2 exit 1 fi system_path() { local path="$1" [[ "$path" == /* ]] || return 2 printf '%s%s\n' "$BOOT_ROOT" "$path" } valid_authorized_keys() { local keys="$1" line saw_key=0 while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]]; then continue fi if ! ssh-keygen -l -f /dev/stdin >/dev/null 2>&1 <<<"$line"; then return 1 fi saw_key=1 done <"$keys" (( saw_key )) } safe_authorized_keys() { local username="$1" user_home="$2" uid ssh_dir keys uid="$(id -u "$username")" || return 1 [[ "$uid" =~ ^[0-9]+$ && "$uid" != 0 && "$user_home" == /* ]] || return 1 ssh_dir="$user_home/.ssh" keys="$ssh_dir/authorized_keys" [[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1 [[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1 [[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1 valid_authorized_keys "$keys" } safe_root_authorized_keys() { local keys keys="$(system_path /root/.ssh/authorized_keys)" || return 1 [[ -f "$keys" && ! -L "$keys" ]] || return 1 [[ "$(stat -Lc '%u:%a' "$keys")" == '0:600' ]] || return 1 valid_authorized_keys "$keys" } detect_ssh_unit() { local unit for unit in sshd.service ssh.service; do systemctl cat "$unit" >/dev/null 2>&1 && { printf '%s\n' "$unit" return 0 } done return 1 } restore_ssh_dropin() { local restore if (( ssh_had_prior )); then [[ -n "$ssh_backup" && -f "$ssh_backup" && ! -L "$ssh_backup" ]] || return 1 restore="$(mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.restore)" || return 1 if ! cp -a -- "$ssh_backup" "$restore"; then remove_ssh_artifact "$restore" || true return 1 fi if ! mv -f -- "$restore" "$ssh_dropin"; then remove_ssh_artifact "$restore" || true return 1 fi else remove_ssh_artifact "$ssh_dropin" fi } restore_ssh_transaction_traps() { trap - EXIT INT TERM [[ -n "${ssh_saved_exit_trap:-}" ]] && eval "$ssh_saved_exit_trap" [[ -n "${ssh_saved_int_trap:-}" ]] && eval "$ssh_saved_int_trap" [[ -n "${ssh_saved_term_trap:-}" ]] && eval "$ssh_saved_term_trap" return 0 } remove_ssh_artifact() { local artifact="$1" [[ -n "$artifact" && ( -e "$artifact" || -L "$artifact" ) ]] || return 0 if rm -f -- "$artifact"; then return 0 fi printf 'SSH transaction cleanup failed. Retained artifact: %s\n' "$artifact" >&2 printf ' rm -f -- %q\n' "$artifact" >&2 return 1 } print_ssh_recovery() { if (( ssh_had_prior )); then printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2 printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2 else printf 'SSH rollback needs manual recovery. No prior drop-in existed.\n' >&2 printf ' rm -f -- %q\n' "$ssh_dropin" >&2 fi printf ' sshd -t\n' >&2 printf ' systemctl reload %s\n' "$ssh_unit" >&2 } policy_is_no() { local policy="$1" setting="$2" awk -v setting="$setting" ' $1 == setting { count += 1; if ($2 != "no") bad = 1 } END { exit count != 1 || bad } ' <<<"$policy" } effective_ssh_policy_is_hardened() { local username="$1" root_policy target_policy context context='host=localhost,addr=127.0.0.1' root_policy="$(sshd -T -C "user=root,$context")" || return 1 policy_is_no "$root_policy" permitrootlogin || return 1 policy_is_no "$root_policy" passwordauthentication || return 1 policy_is_no "$root_policy" kbdinteractiveauthentication || return 1 target_policy="$(sshd -T -C "user=$username,$context")" || return 1 policy_is_no "$target_policy" passwordauthentication || return 1 policy_is_no "$target_policy" kbdinteractiveauthentication } rollback_ssh_transaction() { local reload_restored="$1" rollback_failed=0 restore_ssh_dropin || rollback_failed=1 sshd -t || rollback_failed=1 if (( reload_restored )); then systemctl reload "$ssh_unit" || rollback_failed=1 fi ssh_transaction_state="" restore_ssh_transaction_traps if (( rollback_failed )); then print_ssh_recovery else remove_ssh_artifact "$ssh_backup" || true fi return 1 } handle_ssh_transaction_exit() { if [[ "$ssh_transaction_state" == preparing \ || ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then remove_ssh_artifact "$ssh_candidate" || true remove_ssh_artifact "$ssh_backup" || true elif [[ "$ssh_transaction_state" == activating || "$ssh_transaction_state" == activated ]]; then restore_ssh_dropin || true fi } handle_ssh_transaction_signal() { local signal_status="$1" trap - INT TERM if [[ "$ssh_transaction_state" == preparing \ || ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then ssh_transaction_state="" restore_ssh_transaction_traps remove_ssh_artifact "$ssh_candidate" || true remove_ssh_artifact "$ssh_backup" || true else rollback_ssh_transaction 1 || true fi exit "$signal_status" } harden_server_ssh() { local username="$1" user_home="$2" sshd_dir ssh_dropin harden ssh_unit local ssh_candidate="" ssh_backup="" ssh_had_prior=0 local ssh_transaction_state="" local ssh_saved_exit_trap ssh_saved_int_trap ssh_saved_term_trap sshd_dir="$(system_path /etc/ssh/sshd_config.d)" || return 1 ssh_dropin="$sshd_dir/00-panama.conf" if [[ -L "$ssh_dropin" || ( -e "$ssh_dropin" && ! -f "$ssh_dropin" ) ]]; then printf 'SSH hardening unavailable: %s is not a regular file\n' "$ssh_dropin" >&2 return 2 fi if ! ssh_unit="$(detect_ssh_unit)"; then echo "SSH hardening unavailable: neither sshd.service nor ssh.service is installed" >&2 return 2 fi printf 'Harden sshd (disable root, password, and keyboard-interactive authentication)? [Y/n]: ' read -r harden "$ssh_candidate"; then ssh_transaction_state="" restore_ssh_transaction_traps remove_ssh_artifact "$ssh_candidate" || true return 1 fi if [[ -e "$ssh_dropin" ]]; then ssh_had_prior=1 if ! ssh_backup="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.backup)"; then ssh_transaction_state="" restore_ssh_transaction_traps remove_ssh_artifact "$ssh_candidate" || true return 1 fi if ! cp -a -- "$ssh_dropin" "$ssh_backup"; then ssh_transaction_state="" restore_ssh_transaction_traps remove_ssh_artifact "$ssh_candidate" || true remove_ssh_artifact "$ssh_backup" || true return 1 fi fi ssh_transaction_state=activating if ! mv -f -- "$ssh_candidate" "$ssh_dropin"; then ssh_transaction_state="" restore_ssh_transaction_traps remove_ssh_artifact "$ssh_candidate" || true remove_ssh_artifact "$ssh_backup" || true return 1 fi ssh_candidate="" ssh_transaction_state=activated if ! sshd -t || ! effective_ssh_policy_is_hardened "$username"; then rollback_ssh_transaction 0 || true return 1 fi if ! systemctl reload "$ssh_unit"; then rollback_ssh_transaction 1 || true return 1 fi ssh_transaction_state="" restore_ssh_transaction_traps remove_ssh_artifact "$ssh_backup" || return 1 echo "Wrote $ssh_dropin; make sure your key works before logging out." } # Panama assumes Fedora's repositories and package names. if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then echo "This looks like something other than Fedora; Panama only supports Fedora." >&2 exit 1 fi # ── Root ───────────────────────────────────────────────────────────────────── # # On a desktop, root is a mistake: the clone and every dotfile would land in # root's home and configure the wrong user. On a fresh VPS it is the starting # condition -- Hetzner hands over a root login and nothing else -- so with # --server this walks the machine from that to a normal Panama install: a # user with sudo, keys, an optionally hardened sshd, and `install --server` # running as that user. Every step checks before acting, because the machine # may be anywhere along this path already: a user half-created by hand, keys # already copied, sshd already locked down. if [[ "$(id -u)" -eq 0 ]]; then if (( ! SERVER )); then echo "Run this as your own user, not root: the install configures YOUR desktop." >&2 echo "Setting up a fresh server from its root login is: boot --server" >&2 exit 1 fi # Everything here asks, and a root shell from `bash <(curl ...)` can have # the pipe as stdin, so every prompt reads the terminal explicitly. if ! (exec /dev/null; then echo "No terminal to ask on; run this from an interactive root shell." >&2 exit 1 fi printf 'Username for this server [gib]: ' read -r username /dev/null 2>&1; then echo "User $username already exists" # wheel is what makes sudo work on Fedora; a user created by hand may not # have it, and everything after this depends on it. id -nG "$username" | grep -qw wheel || usermod -aG wheel "$username" else echo "Creating $username with sudo (wheel)" useradd -m -G wheel "$username" fi # useradd leaves the account locked, and sudo asks for this password -- a # user who cannot sudo is a user the install cannot run as. if ! passwd -S "$username" 2>/dev/null | awk '{exit $2 != "PS" && $2 != "P"}'; then echo "Set a password for $username (sudo will ask for it):" passwd "$username" &2 else copy_root_key=1 fi if (( copy_root_key )); then echo "Copying root's authorized_keys to $username" root_keys="$(system_path /root/.ssh/authorized_keys)" if ! runuser -u "$username" -- install -d -m 0700 -- "$user_ssh_dir" \ || ! runuser -u "$username" -- install -m 0600 -- /dev/stdin "$user_keys" \ <"$root_keys"; then echo "SSH hardening unavailable: could not install root's key for $username" >&2 fi fi fi if safe_authorized_keys "$username" "$user_home"; then harden_status=0 harden_server_ssh "$username" "$user_home" || harden_status=$? if (( harden_status != 0 && harden_status != 2 )); then echo "SSH hardening failed; stopping before install handoff." >&2 exit 1 fi else echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2 fi if ! command -v git >/dev/null 2>&1; then echo "Installing git, which the clone needs" dnf install -y git fi # Create or advance the checkout as the target user. A root-owned .git in a # user's home would break every later update. PANAMA_PATH="$bootstrap_home/.local/share/Panama" BOOTSTRAP_USER="$username" prepare_panama_checkout "$PANAMA_PATH" echo "Handing off to install as $username" exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \ "$PANAMA_PATH/install" --server /dev/null 2>&1; then echo "Installing git, which the clone needs" sudo dnf install -y git fi prepare_panama_checkout "$PANAMA_PATH" # A shell invoked from automation can have a pipe as stdin, while the first # thing install runs is the interview, which has to be able to ask. # Reattach the terminal when there is one; without one the interview will say # so itself. # The probe actually opens /dev/tty rather than testing -r: a process with no # controlling terminal passes -r and then fails the redirect. if [[ ! -t 0 ]] && (exec /dev/null; then exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}