Add an admin account
Create a second root-equivalent administrator on a Proxmox VE 9 host in one paste — Unix account, sudo, an Administrator role in the web UI, and SSH keys pulled from GitHub daily.
Overview
This runbook explains how to add a second administrator to a Proxmox VE 9
host, with the same reach as root@pam and an SSH login that works when
the script finishes.
"Administrator" on Proxmox means two separate things, and an account needs
both to be genuinely equal to root. There is the Unix account, which is
what SSH authenticates and what sudo turns into root on the box. And
there is the Proxmox user, which is what the web UI and the API
authenticate, and which carries its own roles independent of anything in
/etc/passwd. The script does both, then adds the third piece nobody
remembers until they are locked out: keys, and a way to keep them current.
One thing stays root's alone. root@pam is special-cased inside Proxmox
and always holds every privilege, whatever the ACLs say; a handful of
operations check for it by name. The account this builds can do everything
through the API and the UI, and can become root with sudo for the rest,
which is as close to equal as Proxmox lets another account be.
Run it again with a different name to add a third admin — everything it installs is either per-account or safe to overwrite.
Before you start
Before you add an admin, ensure:
- You are root on the host, or in a position to become root. This is
Proxmox, so you are probably
root@pamover SSH already. - You have a shell open that you are not about to close. The script
restarts
sshdif it has to change its configuration, and an open session is what you fall back to if the new account does not work. - The GitHub account whose keys you are about to trust is one you control and has two-factor authentication on it. Anyone who can add a key there can become root on this host from that moment on.
- The host can reach
api.github.com. The keys are fetched during the run, not at login, so a firewall that blocks it fails here rather than mysteriously later.
The script asks you for:
NEW_USER— the account name. Lower-case, and notroot. If the account already exists the script updates it instead of creating it, provided it is a real login account rather than a system one.- A Unix password, twice, not echoed. This is not optional: the Proxmox
web UI authenticates PAM accounts against it, and
sudoasks for it. GH_ACCOUNT— the GitHub account to take SSH public keys from.
The script
It asks its three questions, then runs for as long as apt needs — a few
seconds on a host that already has sudo.
(
set -euo pipefail
command -v pveversion >/dev/null ||
{ echo "This is not a Proxmox VE host." >&2; exit 1; }
# ---- Everything it asks for. Nothing here changes the machine. ----
read -rp "New administrator's account name: " NEW_USER
[[ "$NEW_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] ||
{ echo "Not a usable account name: $NEW_USER" >&2; exit 1; }
[[ "$NEW_USER" != "root" ]] ||
{ echo "root already exists and already has all of this." >&2; exit 1; }
EXISTING=0
if id "$NEW_USER" >/dev/null 2>&1; then
CURRENT_UID=$(id -u "$NEW_USER")
[[ "$CURRENT_UID" -ge 1000 ]] ||
{ echo "$NEW_USER is a system account (uid $CURRENT_UID)." >&2; exit 1; }
EXISTING=1
echo " $NEW_USER already exists (uid $CURRENT_UID); it will be updated."
fi
read -rsp "Unix password for $NEW_USER: " PASSWORD; echo
read -rsp "Repeat it: " PASSWORD_AGAIN; echo
[[ "$PASSWORD" == "$PASSWORD_AGAIN" ]] ||
{ echo "The two passwords differ." >&2; exit 1; }
[[ ${#PASSWORD} -ge 8 ]] ||
{ echo "Use at least 8 characters: this password reaches the web UI." >&2; exit 1; }
read -rp "GitHub account to take SSH keys from: " GH_ACCOUNT
[[ -n "$GH_ACCOUNT" ]] ||
{ echo "A GitHub account is required; the new admin logs in by key." >&2; exit 1; }
SUDO="sudo"
if [[ "$EUID" -eq 0 ]]; then
SUDO=""
elif ! sudo -v; then
echo "Become root first: this host may not even have sudo yet." >&2
exit 1
fi
# ---- Everything below here changes the machine. ----
echo "==> Making sure sudo and ssh-import-id are installed"
MISSING=()
command -v sudo >/dev/null || MISSING+=(sudo)
command -v ssh-import-id >/dev/null || MISSING+=(ssh-import-id)
if [[ ${#MISSING[@]} -gt 0 ]]; then
export DEBIAN_FRONTEND=noninteractive
if ! $SUDO apt-get update; then
echo " apt-get update had errors, which an unsubscribed enterprise"
echo " repository does every time. Carrying on."
fi
$SUDO apt-get install -y "${MISSING[@]}"
fi
if [[ "$EXISTING" -eq 0 ]]; then
echo "==> Creating the Unix account $NEW_USER"
$SUDO useradd --create-home --shell /bin/bash "$NEW_USER"
fi
echo "==> Setting the Unix password"
printf '%s:%s\n' "$NEW_USER" "$PASSWORD" | $SUDO chpasswd
echo "==> Putting $NEW_USER in the sudo group"
$SUDO usermod -aG sudo "$NEW_USER"
NEW_HOME=$(getent passwd "$NEW_USER" | cut -d: -f6)
NEW_GROUP=$(id -gn "$NEW_USER")
$SUDO install -d -o "$NEW_USER" -g "$NEW_GROUP" -m 700 "$NEW_HOME/.ssh"
echo "==> Installing the GitHub key importer"
$SUDO tee /usr/local/sbin/import-github-keys >/dev/null <<'SCRIPT'
#!/bin/bash
# Replace a local account's machine-managed authorized keys with whatever the
# GitHub account publishes right now.
set -euo pipefail
user=${1:?usage: import-github-keys <local-user> <github-account>}
account=${2:?usage: import-github-keys <local-user> <github-account>}
home=$(getent passwd "$user" | cut -d: -f6)
group=$(id -gn "$user")
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
ssh-import-id -o - "gh:$account" >"$tmp"
# Refuse to install anything that is not a valid public key file: an empty or
# malformed fetch would otherwise revoke every managed key at once.
ssh-keygen -l -f "$tmp" >/dev/null
install -o "$user" -g "$group" -m 600 "$tmp" "$home/.ssh/authorized_keys.github"
SCRIPT
$SUDO chmod 755 /usr/local/sbin/import-github-keys
$SUDO tee /etc/systemd/system/[email protected] >/dev/null <<'UNIT'
[Unit]
Description=Import %i's authorized SSH keys from GitHub
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/default/import-github-keys-%i
ExecStart=/usr/local/sbin/import-github-keys %i ${GITHUB_ACCOUNT}
UNIT
$SUDO tee /etc/systemd/system/[email protected] >/dev/null <<'UNIT'
[Unit]
Description=Import %i's authorized SSH keys from GitHub daily
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
UNIT
printf 'GITHUB_ACCOUNT=%s\n' "$GH_ACCOUNT" |
$SUDO tee "/etc/default/import-github-keys-$NEW_USER" >/dev/null
echo "==> Fetching gh:$GH_ACCOUNT's keys now"
$SUDO /usr/local/sbin/import-github-keys "$NEW_USER" "$GH_ACCOUNT"
$SUDO systemctl daemon-reload
$SUDO systemctl enable --now "import-github-keys@$NEW_USER.timer"
echo "==> Telling sshd to read the managed key file"
AKF_CONF=/etc/ssh/sshd_config.d/20-authorized-keys.conf
AKF_LINE='AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys.github'
if [[ "$($SUDO cat "$AKF_CONF" 2>/dev/null)" != "$AKF_LINE" ]]; then
printf '%s\n' "$AKF_LINE" | $SUDO tee "$AKF_CONF" >/dev/null
$SUDO /usr/sbin/sshd -t
if systemctl is-active --quiet ssh.socket; then
$SUDO systemctl restart ssh.socket
else
$SUDO systemctl restart ssh
fi
else
echo " already set; sshd left alone"
fi
echo "==> Giving $NEW_USER@pam the Administrator role"
if $SUDO pveum user list | grep -qw "$NEW_USER@pam"; then
echo " $NEW_USER@pam already exists"
else
$SUDO pveum user add "$NEW_USER@pam"
fi
$SUDO pveum acl modify / --users "$NEW_USER@pam" --roles Administrator
# ---- Check the three halves actually work. ----
echo "==> Checking"
PROBLEMS=()
KEY_COUNT=$($SUDO grep -cvE '^[[:space:]]*(#|$)' \
"$NEW_HOME/.ssh/authorized_keys.github" 2>/dev/null || true)
if [[ "$KEY_COUNT" -eq 0 ]]; then
PROBLEMS+=("no keys were imported from gh:$GH_ACCOUNT")
else
echo " keys: $KEY_COUNT imported from gh:$GH_ACCOUNT"
fi
if $SUDO sudo -l -U "$NEW_USER" >/dev/null 2>&1; then
echo " sudo: $NEW_USER may run commands as root"
else
PROBLEMS+=("sudo would refuse $NEW_USER")
fi
if $SUDO pveum acl list | grep -qw "$NEW_USER@pam"; then
echo " proxmox: $NEW_USER@pam holds a role on /"
else
PROBLEMS+=("the Proxmox ACL grant did not take; see 'pveum acl list'")
fi
if EFFECTIVE=$($SUDO /usr/sbin/sshd -T \
-C "user=$NEW_USER,host=localhost,addr=127.0.0.1" 2>/dev/null); then
ALLOW=$(awk '/^allowusers /{ $1=""; print }' <<<"$EFFECTIVE")
AKF=$(awk '/^authorizedkeysfile /{ $1=""; print }' <<<"$EFFECTIVE")
SSHD_OK=1
if ! grep -q 'authorized_keys.github' <<<"$AKF"; then
PROBLEMS+=("sshd never reads .ssh/authorized_keys.github: no Include")
SSHD_OK=0
fi
if [[ -n "$ALLOW" ]] && ! grep -qw -- "$NEW_USER" <<<"$ALLOW"; then
PROBLEMS+=("sshd has AllowUsers set to$ALLOW, excluding $NEW_USER")
SSHD_OK=0
fi
if [[ "$SSHD_OK" -eq 1 ]]; then
echo " sshd: will accept $NEW_USER by key"
fi
else
echo " sshd: could not dump the effective config; check it by hand"
fi
HOST=$(hostname -f 2>/dev/null || hostname)
echo
if [[ ${#PROBLEMS[@]} -gt 0 ]]; then
echo "Done, with problems:"
printf ' - %s\n' "${PROBLEMS[@]}"
echo
else
echo "Done. $NEW_USER is an administrator on this host."
fi
echo " ssh $NEW_USER@$HOST"
echo " https://$HOST:8006/ - log in with realm 'Linux PAM standard authentication'"
echo " keys refresh daily: systemctl list-timers 'import-github-keys@*'"
echo
echo "Test the SSH login from a SECOND terminal before you close this one."
)How it works
The three accounts that make one administrator
useradd --create-home --shell /bin/bash makes the Unix account. That is
what SSH authenticates and what owns a home directory to keep keys in. On
its own it can log in and do nothing.
usermod -aG sudo is what makes it root-capable on the box. Debian's
/etc/sudoers, which Proxmox inherits, carries
%sudo ALL=(ALL:ALL) ALL, so membership of that group is the whole grant.
The -a matters: usermod -G sudo without it replaces every
supplementary group the account has. Group membership is fixed when a
session is created, so an already-open shell belonging to this account
would not see it — irrelevant for an account created a moment ago, but not
for a second run against an existing one.
pveum user add "$NEW_USER@pam" creates the Proxmox user. This is a
separate register from /etc/passwd, and the @pam realm means Proxmox
does not store a password for it — it hands the one you type at the web UI
straight to PAM, which checks it against the Unix account. That is why the
password is not optional here and why it is asked for even though the SSH
login is by key: without it the web UI has nothing to authenticate
against, and sudo has nothing to prompt for. Adding a NOPASSWD rule to
sudoers instead would fix the second of those and not the first.
pveum acl modify / --users … --roles Administrator is the grant itself.
Roles on Proxmox attach to a path, / is the root of the object tree, and
ACLs propagate downwards by default, so a role granted there covers every
node, VM, storage, and pool on the cluster. Administrator is the role
that carries every privilege including Permissions.Modify — the ability
to hand out roles in turn, which is what makes it the root-equivalent one
rather than PVEAdmin. Take that seriously: this account can grant itself
and anyone else anything from here on.
Installing what is missing rather than what is listed
Proxmox installs a deliberately small Debian, and sudo is not part of
it. ssh-import-id never is. Both are checked for with command -v and
only the absent ones are installed, so a host that already has them never
touches apt at all.
apt-get update is allowed to fail. On a host with the enterprise
repository configured and no subscription, that command exits non-zero
every time it runs, and under set -e it would end the script before
anything useful happened. The install that follows is not allowed to fail,
so a genuine problem still stops the run — this only tolerates the noise
everyone with an unsubscribed host already knows about.
Keys, and keeping them current
ssh-import-id -o - "gh:$account" asks
https://api.github.com/users/<account>/keys for the keys the account
publishes and writes them to standard output. Writing to - rather than
to a file sidesteps the tool's normal behaviour, which is to append to
~/.ssh/authorized_keys and never remove anything — a key deleted at
GitHub would keep working here forever. Rebuilding the file from each
fetch is what makes revocation propagate: whatever GitHub stops
publishing is gone from this host within a day.
ssh-keygen -l -f is the guard. ssh-import-id already fails on a 404 or
an empty key list, but a proxy or captive portal can answer 200 with a
body that is not a key file, and set -euo pipefail stops the script
before that body reaches the account's keys. A failed fetch therefore
leaves yesterday's keys in place rather than revoking everything at once.
The keys land in authorized_keys.github, which the timer owns and
overwrites, while authorized_keys next to it is never touched. That
split is what lets you add a key by hand later — a break-glass key that
does not depend on GitHub — without the next daily run wiping it.
20-authorized-keys.conf is what tells sshd to read both, and it is
written only when its content would change, so a second run for a second
admin does not restart sshd for nothing.
One timer per admin
The units are systemd templates — [email protected] and
[email protected], with %i standing in for whatever follows
the @. Enabling [email protected] instantiates them for
alice alone, and running this runbook again for bob adds a second
instance beside it rather than overwriting the first. A pair of
fixed-name units with the account baked into ExecStart would have made
the second admin silently replace the first's schedule.
The GitHub account cannot go in the instance name without making it
unreadable, so it lives in /etc/default/import-github-keys-alice and
reaches the unit through EnvironmentFile, which understands %i the
same way ExecStart does. To point an admin at a different GitHub
account later, edit that one file and nothing else.
OnCalendar=daily fires at midnight, RandomizedDelaySec=1h scatters the
fetch across the following hour so a fleet of hosts does not arrive at
GitHub together, and Persistent=true runs a missed fetch once the
machine comes back up. Failures land in
journalctl -u "[email protected]".
Checking rather than hoping
The last stage exists because "the script finished" and "the account works" are different claims, and this runbook only makes the second one worth reading.
sudo -l -U "$NEW_USER" asks sudo itself what that account may run,
without needing the account's password, and exits non-zero when the answer
is nothing. pveum acl list is read back to confirm the grant landed
rather than assuming the command that made it did what it said.
sshd -T -C user=… is the useful one. It dumps the configuration sshd
would actually apply to a connection from that account — every Include
resolved and every Match block evaluated — so it catches the two
failures that otherwise show up as a wordless Permission denied from the
other end of the network. The first is authorizedkeysfile not mentioning
.ssh/authorized_keys.github, which means the drop-in was written but
nothing reads that directory. The second is an AllowUsers allowlist left
by earlier hardening, which refuses every account not named in it
regardless of its keys — a new admin is exactly the account such a list
was written before, and adding the name there is the fix.
Problems are collected and printed together at the end rather than stopping the run, because by that point every change has already been made and the useful thing is a list of what to go and fix.
See also
- Proxmox VE wiki: User Management
- pveum(1)
- First boot — the same key handling for the account a Debian installer already made
- ssh-import-id(1)
- systemd.timer(5)
Set up NVIDIA GPU passthrough
Install the libvirt/QEMU stack on a Debian 13 host and reserve an NVIDIA card for VFIO in one paste, up to the reboot that hands the card over.
Set up NVIDIA GPU passthrough
Reserve an NVIDIA card for VFIO on a Proxmox VE 9 host in one paste — module binding, initramfs, and the kernel command line for whichever bootloader the host uses.