2026-07-10 20:43:24 +00:00
#!/usr/bin/env bash
2026-07-11 08:25:48 +00:00
# rig bootstrap — OS plumbing for a pristine Debian box.
2026-07-10 20:43:24 +00:00
# Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail
2026-07-11 08:25:48 +00:00
log( ) { printf 'rig-bootstrap: %s\n' " $* " ; }
warn( ) { printf 'rig-bootstrap: WARNING: %s\n' " $* " >& 2; }
die( ) { printf 'rig-bootstrap: ERROR: %s\n' " $1 " >& 2; exit " ${ 2 :- 1 } " ; }
2026-07-10 20:43:24 +00:00
usage( ) {
cat <<'EOF'
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
usage: rig bootstrap <control-plane| workload| runner> [ --hostname <name>]
[ --ts-tag <tag>] [ --admin-user <name>] [ --admin-key <pubkey>]
[ --lock-root]
2026-07-10 20:43:24 +00:00
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
--hostname system + tailnet hostname ( default: the role name)
--ts-tag tailnet tag to advertise ( default: tag:server;
role runner defaults to tag:ci and refuses tag:server —
a CI box executes repo-controlled code, and your server
tag' s grants must never extend to it)
--admin-user non-root admin account to create on every role ( default: admin;
refuses root) . sudo group, key-only, NEVER the docker group.
Its authorized_keys is seeded ONCE from root' s at creation —
you are connected as root with one of those keys right now, so
the copy is live proof the private key is in your hands.
--admin-key an extra public key to add to the admin account at creation,
composed with the seed above ( optional) .
--lock-root close root' s SSH door ( PermitRootLogin no) once the admin user
is proven reachable. ROLE-GATED: refused on control-plane
( Coolify SSHes to its OWN host) and on workload ( needs Coolify' s
experimental non-root mode, which rig does not provision) ;
allowed on runner.
2026-07-10 20:43:24 +00:00
Provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or
enter it at the interactive prompt. It is used once and never written to disk.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
ROLE = " ${ 1 :- } "
case " $ROLE " in
2026-07-11 18:25:47 +00:00
control-plane| workload| runner) shift ; ;
2026-07-10 20:43:24 +00:00
-h| --help) usage; exit 0 ; ;
2026-07-11 18:25:47 +00:00
"" ) usage >& 2; die "role required (control-plane|workload|runner)" 2 ; ;
*) die " unknown role: $ROLE (want control-plane|workload|runner) " 2 ; ;
2026-07-10 20:43:24 +00:00
esac
TS_HOSTNAME = " $ROLE "
2026-07-11 18:25:47 +00:00
if [ " $ROLE " = "runner" ] ; then
TS_TAG = "tag:ci"
else
TS_TAG = "tag:server"
fi
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
ADMIN_USER = "admin" # generic default; nothing org-specific ever ships in rig
ADMIN_KEY = ""
LOCK_ROOT = 0
2026-07-10 20:43:24 +00:00
while [ $# -gt 0 ] ; do
case " $1 " in
--hostname)
[ $# -ge 2 ] || die "--hostname needs a value" 2
TS_HOSTNAME = " $2 " ; shift 2 ; ;
--ts-tag)
[ $# -ge 2 ] || die "--ts-tag needs a value" 2
TS_TAG = " $2 " ; shift 2 ; ;
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
--admin-user)
[ $# -ge 2 ] || die "--admin-user needs a value" 2
ADMIN_USER = " $2 " ; shift 2 ; ;
--admin-key)
[ $# -ge 2 ] || die "--admin-key needs a value" 2
ADMIN_KEY = " $2 " ; shift 2 ; ;
--lock-root)
LOCK_ROOT = 1; shift ; ;
2026-07-10 20:43:24 +00:00
*) die " unknown flag: $1 " 2 ; ;
esac
done
2026-07-11 18:25:47 +00:00
# A runner executes repo-controlled code; advertising the server tag would
# extend every grant your servers hold to that code. Refused, not warned.
if [ " $ROLE " = "runner" ] && [ " $TS_TAG " = "tag:server" ] ; then
die "role runner must not advertise tag:server" 2
fi
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
# The admin account is the non-root human door; making it "root" is a
# contradiction in terms. Refused, not warned — same spirit as runner-install
# refusing --user root.
[ " $ADMIN_USER " != "root" ] || die "--admin-user must not be root" 2
# Role-aware root policy. --lock-root means exactly `PermitRootLogin no` (see the
# lock-root block far below for why not the other four "lock root" techniques),
# and on the two Coolify roles that is a self-inflicted fleet outage, so it is
# REFUSED here (exit 2) rather than warned — a flag that silently bricks a box's
# only door is worse than no flag. Validated before the root check so the
# refusal is unit-testable without a live box.
if [ " $LOCK_ROOT " -eq 1 ] ; then
case " $ROLE " in
control-plane)
die "role control-plane must not --lock-root: Coolify reaches its OWN host over SSH (host.docker.internal) and non-root localhost is unsupported upstream (coollabsio/coolify#4245); PermitRootLogin no would cut the control plane off from itself" 2 ; ;
workload)
die "role workload must not --lock-root: closing root here needs Coolify's experimental non-root mode — a 'coolify' user with NOPASSWD: ALL (root by another name), which rig does not provision. Get attribution cheaper via sshd key-fingerprint logging + auditd. Revisit when Coolify ships granular sudo" 2 ; ;
runner)
: ; ; # no Coolify on a runner; lock-root is allowed once the admin proves reachable
esac
fi
2026-07-10 20:43:24 +00:00
# --- guards ------------------------------------------------------------------
[ " $( id -u) " -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ] ; then
2026-07-11 19:37:48 +00:00
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars.
2026-07-10 20:43:24 +00:00
# shellcheck source=/dev/null
2026-07-11 19:37:48 +00:00
OS_FAMILY = " $( . /etc/os-release && printf '%s %s' " ${ ID :- } " " ${ ID_LIKE :- } " ) "
case " $OS_FAMILY " in
2026-07-10 20:43:24 +00:00
*debian*) ; ;
2026-07-11 19:37:48 +00:00
*) warn " not a Debian-family system ( ${ OS_FAMILY :- unknown } ); proceeding anyway " ; ;
2026-07-10 20:43:24 +00:00
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
2026-07-12 15:26:34 +00:00
# The pre-auth key is acquired LATER, in the tailscale block — and only if the
# box has not already joined. rig is convergent by contract, so re-running it to
# pick up a fix (e.g. the 2026-07-12 sshd first-wins fix) must not demand a
# credential it will never spend: prompting up front made the repair path cost a
# throwaway Tailscale key, which is exactly the friction that stops people from
# re-running it.
2026-07-10 20:43:24 +00:00
# --- packages ----------------------------------------------------------------
export DEBIAN_FRONTEND = noninteractive
log "installing base packages"
apt-get update -qq
2026-07-11 10:38:14 +00:00
# openssh-server: a rig box is managed over SSH (Coolify SSHes in as root),
# and the hardening drop-in below targets /etc/ssh/sshd_config.d/ — which
# only exists once the package is installed. Cloud images ship it; pristine
# container/VM images (the Incus rehearsal) do not.
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
# sudo: the admin user below is placed in the sudo group, which the sudo
# package creates — and the lock-root verification runs `sudo -n true` under
# the admin before it will close root's door. A pristine Debian container ships
# neither the package nor the group; cloud images do.
apt-get install -y -qq curl ca-certificates unattended-upgrades openssh-server sudo
2026-07-10 20:43:24 +00:00
# enable periodic unattended upgrades (canonical file; idempotent overwrite)
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1" ;
APT::Periodic::Unattended-Upgrade "1" ;
EOF
# --- sshd hardening (restart only when the drop-in actually changed) ---------
2026-07-12 15:26:34 +00:00
# The name must sort BEFORE cloud-init's drop-in. sshd_config is FIRST-wins
# ("for each keyword, the first obtained value will be used" — sshd_config(5)),
# and Include expands the glob in lexical order. Cloud images ship
# /etc/ssh/sshd_config.d/50-cloud-init.conf carrying `PasswordAuthentication
# yes`, so the old 99-rig.conf was read second and silently lost every keyword
# it set. 00- wins. (Found 2026-07-12: every Hetzner box rig had bootstrapped
# was still serving `passwordauthentication yes`. The Incus rehearsal never
# caught it — a pristine Debian container has no cloud-init drop-in.)
DROPIN = /etc/ssh/sshd_config.d/00-rig.conf
LEGACY_DROPIN = /etc/ssh/sshd_config.d/99-rig.conf
2026-07-10 20:43:24 +00:00
TMP = " $( mktemp) "
cat > " $TMP " <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
EOF
2026-07-12 15:26:34 +00:00
if ! cmp -s " $TMP " " $DROPIN " 2>/dev/null || [ -e " $LEGACY_DROPIN " ] ; then
2026-07-12 15:29:09 +00:00
BACKUP = ""
[ -e " $DROPIN " ] && { BACKUP = " $( mktemp) " ; cp -a " $DROPIN " " $BACKUP " ; }
2026-07-10 20:43:24 +00:00
install -m 0644 " $TMP " " $DROPIN "
2026-07-12 15:26:34 +00:00
rm -f " $LEGACY_DROPIN " # sweep the losing file from already-bootstrapped boxes
2026-07-12 15:29:09 +00:00
# Validate the MERGED config BEFORE bouncing the daemon. On a box whose only
# door is SSH, `systemctl restart ssh` against a config sshd refuses to parse
# leaves no listener and no way back in. `sshd -t` parses everything sshd
# would parse — our drop-in, cloud-init's, and any third-party file — so a
# broken neighbour is caught here rather than after the door has shut.
if ! sshd -t 2>/dev/null; then
if [ -n " $BACKUP " ] ; then cp -a " $BACKUP " " $DROPIN " ; else rm -f " $DROPIN " ; fi
rm -f " $TMP " " $BACKUP "
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
fi
rm -f " $BACKUP "
2026-07-10 20:43:24 +00:00
systemctl restart ssh
log "sshd hardening drop-in installed"
else
log "sshd hardening drop-in already in place"
fi
rm -f " $TMP "
2026-07-12 15:26:34 +00:00
# Assert the EFFECTIVE config, not the file's existence — asserting the file is
# what let the first-wins bug ship green. `sshd -T` is what the daemon actually
# resolved, cloud-init and all.
eff = " $( sshd -T 2>/dev/null) " || die "sshd -T failed; refusing to claim a hardened box"
echo " $eff " | grep -qx 'passwordauthentication no' \
|| die " sshd still resolves passwordauthentication=yes — a drop-in is beating ${ DROPIN } ; check ls /etc/ssh/sshd_config.d/ "
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
# `no` is accepted here too, not just the two prohibit-password spellings: once
# --lock-root has installed 00-rig-root.conf (below), the effective policy is
# `no`, which is STRICTLY MORE restrictive (root cannot log in at all, password
# or key). Refusing it would make the very first re-run of a locked box die in
# the base-hardening assert — breaking convergence exactly where it matters
# most. `no` still means "no root password login", so it satisfies the intent.
echo " $eff " | grep -qxE 'permitrootlogin (prohibit-password|without-password|no)' \
2026-07-12 15:26:34 +00:00
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
log "sshd hardening verified (sshd -T: passwordauthentication no)"
bootstrap: create an admin user and make the root door role-aware
Adds a non-root admin user (sudo group, never docker, NOPASSWD sudo,
key-only) on every role, with its authorized_keys seeded ONCE from root's
at creation — live proof the operator holds the private key. --admin-key
composes an explicit key with the seed.
Adds --lock-root (PermitRootLogin no), role-gated: refused (exit 2) on
control-plane (Coolify self-SSH) and workload (needs Coolify's experimental
non-root mode rig doesn't provision), allowed on runner. Before closing
root's door it verifies the admin is reachable (account not expired, real
shell, sane authorized_keys, sudo -n true, sshd -T -C resolution permits
the login); any failure leaves the door open and says why.
The PermitRootLogin no drop-in is 00-rig-root.conf, sorting BEFORE
00-rig.conf so first-wins sshd_config semantics actually apply it, installed
with the same validate-before-restart + sshd -t + rollback + sshd -T
effective-assert dance as the base hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:07 +00:00
# --- admin user (a non-root human door on every role) ------------------------
# rig hardens the SSH door but, until now, never created a human to walk through
# it: every box was administered as root, survivable only because of the
# prohibit-password drop-in above. The admin is a non-root account in the sudo
# group with an SSH key — and NEVER the docker group: the docker socket is a
# root API and docker-group membership is root-equivalent, a gratuitous path to
# root that runner-install refuses for the same reason. sudo is the ONLY
# supplementary group it gets. Created on every role (control-plane included,
# where root must stay) so there is always a human door even where root's stays
# open.
ADMIN_HOME = " $( getent passwd " $ADMIN_USER " 2>/dev/null | cut -d: -f6) "
if [ -z " $ADMIN_HOME " ] ; then
log " creating admin user ${ ADMIN_USER } (sudo group, no docker) "
useradd --create-home --shell /bin/bash " $ADMIN_USER "
usermod -aG sudo " $ADMIN_USER "
ADMIN_HOME = " $( getent passwd " $ADMIN_USER " | cut -d: -f6) "
# NOPASSWD sudo for the admin — and yes, this is the same NOPASSWD the issue
# warns against for Coolify's service user. The distinction is who holds the
# account: the admin is a HUMAN who authenticates with an SSH key they hold
# and has NO password (useradd leaves the password locked). Requiring a sudo
# password they do not have would make sudo unusable — a non-root user who
# cannot escalate is not an admin. Key-only + NOPASSWD sudo is exactly what
# Debian/Ubuntu cloud images do for their default user. It is wrong for
# Coolify's user (a non-human identity you are trying to CONSTRAIN, where
# NOPASSWD hands an attacker who takes the account full root and makes
# attribution merely cooperative); it is right for a human you are EMPOWERING.
# visudo -cf validates before install: a malformed sudoers file breaks sudo
# for everyone, and we are about to (maybe) close root's door behind it.
SUDOERS_TMP = " $( mktemp) "
printf '%s ALL=(ALL) NOPASSWD:ALL\n' " $ADMIN_USER " > " $SUDOERS_TMP "
if visudo -cf " $SUDOERS_TMP " >/dev/null 2>& 1; then
install -m 0440 -o root -g root " $SUDOERS_TMP " " /etc/sudoers.d/90-rig- ${ ADMIN_USER } "
else
rm -f " $SUDOERS_TMP "
die " generated sudoers file for ${ ADMIN_USER } failed visudo -c; not installed "
fi
rm -f " $SUDOERS_TMP "
# Seed authorized_keys from ROOT's — ONCE, at creation, and never again.
# WHY seed from root: the operator is connected as root RIGHT NOW using one
# of root's keys, so copying them into the admin account is live proof the
# matching private key is in their hands — strictly better than any check
# rig could invent, needs no new argument, and a public key is not a secret,
# so "no credential, ever" does not bend.
# WHY only once: re-seeding on every run would resurrect a key the operator
# DELIBERATELY removed from the admin account. Seed-once is therefore, in
# strict honesty, NOT convergent — an exception named here rather than
# papered over. An existing admin user (the else branch) is left untouched.
install -d -m 0700 -o " $ADMIN_USER " -g " $ADMIN_USER " " $ADMIN_HOME /.ssh "
ADMIN_KEYS = " $ADMIN_HOME /.ssh/authorized_keys "
: > " $ADMIN_KEYS "
if [ -r /root/.ssh/authorized_keys ] ; then
# Two hazards make a blind copy wrong:
# - Coolify writes its OWN key into root's authorized_keys when it
# registers a server. We cannot tell it from the operator's, so we cannot
# drop it; on control-plane/workload the operator should audit the seeded
# file (documented in the README).
# - Cloud images can carry command="…"/from="…" forced-command or source
# restrictions on a key. Copied verbatim those silently follow to the
# admin (a from="1.2.3.4" that no longer matches would lock the admin out
# just as surely). We SKIP any line whose first field is not a bare key
# type — i.e. one carrying leading options — and warn, rather than seed a
# key that behaves differently than it reads. Pass it via --admin-key if
# the restriction is intended.
while IFS = read -r line; do
case " $line " in
"" | \# *) continue ; ;
ssh-*| ecdsa-*| sk-ssh-*| sk-ecdsa-*)
printf '%s\n' " $line " >> " $ADMIN_KEYS " ; ;
*)
warn " skipping a restricted key line (command=/from=/…) while seeding ${ ADMIN_USER } from root; re-add it with --admin-key if intended " ; ;
esac
done < /root/.ssh/authorized_keys
fi
# --admin-key composes with the seed: an explicit key the operator supplies,
# added at creation alongside whatever was copied from root.
if [ -n " $ADMIN_KEY " ] ; then
printf '%s\n' " $ADMIN_KEY " >> " $ADMIN_KEYS "
log " added --admin-key to ${ ADMIN_USER } "
fi
chown -R " $ADMIN_USER : $ADMIN_USER " " $ADMIN_HOME /.ssh "
chmod 0600 " $ADMIN_KEYS "
if [ ! -s " $ADMIN_KEYS " ] ; then
warn " ${ ADMIN_USER } has an EMPTY authorized_keys (root had none to seed and no --admin-key given) — it cannot log in yet; add a key before relying on it "
fi
log " admin user ${ ADMIN_USER } created "
else
# Convergent: an existing admin user is left completely alone — no re-seeding
# (see the seed-once note above), no group changes, no sudoers rewrite.
log " admin user ${ ADMIN_USER } already exists; leaving it and its keys untouched "
fi
# --- lock root's SSH door (--lock-root, role-permitted only) ------------------
# Reached only when --lock-root was given AND the role passed the policy gate
# above (control-plane/workload already died at exit 2; only runner arrives
# here). "Lock root" is FOUR different actions that do NOT behave alike
# (measured on OpenSSH 10 / Debian 13):
# passwd -l root key-based root SSH still WORKS (near no-op)
# PermitRootLogin prohibit key SSH works (what rig does today)
# usermod --expiredate 1 BREAKS root SSH via PAM
# root shell -> nologin BREAKS (and then chsh fails too)
# PermitRootLogin no BREAKS root SSH <-- the ONLY one we want
# We want exactly `PermitRootLogin no`. The other break-paths (expiredate,
# nologin) would also break rig's OWN convergence: rig is run as root over SSH,
# so a re-run to pick up a fix would find the door bolted from a direction sshd
# cannot reopen. `PermitRootLogin no` leaves the account intact and reopenable
# by deleting one drop-in.
if [ " $LOCK_ROOT " -eq 1 ] ; then
log " verifying ${ ADMIN_USER } is reachable before closing root's door "
# NEVER close root's door in the same breath as opening the admin's without
# these checks passing. What rig CANNOT verify is that the operator holds the
# admin's private key — which is exactly why we seeded authorized_keys from
# root's (the key they are connected with right now). Everything else, we can:
fail( ) { die " refusing --lock-root: $1 (root's door stays OPEN) " 1; }
# 1. Account exists.
id -u " $ADMIN_USER " >/dev/null 2>& 1 || fail " admin user ${ ADMIN_USER } does not exist "
# 2. Account not expired/disabled. A locked PASSWORD is fine (key auth is
# unaffected — that is the whole lesson of this issue), but an EXPIRED
# account (shadow field 8 in the past, i.e. `usermod --expiredate 1`) is
# refused by PAM and would block the admin's SSH too. Field 8 empty = never.
expire_days = " $( getent shadow " $ADMIN_USER " | cut -d: -f8) "
if [ -n " $expire_days " ] ; then
today_days = $(( $( date -u +%s) / 86400 ))
[ " $expire_days " -gt " $today_days " ] 2>/dev/null \
|| fail " admin account ${ ADMIN_USER } is expired/disabled (shadow expire= ${ expire_days } ) "
fi
# 3. Valid, real login shell — not nologin/false (which PAM/login refuse).
admin_shell = " $( getent passwd " $ADMIN_USER " | cut -d: -f7) "
case " $admin_shell " in
*/nologin| */false| "" ) fail " admin ${ ADMIN_USER } has no usable login shell ( ${ admin_shell :- none } ) " ; ;
esac
[ -x " $admin_shell " ] || fail " admin ${ ADMIN_USER } 's shell ${ admin_shell } is not executable "
# 4. authorized_keys non-empty, sane ownership + perms. sshd silently ignores
# a keys file that is group/world-writable or not owned by the user, so a
# present-but-rejected file is as good as no key.
akeys = " $ADMIN_HOME /.ssh/authorized_keys "
[ -s " $akeys " ] || fail " admin ${ ADMIN_USER } has an empty/missing authorized_keys ( ${ akeys } ) "
owner = " $( stat -c '%U' " $akeys " 2>/dev/null) "
[ " $owner " = " $ADMIN_USER " ] || fail " authorized_keys is owned by ${ owner :- ? } , not ${ ADMIN_USER } "
perms = " $( stat -c '%a' " $akeys " 2>/dev/null) "
case " $perms " in
600| 640| 644| 400| 440) ; ; # not group/world writable
*) fail " authorized_keys perms ${ perms } are too open (sshd would ignore it); want 0600 " ; ;
esac
# 5. sudo actually works for the admin, non-interactively, as the box will use
# it. runuser (not su) mirrors the runner-install precedent.
runuser -u " $ADMIN_USER " -- sudo -n true >/dev/null 2>& 1 \
|| fail " sudo -n true fails for ${ ADMIN_USER } (no working passwordless sudo) "
# 6. sshd's EFFECTIVE resolution for THIS user must permit a key login. An
# AllowUsers/AllowGroups/DenyUsers/DenyGroups or Match block elsewhere can
# silently exclude the admin even though the account is perfect. We assert
# against `sshd -T -C user=<admin>` — the daemon's own resolution — not the
# file we wrote, same discipline as the base hardening above. (A Match on
# address cannot be resolved without a real connection; that residual gap
# is what the second-terminal rehearsal covers.)
actx = " $( sshd -T -C user = " $ADMIN_USER " 2>/dev/null) " \
|| fail " sshd -T -C user= ${ ADMIN_USER } failed to resolve "
echo " $actx " | grep -qx 'pubkeyauthentication yes' \
|| fail " sshd does not offer publickey auth to ${ ADMIN_USER } "
admin_groups = " $( id -nG " $ADMIN_USER " 2>/dev/null) "
au = " $( echo " $actx " | sed -n 's/^allowusers //p' ) "
if [ -n " $au " ] ; then
printf '%s' " $au " | grep -qF " $ADMIN_USER " \
|| fail " sshd AllowUsers excludes ${ ADMIN_USER } "
fi
du = " $( echo " $actx " | sed -n 's/^denyusers //p' ) "
if [ -n " $du " ] && printf '%s' " $du " | grep -qF " $ADMIN_USER " ; then
fail " sshd DenyUsers lists ${ ADMIN_USER } "
fi
ag = " $( echo " $actx " | sed -n 's/^allowgroups //p' ) "
if [ -n " $ag " ] ; then
permitted = 0
for g in $ag ; do
case " $admin_groups " in *" $g " *) permitted = 1; break ; ; esac
done
[ " $permitted " -eq 1 ] || fail " sshd AllowGroups admits none of ${ ADMIN_USER } 's groups "
fi
dg = " $( echo " $actx " | sed -n 's/^denygroups //p' ) "
if [ -n " $dg " ] ; then
for g in $dg ; do
case " $admin_groups " in *" $g " *) fail " sshd DenyGroups lists ${ ADMIN_USER } 's group ${ g } " ; ; esac
done
fi
log " admin ${ ADMIN_USER } verified reachable (account, shell, keys, sudo, sshd resolution) "
# Only NOW do we touch root's door — with the exact validate-before-restart +
# sshd -t + rollback + sshd -T effective-assert dance the base drop-in uses.
# The file sorts BEFORE 00-rig.conf, on purpose: sshd_config is FIRST-wins, so
# `PermitRootLogin no` in a 10-* file would be read AFTER 00-rig.conf's
# `prohibit-password` and silently discarded — the same first-wins trap that
# cost this repo a month of boxes serving passwordauthentication=yes. 00-rig-
# root.conf sorts first ('-' < '.'), so it wins over both 00-rig.conf and
# cloud-init. (Reopening root is a deliberate manual act: rm this file and
# restart ssh — rig does not silently reopen it on a re-run without --lock-root.)
ROOT_DROPIN = /etc/ssh/sshd_config.d/00-rig-root.conf
RTMP = " $( mktemp) "
printf 'PermitRootLogin no\n' > " $RTMP "
if ! cmp -s " $RTMP " " $ROOT_DROPIN " 2>/dev/null; then
RBACK = ""
[ -e " $ROOT_DROPIN " ] && { RBACK = " $( mktemp) " ; cp -a " $ROOT_DROPIN " " $RBACK " ; }
install -m 0644 " $RTMP " " $ROOT_DROPIN "
if ! sshd -t 2>/dev/null; then
if [ -n " $RBACK " ] ; then cp -a " $RBACK " " $ROOT_DROPIN " ; else rm -f " $ROOT_DROPIN " ; fi
rm -f " $RTMP " " $RBACK "
die "sshd rejects the merged config with PermitRootLogin no; rolled back, daemon untouched, root's door still OPEN. Run 'sshd -t'."
fi
rm -f " $RBACK "
systemctl restart ssh
log "root SSH door closed (PermitRootLogin no drop-in installed)"
else
log "root SSH door already closed (00-rig-root.conf in place)"
fi
rm -f " $RTMP "
# Assert the EFFECTIVE policy, never the file — assert-the-file is what let the
# first-wins bug ship green once already.
reff = " $( sshd -T 2>/dev/null) " || die "sshd -T failed after locking root; investigate before trusting this box" 1
echo " $reff " | grep -qx 'permitrootlogin no' \
|| die " root door did NOT take effect (sshd -T still permits root login) — a drop-in is beating ${ ROOT_DROPIN } ; check ls /etc/ssh/sshd_config.d/ " 1
log " root door verified closed (sshd -T: permitrootlogin no) — from now on re-run rig THROUGH the ${ ADMIN_USER } account "
fi
2026-07-12 15:26:34 +00:00
# --- system hostname ----------------------------------------------------------
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
# passed --hostname only to `tailscale up`, so a box reached as `coolify-box`
# still greeted the operator with Hetzner's default (`root@internal-tooling`).
# The shell prompt is the operator's only "am I on the right box" signal before
# they run something destructive, and it was lying on every box rig built.
if [ " $( hostname) " != " $TS_HOSTNAME " ] ; then
log " setting system hostname to ${ TS_HOSTNAME } "
hostnamectl set-hostname " $TS_HOSTNAME "
# keep 127.0.1.1 in step, or sudo/sshd warn about an unresolvable host
if grep -qE '^127\.0\.1\.1[[:space:]]' /etc/hosts; then
sed -i -E " s/^127\.0\.1\.1[[:space:]].*/127.0.1.1\t ${ TS_HOSTNAME } / " /etc/hosts
else
printf '127.0.1.1\t%s\n' " $TS_HOSTNAME " >> /etc/hosts
fi
else
log " system hostname already ${ TS_HOSTNAME } "
fi
2026-07-10 20:43:24 +00:00
# --- tailscale ----------------------------------------------------------------
if ! command -v tailscale >/dev/null 2>& 1; then
log "installing tailscale"
curl -fsSL https://tailscale.com/install.sh | sh
fi
if tailscale status >/dev/null 2>& 1; then
2026-07-12 15:26:34 +00:00
log "tailnet already joined; skipping tailscale up (no pre-auth key needed)"
2026-07-12 15:47:38 +00:00
# ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name
# never converged: a box that joined under the wrong name (e.g. --hostname
# omitted, so it defaulted to the ROLE) stayed misnamed forever, and re-running
# rig — the documented repair — could not fix it. rig is convergent by
# contract; this was the one field that wasn't. `tailscale set` converges it
# without a re-auth or a pre-auth key.
#
# Safe by construction here: Tailscale ACLs cannot bind a rule's dst to a
# hostname (it must be a tag, an IP, or a `hosts` alias — which is exactly why
# acl.hujson pins coolify-box to an IP), so a rename cannot silently void a
# grant. It also will NOT clobber a deliberate rename: a machine renamed in the
# admin console keeps that name, and the device hostname no longer overrides it.
current_ts_name = " $( tailscale status --peers= false 2>/dev/null | awk 'NR==1 {print $2}' ) "
if [ -n " $current_ts_name " ] && [ " $current_ts_name " != " $TS_HOSTNAME " ] ; then
log " tailnet hostname is ' ${ current_ts_name } ', want ' ${ TS_HOSTNAME } ' — converging "
tailscale set --hostname= " $TS_HOSTNAME " \
|| warn " tailscale set --hostname failed; rename ' ${ current_ts_name } ' -> ' ${ TS_HOSTNAME } ' in the admin console "
else
log " tailnet hostname already ${ TS_HOSTNAME } "
fi
2026-07-10 20:43:24 +00:00
else
2026-07-12 15:26:34 +00:00
# env override, else prompt; never touches disk
if [ -z " ${ TS_AUTHKEY :- } " ] ; then
read -rsp "tailscale pre-auth key (single-use, tagged, <=1h expiry): " TS_AUTHKEY
echo
fi
[ -n " ${ TS_AUTHKEY :- } " ] || die "empty pre-auth key"
2026-07-10 20:43:24 +00:00
log " joining tailnet as ${ TS_HOSTNAME } ( ${ TS_TAG } ) "
tailscale up --authkey= " $TS_AUTHKEY " --hostname= " $TS_HOSTNAME " --advertise-tags= " $TS_TAG "
fi
log " done — role ${ ROLE } , hostname ${ TS_HOSTNAME } "
if [ " $ROLE " = "control-plane" ] ; then
2026-07-11 08:25:48 +00:00
log "next: rig coolify install --version <pin>"
2026-07-11 18:25:47 +00:00
elif [ " $ROLE " = "runner" ] ; then
log "next: rig runner install --repo <owner/repo> --version <pin>"
2026-07-10 20:43:24 +00:00
fi