Last active
July 30, 2026 03:49
-
-
Save dragonworx/0953c926ddcb0aae054e5426f19213e7 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 | |
| # | |
| # provision.sh — first-run provisioning for a fresh Ubuntu 26.04 LTS VPS | |
| # | |
| # Target : Ubuntu 26.04 LTS "Resolute Raccoon", 4 vCPU / 8 GB RAM | |
| # Run as : root, on a brand new machine, once | |
| # | |
| # sudo bash provision.sh 2>&1 | tee /root/provision.log | |
| # | |
| # Takes roughly 10-15 minutes. A verification pass at the end reports | |
| # PASS/FAIL for every component, then prints the key material. | |
| # | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # PRIVILEGE MODEL | |
| # | |
| # dev never runs as root. It has two separate paths to get there: | |
| # | |
| # 1. HUMAN PATH — full root, password required, zero credential caching. | |
| # You type the password. An agent cannot: its bash tool has no | |
| # interactive TTY, so `sudo bash` prompts, hangs, and fails. This is the | |
| # fail-closed boundary and it does the real work. | |
| # | |
| # 2. AGENT PATH — a fixed list of maintenance binaries, NOPASSWD. | |
| # apt, systemctl, ufw, journalctl, caddy. Enough for coding agents to | |
| # install packages, manage services and open ports. Deliberately excludes | |
| # every one-step root shell: no bash, tee, cp, chmod, chown, dd, find, | |
| # editors, docker. | |
| # | |
| # On top, both agents are configured so `sudo` always triggers a | |
| # confirmation prompt, even in auto-approve mode. | |
| # | |
| # Honest limit: this stops accidents, not determined escalation. `apt-get | |
| # install` runs maintainer scripts as root by design, so anything that can | |
| # install software can eventually own the box. What you get is a hard stop | |
| # on casual mistakes, a prompt on every escalation, and a full audit trail. | |
| # There is no configuration where an agent configures this host AND is | |
| # contained from it — you're choosing where the human sits. | |
| # | |
| set -euo pipefail | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # CONFIGURATION — review before running | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Every value below can be overridden from the environment, so this file can | |
| # live in a public gist without carrying your identity or your host layout: | |
| # | |
| # GIT_NAME="Your Name" GIT_EMAIL="you@example.com" bash provision.sh | |
| # | |
| DEV_USER="${DEV_USER:-dev}" | |
| DEV_HOME="/home/${DEV_USER}" | |
| HOSTNAME_SET="${HOSTNAME_SET:-}" # empty = leave the hostname alone | |
| # Required — no defaults, deliberately. See the preflight check below. | |
| GIT_NAME="${GIT_NAME:-}" | |
| GIT_EMAIL="${GIT_EMAIL:-}" | |
| SSH_PORT="${SSH_PORT:-22}" # change only if you know what you're doing | |
| PERMIT_ROOT_LOGIN="${PERMIT_ROOT_LOGIN:-prohibit-password}" # "no" once dev login is confirmed | |
| LOCALE="${LOCALE:-en_AU.UTF-8}" | |
| DEV_PASSWORD="${DEV_PASSWORD:-}" # empty = auto-generate and print | |
| AGENT_SUDO_WHITELIST="${AGENT_SUDO_WHITELIST:-true}" # false = no passwordless sudo at all | |
| SUDO_TIMESTAMP_TIMEOUT="${SUDO_TIMESTAMP_TIMEOUT:-0}" # 0 = password authorises one command | |
| SKIP_SYSTEM_UPGRADE="${SKIP_SYSTEM_UPGRADE:-false}" # true when bootstrap.sh already upgraded | |
| # Docker privilege model. | |
| # rootless -> daemon runs as dev; containers cannot escalate to host root, | |
| # and published ports respect ufw instead of bypassing it. | |
| # group -> classic docker group. Root-equivalent. Undoes the model above. | |
| DOCKER_MODE="${DOCKER_MODE:-rootless}" | |
| # Rootless Docker needs unprivileged user namespaces. Ubuntu 24.04+ blocks | |
| # these by default via AppArmor, and the targeted per-binary exemption often | |
| # fails to survive RootlessKit's /proc/self/exe re-exec. Set this true to lift | |
| # the restriction system-wide when the exemption doesn't take. | |
| # | |
| # Cost: unprivileged userns becomes available to every user on the box. That | |
| # is how Linux worked before Ubuntu 24.04 and how most distributions still | |
| # work; it widens the kernel's local-privilege-escalation surface. On a | |
| # single-admin VPS with no untrusted local users, narrow. Left false by | |
| # default because silently lowering a kernel security setting shouldn't be | |
| # something a script does without being asked. | |
| PERMIT_USERNS="${PERMIT_USERNS:-false}" | |
| # The public key authorised for dev. Left empty, provision inherits whatever | |
| # key root is currently reachable by — i.e. the one your VPS provider installed | |
| # at creation and that you are logged in with right now. That is the key you | |
| # already hold, so it is guaranteed to work. | |
| ADMIN_PUBKEY="${ADMIN_PUBKEY:-}" | |
| # The GitHub key is generated on this server, which does NOT contradict the | |
| # rule above: for GitHub, this machine is the client. The private half is born | |
| # here and never travels. Set false if you use HTTPS tokens instead. | |
| GENERATE_GITHUB_KEY="${GENERATE_GITHUB_KEY:-true}" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Plumbing | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| export DEBIAN_FRONTEND=noninteractive | |
| APT_OPTS=(-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold) | |
| C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; 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} $*"; } | |
| warn() { printf '%s\n' " ${C_WARN}WARN${C_OFF} $*" >&2; } | |
| die() { printf '%s\n' "${C_ERR}FATAL:${C_OFF} $*" >&2; exit 1; } | |
| trap 'die "aborted at line $LINENO — see output above"' ERR | |
| SOFT_FAILURES=() | |
| soft() { # soft <label> <command...> — may fail without killing the run | |
| local label="$1"; shift | |
| if "$@"; then ok "$label"; else | |
| warn "$label FAILED (continuing)" | |
| SOFT_FAILURES+=("$label") | |
| fi | |
| } | |
| # Run a heredoc script as dev with a clean, predictable environment. | |
| as_dev() { | |
| runuser -u "$DEV_USER" -- env -i \ | |
| HOME="$DEV_HOME" USER="$DEV_USER" LOGNAME="$DEV_USER" SHELL=/usr/bin/zsh \ | |
| LANG="$LOCALE" TERM="${TERM:-xterm-256color}" \ | |
| PATH="$DEV_HOME/.local/bin:$DEV_HOME/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ | |
| bash -s | |
| } | |
| have() { command -v "$1" >/dev/null 2>&1; } | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 0. Preflight | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| [[ $EUID -eq 0 ]] || die "run this as root (sudo bash $0)" | |
| if [[ -z "$GIT_NAME" || -z "$GIT_EMAIL" ]]; then | |
| die "set your git identity first, e.g. | |
| GIT_NAME=\"Your Name\" GIT_EMAIL=\"you@example.com\" bash $0" | |
| fi | |
| # Refuse to run straight off a pipe. This script feeds heredocs to | |
| # 'bash -s' subprocesses; if the script itself is arriving on stdin, a single | |
| # stray stdin read silently swallows the rest of the file and you get a | |
| # half-provisioned box with no error. Download it to disk first. | |
| if [[ ! -t 0 && ! -f "${BASH_SOURCE[0]:-}" ]]; then | |
| die "don't pipe this into bash. Save it first: | |
| curl -fsSL <url> -o provision.sh && bash provision.sh" | |
| fi | |
| [[ -r /etc/os-release ]] || die "cannot read /etc/os-release" | |
| # ── Resolve the key that will get you back in ──────────────────────────────── | |
| # This runs FIRST, while the machine is still untouched. Provision disables | |
| # password authentication later; if dev ends up with no authorised key at that | |
| # moment, the box becomes unreachable and only the provider's console can | |
| # rescue it. So: establish the key now, or refuse to start. | |
| if [[ -z "$ADMIN_PUBKEY" && -r /root/.ssh/authorized_keys ]]; then | |
| ADMIN_PUBKEY="$(grep -m1 -E '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys || true)" | |
| [[ -n "$ADMIN_PUBKEY" ]] && ADMIN_KEY_SOURCE="inherited from root" | |
| fi | |
| [[ -n "$ADMIN_PUBKEY" ]] || die "no SSH public key available for ${DEV_USER}. | |
| Provision turns off password login, so ${DEV_USER} needs a key up front or | |
| you will be locked out. Root has no authorized_keys to inherit, which means | |
| you are logged in by password. | |
| On the machine you want to connect FROM: | |
| ssh-keygen -t ed25519 -C \"me@laptop\" # if you have no key yet | |
| cat ~/.ssh/id_ed25519.pub | |
| Then re-run with that line: | |
| ADMIN_PUBKEY=\"ssh-ed25519 AAAA... me@laptop\" bash \$0" | |
| printf '%s\n' "$ADMIN_PUBKEY" | ssh-keygen -lf - >/dev/null 2>&1 \ | |
| || die "ADMIN_PUBKEY is not a valid public key: ${ADMIN_PUBKEY:0:40}..." | |
| ADMIN_KEY_SOURCE="${ADMIN_KEY_SOURCE:-supplied via ADMIN_PUBKEY}" | |
| . /etc/os-release | |
| [[ "${ID:-}" == "ubuntu" ]] || die "this script targets Ubuntu, found: ${ID:-unknown}" | |
| UBUNTU_CODENAME="${VERSION_CODENAME:-resolute}" | |
| ARCH="$(dpkg --print-architecture)" | |
| log "Ubuntu ${VERSION_ID:-?} (${UBUNTU_CODENAME}) on ${ARCH}" | |
| [[ "${VERSION_ID:-}" == "26.04" ]] || warn "expected 26.04, found ${VERSION_ID:-unknown} — continuing" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 0b. Hostname | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Done before anything else so the rest of this run — and the github key's | |
| # comment — carry the right name. Three things must agree, and only the first | |
| # is obvious: | |
| # · the kernel hostname (hostnamectl) | |
| # · /etc/hosts (or sudo stalls on a failed reverse lookup) | |
| # · cloud-init preserve_hostname (or a reboot silently reverts it) | |
| if [[ -n "$HOSTNAME_SET" ]]; then | |
| log "Setting hostname to ${HOSTNAME_SET}" | |
| hostnamectl set-hostname "$HOSTNAME_SET" | |
| if grep -q '^127.0.1.1' /etc/hosts; then | |
| sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t${HOSTNAME_SET}/" /etc/hosts | |
| else | |
| printf '127.0.1.1\t%s\n' "$HOSTNAME_SET" >> /etc/hosts | |
| fi | |
| if [[ -f /etc/cloud/cloud.cfg ]]; then | |
| if grep -q '^preserve_hostname:' /etc/cloud/cloud.cfg; then | |
| sed -i 's/^preserve_hostname:.*/preserve_hostname: true/' /etc/cloud/cloud.cfg | |
| else | |
| echo 'preserve_hostname: true' >> /etc/cloud/cloud.cfg | |
| fi | |
| ok "cloud-init told to preserve it across reboots" | |
| fi | |
| ok "hostname: $(hostname -s), /etc/hosts updated" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 1. System update + base packages | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Applying all pending updates" | |
| apt-get update -y | |
| if [[ "$SKIP_SYSTEM_UPGRADE" == "true" ]]; then | |
| ok "full-upgrade skipped (bootstrap stage already did it)" | |
| else | |
| apt-get "${APT_OPTS[@]}" full-upgrade | |
| apt-get "${APT_OPTS[@]}" autoremove --purge | |
| ok "system up to date" | |
| fi | |
| log "Installing base packages" | |
| apt-get "${APT_OPTS[@]}" install \ | |
| ca-certificates curl wget gnupg lsb-release apt-transport-https \ | |
| software-properties-common build-essential pkg-config \ | |
| git zsh mosh ufw unzip zip tar xz-utils file jq \ | |
| ripgrep fd-find tree htop ncdu rsync locales acl \ | |
| uidmap bubblewrap socat unattended-upgrades apt-listchanges | |
| ok "base packages installed" | |
| log "Configuring locale and automatic security updates" | |
| grep -q "^${LOCALE}" /etc/locale.gen 2>/dev/null || echo "${LOCALE} UTF-8" >> /etc/locale.gen | |
| locale-gen >/dev/null | |
| update-locale LANG="$LOCALE" LC_ALL="$LOCALE" | |
| cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF' | |
| APT::Periodic::Update-Package-Lists "1"; | |
| APT::Periodic::Unattended-Upgrade "1"; | |
| APT::Periodic::Download-Upgradeable-Packages "1"; | |
| APT::Periodic::AutocleanInterval "7"; | |
| EOF | |
| systemctl enable --now unattended-upgrades >/dev/null 2>&1 || true | |
| ok "unattended security updates on, locale ${LOCALE} generated" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 2. Swap file | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Configuring swap" | |
| if swapon --show --noheadings | grep -q .; then | |
| ok "swap already active: $(swapon --show --noheadings | awk '{print $1, $3}' | tr '\n' ' ')" | |
| else | |
| RAM_MB=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo) | |
| # No hibernation on a VPS: small boxes get 2x RAM, mid-range gets half. | |
| if (( RAM_MB <= 2048 )); then SWAP_MB=$(( RAM_MB * 2 )) | |
| elif (( RAM_MB <= 8192 )); then SWAP_MB=$(( RAM_MB / 2 )) | |
| else SWAP_MB=8192 | |
| fi | |
| (( SWAP_MB < 2048 )) && SWAP_MB=2048 | |
| (( SWAP_MB > 8192 )) && SWAP_MB=8192 | |
| FREE_MB=$(df -Pm / | awk 'NR==2 {print $4}') | |
| if (( FREE_MB < SWAP_MB + 5120 )); then | |
| SWAP_MB=$(( (FREE_MB - 5120) / 1024 * 1024 )) | |
| warn "low disk; reducing swap to ${SWAP_MB}MB" | |
| fi | |
| (( SWAP_MB >= 512 )) || die "not enough free disk to create a swap file" | |
| FSTYPE=$(findmnt -no FSTYPE -T /) | |
| if [[ "$FSTYPE" == "btrfs" ]]; then | |
| truncate -s 0 /swapfile && chattr +C /swapfile 2>/dev/null || true | |
| dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none | |
| else | |
| fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || \ | |
| dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none | |
| fi | |
| chmod 600 /swapfile | |
| mkswap /swapfile >/dev/null | |
| swapon /swapfile | |
| grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab | |
| ok "created ${SWAP_MB}MB swap file (RAM ${RAM_MB}MB, fs ${FSTYPE})" | |
| fi | |
| cat > /etc/sysctl.d/60-swap-tuning.conf <<'EOF' | |
| # Server profile: prefer RAM, treat swap as an overflow safety net. | |
| vm.swappiness=10 | |
| vm.vfs_cache_pressure=50 | |
| EOF | |
| sysctl --quiet --load /etc/sysctl.d/60-swap-tuning.conf | |
| ok "vm.swappiness=10" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 3. The dev user (human sudo path) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Creating user '${DEV_USER}'" | |
| if id -u "$DEV_USER" >/dev/null 2>&1; then | |
| ok "user already exists" | |
| else | |
| useradd --create-home --shell /usr/bin/zsh --user-group "$DEV_USER" | |
| ok "user created with zsh login shell" | |
| fi | |
| usermod -aG sudo "$DEV_USER" | |
| # Subordinate UID/GID ranges, needed later by rootless Docker to map container | |
| # UIDs. useradd allocates these automatically, but only if /etc/subuid exists | |
| # at the time — which it may not on a minimal image. Cheap to make certain. | |
| touch /etc/subuid /etc/subgid | |
| if ! grep -q "^${DEV_USER}:" /etc/subuid || ! grep -q "^${DEV_USER}:" /etc/subgid; then | |
| usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$DEV_USER" 2>/dev/null || { | |
| grep -q "^${DEV_USER}:" /etc/subuid || echo "${DEV_USER}:100000:65536" >> /etc/subuid | |
| grep -q "^${DEV_USER}:" /etc/subgid || echo "${DEV_USER}:100000:65536" >> /etc/subgid | |
| } | |
| fi | |
| ok "subuid/subgid: $(grep "^${DEV_USER}:" /etc/subuid || echo none)" | |
| # Full root requires the password, every single time. Without | |
| # timestamp_timeout=0 the default 15-minute cache is a hole: you authenticate | |
| # once and any agent on that same TTY inherits passwordless root for the window. | |
| # The `|| true` is load-bearing. tr reads /dev/urandom forever; head takes its | |
| # 24 bytes and exits; tr then dies of SIGPIPE (141). Under `set -o pipefail` | |
| # that becomes the pipeline's status and `set -e` kills the script. The 24 | |
| # bytes were already captured, so the signal is noise — swallow it, then | |
| # assert we actually got a password. | |
| if [[ -z "$DEV_PASSWORD" ]]; then | |
| DEV_PASSWORD="$(LC_ALL=C tr -dc 'A-Za-z0-9!@#%^_+=' </dev/urandom 2>/dev/null | head -c 24 || true)" | |
| # The `|| true` above is load-bearing: tr reads /dev/urandom forever, head | |
| # takes its 24 bytes and exits, tr dies of SIGPIPE, and pipefail would | |
| # otherwise promote that to a script-killing failure. | |
| (( ${#DEV_PASSWORD} >= 16 )) || die "could not generate a password (got ${#DEV_PASSWORD} chars)" | |
| DEV_PASSWORD_GENERATED=true | |
| else | |
| # Supplied by the operator. Enforce a floor, but do not impose the length we | |
| # would have generated — a password you can type is worth more here than one | |
| # you cannot, given you will be typing it constantly. | |
| (( ${#DEV_PASSWORD} >= 8 )) || die "DEV_PASSWORD is too short (${#DEV_PASSWORD} chars, need 8+)" | |
| DEV_PASSWORD_GENERATED=false | |
| fi | |
| printf '%s:%s\n' "$DEV_USER" "$DEV_PASSWORD" | chpasswd | |
| cat > "/etc/sudoers.d/90-${DEV_USER}" <<EOF | |
| # Managed by provision.sh — human path to root. | |
| Defaults:${DEV_USER} timestamp_timeout=${SUDO_TIMESTAMP_TIMEOUT} | |
| Defaults:${DEV_USER} passwd_tries=3 | |
| EOF | |
| chmod 440 "/etc/sudoers.d/90-${DEV_USER}" | |
| visudo -c >/dev/null || die "sudoers validation failed" | |
| ok "sudo via password, no credential caching" | |
| install -d -m 700 -o "$DEV_USER" -g "$DEV_USER" "$DEV_HOME/.ssh" | |
| # Parents must be listed BEFORE their children. `install -d a/b` creates the | |
| # missing parent `a` as root:root and applies -o/-g only to `b`, which leaves | |
| # dev unable to mkdir anything else inside it — that's what broke the Claude | |
| # Code installer trying to create ~/.local/share. | |
| install -d -m 755 -o "$DEV_USER" -g "$DEV_USER" \ | |
| "$DEV_HOME/.local" \ | |
| "$DEV_HOME/.local/bin" "$DEV_HOME/.local/share" "$DEV_HOME/.local/state" \ | |
| "$DEV_HOME/.cache" "$DEV_HOME/.config" "$DEV_HOME/projects" | |
| # Belt and braces: anything created above by root, hand back to dev. | |
| chown -R "$DEV_USER:$DEV_USER" "$DEV_HOME" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 4. SSH access | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Nothing here generates a key for a client device. A private key generated on | |
| # the server has to travel to reach the device that needs it, and until it is | |
| # deleted it sits in a filesystem that everything running as dev can read. | |
| # Instead: each device makes its own keypair and only the public half is sent | |
| # here, via `sshkey add`. | |
| log "Authorising SSH access for ${DEV_USER}" | |
| AUTH="$DEV_HOME/.ssh/authorized_keys" | |
| printf '# Seeded by provision.sh (%s)\n%s\n' "$ADMIN_KEY_SOURCE" "$ADMIN_PUBKEY" > "$AUTH" | |
| chown "$DEV_USER:$DEV_USER" "$AUTH"; chmod 600 "$AUTH" | |
| ok "seeded with $(printf '%s\n' "$ADMIN_PUBKEY" | ssh-keygen -lf - | awk '{print $2}') (${ADMIN_KEY_SOURCE})" | |
| # The GitHub key is the one exception, and it is not a client key: this server | |
| # is the SSH client in that relationship, so the private half belongs here and | |
| # never needs to move. That satisfies the same rule rather than breaking it. | |
| if [[ "$GENERATE_GITHUB_KEY" == "true" && ! -f "$DEV_HOME/.ssh/github" ]]; then | |
| runuser -u "$DEV_USER" -- ssh-keygen -t ed25519 -a 100 -N '' \ | |
| -C "github@$(hostname -s)" -f "$DEV_HOME/.ssh/github" >/dev/null | |
| ok "github keypair generated in place at ~/.ssh/github" | |
| fi | |
| cat > "$DEV_HOME/.ssh/config" <<EOF | |
| Host github.com | |
| HostName github.com | |
| User git | |
| IdentityFile ~/.ssh/github | |
| IdentitiesOnly yes | |
| Host * | |
| ServerAliveInterval 60 | |
| ServerAliveCountMax 3 | |
| EOF | |
| chown "$DEV_USER:$DEV_USER" "$DEV_HOME/.ssh/config"; chmod 600 "$DEV_HOME/.ssh/config" | |
| ssh-keyscan -t rsa,ecdsa,ed25519 github.com 2>/dev/null > "$DEV_HOME/.ssh/known_hosts" || true | |
| chown "$DEV_USER:$DEV_USER" "$DEV_HOME/.ssh/known_hosts" 2>/dev/null || true | |
| ok "ssh client config written" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 4b. The sshkey helper | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Embedded rather than fetched so this script has no extra dependency to | |
| # coordinate. Adds/lists/removes device public keys, refuses private keys, | |
| # refuses to remove your last key, and backs up authorized_keys each time. | |
| log "Installing the sshkey helper" | |
| cat > /usr/local/bin/sshkey <<'SSHKEY_EOF' | |
| #!/usr/bin/env bash | |
| # | |
| # sshkey — add, list and remove device keys for this account | |
| # | |
| # sshkey add ipad # paste the public key when prompted | |
| # sshkey add laptop "ssh-ed25519 AAAA... me@laptop" | |
| # sshkey add backup --sftp # SFTP-only, no shell | |
| # sshkey list | |
| # sshkey remove ipad | |
| # | |
| # The private key never touches this machine. Generate it on the device, paste | |
| # the public half here. That is the whole point: a public key is safe to email, | |
| # paste into a chat, or read aloud. A private key is not, and the moment one is | |
| # sitting in a home directory anything running as you can take it. | |
| # | |
| set -euo pipefail | |
| AUTH="${HOME}/.ssh/authorized_keys" | |
| BACKUP_DIR="${HOME}/.ssh/backups" | |
| C_OK=$'\033[32m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m' | |
| die() { printf '%s\n' "${C_ERR}error:${C_OFF} $*" >&2; exit 1; } | |
| ok() { printf '%s\n' "${C_OK}✓${C_OFF} $*"; } | |
| usage() { | |
| sed -n '3,16p' "$0" | sed 's/^# \?//' | |
| exit "${1:-0}" | |
| } | |
| backup() { | |
| mkdir -p "$BACKUP_DIR"; chmod 700 "$BACKUP_DIR" | |
| [[ -f "$AUTH" ]] && cp "$AUTH" "${BACKUP_DIR}/authorized_keys.$(date +%Y%m%d-%H%M%S)" | |
| # Keep the last 10 so a bad edit is always recoverable. | |
| ls -1t "${BACKUP_DIR}"/authorized_keys.* 2>/dev/null | tail -n +11 | xargs -r rm -- | |
| } | |
| cmd_add() { | |
| local label="${1:-}" key="" sftp=false | |
| shift || true | |
| for a in "$@"; do | |
| case "$a" in | |
| --sftp) sftp=true ;; | |
| ssh-*|ecdsa-*|sk-*) key="$a" ;; | |
| *) [[ -z "$key" ]] && key="$a" ;; | |
| esac | |
| done | |
| [[ -n "$label" ]] || die "need a label, e.g. 'sshkey add ipad'" | |
| [[ "$label" =~ ^[a-zA-Z0-9_-]+$ ]] || die "label must be letters, digits, dash or underscore" | |
| if [[ -z "$key" ]]; then | |
| echo "Paste the PUBLIC key for '${label}', then press Enter." | |
| echo "${C_DIM}It starts with ssh-ed25519, ssh-rsa or sk-ssh-...${C_OFF}" | |
| printf '> ' | |
| IFS= read -r key < /dev/tty | |
| fi | |
| key="$(printf '%s' "$key" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" | |
| [[ -n "$key" ]] || die "nothing pasted" | |
| # Refuse a private key outright. Easy mistake, bad consequences, and the | |
| # error you'd otherwise get is unhelpful. | |
| if [[ "$key" == *"PRIVATE KEY"* ]]; then | |
| die "that is a PRIVATE key. Never put one on a server. | |
| On your device run: ssh-keygen -y -f <keyfile> | |
| and paste the single line it prints instead." | |
| fi | |
| # Validate by round-tripping through ssh-keygen rather than pattern matching. | |
| local fp | |
| fp="$(printf '%s\n' "$key" | ssh-keygen -lf - 2>/dev/null)" \ | |
| || die "not a valid public key. Expect one line: '<type> <base64> <comment>'. | |
| If you pasted from a terminal, line wrapping may have broken it." | |
| mkdir -p "${HOME}/.ssh"; chmod 700 "${HOME}/.ssh" | |
| touch "$AUTH"; chmod 600 "$AUTH" | |
| local body; body="$(printf '%s' "$key" | awk '{print $2}')" | |
| if [[ -f "$AUTH" ]] && grep -qF "$body" "$AUTH"; then | |
| die "that key is already authorised (label may differ — try 'sshkey list')" | |
| fi | |
| if grep -q " ${label}@sshkey\$" "$AUTH" 2>/dev/null; then | |
| die "label '${label}' is taken. Use a different one, or: sshkey remove ${label}" | |
| fi | |
| backup | |
| local type_and_body comment_stripped | |
| type_and_body="$(printf '%s' "$key" | awk '{print $1, $2}')" | |
| comment_stripped="${type_and_body} ${label}@sshkey" | |
| if $sftp; then | |
| printf 'restrict,command="internal-sftp" %s\n' "$comment_stripped" >> "$AUTH" | |
| ok "added '${label}' as SFTP-only (no shell, no forwarding)" | |
| else | |
| printf '%s\n' "$comment_stripped" >> "$AUTH" | |
| ok "added '${label}' with full shell access" | |
| fi | |
| echo " ${fp}" | |
| echo | |
| echo "Test it from that device BEFORE closing this session:" | |
| echo " ssh $(id -un)@$(hostname -I 2>/dev/null | awk '{print $1}')" | |
| } | |
| cmd_list() { | |
| [[ -s "$AUTH" ]] || { echo "no keys authorised"; return; } | |
| printf '%-14s %-8s %s\n' "LABEL" "ACCESS" "FINGERPRINT" | |
| while IFS= read -r line; do | |
| [[ -z "$line" || "$line" == \#* ]] && continue | |
| local access="shell" label fp keypart | |
| [[ "$line" == *"internal-sftp"* ]] && access="sftp" | |
| label="$(printf '%s' "$line" | awk '{print $NF}' | sed 's/@sshkey$//')" | |
| # Strip any authorized_keys option prefix so only "<type> <body>" is | |
| # fingerprinted. `|| true` because a key we cannot parse must not abort | |
| # the loop under pipefail — we still want to list it. | |
| keypart="$(printf '%s' "$line" \ | |
| | grep -oE '(ssh-ed25519|ssh-rsa|ecdsa-sha2-[a-z0-9]+|sk-[a-z0-9@.-]+) [A-Za-z0-9+/=]+' \ | |
| || true)" | |
| fp="$(printf '%s\n' "$keypart" | ssh-keygen -lf - 2>/dev/null | awk '{print $2}' || true)" | |
| printf '%-14s %-8s %s\n' "${label:-?}" "$access" "${fp:-unreadable}" | |
| done < "$AUTH" | |
| } | |
| cmd_remove() { | |
| local label="${1:-}" | |
| [[ -n "$label" ]] || die "which one? try 'sshkey list'" | |
| grep -q " ${label}@sshkey\$" "$AUTH" 2>/dev/null || die "no key labelled '${label}'" | |
| # Never leave zero keys behind — that is a lockout. | |
| local remaining | |
| remaining="$(grep -cv "^\s*$\|^#" "$AUTH" 2>/dev/null || echo 0)" | |
| if (( remaining <= 1 )); then | |
| die "that is your last key. Add another first, or you will be locked out." | |
| fi | |
| backup | |
| sed -i "/ ${label}@sshkey\$/d" "$AUTH" | |
| ok "removed '${label}'" | |
| echo " ${C_DIM}Existing sessions using it stay connected until they disconnect.${C_OFF}" | |
| } | |
| case "${1:-}" in | |
| add) shift; cmd_add "$@" ;; | |
| list|ls) cmd_list ;; | |
| remove|rm) shift; cmd_remove "$@" ;; | |
| -h|--help|help|"") usage 0 ;; | |
| *) die "unknown command '$1' — try 'sshkey help'" ;; | |
| esac | |
| SSHKEY_EOF | |
| chmod 755 /usr/local/bin/sshkey | |
| bash -n /usr/local/bin/sshkey || die "embedded sshkey helper is corrupt" | |
| ok "sshkey installed — 'sshkey add <label>' to authorise a device" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 5. sshd — key-only auth, internal sftp | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Hardening sshd" | |
| cat > /etc/ssh/sshd_config.d/99-provision.conf <<EOF | |
| # Managed by provision.sh | |
| Port ${SSH_PORT} | |
| PermitRootLogin ${PERMIT_ROOT_LOGIN} | |
| PasswordAuthentication no | |
| KbdInteractiveAuthentication no | |
| ChallengeResponseAuthentication no | |
| PubkeyAuthentication yes | |
| PermitEmptyPasswords no | |
| UsePAM yes | |
| X11Forwarding no | |
| MaxAuthTries 4 | |
| LoginGraceTime 30 | |
| ClientAliveInterval 300 | |
| ClientAliveCountMax 2 | |
| AllowAgentForwarding yes | |
| # SFTP runs in-process. The sftp key in authorized_keys forces this command, | |
| # so those sessions get no shell and start in ${DEV_HOME}. | |
| Subsystem sftp internal-sftp -f AUTH -l INFO | |
| EOF | |
| if [[ "$SSH_PORT" != "22" ]]; then | |
| # Ubuntu socket-activates ssh; the Port directive alone is ignored. | |
| mkdir -p /etc/systemd/system/ssh.socket.d | |
| printf '[Socket]\nListenStream=\nListenStream=%s\n' "$SSH_PORT" \ | |
| > /etc/systemd/system/ssh.socket.d/override.conf | |
| systemctl daemon-reload | |
| systemctl restart ssh.socket | |
| fi | |
| sshd -t || die "sshd config invalid — NOT restarting ssh, fix /etc/ssh/sshd_config.d/99-provision.conf" | |
| systemctl restart ssh | |
| ok "sshd restarted; password auth disabled globally" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 6. Firewall | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Configuring ufw" | |
| ufw --force reset >/dev/null | |
| ufw default deny incoming >/dev/null | |
| ufw default allow outgoing >/dev/null | |
| ufw limit "${SSH_PORT}/tcp" comment 'ssh (rate limited)' >/dev/null | |
| ufw allow 60000:61000/udp comment 'mosh' >/dev/null | |
| ufw allow 80/tcp comment 'caddy http' >/dev/null | |
| ufw allow 443/tcp comment 'caddy https' >/dev/null | |
| ufw allow 443/udp comment 'caddy http/3' >/dev/null | |
| ufw --force enable >/dev/null | |
| systemctl enable ufw >/dev/null 2>&1 || true | |
| ok "ufw active on: $(ufw status | awk '/^[0-9]/ {printf "%s ", $1}')" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 7. git | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Configuring git" | |
| as_dev <<EOF | |
| set -euo pipefail | |
| git config --global user.name "${GIT_NAME}" | |
| git config --global user.email "${GIT_EMAIL}" | |
| git config --global init.defaultBranch main | |
| git config --global pull.rebase true | |
| git config --global push.autoSetupRemote true | |
| git config --global core.editor nvim | |
| git config --global fetch.prune true | |
| git config --global diff.colorMoved zebra | |
| EOF | |
| ok "git identity: ${GIT_NAME} <${GIT_EMAIL}>" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 8. bat + eza | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing bat and eza" | |
| apt-get "${APT_OPTS[@]}" install bat || warn "bat unavailable via apt" | |
| # Ubuntu ships the binary as 'batcat' to avoid a name clash. | |
| if have batcat && [[ ! -e "$DEV_HOME/.local/bin/bat" ]]; then | |
| ln -sf /usr/bin/batcat "$DEV_HOME/.local/bin/bat" | |
| chown -h "$DEV_USER:$DEV_USER" "$DEV_HOME/.local/bin/bat" | |
| fi | |
| if have fdfind && [[ ! -e "$DEV_HOME/.local/bin/fd" ]]; then | |
| ln -sf /usr/bin/fdfind "$DEV_HOME/.local/bin/fd" | |
| chown -h "$DEV_USER:$DEV_USER" "$DEV_HOME/.local/bin/fd" | |
| fi | |
| if ! apt-get "${APT_OPTS[@]}" install eza 2>/dev/null; then | |
| warn "eza not in the archive; adding the upstream apt repo" | |
| install -d -m 0755 /etc/apt/keyrings | |
| curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \ | |
| | gpg --dearmor -o /etc/apt/keyrings/gierens.gpg | |
| chmod 644 /etc/apt/keyrings/gierens.gpg | |
| echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main" \ | |
| > /etc/apt/sources.list.d/gierens.list | |
| apt-get update -y && apt-get "${APT_OPTS[@]}" install eza | |
| fi | |
| ok "bat + eza installed" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 9. zsh + Pure prompt + .zshrc | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing the Pure prompt" | |
| if [[ ! -d "$DEV_HOME/.zsh/pure" ]]; then | |
| install -d -m 755 -o "$DEV_USER" -g "$DEV_USER" "$DEV_HOME/.zsh" | |
| as_dev <<'EOF' | |
| set -euo pipefail | |
| git clone --depth 1 https://github.com/sindresorhus/pure.git "$HOME/.zsh/pure" | |
| EOF | |
| fi | |
| ok "pure cloned to ~/.zsh/pure" | |
| log "Writing .zshrc" | |
| cat > "$DEV_HOME/.zshrc" <<'ZSHRC' | |
| # ── aliases ────────────────────────────────────────────────────────────────── | |
| # Kept at the top so they are the first thing you see when you open this file. | |
| alias caddyreload='sudo systemctl reload caddy' | |
| alias reload='source ~/.zshrc' | |
| alias ls='eza --group-directories-first --icons=auto' | |
| alias ll='eza -lhg --group-directories-first --git --icons=auto' | |
| alias la='eza -lhga --group-directories-first --git --icons=auto' | |
| alias lt='eza --tree --level=2 --icons=auto' | |
| # `cat` and `less` are deliberately NOT aliased to bat. Shadowing a core | |
| # utility surprises pipelines and anything that expects plain output. Call | |
| # `bat` by name when you want highlighting or paging. | |
| alias vim='nvim' | |
| alias vi='nvim' | |
| alias gs='git status -sb' | |
| alias gd='git diff' | |
| alias gl='git log --oneline --graph --decorate -20' | |
| alias dc='docker compose' | |
| alias ..='cd ..' | |
| alias ...='cd ../..' | |
| # ── history ────────────────────────────────────────────────────────────────── | |
| HISTFILE=~/.zsh_history | |
| HISTSIZE=50000 | |
| SAVEHIST=50000 | |
| setopt SHARE_HISTORY HIST_IGNORE_ALL_DUPS HIST_IGNORE_SPACE HIST_REDUCE_BLANKS | |
| setopt EXTENDED_HISTORY INC_APPEND_HISTORY | |
| # ── behaviour ──────────────────────────────────────────────────────────────── | |
| setopt AUTO_CD INTERACTIVE_COMMENTS NO_BEEP PROMPT_SUBST | |
| bindkey -e | |
| autoload -Uz compinit && compinit -d "${XDG_CACHE_HOME:-$HOME/.cache}/zcompdump" | |
| zstyle ':completion:*' menu select | |
| zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' | |
| # ── path ───────────────────────────────────────────────────────────────────── | |
| typeset -U path PATH | |
| path=("$HOME/.local/bin" "$HOME/bin" $path) | |
| export PATH | |
| export EDITOR=nvim VISUAL=nvim | |
| export LANG=en_AU.UTF-8 | |
| # ── prompt: pure ───────────────────────────────────────────────────────────── | |
| fpath+=("$HOME/.zsh/pure") | |
| autoload -U promptinit && promptinit | |
| zstyle :prompt:pure:path color cyan | |
| zstyle :prompt:pure:git:branch color yellow | |
| prompt pure | |
| # ── nvm ────────────────────────────────────────────────────────────────────── | |
| export NVM_DIR="$HOME/.nvm" | |
| [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" | |
| [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" | |
| # ── bun ────────────────────────────────────────────────────────────────────── | |
| export BUN_INSTALL="$HOME/.bun" | |
| [ -d "$BUN_INSTALL/bin" ] && path=("$BUN_INSTALL/bin" $path) && export PATH | |
| [ -s "$BUN_INSTALL/_bun" ] && source "$BUN_INSTALL/_bun" | |
| # ── rootless docker ────────────────────────────────────────────────────────── | |
| if [ -S "/run/user/$(id -u)/docker.sock" ]; then | |
| export DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock" | |
| fi | |
| ZSHRC | |
| chown "$DEV_USER:$DEV_USER" "$DEV_HOME/.zshrc" | |
| ok ".zshrc written" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 10. nvm + Node LTS + latest npm | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing nvm, Node LTS and the latest npm" | |
| NVM_TAG="$(curl -fsSL https://api.github.com/repos/nvm-sh/nvm/releases/latest 2>/dev/null \ | |
| | jq -r '.tag_name // empty' || true)" | |
| [[ -n "$NVM_TAG" ]] || { NVM_TAG="v0.40.3"; warn "GitHub API unavailable; pinning nvm ${NVM_TAG}"; } | |
| as_dev <<EOF | |
| set -euo pipefail | |
| export PROFILE=/dev/null # .zshrc is managed here, don't let nvm edit it | |
| curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_TAG}/install.sh" | bash | |
| export NVM_DIR="\$HOME/.nvm" | |
| . "\$NVM_DIR/nvm.sh" | |
| nvm install --lts | |
| nvm alias default 'lts/*' | |
| nvm use default | |
| npm install -g npm@latest | |
| node --version && npm --version | |
| EOF | |
| ok "nvm ${NVM_TAG} + Node LTS + npm@latest" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 11. Bun | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing Bun" | |
| as_dev <<'EOF' | |
| set -euo pipefail | |
| export BUN_INSTALL="$HOME/.bun" | |
| curl -fsSL https://bun.sh/install | bash | |
| "$BUN_INSTALL/bin/bun" --version | |
| EOF | |
| ok "bun installed to ~/.bun" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 12. Coding agents — all user-scoped, no root required | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing Claude Code" | |
| as_dev <<'EOF' | |
| set -euo pipefail | |
| curl -fsSL https://claude.ai/install.sh | bash | |
| "$HOME/.local/bin/claude" --version | |
| EOF | |
| ok "claude installed (run 'claude' once to authenticate)" | |
| log "Installing opencode" | |
| as_dev <<'EOF' | |
| set -euo pipefail | |
| curl -fsSL https://opencode.ai/install | bash | |
| EOF | |
| ok "opencode installed" | |
| log "Installing herdr" | |
| as_dev <<'EOF' | |
| set -euo pipefail | |
| curl -fsSL https://herdr.dev/install.sh | sh | |
| EOF | |
| ok "herdr installed" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 13. Caddy | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing Caddy" | |
| if ! have caddy; then | |
| apt-get "${APT_OPTS[@]}" install debian-keyring debian-archive-keyring | |
| curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | |
| | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg | |
| curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | |
| > /etc/apt/sources.list.d/caddy-stable.list | |
| apt-get update -y | |
| apt-get "${APT_OPTS[@]}" install caddy | |
| fi | |
| systemctl enable --now caddy >/dev/null 2>&1 || true | |
| ok "caddy installed" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 14. Neovim + LazyVim | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing Neovim (upstream release, not the archive build)" | |
| NVIM_ASSET="nvim-linux-x86_64" | |
| [[ "$ARCH" == "arm64" ]] && NVIM_ASSET="nvim-linux-arm64" | |
| if ! have nvim || [[ "$(nvim --version 2>/dev/null | head -1 | grep -oP '0\.\K[0-9]+' || echo 0)" -lt 11 ]]; then | |
| TMPD="$(mktemp -d)" | |
| curl -fsSL -o "$TMPD/nvim.tar.gz" \ | |
| "https://github.com/neovim/neovim/releases/latest/download/${NVIM_ASSET}.tar.gz" | |
| rm -rf "/opt/${NVIM_ASSET}" | |
| tar -C /opt -xzf "$TMPD/nvim.tar.gz" | |
| ln -sf "/opt/${NVIM_ASSET}/bin/nvim" /usr/local/bin/nvim | |
| rm -rf "$TMPD" | |
| fi | |
| ok "$(nvim --version | head -1 || true)" | |
| log "Installing LazyVim" | |
| if [[ ! -d "$DEV_HOME/.config/nvim/lua" ]]; then | |
| as_dev <<'EOF' | |
| set -euo pipefail | |
| rm -rf "$HOME/.config/nvim" "$HOME/.local/share/nvim" "$HOME/.local/state/nvim" "$HOME/.cache/nvim" | |
| git clone --depth 1 https://github.com/LazyVim/starter "$HOME/.config/nvim" | |
| rm -rf "$HOME/.config/nvim/.git" | |
| EOF | |
| fi | |
| install -d -m 755 -o "$DEV_USER" -g "$DEV_USER" "$DEV_HOME/.config/nvim/lua/config" | |
| cat > "$DEV_HOME/.config/nvim/lua/config/options.lua" <<'LUA' | |
| -- Loaded automatically by LazyVim, after its own defaults. | |
| local opt = vim.opt | |
| opt.relativenumber = true | |
| opt.number = true | |
| opt.scrolloff = 8 | |
| opt.sidescrolloff = 8 | |
| opt.expandtab = true | |
| opt.shiftwidth = 2 | |
| opt.tabstop = 2 | |
| opt.softtabstop = 2 | |
| opt.smartindent = true | |
| opt.wrap = false | |
| opt.ignorecase = true | |
| opt.smartcase = true | |
| opt.undofile = true | |
| opt.undolevels = 10000 | |
| opt.updatetime = 200 | |
| opt.timeoutlen = 400 | |
| opt.splitbelow = true | |
| opt.splitright = true | |
| opt.termguicolors = true | |
| opt.cursorline = true | |
| opt.signcolumn = "yes" | |
| opt.confirm = true | |
| opt.clipboard = "" -- headless server: keep yanks local | |
| -- Trim trailing whitespace on save. | |
| vim.api.nvim_create_autocmd("BufWritePre", { | |
| pattern = "*", | |
| callback = function() | |
| local save = vim.fn.winsaveview() | |
| vim.cmd([[keeppatterns %s/\s\+$//e]]) | |
| vim.fn.winrestview(save) | |
| end, | |
| }) | |
| LUA | |
| chown -R "$DEV_USER:$DEV_USER" "$DEV_HOME/.config/nvim" | |
| log "Bootstrapping LazyVim plugins headlessly (takes a minute)" | |
| soft "lazyvim plugin sync" bash -c "runuser -u '$DEV_USER' -- env HOME='$DEV_HOME' \ | |
| PATH=/usr/local/bin:/usr/bin:/bin timeout 420 nvim --headless '+Lazy! sync' +qa >/dev/null 2>&1" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 15. Docker + Compose | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| log "Installing Docker Engine" | |
| if ! have docker; then | |
| install -m 0755 -d /etc/apt/keyrings | |
| curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc | |
| chmod a+r /etc/apt/keyrings/docker.asc | |
| # Docker sometimes lags a new Ubuntu release; fall back to the newest suite | |
| # they actually publish. | |
| DOCKER_SUITE="" | |
| for suite in "$UBUNTU_CODENAME" questing noble; do | |
| if curl -fsI "https://download.docker.com/linux/ubuntu/dists/${suite}/Release" >/dev/null 2>&1; then | |
| DOCKER_SUITE="$suite"; break | |
| fi | |
| done | |
| [[ -n "$DOCKER_SUITE" ]] || die "no usable Docker apt suite found" | |
| [[ "$DOCKER_SUITE" == "$UBUNTU_CODENAME" ]] || \ | |
| warn "docker has no '${UBUNTU_CODENAME}' repo yet; using '${DOCKER_SUITE}'" | |
| echo "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${DOCKER_SUITE} stable" \ | |
| > /etc/apt/sources.list.d/docker.list | |
| apt-get update -y | |
| apt-get "${APT_OPTS[@]}" install \ | |
| docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin | |
| fi | |
| # `docker-compose` as a name, backed by the v2 plugin. | |
| cat > /usr/local/bin/docker-compose <<'EOF' | |
| #!/bin/sh | |
| exec docker compose "$@" | |
| EOF | |
| chmod 755 /usr/local/bin/docker-compose | |
| ok "docker + compose plugin installed" | |
| if [[ "$DOCKER_MODE" == "rootless" ]]; then | |
| log "Switching Docker to rootless mode for ${DEV_USER}" | |
| apt-get "${APT_OPTS[@]}" install uidmap dbus-user-session slirp4netns \ | |
| fuse-overlayfs docker-ce-rootless-extras | |
| # Ubuntu 24.04+ restricts unprivileged user namespaces via AppArmor. The | |
| # exemption must be loaded with apparmor_parser -r; restarting the service | |
| # does not reliably pick up a brand new profile. The abi pin is also dropped | |
| # on releases that don't ship that ABI, where it would fail to parse silently. | |
| for RK in /usr/bin/rootlesskit "$DEV_HOME/bin/rootlesskit"; do | |
| PROFILE_NAME="$(echo "${RK#/}" | tr '/' '.')" | |
| cat > "/etc/apparmor.d/${PROFILE_NAME}" <<EOF | |
| abi <abi/4.0>, | |
| include <tunables/global> | |
| "${RK}" flags=(unconfined) { | |
| userns, | |
| include if exists <local/${PROFILE_NAME}> | |
| } | |
| EOF | |
| apparmor_parser -r -W "/etc/apparmor.d/${PROFILE_NAME}" 2>/dev/null || { | |
| sed -i '/^abi /d' "/etc/apparmor.d/${PROFILE_NAME}" | |
| apparmor_parser -r -W "/etc/apparmor.d/${PROFILE_NAME}" 2>/dev/null || \ | |
| warn "could not load AppArmor profile ${PROFILE_NAME}" | |
| } | |
| done | |
| # Trust nothing: check dev can actually create a user namespace. | |
| if runuser -u "$DEV_USER" -- unshare --user --map-root-user true 2>/dev/null; then | |
| ok "${DEV_USER} can create user namespaces" | |
| elif [[ "$PERMIT_USERNS" == "true" ]]; then | |
| sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null 2>&1 || true | |
| echo 'kernel.apparmor_restrict_unprivileged_userns=0' > /etc/sysctl.d/60-userns.conf | |
| if runuser -u "$DEV_USER" -- unshare --user --map-root-user true 2>/dev/null; then | |
| ok "${DEV_USER} can create user namespaces (kernel restriction lifted)" | |
| else | |
| warn "userns still blocked — the cause is not AppArmor" | |
| fi | |
| else | |
| warn "${DEV_USER} cannot create user namespaces; rootless will fail." | |
| warn "Set PERMIT_USERNS=true to lift the kernel restriction, or use DOCKER_MODE=group." | |
| fi | |
| systemctl disable --now docker.service docker.socket >/dev/null 2>&1 || true | |
| loginctl enable-linger "$DEV_USER" | |
| DEV_UID="$(id -u "$DEV_USER")" | |
| for _ in {1..20}; do [[ -d "/run/user/$DEV_UID" ]] && break; sleep 1; done | |
| soft "rootless docker" bash -c "runuser -u '$DEV_USER' -- env \ | |
| HOME='$DEV_HOME' USER='$DEV_USER' \ | |
| XDG_RUNTIME_DIR=/run/user/$DEV_UID \ | |
| DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$DEV_UID/bus \ | |
| PATH=/usr/bin:/bin:/usr/local/bin \ | |
| bash -lc 'dockerd-rootless-setuptool.sh install --force && \ | |
| systemctl --user enable --now docker'" | |
| if [[ " ${SOFT_FAILURES[*]:-} " == *" rootless docker "* ]]; then | |
| warn "set DOCKER_MODE=group at the top and re-run, or debug with:" | |
| warn " runuser -u ${DEV_USER} -- journalctl --user -u docker" | |
| else | |
| ok "rootless docker running as ${DEV_USER}, not in the docker group" | |
| fi | |
| else | |
| usermod -aG docker "$DEV_USER" | |
| systemctl enable --now docker | |
| warn "docker group grants ${DEV_USER} root-equivalent access — this undoes the sudo model" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 16. Agent privileges (runs last: every whitelisted binary now exists) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # sudo-rs is the default on 26.04 and does NOT support wildcards in command | |
| # arguments. A bare binary path already permits any arguments, so that's the | |
| # form used — simpler, and portable across sudo and sudo-rs. | |
| # | |
| # Never add: bash, sh, zsh, tee, cp, mv, dd, chmod, chown, chgrp, find, vi, | |
| # nvim, python, perl, docker, env, nice, su, sudoedit, or anything taking a | |
| # script or interpreter argument. Each is a one-step full root shell. | |
| AGENT_CMDS=( | |
| # package management | |
| /usr/bin/apt /usr/bin/apt-get /usr/bin/apt-cache | |
| /usr/bin/apt-mark /usr/bin/dpkg /usr/bin/dpkg-query | |
| /usr/bin/unattended-upgrade | |
| # services | |
| /usr/bin/systemctl /usr/bin/journalctl /usr/sbin/service | |
| /usr/bin/timedatectl /usr/bin/hostnamectl /usr/bin/loginctl | |
| # networking / firewall | |
| /usr/sbin/ufw /usr/sbin/ss /usr/bin/ss | |
| /usr/bin/lsof /usr/sbin/sysctl /usr/sbin/ip | |
| # web server | |
| /usr/bin/caddy | |
| # read-only diagnostics | |
| /usr/bin/dmesg /usr/bin/du /usr/bin/df | |
| /usr/sbin/dmidecode /usr/bin/lsblk | |
| ) | |
| SUDOERS_AGENT="/etc/sudoers.d/50-${DEV_USER}-agent" | |
| if [[ "$AGENT_SUDO_WHITELIST" == "true" ]]; then | |
| log "Granting the agent maintenance whitelist" | |
| PRESENT=() | |
| for c in "${AGENT_CMDS[@]}"; do [[ -x "$c" ]] && PRESENT+=("$c"); done | |
| (( ${#PRESENT[@]} )) || die "none of the whitelisted binaries were found" | |
| { | |
| echo "# Managed by provision.sh — agent path to root." | |
| echo "# Passwordless maintenance commands only. Full root still needs the" | |
| echo "# password (see 90-${DEV_USER}), which an agent cannot supply." | |
| echo | |
| printf 'Cmnd_Alias AGENT_OPS = \\\n' | |
| for i in "${!PRESENT[@]}"; do | |
| if (( i == ${#PRESENT[@]} - 1 )); then printf ' %s\n' "${PRESENT[$i]}" | |
| else printf ' %s, \\\n' "${PRESENT[$i]}" | |
| fi | |
| done | |
| echo | |
| echo "${DEV_USER} ALL=(root) NOPASSWD: AGENT_OPS" | |
| } > "$SUDOERS_AGENT" | |
| chmod 440 "$SUDOERS_AGENT" | |
| if ! visudo -c -f "$SUDOERS_AGENT" >/dev/null 2>&1; then | |
| warn "Cmnd_Alias form rejected; falling back to a flat rule list" | |
| { | |
| echo "# Managed by provision.sh (flat fallback)" | |
| for c in "${PRESENT[@]}"; do | |
| printf '%s ALL=(root) NOPASSWD: %s\n' "$DEV_USER" "$c" | |
| done | |
| } > "$SUDOERS_AGENT" | |
| chmod 440 "$SUDOERS_AGENT" | |
| visudo -c -f "$SUDOERS_AGENT" >/dev/null || { rm -f "$SUDOERS_AGENT"; die "sudoers invalid — nothing changed"; } | |
| fi | |
| visudo -c >/dev/null || die "global sudoers validation failed" | |
| ok "${#PRESENT[@]} maintenance commands granted NOPASSWD" | |
| else | |
| rm -f "$SUDOERS_AGENT" | |
| ok "agent whitelist disabled — all sudo requires the password" | |
| fi | |
| # Every path dev can write to directly is one fewer reason to reach for sudo. | |
| log "Granting direct write access to config paths" | |
| for d in /etc/caddy /srv/www /var/log/caddy; do | |
| install -d -m 2775 -o root -g "$DEV_USER" "$d" | |
| setfacl -R -m "u:${DEV_USER}:rwX" "$d" 2>/dev/null || true | |
| setfacl -dR -m "u:${DEV_USER}:rwX" "$d" 2>/dev/null || true | |
| done | |
| ok "/etc/caddy, /srv/www, /var/log/caddy writable without sudo" | |
| cat > /usr/local/bin/sudo-audit <<'EOF' | |
| #!/bin/sh | |
| # Every privilege escalation on this box, newest last. | |
| exec journalctl -t sudo -t sudo-rs --no-hostname "$@" | |
| EOF | |
| chmod 755 /usr/local/bin/sudo-audit | |
| ok "audit trail: sudo-audit -n 50, or sudo-audit -f to watch live" | |
| log "Making sudo fail fast for processes with no terminal" | |
| # An agent's bash tool has no controlling terminal. When sudo needs a password | |
| # it falls back to reading stdin and waits — so a blocked command looks like a | |
| # stall, not a refusal. The agent has nothing to report, retries, and starts | |
| # inventing workarounds. | |
| # | |
| # This wrapper adds -n when there is no terminal anywhere on fd 0/1/2, so those | |
| # calls exit at once with "sudo: a password is required" — a clear, actionable | |
| # message. Interactive sudo is untouched: with a tty, it passes straight | |
| # through unchanged. | |
| # | |
| # Root-owned and outside ${DEV_HOME} on purpose: dev must not be able to remove | |
| # the thing that shapes its own behaviour. It sits in /usr/local/bin, which | |
| # precedes /usr/bin in the default PATH for login shells, systemd units and | |
| # non-interactive shells alike — so it applies wherever an agent runs. | |
| # | |
| # NOTE TO FUTURE SELF: if sudo ever behaves oddly here, this file is why. | |
| cat > /usr/local/bin/sudo <<'SUDOWRAP' | |
| #!/bin/sh | |
| # Installed by provision.sh. See the comment block in that script. | |
| # No terminal anywhere? Force non-interactive mode so we fail instead of hang. | |
| if [ ! -t 0 ] && [ ! -t 1 ] && [ ! -t 2 ]; then | |
| exec /usr/bin/sudo -n "$@" | |
| fi | |
| exec /usr/bin/sudo "$@" | |
| SUDOWRAP | |
| chown root:root /usr/local/bin/sudo | |
| chmod 755 /usr/local/bin/sudo | |
| ok "sudo wrapper installed (no-tty calls fail in under a second)" | |
| log "Configuring agent permissions" | |
| # The security boundary is the sudoers whitelist, enforced by the kernel. These | |
| # rules are convenience, not defence — so they cover only what is unwise | |
| # regardless of who runs it. Asking about `sudo apt install`, which sudoers | |
| # already permits without a password, is pure friction: it trains you to click | |
| # through prompts, which is worse than not having them. | |
| install -d -m 755 -o "$DEV_USER" -g "$DEV_USER" "$DEV_HOME/.claude" | |
| cat > "$DEV_HOME/.claude/settings.json" <<'JSON' | |
| { | |
| "permissions": { | |
| "defaultMode": "default", | |
| "ask": [ | |
| "Bash(sudo bash:*)", | |
| "Bash(sudo sh:*)", | |
| "Bash(sudo su:*)", | |
| "Bash(sudo -i:*)", | |
| "Bash(git push:*)", | |
| "Bash(rm -rf:*)" | |
| ], | |
| "deny": [ | |
| "Read(//home/dev/.ssh/**)", | |
| "Read(//root/**)", | |
| "Read(//etc/shadow)", | |
| "Read(//etc/sudoers)", | |
| "Read(//etc/sudoers.d/**)", | |
| "Bash(sudo visudo:*)", | |
| "Bash(sudo passwd:*)", | |
| "Bash(sudo useradd:*)", | |
| "Bash(sudo usermod:*)", | |
| "Bash(sudo chown:*)", | |
| "Bash(sudo chmod:*)" | |
| ] | |
| }, | |
| "sandbox": { | |
| "enabled": true, | |
| "autoAllowBashIfSandboxed": true, | |
| "failIfUnavailable": true | |
| } | |
| } | |
| JSON | |
| chown "$DEV_USER:$DEV_USER" "$DEV_HOME/.claude/settings.json" | |
| install -d -m 755 -o "$DEV_USER" -g "$DEV_USER" "$DEV_HOME/.config/opencode" | |
| cat > "$DEV_HOME/.config/opencode/opencode.json" <<'JSON' | |
| { | |
| "$schema": "https://opencode.ai/config.json", | |
| "permission": { | |
| "edit": "allow", | |
| "webfetch": "allow", | |
| "bash": { | |
| "*": "allow", | |
| "git push *": "ask", | |
| "rm -rf *": "ask", | |
| "sudo bash*": "deny", | |
| "sudo sh*": "deny", | |
| "sudo su*": "deny", | |
| "sudo -i*": "deny", | |
| "sudo visudo*": "deny", | |
| "sudo passwd*": "deny", | |
| "sudo useradd*": "deny", | |
| "sudo usermod*": "deny", | |
| "sudo chown*": "deny", | |
| "sudo chmod*": "deny", | |
| "cat /home/dev/.ssh/*": "deny" | |
| } | |
| } | |
| } | |
| JSON | |
| chown "$DEV_USER:$DEV_USER" "$DEV_HOME/.config/opencode/opencode.json" | |
| python3 -c 'import json,sys; [json.load(open(f)) for f in sys.argv[1:]]' \ | |
| "$DEV_HOME/.claude/settings.json" "$DEV_HOME/.config/opencode/opencode.json" \ | |
| 2>/dev/null && ok "both agent configs are valid JSON" \ | |
| || warn "an agent config failed to parse — check it by hand" | |
| cat > "$DEV_HOME/CLAUDE.md" <<'MD' | |
| # Host conventions | |
| Personal VPS. You run as the unprivileged user `dev`. | |
| ## What sudo can and cannot do | |
| Passwordless, use freely: | |
| `apt` `apt-get` `dpkg` `systemctl` `journalctl` `service` `ufw` `ss` `ip` | |
| `sysctl` `caddy` `timedatectl` `hostnamectl` `loginctl` `dmesg` `df` `du` | |
| `lsblk` | |
| Everything else needs a password you do not have, and will fail immediately | |
| with "sudo: a password is required". That is expected. It is not a bug, not a | |
| transient error, and not something to retry or route around. | |
| When you hit it: stop and say which command needs privileges and why. Do not | |
| try `sudo bash -c`, do not write to a directory on `$PATH` and invoke it, do | |
| not add yourself to a group, do not edit sudoers. | |
| ## No sudo needed | |
| - Docker runs rootless as `dev`. Use `docker` directly, never `sudo docker`. | |
| - `/etc/caddy`, `/srv/www` and `/var/log/caddy` are writable by `dev`. | |
| - Anything under `/home/dev`. | |
| ## Before changing system state | |
| One line on what you are about to change and why, then do it. Prefer the | |
| smallest reversible change. Prefer explicit package names over `upgrade`. | |
| Prefer `systemctl reload` over `restart` where the unit supports it. | |
| MD | |
| chown "$DEV_USER:$DEV_USER" "$DEV_HOME/CLAUDE.md" | |
| ln -sf "$DEV_HOME/CLAUDE.md" "$DEV_HOME/AGENTS.md" | |
| chown -h "$DEV_USER:$DEV_USER" "$DEV_HOME/AGENTS.md" | |
| ok "CLAUDE.md + AGENTS.md written" | |
| chown -R "$DEV_USER:$DEV_USER" "$DEV_HOME" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 17. Verification | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| echo | |
| log "Verification" | |
| FAILED=0 | |
| check() { # check <label> <shell command run as dev> | |
| if runuser -u "$DEV_USER" -- env HOME="$DEV_HOME" \ | |
| PATH="$DEV_HOME/.local/bin:$DEV_HOME/.bun/bin:$DEV_HOME/bin:/usr/local/bin:/usr/bin:/bin" \ | |
| bash -lc "$2" >/dev/null 2>&1 | |
| then printf ' %sOK%s %s\n' "$C_OK" "$C_OFF" "$1" | |
| else printf ' %sFAIL%s %-22s (%s)\n' "$C_ERR" "$C_OFF" "$1" "$2"; FAILED=1 | |
| fi | |
| } | |
| check "swap" 'swapon --show | grep -q swapfile' | |
| check "dev user" 'id dev' | |
| check "git" 'git --version && git config --global user.email' | |
| check "ufw" 'systemctl is-active ufw' | |
| check "mosh" 'mosh-server --version 2>&1 | grep -qi mosh' | |
| check "zsh" 'zsh --version' | |
| check "pure prompt" 'test -f "$HOME/.zsh/pure/pure.zsh"' | |
| check "nvm" 'test -s "$HOME/.nvm/nvm.sh"' | |
| check "node" '. "$HOME/.nvm/nvm.sh" && node --version' | |
| check "npm" '. "$HOME/.nvm/nvm.sh" && npm --version' | |
| check "bun" '"$HOME/.bun/bin/bun" --version' | |
| check "bat" 'batcat --version' | |
| check "eza" 'eza --version' | |
| check "claude code" 'claude --version' | |
| check "opencode" 'opencode --version' | |
| check "herdr" 'herdr --version' | |
| check "caddy" 'caddy version' | |
| check "sftp subsystem" 'grep -rq internal-sftp /etc/ssh/sshd_config.d/' | |
| check "authorized_keys" 'test -s "$HOME/.ssh/authorized_keys"' | |
| check "sshkey helper" 'sshkey list' | |
| check "github key" 'test -f "$HOME/.ssh/github" || true' | |
| check "neovim" 'nvim --version' | |
| check "lazyvim" 'test -f "$HOME/.config/nvim/lua/config/lazy.lua"' | |
| check "docker" 'docker --version' | |
| check "docker-compose" 'docker-compose version || docker compose version' | |
| check "bubblewrap" 'command -v bwrap' | |
| check "claude settings" 'test -s "$HOME/.claude/settings.json"' | |
| check "opencode config" 'test -s "$HOME/.config/opencode/opencode.json"' | |
| if [[ "$AGENT_SUDO_WHITELIST" == "true" ]]; then | |
| check "apt sudo NOPASSWD" 'sudo -n -l /usr/bin/apt-get' | |
| check "systemctl NOPASSWD" 'sudo -n -l /usr/bin/systemctl' | |
| check "bash sudo BLOCKED" '! sudo -n -l /usr/bin/bash' | |
| check "tee sudo BLOCKED" '! sudo -n -l /usr/bin/tee' | |
| fi | |
| visudo -c >/dev/null && printf ' %sOK%s sudoers valid\n' "$C_OK" "$C_OFF" || FAILED=1 | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 18. Key material | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| SUMMARY="/root/provision-summary.txt" | |
| { | |
| echo | |
| echo "════════════════════════════════════════════════════════════════════════" | |
| echo " CREDENTIALS — record the password, then delete this file" | |
| echo "════════════════════════════════════════════════════════════════════════" | |
| echo | |
| echo "Host : $(hostname -f 2>/dev/null || hostname) $(hostname -I | awk '{print $1}')" | |
| echo "SSH port : ${SSH_PORT}" | |
| echo "User : ${DEV_USER}" | |
| if [[ "${DEV_PASSWORD_GENERATED:-true}" == "true" ]]; then | |
| echo "Sudo passwd : ${DEV_PASSWORD}" | |
| else | |
| echo "Sudo passwd : (the one you chose at bootstrap — not recorded here)" | |
| fi | |
| echo | |
| echo " The password is for sudo ONLY. SSH is key-only — PasswordAuthentication" | |
| echo " is off, so this password cannot be used to log in. Put it in your" | |
| echo " password manager and never store it on this machine." | |
| echo | |
| if [[ -f "${DEV_HOME}/.ssh/github.pub" ]]; then | |
| echo "────────────────────────────────────────────────────────────────────────" | |
| echo " GITHUB — PUBLIC KEY (paste into github.com/settings/keys)" | |
| echo " The private half stays on this server at ${DEV_HOME}/.ssh/github" | |
| echo " Fingerprint: $(ssh-keygen -lf "${DEV_HOME}/.ssh/github.pub" | awk '{print $2}')" | |
| echo "────────────────────────────────────────────────────────────────────────" | |
| cat "${DEV_HOME}/.ssh/github.pub" | |
| echo | |
| fi | |
| echo "════════════════════════════════════════════════════════════════════════" | |
| echo | |
| echo "Connect: ssh ${DEV_USER}@<host> (with the key you already have)" | |
| echo "Add a device: sshkey add <label> then paste its PUBLIC key" | |
| echo "GitHub: ssh -T git@github.com (as ${DEV_USER}, after adding the key)" | |
| echo | |
| } | tee "$SUMMARY" | |
| chmod 600 "$SUMMARY" | |
| echo | |
| (( ${#SOFT_FAILURES[@]} )) && warn "non-fatal failures: ${SOFT_FAILURES[*]}" | |
| if (( FAILED )); then | |
| printf '%s\n' "${C_ERR}Some checks failed — see the FAIL rows above.${C_OFF}" | |
| else | |
| printf '%s\n' "${C_OK}All checks passed.${C_OFF}" | |
| fi | |
| cat <<EOF | |
| Saved to ${SUMMARY}. Next steps: | |
| 1. Record the sudo password, then: shred -u ${SUMMARY} | |
| 2. Add your other devices. On each one, generate a key locally, then here: | |
| sshkey add ipad # paste its PUBLIC key | |
| sshkey add nas --sftp # SFTP-only, no shell | |
| 3. Log in as ${DEV_USER} and run 'claude' to authenticate Claude Code. | |
| 4. Add the GitHub public key at https://github.com/settings/keys | |
| 5. Once ${DEV_USER} login is confirmed: set PermitRootLogin no, reload ssh | |
| 6. Reboot to pick up the new kernel. | |
| Two things that must stay true for the privilege model to hold: | |
| · Never run an agent with --dangerously-skip-permissions on this box. | |
| · Never store the ${DEV_USER} password on the machine — not in a file, not | |
| in an env var, not in a shell history. It is the whole boundary. | |
| EOF |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment