Last active
July 29, 2026 23:13
-
-
Save dragonworx/1ef59bb118bf018d876d4a0cab78e67d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # ubuntu-bootstrap.sh — stage 1 of 2. Small enough to read before you run it. | |
| # | |
| # Fresh Ubuntu 26.04 box, logged in as root: | |
| # | |
| # curl -fsSL "https://gist.githubusercontent.com/dragonworx/1ef59bb118bf018d876d4a0cab78e67d/raw/ubuntu-bootstrap.sh?cb=$(date +%s)" | bash | |
| # | |
| # Job: collect what stage 2 needs, patch the box, install the packages needed | |
| # to fetch stage 2, then hand off. Nothing else. | |
| # | |
| # Everything is asked for UP FRONT, before any change is made, so a missing | |
| # answer costs you nothing. Neither gist contains your identity or your keys. | |
| # | |
| set -euo pipefail | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Stage 2 location | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| STAGE2_URL="${STAGE2_URL:-https://gist.githubusercontent.com/dragonworx/0953c926ddcb0aae054e5426f19213e7/raw/ubuntu-provision.sh}" | |
| # This raw URL always serves the newest revision, which is what you want: | |
| # edit the gist, the next box picks it up. To freeze a build instead, insert a | |
| # 40-char revision SHA: .../raw/<sha>/ubuntu-provision.sh | |
| STAGE2_SHA256="${STAGE2_SHA256:-}" | |
| STAGE2_PATH="/root/ubuntu-provision.sh" | |
| # Passed through to stage 2. Rootless Docker needs unprivileged user | |
| # namespaces, which Ubuntu blocks by default; true lifts that restriction | |
| # system-wide if the targeted AppArmor exemption fails. | |
| PERMIT_USERNS="${PERMIT_USERNS:-true}" | |
| C_OK=$'\033[32m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_HDR=$'\033[1;36m'; C_OFF=$'\033[0m' | |
| log() { printf '%s\n' "${C_HDR}==>${C_OFF} $*"; } | |
| ok() { printf '%s\n' " ${C_OK}OK${C_OFF} $*"; } | |
| die() { printf '%s\n' "${C_ERR}FATAL:${C_OFF} $*" >&2; exit 1; } | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 0. Preflight | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| [[ $EUID -eq 0 ]] || die "run this as root" | |
| [[ -r /etc/os-release ]] || die "cannot read /etc/os-release" | |
| . /etc/os-release | |
| [[ "${ID:-}" == "ubuntu" ]] || die "this targets Ubuntu, found: ${ID:-unknown}" | |
| log "Ubuntu ${VERSION_ID:-?} (${VERSION_CODENAME:-?})" | |
| # Prompts read from /dev/tty, not stdin. When this script arrives via | |
| # `curl | bash`, stdin IS the script — a bare `read` would swallow the next | |
| # line of code instead of waiting for you. | |
| HAVE_TTY=false | |
| if [[ -e /dev/tty ]] && : >/dev/tty 2>/dev/null; then HAVE_TTY=true; fi | |
| valid_pubkey() { | |
| local k="$1" | |
| if command -v ssh-keygen >/dev/null 2>&1; then | |
| printf '%s\n' "$k" | ssh-keygen -lf - >/dev/null 2>&1 | |
| else | |
| [[ "$k" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-[a-z0-9]+|sk-[a-z0-9@.-]+)[[:space:]]+[A-Za-z0-9+/=]{20,} ]] | |
| fi | |
| } | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 1. The SSH key that will get you back in | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Asked first, because it is the only answer whose absence can lock you out. | |
| # Stage 2 disables password authentication; dev must already be reachable by | |
| # key before that happens. | |
| ADMIN_PUBKEY="${ADMIN_PUBKEY:-}" | |
| KEY_SOURCE="" | |
| if [[ -n "$ADMIN_PUBKEY" ]]; then | |
| KEY_SOURCE="supplied via ADMIN_PUBKEY" | |
| elif [[ -r /root/.ssh/authorized_keys ]] \ | |
| && grep -qE '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys; then | |
| ADMIN_PUBKEY="$(grep -m1 -E '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys)" | |
| KEY_SOURCE="inherited from root" | |
| elif $HAVE_TTY; then | |
| cat > /dev/tty <<'PROMPT' | |
| You are logged in as root by password, so there is no key to inherit. | |
| Stage 2 turns password login off, so dev needs a public key now. | |
| On the machine you want to connect FROM: | |
| cat ~/.ssh/id_ed25519.pub | |
| ssh-keygen -t ed25519 -C "me@laptop" # if you have no key yet | |
| Paste the PUBLIC key below — one line, starting ssh-ed25519 or ssh-rsa. | |
| PROMPT | |
| while :; do | |
| printf '> ' > /dev/tty | |
| IFS= read -r ADMIN_PUBKEY < /dev/tty | |
| ADMIN_PUBKEY="$(printf '%s' "$ADMIN_PUBKEY" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" | |
| if [[ "$ADMIN_PUBKEY" == *"PRIVATE KEY"* ]]; then | |
| echo " That is a PRIVATE key — never put one on a server." > /dev/tty | |
| echo " Run 'ssh-keygen -y -f <keyfile>' and paste that one line." > /dev/tty | |
| continue | |
| fi | |
| if valid_pubkey "$ADMIN_PUBKEY"; then KEY_SOURCE="pasted at bootstrap"; break; fi | |
| echo " Not a valid public key. Expect: '<type> <base64> <comment>'" > /dev/tty | |
| echo " If you copied from a terminal, line wrapping may have broken it." > /dev/tty | |
| done | |
| else | |
| die "no SSH public key, and no terminal to ask for one. | |
| Re-run with: ADMIN_PUBKEY=\"ssh-ed25519 AAAA... me@laptop\" bash \$0" | |
| fi | |
| valid_pubkey "$ADMIN_PUBKEY" || die "ADMIN_PUBKEY is not a valid public key" | |
| if command -v ssh-keygen >/dev/null 2>&1; then | |
| ok "key $(printf '%s\n' "$ADMIN_PUBKEY" | ssh-keygen -lf - | awk '{print $2}') (${KEY_SOURCE})" | |
| else | |
| ok "key accepted (${KEY_SOURCE})" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 1b. Server name | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Cosmetic, but fiddly to change later because three separate things have to | |
| # agree: the kernel hostname, /etc/hosts, and cloud-init's preserve_hostname. | |
| # Miss the /etc/hosts line and every sudo stalls for seconds on a failed | |
| # reverse lookup, which is easy to misdiagnose as something else entirely. | |
| HOSTNAME_SET="${HOSTNAME_SET:-}" | |
| HOSTNAME_RE='^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' | |
| if [[ -z "$HOSTNAME_SET" ]] && $HAVE_TTY; then | |
| echo > /dev/tty | |
| echo "Server name — currently '$(hostname -s)'. Blank to leave it alone." > /dev/tty | |
| echo " Lowercase letters, digits and hyphens. Max 63 characters." > /dev/tty | |
| echo > /dev/tty | |
| while :; do | |
| printf ' Server name : ' > /dev/tty | |
| IFS= read -r HOSTNAME_SET < /dev/tty | |
| HOSTNAME_SET="$(printf '%s' "$HOSTNAME_SET" | tr -d '\r' | tr '[:upper:]' '[:lower:]' \ | |
| | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" | |
| [[ -z "$HOSTNAME_SET" ]] && break | |
| [[ "$HOSTNAME_SET" =~ $HOSTNAME_RE ]] && break | |
| echo " Not a valid hostname — letters, digits, hyphens; no leading or" > /dev/tty | |
| echo " trailing hyphen, no dots, no underscores." > /dev/tty | |
| done | |
| fi | |
| if [[ -n "$HOSTNAME_SET" ]]; then | |
| [[ "$HOSTNAME_SET" =~ $HOSTNAME_RE ]] || die "HOSTNAME_SET is not a valid hostname: ${HOSTNAME_SET}" | |
| ok "server name ${HOSTNAME_SET}" | |
| else | |
| ok "server name unchanged ($(hostname -s))" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 2. Git identity | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| GIT_NAME="${GIT_NAME:-}" | |
| GIT_EMAIL="${GIT_EMAIL:-}" | |
| EMAIL_RE='^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' | |
| if [[ -z "$GIT_NAME" || -z "$GIT_EMAIL" ]]; then | |
| $HAVE_TTY || die "no terminal — pass the identity instead: | |
| GIT_NAME=\"Your Name\" GIT_EMAIL=\"you@example.com\" bash \$0" | |
| echo > /dev/tty | |
| echo "Git identity — used for commits made by the dev user on this box." > /dev/tty | |
| echo > /dev/tty | |
| while [[ -z "$GIT_NAME" ]]; do | |
| printf ' Full name : ' > /dev/tty | |
| read -r GIT_NAME < /dev/tty | |
| done | |
| while [[ ! "$GIT_EMAIL" =~ $EMAIL_RE ]]; do | |
| printf ' Email : ' > /dev/tty | |
| read -r GIT_EMAIL < /dev/tty | |
| [[ "$GIT_EMAIL" =~ $EMAIL_RE ]] || \ | |
| echo " ...that doesn't look like an email address." > /dev/tty | |
| done | |
| fi | |
| ok "identity ${GIT_NAME} <${GIT_EMAIL}>" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 2b. Sudo password | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # This is for sudo only — SSH is key-only, so it can never be used to log in. | |
| # You will type it often, because credential caching is disabled by default, so | |
| # pick something you can actually type on a phone keyboard. | |
| DEV_PASSWORD="${DEV_PASSWORD:-}" | |
| PASSWORD_SOURCE="supplied via DEV_PASSWORD" | |
| if [[ -z "$DEV_PASSWORD" ]] && $HAVE_TTY; then | |
| cat > /dev/tty <<'PROMPT' | |
| Sudo password for the dev user. | |
| · Used for sudo only. SSH is key-only, so this cannot log anyone in. | |
| · You will type it often — no credential caching by default. | |
| · Leave blank to have a random 24-character one generated instead. | |
| PROMPT | |
| while :; do | |
| printf ' Password (hidden) : ' > /dev/tty | |
| IFS= read -rs DEV_PASSWORD < /dev/tty; echo > /dev/tty | |
| if [[ -z "$DEV_PASSWORD" ]]; then | |
| PASSWORD_SOURCE="auto-generated, printed at the end"; break | |
| fi | |
| if (( ${#DEV_PASSWORD} < 12 )); then | |
| echo " Too short — 12 characters minimum." > /dev/tty | |
| DEV_PASSWORD=""; continue | |
| fi | |
| printf ' Confirm : ' > /dev/tty | |
| IFS= read -rs CONFIRM < /dev/tty; echo > /dev/tty | |
| if [[ "$DEV_PASSWORD" == "$CONFIRM" ]]; then | |
| PASSWORD_SOURCE="set by you (${#DEV_PASSWORD} characters)"; break | |
| fi | |
| echo " They don't match — try again." > /dev/tty | |
| DEV_PASSWORD="" | |
| done | |
| unset CONFIRM | |
| elif [[ -z "$DEV_PASSWORD" ]]; then | |
| PASSWORD_SOURCE="auto-generated, printed at the end" | |
| fi | |
| ok "sudo password ${PASSWORD_SOURCE}" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 3. Confirm, then start changing the machine | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| if $HAVE_TTY; then | |
| cat > /dev/tty <<EOF | |
| ──────────────────────────────────────────────────────────────────────────── | |
| SSH key ${KEY_SOURCE} | |
| Server name ${HOSTNAME_SET:-unchanged} | |
| Git identity ${GIT_NAME} <${GIT_EMAIL}> | |
| Sudo password ${PASSWORD_SOURCE} | |
| Rootless PERMIT_USERNS=${PERMIT_USERNS} | |
| Nothing has changed yet. Past this point stage 2 creates the dev user, | |
| disables password authentication and raises the firewall. | |
| ──────────────────────────────────────────────────────────────────────────── | |
| EOF | |
| printf ' Proceed? [y/N] ' > /dev/tty | |
| read -r reply < /dev/tty | |
| [[ "$reply" =~ ^[Yy]$ ]] || die "aborted — nothing has been changed" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 4. Patch the box | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| export DEBIAN_FRONTEND=noninteractive | |
| APT_OPTS=(-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold) | |
| log "Applying all pending security and package updates" | |
| apt-get update -y | |
| apt-get "${APT_OPTS[@]}" full-upgrade | |
| apt-get "${APT_OPTS[@]}" autoremove --purge | |
| ok "system patched" | |
| # Only what's needed to fetch and verify stage 2. Everything else — git | |
| # included — is stage 2's job. A gist is fetched over HTTPS, not cloned. | |
| log "Installing fetch prerequisites" | |
| apt-get "${APT_OPTS[@]}" install curl ca-certificates openssh-client | |
| ok "curl + ca-certificates + openssh-client" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 5. Fetch stage 2 | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Fetching ubuntu-provision.sh" | |
| # ?cb= is a cache-buster. GitHub serves raw gist content via a CDN, so for a | |
| # minute or two after an edit the plain URL can still return the old revision, | |
| # which looks exactly like "my fix didn't work". | |
| curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --max-time 60 \ | |
| "${STAGE2_URL}?cb=$(date +%s)$$" -o "$STAGE2_PATH" \ | |
| || die "could not fetch stage 2 from ${STAGE2_URL}" | |
| [[ -s "$STAGE2_PATH" ]] || die "stage 2 downloaded empty" | |
| head -1 "$STAGE2_PATH" | grep -q '^#!' || die "stage 2 has no shebang. | |
| Either the gist still holds a placeholder, or the URL returned an error | |
| page. Check: curl -fsSL \"${STAGE2_URL}\" | head" | |
| if [[ -n "$STAGE2_SHA256" ]]; then | |
| GOT="$(sha256sum "$STAGE2_PATH" | awk '{print $1}')" | |
| [[ "$GOT" == "$STAGE2_SHA256" ]] || die "checksum mismatch | |
| expected ${STAGE2_SHA256} | |
| got ${GOT}" | |
| ok "sha256 verified" | |
| else | |
| ok "downloaded $(wc -l < "$STAGE2_PATH") lines" | |
| fi | |
| bash -n "$STAGE2_PATH" || die "stage 2 failed a syntax check — download is corrupt" | |
| chmod 700 "$STAGE2_PATH" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 6. Hand off | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| cat <<EOF | |
| ──────────────────────────────────────────────────────────────────────────── | |
| Stage 1 done. Stage 2 takes 10-15 minutes. | |
| KEEP THIS SESSION OPEN until 'ssh dev@<host>' works from a second terminal. | |
| Password login stops partway through, and dev will then be reachable only by | |
| the key you supplied above. | |
| Output is logged to /root/provision.log | |
| ──────────────────────────────────────────────────────────────────────────── | |
| EOF | |
| log "Starting stage 2" | |
| # Exported, not passed as arguments to `env`: a command line is visible in | |
| # `ps` to every user on the machine, while an exported variable lives in | |
| # /proc/PID/environ and is readable only by the process owner. That matters | |
| # here because one of these is a password. | |
| export ADMIN_PUBKEY GIT_NAME GIT_EMAIL PERMIT_USERNS DEV_PASSWORD HOSTNAME_SET | |
| export SKIP_SYSTEM_UPGRADE=true | |
| exec bash "$STAGE2_PATH" 2>&1 | tee /root/provision.log |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment