hero4hire
RunbooksProxmox 9 x64

Add a user account

Proxmox 9 x64linuxsecurityUnverified

Overview

This runbook explains how to add a user account to a Proxmox VE 9 host. It asks for a name, a password, a GitHub account to take SSH keys from, and whether the account should be an administrator, then creates both halves an account needs to be usable: the Unix account that SSH authenticates, and the Proxmox user that the web UI and API authenticate against it. An administrator gets the Administrator role on / and sudo on the host; anyone else gets PVEAuditor, which sees the whole cluster and changes none of it. Where a GitHub account is given, the keys it publishes are installed and a daily timer rebuilds them, so a key revoked there stops working here within a day; leave it empty and the account logs in by password alone.

Before you start

Before you add an account, 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 or the new user controls, with two-factor authentication on it. Anyone who can add a key there can log in as this account from the next fetch onwards.
  • The host can reach github.com, if you are giving a GitHub account. The keys are fetched during the run, not at login, so a firewall that blocks it fails here rather than mysteriously later.
  • Password authentication is on in sshd, if you are not giving one. Otherwise the account will have no way to reach the host over SSH, and the script will say so at the end rather than leave you to find out.

The script

It asks its four questions, then runs for as long as apt needs — a few seconds on a host that already has what it needs.

bash
(
  set -euo pipefail

  # This runbook is written for Proxmox VE 9; pveversion only exists there.
  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. ----

  # The Unix account name, which is also the username at the web UI. Lower
  # case, and not root. An account that already exists is updated rather than
  # recreated, which is how you change someone's role or keys later.
  read -rp "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
    # A uid below 1000 is a service account that some package owns. Handing
    # one of those a password, a shell, and a role is a mistake worth
    # refusing rather than making quietly.
    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

  # The Unix password. Not optional even though the SSH login is by key: the
  # web UI hands this to PAM to check, and sudo prompts for it.
  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; }

  # The GitHub account to trust for SSH keys — the name in the profile URL,
  # github.com/<name>. Whoever controls it can log in as this account from
  # the next daily fetch onwards. Leave it empty to skip keys entirely, and
  # the account will have the password and nothing else to log in with.
  read -rp "GitHub account to take SSH keys from (empty to skip): " GH_ACCOUNT
  if [[ -n "$GH_ACCOUNT" ]]; then WANT_KEYS=1; else WANT_KEYS=0; fi

  # How much the account gets, as one decision rather than a menu. An
  # administrator holds Administrator on / — every privilege the API has,
  # including handing out roles — and sudo on the host, which is the shell
  # half of the same job. Anything else gets PVEAuditor and no sudo: it sees
  # the whole cluster in the web UI, changes none of it, and has an ordinary
  # unprivileged shell over SSH. Empty answer means no.
  read -rp "Should $NEW_USER be an administrator? [y/N]: " ANSWER
  case "${ANSWER:-n}" in
    y|Y|yes|YES) ROLE=Administrator; WANT_SUDO=1 ;;
    n|N|no|NO)   ROLE=PVEAuditor;    WANT_SUDO=0 ;;
    *) echo "Answer y or n." >&2; exit 1 ;;
  esac

  # Run as root if we already are, and ask for the password now rather than in
  # the middle of the run.
  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. ----

  # Proxmox installs a deliberately small Debian, so neither of these is
  # guaranteed — though curl usually is already there. Only what is actually
  # absent is installed, and each only when this run has a use for it, since
  # `usermod -aG sudo` needs the group that installing sudo creates.
  echo "==> Making sure the packages this needs are installed"
  MISSING=()
  if [[ "$WANT_KEYS" -eq 1 ]]; then
    command -v curl >/dev/null || MISSING+=(curl)
  fi
  if [[ "$WANT_SUDO" -eq 1 ]]; then
    command -v sudo >/dev/null || MISSING+=(sudo)
  fi
  if [[ ${#MISSING[@]} -gt 0 ]]; then
    export DEBIAN_FRONTEND=noninteractive
    # An enterprise repository with no subscription makes this exit non-zero
    # on every host that has one, which under `set -e` would end the run
    # before anything happened. The install below is not allowed to fail, so
    # a genuine problem still stops the script.
    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

  # The Unix half: what SSH authenticates, and what owns a home directory for
  # the keys to live in.
  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

  # Debian's sudoers, which Proxmox inherits, carries `%sudo ALL=(ALL:ALL) ALL`,
  # so membership of that group is the whole grant. Answering no does not take
  # sudo away from an account that already had it; the checks at the end say
  # so rather than leaving it unsaid.
  if [[ "$WANT_SUDO" -eq 1 ]]; then
    echo "==> Putting $NEW_USER in the sudo group"
    $SUDO usermod -aG sudo "$NEW_USER"
  fi

  # Read off the account rather than assumed, so this still works where home
  # directories are not under /home or the primary group is not the username.
  NEW_HOME=$(getent passwd "$NEW_USER" | cut -d: -f6)
  NEW_GROUP=$(id -gn "$NEW_USER")
  # Created whether or not GitHub keys were asked for: it is where a key
  # added by hand later has to go, and it has to have mode 700 to be used.
  $SUDO install -d -o "$NEW_USER" -g "$NEW_GROUP" -m 700 "$NEW_HOME/.ssh"

  # Everything to do with keys hangs off this: with no GitHub account there
  # is nothing to fetch, nothing to schedule, and no reason to teach sshd
  # about a key file that will never exist.
  if [[ "$WANT_KEYS" -eq 1 ]]; then
    # The importer is installed as a script on disk rather than inlined into the
    # unit below, because the timer runs it again every day long after this
    # runbook is closed, and a file is something you can read and run by hand.
    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

# github.com/<account>.keys returns the published keys as plain text, two
# fields to a line and no comment. -f turns a 404 into a failure rather than
# an HTML page written over the keys, and the whole pipeline is under
# `set -o pipefail`, so a bad fetch stops here with yesterday's keys intact.
# The awk stamps each key with where it came from: everything after the
# base64 is the comment field, so this shows up in `ssh-keygen -l` and in
# sshd's auth log rather than leaving an unattributed key in the file.
curl -fsS --max-time 10 "https://github.com/$account.keys" |
  awk -v src="gh:$account" 'NF { print $0 " " src }' >"$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

    # Template units — %i is whatever follows the @ when the timer is enabled —
    # so a second account gets its own instance beside this one rather than
    # overwriting its schedule.
    $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

    # The GitHub account cannot go in the instance name without making it
    # unreadable, so it reaches the unit through this file instead. To point an
    # account at a different GitHub user later, edit this and nothing else.
    printf 'GITHUB_ACCOUNT=%s\n' "$GH_ACCOUNT" |
      $SUDO tee "/etc/default/import-github-keys-$NEW_USER" >/dev/null

    # Fetch once now, so the SSH login works when the script finishes rather
    # than at midnight.
    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"

    # sshd reads only .ssh/authorized_keys by default, and the imported keys are
    # deliberately not in that file. Written only when it would change, so a
    # second run for a second account does not restart sshd for nothing.
    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
  else
    echo "==> No GitHub account given; skipping SSH keys"
    echo "    $NEW_USER logs in with the password until a key is added"
    echo "    by hand to $NEW_HOME/.ssh/authorized_keys."
  fi

  # The Proxmox half. The @pam realm means Proxmox stores no password of its
  # own for this user and hands what is typed at the web UI to PAM, which
  # checks it against the Unix account created above.
  echo "==> Giving $NEW_USER@pam the $ROLE 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

  # `pveum acl modify` adds a role at a path; it does not replace what is
  # there. Without this a second run to demote an administrator would leave
  # them holding Administrator as well as PVEAuditor, quietly keeping the
  # privileges the run was meant to take away — so every role the host knows
  # about is cleared from / for this account first. Deleting a grant that was
  # never there is a no-op, which is why this can be a sweep rather than a
  # read of what they currently hold.
  KNOWN_ROLES=$($SUDO pveum role list --noborder --noheader | awk 'NF { print $1 }')
  for KNOWN in $KNOWN_ROLES; do
    $SUDO pveum acl delete / --users "$NEW_USER@pam" --roles "$KNOWN" \
      >/dev/null 2>&1 || true
  done
  $SUDO pveum acl modify / --users "$NEW_USER@pam" --roles "$ROLE"

  # ---- Check the halves actually work. ----

  # Finishing and working are different claims; collect what is wrong rather
  # than stopping at the first, since every change has already been made.
  echo "==> Checking"
  PROBLEMS=()

  if [[ "$WANT_KEYS" -eq 1 ]]; then
    KEY_COUNT=$($SUDO grep -cvE '^[[:space:]]*(#|$)' \
      "$NEW_HOME/.ssh/authorized_keys.github" 2>/dev/null || true)
    if [[ "${KEY_COUNT:-0}" -eq 0 ]]; then
      PROBLEMS+=("no keys were imported from gh:$GH_ACCOUNT")
    else
      echo "    keys: $KEY_COUNT imported from gh:$GH_ACCOUNT"
    fi
  fi

  # sudo -l -U asks sudo itself what the account may run, without needing that
  # account's password. Where sudo was declined the interesting failure is the
  # opposite one: an account that came in with it and kept it.
  if [[ "$WANT_SUDO" -eq 1 ]]; then
    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
  elif command -v sudo >/dev/null && $SUDO sudo -l -U "$NEW_USER" >/dev/null 2>&1; then
    PROBLEMS+=("$NEW_USER already had sudo and still does; this run did not remove it")
  else
    echo "    sudo: $NEW_USER cannot become root on the host, as asked"
  fi

  ACL_LINE=$($SUDO pveum acl list --noborder --noheader | grep -w "$NEW_USER@pam" || true)
  if grep -qw -- "$ROLE" <<<"$ACL_LINE"; then
    echo "    proxmox: $NEW_USER@pam holds $ROLE on /"
  else
    PROBLEMS+=("the $ROLE grant did not take; see 'pveum acl list'")
  fi

  # The configuration sshd would actually apply to a connection from this
  # account, every Include resolved and every Match evaluated — which is where
  # the two failures that otherwise read as a wordless "Permission denied"
  # show up.
  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")
    PASSAUTH=$(awk '/^passwordauthentication /{ print $2 }' <<<"$EFFECTIVE")
    SSHD_OK=1
    if [[ "$WANT_KEYS" -eq 1 ]] && ! grep -q 'authorized_keys.github' <<<"$AKF"; then
      PROBLEMS+=("sshd never reads .ssh/authorized_keys.github: no Include")
      SSHD_OK=0
    fi
    # With no keys the password is the only way in, so a host that has turned
    # password authentication off has just been given an account that cannot
    # log in over SSH at all.
    if [[ "$WANT_KEYS" -eq 0 && "$PASSAUTH" != "yes" ]]; then
      PROBLEMS+=("$NEW_USER has no keys and sshd has PasswordAuthentication $PASSAUTH")
      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"
    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 can log in to this host and holds $ROLE on /."
  fi
  echo "  ssh $NEW_USER@$HOST"
  echo "  https://$HOST:8006/  - log in with realm 'Linux PAM standard authentication'"
  if [[ "$WANT_KEYS" -eq 1 ]]; then
    echo "  keys refresh daily: systemctl list-timers 'import-github-keys@*'"
  fi
  echo
  echo "Test the SSH login from a SECOND terminal before you close this one."
)

How it works

The two registers an account has to exist in

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 over SSH and see nothing at all in the web UI.

pveum user add "$NEW_USER@pam" creates the Proxmox user, a separate register from /etc/passwd. The @pam realm is the load-bearing part: Proxmox stores no password for a user in that realm and instead hands the one typed at the web UI straight to PAM, which checks it against the Unix account. That is why the password 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.

The realm is also what the reader has to pick at the login form. The dropdown defaults to Proxmox VE authentication server, which is a different register again, with its own passwords and no Unix account behind it, and an account made by this script does not exist there. Linux PAM standard authentication is the one that works, and choosing wrong is the most common way a correct setup looks broken.

What the one question decides

pveum acl modify / --users … --roles "$ROLE" is the grant. 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.

Answering y grants Administrator, which carries every privilege the API has including Permissions.Modify — the ability to hand out roles in turn, which is what makes it root-equivalent rather than merely powerful. Grant it deliberately: the account can escalate itself and anyone else from that moment on, and it is worth adding two-factor authentication to anything holding it, under Datacenter → Permissions → Two Factor.

Anything else grants PVEAuditor, which carries the *.Audit privileges and nothing else, so the account sees the whole cluster in the UI and changes none of it. It is the answer that cannot cost anything, which is why it is what an empty answer gets.

Those two are the ends of the range rather than the whole of it. Proxmox ships roles in between — PVEVMUser adds console access and power control over guests, PVEVMAdmin adds creating and destroying them, and pveum role list shows the rest — and the script deliberately does not offer them, because a runbook that asks the reader to pick from twenty answers is a menu rather than a paste. To land on one of the middle roles, run the script answering n and then move the grant: add the narrower one with pveum acl modify /vms/100 --users alice@pam --roles PVEVMUser, then take the wide one away with pveum acl delete / --users alice@pam --roles PVEAuditor. In that order — reversed, there is a window in which the account can reach nothing.

One privilege is worth knowing by name. Sys.Console is what the Shell button on a node needs, and among the built-in roles only Administrator and PVEAdmin include it. A PVEAuditor who SSHes in has an ordinary unprivileged shell on the host; the same account in the web UI has no shell button at all. Those are two different doors governed by two different things, which surprises people.

Why sudo follows the same answer

The role governs the API and the web UI. sudo governs the shell. They are genuinely independent — an Administrator with no sudo cannot edit a file on the host, and a PVEAuditor with sudo is root on the box whatever the read-only badge in the UI suggests — and the script still ties them to one answer, because "administrator" on a Proxmox host means both halves and asking twice makes the first question nearly meaningless.

The cost of that is the two combinations it cannot produce. Both are one command away afterwards: usermod -aG sudo alice gives an administrator's shell to an auditor, and deluser alice sudo takes it off an administrator while leaving the role alone.

usermod -aG sudo is the whole grant. Debian's /etc/sudoers, which Proxmox inherits, carries %sudo ALL=(ALL:ALL) ALL, so membership of that group is all there is to it. 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 the change — irrelevant for an account created a moment ago, but not for a second run against an existing one, which has to log out and back in.

Answering n does not remove sudo from an account that already had it. Revoking a privilege is a different operation with different consequences — it can lock the last administrator out of a host — and doing it as a side effect of a default answer is not something a pasted script should do. What the script does instead is notice and say so, which is the already had sudo and still does line in the checks.

The one thing no role and no group can reach is root@pam itself. Proxmox special-cases it internally: it always holds every privilege whatever the ACLs say, and a handful of operations check for it by name. An account with Administrator and sudo is as close to equal as another account gets.

Replacing a grant rather than adding to it

pveum acl modify adds a role at a path. It does not replace what is already there, and there is no flag that makes it. So running this script a second time against the same account to demote them — Administrator last month, PVEAuditor today — would leave both grants in place, and Proxmox takes the union of a user's roles. The demotion would appear to succeed and change nothing.

The loop calling pveum acl delete is what makes the second run mean what it says. pveum role list --noborder --noheader gives it every role the host knows about — built-in and custom alike, which matters because the account may be holding one this script never grants — and it sweeps all of them off /. It is a sweep rather than a read of what the account currently holds because deleting an ACL entry that does not exist is a no-op, which makes it both simpler and more robust than parsing pveum acl list and depending on its column order. Failures are swallowed with || true for the same reason: the only expected outcome of most iterations is "there was nothing there".

It clears / only. A grant this script made is always on /, but one made by hand on /vms/100 survives, which is the right way round — the sweep should undo this runbook's own grants, not somebody else's careful narrowing.

Installing what is missing rather than what is listed

Proxmox installs a deliberately small Debian. sudo is not part of it, which is why the script cannot simply assume it when it needs to become root, and curl is not guaranteed either — though in practice it is usually already there. 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.

Each is installed only when this run has a use for it. For sudo that is not just tidiness: usermod -aG sudo needs the sudo group to exist, and it is the package that creates it, so installing it is part of the grant rather than a prerequisite of the script. curl is skipped when no GitHub account was given, which keeps a run that was never going to fetch anything from pulling a package onto the host to prove it.

curl is also the reason this stage is as small as it is. The obvious tool for the job is ssh-import-id, and it would bring python3, python3-requests, python3-distro, and wget onto the host with it — which is why the importer below fetches the keys itself instead.

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

An empty answer to the GitHub question skips this whole stage — the package, the importer, both units, the timer, and the sshd drop-in. None of it is per-account except the timer instance, but installing host-wide machinery for a fetch that will never run leaves the next reader wondering what it is for, and an importer on disk that cannot fetch is worse than absent. The account still gets a .ssh directory with mode 700, because that is where a key added by hand has to go, and sshd ignores one with looser permissions.

What that account has instead is the password, which is why the checks look at sshd's effective passwordauthentication in that case. A host hardened to keys-only plus an account with no keys is an account that cannot log in at all, and it is better to hear that from the script than from a Permission denied three minutes later.

curl -fsS "https://github.com/$account.keys" fetches the keys. That endpoint returns them as plain text, two fields to a line — a key type and its base64 — with no comment and no JSON, which is what makes the whole importer a shell script with no interpreter behind it.

The alternative is ssh-import-id, the tool built for this. It is worth knowing why this does not use it. It asks https://api.github.com/users/<account>/keys instead, gets JSON back, and therefore needs Python and requests to read it — four packages on a host that had none of them. It also appends to ~/.ssh/authorized_keys by default and never removes anything, so a key deleted at GitHub would keep working here forever; using it here at all meant passing -o - to redirect its output and rebuild the file, which is most of the way to not using it. And the REST API rate-limits unauthenticated callers to 60 requests an hour per address, which a fleet of hosts fetching from behind one NAT address can reach. The .keys endpoint is not on that budget.

What is given up is the provenance ssh-import-id writes into each key line, so the importer adds its own: awk -v src="gh:$account" appends the source to every key. Everything after the base64 in an authorized_keys line is the comment field, so the stamp is legal syntax, and it surfaces where it is useful — ssh-keygen -l prints it beside the fingerprint, and sshd logs it when the key is accepted. A file of unattributed keys tells the next reader nothing about where they came from.

-f is what turns a 404 into a failure. Without it curl writes GitHub's error page to standard output with a zero exit status, and a renamed account would replace the keys with HTML. -sS keeps the progress meter out of the timer's journal while letting real errors through.

ssh-keygen -l -f is the guard behind that. -f already fails on a 404, 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. Rebuilding the file from each successful fetch is what makes revocation propagate: whatever GitHub stops publishing is gone from this host within a day.

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 account does not restart sshd for nothing.

The restart is conditional in a second way. Debian 13 starts sshd through ssh.socket under socket activation, where restarting ssh.service alone does not pick up new configuration; systemctl is-active --quiet ssh.socket is what picks the right unit on a host that could be either. sshd -t runs first, so a configuration that would not parse is caught before the running daemon is asked to reload it. The version of this that skips the test is the one that takes SSH down on a host you are connected to over SSH.

One timer per account

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 account 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 account 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. It is run in both directions: as a confirmation when sudo was granted, and as a warning when it was not and the account turns out to have it anyway. pveum acl list is read back and matched against the role that was asked for, rather than assuming the command that made the grant did what it said — which is also what would catch the sweep above failing to clear an older role.

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 failures that otherwise show up as a wordless Permission denied from the other end of the network. Which of them apply depends on the run: passwordauthentication is only interesting for an account with no keys, and authorizedkeysfile only for one with them. 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 account is exactly the case 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.

Commands used

CommandArgumentsPurpose
command-vPrint the path of a command, or fail if it is not installed
read-rTake the answer literally; a backslash in it is not an escape
read-pPrint the prompt before reading, without a trailing newline
read-sKeep the answer off the screen and out of the scrollback
id-uPrint the account's numeric user ID
id-gnPrint the account's primary group by name
awkNFSkip blank lines, so only real rows are read
getentpasswdRead the passwd database, whatever backend supplies it
cut-d: -f6Take the home directory field out of a passwd entry
apt-getupdateRefresh the package lists
apt-getinstall -yInstall the named packages without asking for confirmation
useradd--create-homeCreate the home directory as well as the account
useradd--shellSet the account's login shell
chpasswdSet a password from user:password read on standard input
usermod-aGAdd to a group, leaving the account's other groups alone
install-dCreate a directory rather than copy a file
install-o, -g, -mSet owner, group, and mode as the file is created
teeWrite standard input to a file, through sudo, without a shell redirect
chmodSet a file's mode
mktempCreate a temporary file with a name nothing else will collide with
trapRun a command when the shell exits, however it exits
curl-fFail on an HTTP error instead of writing the error page out
curl-sSNo progress meter, but still print real errors
curl--max-timeGive up rather than hang the daily timer on a dead network
awk-vPass a shell value in as an awk variable, without quoting it into the program
ssh-keygen-l -fList a key file's fingerprints; fails when the file is not one
sshd-tTest the configuration and exit without starting
sshd-T -CDump the configuration that would apply to one named connection
systemctldaemon-reloadRe-read unit files after writing new ones
systemctlenable --nowEnable the unit and start it in the same step
systemctlis-active --quietExit non-zero unless the unit is running, printing nothing
systemctlrestartStop and start a unit, so it reads its configuration again
pveumrole list --noborder --noheaderList the roles this host has, as bare rows
pveumuser listList the Proxmox users, to tell a create from an update
pveumuser addCreate a Proxmox user, a separate register from /etc/passwd
pveumacl modifyGrant a role on a path in the Proxmox object tree
pveumacl deleteRemove a role from a path; a no-op where it was not held
pveumacl listRead the grants back, to check the one just made landed
sudo-vRefresh the credential cache, so the password is asked for now
sudo-l -UList what another account may run, without needing its password
grep-qPrint nothing; the exit status is the answer
grep-wMatch whole words only
grep-cvECount the lines an extended regex does not match
hostname-fPrint the fully qualified name, when the host has one

See also

On this page