Add a user account
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@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 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.
(
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
| Command | Arguments | Purpose |
|---|---|---|
command | -v | Print the path of a command, or fail if it is not installed |
read | -r | Take the answer literally; a backslash in it is not an escape |
read | -p | Print the prompt before reading, without a trailing newline |
read | -s | Keep the answer off the screen and out of the scrollback |
id | -u | Print the account's numeric user ID |
id | -gn | Print the account's primary group by name |
awk | NF | Skip blank lines, so only real rows are read |
getent | passwd | Read the passwd database, whatever backend supplies it |
cut | -d: -f6 | Take the home directory field out of a passwd entry |
apt-get | update | Refresh the package lists |
apt-get | install -y | Install the named packages without asking for confirmation |
useradd | --create-home | Create the home directory as well as the account |
useradd | --shell | Set the account's login shell |
chpasswd | — | Set a password from user:password read on standard input |
usermod | -aG | Add to a group, leaving the account's other groups alone |
install | -d | Create a directory rather than copy a file |
install | -o, -g, -m | Set owner, group, and mode as the file is created |
tee | — | Write standard input to a file, through sudo, without a shell redirect |
chmod | — | Set a file's mode |
mktemp | — | Create a temporary file with a name nothing else will collide with |
trap | — | Run a command when the shell exits, however it exits |
curl | -f | Fail on an HTTP error instead of writing the error page out |
curl | -sS | No progress meter, but still print real errors |
curl | --max-time | Give up rather than hang the daily timer on a dead network |
awk | -v | Pass a shell value in as an awk variable, without quoting it into the program |
ssh-keygen | -l -f | List a key file's fingerprints; fails when the file is not one |
sshd | -t | Test the configuration and exit without starting |
sshd | -T -C | Dump the configuration that would apply to one named connection |
systemctl | daemon-reload | Re-read unit files after writing new ones |
systemctl | enable --now | Enable the unit and start it in the same step |
systemctl | is-active --quiet | Exit non-zero unless the unit is running, printing nothing |
systemctl | restart | Stop and start a unit, so it reads its configuration again |
pveum | role list --noborder --noheader | List the roles this host has, as bare rows |
pveum | user list | List the Proxmox users, to tell a create from an update |
pveum | user add | Create a Proxmox user, a separate register from /etc/passwd |
pveum | acl modify | Grant a role on a path in the Proxmox object tree |
pveum | acl delete | Remove a role from a path; a no-op where it was not held |
pveum | acl list | Read the grants back, to check the one just made landed |
sudo | -v | Refresh the credential cache, so the password is asked for now |
sudo | -l -U | List what another account may run, without needing its password |
grep | -q | Print nothing; the exit status is the answer |
grep | -w | Match whole words only |
grep | -cvE | Count the lines an extended regex does not match |
hostname | -f | Print the fully qualified name, when the host has one |
See also
- Proxmox VE wiki: User Management
- pveum(1)
- curl(1)
- sshd(8) — the
AUTHORIZED_KEYS FILE FORMATsection is where the comment field is defined - systemd.timer(5)