Skip to content

Instantly share code, notes, and snippets.

@dragonworx
Last active July 30, 2026 03:49
Show Gist options
  • Select an option

  • Save dragonworx/0953c926ddcb0aae054e5426f19213e7 to your computer and use it in GitHub Desktop.

Select an option

Save dragonworx/0953c926ddcb0aae054e5426f19213e7 to your computer and use it in GitHub Desktop.
#!/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