hero4hire
RunbooksProxmox 9 x64

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.

Proxmox 9 x64linuxsecurityUnverified

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@pam over SSH already.
  • You have a shell open that you are not about to close. The script restarts sshd if 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 not root. 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 sudo asks 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.

bash
(
  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

On this page