Skip to content

Commit 2aa390b

Browse files
ralyodioclaude
andauthored
root-ubuntu: run one ssh-agent per user under systemd (#35)
Boxes this script provisions had no agent at all, so every ssh and every tool that leans on one (diskpush, git over ssh, rsync to another host) either prompted for a passphrase per connection or failed outright. A systemd user service rather than a line in .zshrc: the shell-snippet version starts a new agent per shell, so every tmux pane and every reconnect gets its own, a key added in one is invisible to the next, and the dead ones accumulate until reboot. The unit lives in /etc/systemd/user enabled --global, so accounts created later pick it up with no re-run, and anyone who wants none of it can `systemctl --user mask ssh-agent`. Two pieces beyond the unit. /etc/profile.d/ssh-agent.sh points login shells at the socket, because the unit's own Environment= only reaches services systemd starts, not an sshd login shell; it is POSIX so Debian's zsh reads it under `emulate sh` too, and it refuses to overwrite a live inherited SSH_AUTH_SOCK so `ssh -A` forwarding still wins. And linger, without which the user manager exits with the last session and takes the agent (and any detached tmux) with it. No key is ever loaded here -- a passphrase prompt has no place in an unattended root run. Verified on this box against real systemd and a real ssh-agent: unit starts and answers ssh-add, ExecStartPre recovers from a stale socket file, the snippet resolves the socket under both dash and zsh, keeps an inherited live agent, and replaces a dead one. Re-running install_ssh_agent writes nothing the second time. Claude-Session: https://claude.ai/code/session_01EhwDgBphuy2UKenqMKkezQ Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 809a5c0 commit 2aa390b

1 file changed

Lines changed: 107 additions & 2 deletions

File tree

root-ubuntu.sh

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
# 5. oh-my-zsh + plugins, oh-my-tmux, irssi configs
3535
# 6. mise (curl https://mise.run | sh)
3636
# 7. moshcode (curl https://moshcode.sh/install.sh | sh)
37-
# 8. motd from $MOTD_URL
38-
# 9. nginx per-user pages, per-user dev apps, TLS
37+
# 8. a per-user ssh-agent as a systemd user service
38+
# 9. motd from $MOTD_URL
39+
# 10. nginx per-user pages, per-user dev apps, TLS
3940
#
4041
# Usage, as root:
4142
# ./root-ubuntu.sh # first run, or a refresh
@@ -2644,6 +2645,102 @@ update_moshcode_tools() {
26442645
return 0
26452646
}
26462647

2648+
# ------------------------------------------------------------- ssh agent ---
2649+
2650+
# One ssh-agent per user, started by systemd, on a socket path that is the
2651+
# same at every login: $XDG_RUNTIME_DIR/ssh-agent.socket.
2652+
#
2653+
# Why a unit and not a line in .zshrc. The shell-snippet version of this
2654+
# ("start an agent if $SSH_AUTH_SOCK looks dead") starts a NEW agent per
2655+
# shell, so every tmux pane and every reconnect gets its own, a key added in
2656+
# one is invisible to the next, and the dead ones pile up until reboot.
2657+
# systemd gives exactly one per user and restarts it if it dies.
2658+
#
2659+
# It goes in /etc/systemd/user enabled --global, rather than into each
2660+
# ~/.config/systemd/user: one file to update, and accounts created later pick
2661+
# it up without a re-run. Anyone who wants none of it can still turn it off
2662+
# for themselves with `systemctl --user mask ssh-agent`, which outranks the
2663+
# global enable -- so this is a default, not a policy.
2664+
#
2665+
# NOTHING here loads a key. Every key worth having is passphrased, and an
2666+
# unattended root script is the last thing that should be asking for one.
2667+
# Use `ssh-add` on first login, or AddKeysToAgent in your own ~/.ssh/config.
2668+
install_ssh_agent() {
2669+
local agent unit=/etc/systemd/user/ssh-agent.service
2670+
local snippet=/etc/profile.d/ssh-agent.sh
2671+
2672+
agent="$(command -v ssh-agent)" || { warn "ssh-agent is not installed"; return 1; }
2673+
[[ -d /run/systemd/system ]] || { info "not running systemd -- skipping ssh-agent"; return 0; }
2674+
2675+
install -d -m 0755 /etc/systemd/user
2676+
write_if_changed "$unit" <<-EOF && note "ssh-agent user unit"
2677+
# managed by root-ubuntu.sh
2678+
[Unit]
2679+
Description=SSH authentication agent
2680+
Documentation=man:ssh-agent(1)
2681+
2682+
[Service]
2683+
Type=simple
2684+
Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket
2685+
# A socket left behind by a killed agent makes the next bind fail with
2686+
# "Address already in use", and then the unit never comes back.
2687+
ExecStartPre=-/bin/rm -f %t/ssh-agent.socket
2688+
ExecStart=$agent -D -a %t/ssh-agent.socket
2689+
Restart=on-failure
2690+
RestartSec=2
2691+
2692+
[Install]
2693+
WantedBy=default.target
2694+
EOF
2695+
2696+
# --global writes the wants symlink under /etc, so it covers accounts that
2697+
# do not exist yet. It starts nothing: the agent comes up with each user's
2698+
# manager at their next login, which is also when an edited unit is picked
2699+
# up -- there is no system-wide reload that reaches running user managers.
2700+
systemctl --global enable ssh-agent.service >/dev/null 2>&1 \
2701+
|| warn "could not enable ssh-agent.service globally"
2702+
2703+
# The unit sets SSH_AUTH_SOCK for services systemd starts, and a login
2704+
# shell is not one of those, so the shell has to be told where the socket
2705+
# is. Debian sources /etc/profile.d/*.sh from bash AND zsh login shells, so
2706+
# one file covers both -- zsh reads it under `emulate sh`, hence no bashisms.
2707+
# (write_if_changed installs a file, not a path, and a stripped-down image
2708+
# can be missing /etc/profile.d entirely.)
2709+
install -d -m 0755 /etc/profile.d
2710+
write_if_changed "$snippet" <<-'EOF' && note "ssh-agent profile snippet"
2711+
# managed by root-ubuntu.sh -- point this shell at the systemd ssh-agent.
2712+
#
2713+
# Only when there is not already a working agent. An inherited
2714+
# SSH_AUTH_SOCK is usually a forwarded one (ssh -A), and overwriting it
2715+
# would swap the keys you brought with you for the ones on this box.
2716+
# Set-but-dead is the reattached-tmux case, and that one is fair game.
2717+
if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK:-}" ]; then
2718+
_agent_sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/ssh-agent.socket"
2719+
if [ -S "$_agent_sock" ]; then
2720+
SSH_AUTH_SOCK="$_agent_sock"
2721+
export SSH_AUTH_SOCK
2722+
fi
2723+
unset _agent_sock
2724+
fi
2725+
EOF
2726+
return 0
2727+
}
2728+
2729+
# Without lingering, the user manager stops when the last session ends and
2730+
# takes the agent with it -- so a key added in one ssh session is gone by the
2731+
# next, which is most of the point of running an agent at all. It is also what
2732+
# keeps a detached tmux alive after logout.
2733+
enable_ssh_agent_for() {
2734+
local login="$1"
2735+
[[ -d /run/systemd/system ]] || return 0
2736+
command -v loginctl >/dev/null || return 0
2737+
[[ "$(loginctl show-user "$login" -p Linger --value 2>/dev/null)" == yes ]] && return 0
2738+
loginctl enable-linger "$login" >/dev/null 2>&1 \
2739+
|| { warn "could not enable linger for $login"; return 1; }
2740+
note "$login: ssh-agent now persists between logins"
2741+
return 0
2742+
}
2743+
26472744
# ------------------------------------------------------------- tailscale ---
26482745

26492746
# Joining a tailnet needs a credential. With TS_AUTHKEY it is unattended;
@@ -4270,11 +4367,17 @@ refresh_user() {
42704367
install_dotfiles "$home" "$login"
42714368
install_public_html "$home" "$login"
42724369
install_dev_apps_dir "$home" "$login"
4370+
enable_ssh_agent_for "$login"
42734371
# last word on permissions, after everything has written into the home
42744372
fix_home_permissions "$home" "$login" || warn "$login home permissions need attention"
42754373
return 0
42764374
}
42774375

4376+
# Before the accounts, so that the unit is already in place by the time
4377+
# refresh_user turns on lingering for each of them.
4378+
log "installing the ssh-agent user service"
4379+
try "ssh-agent" install_ssh_agent
4380+
42784381
if [[ ${#USERS[@]} -gt 0 ]]; then
42794382
log "creating users"
42804383
for i in "${!USERS[@]}"; do
@@ -4295,6 +4398,8 @@ log "installing dotfiles for root"
42954398
try "root dotfiles" install_dotfiles /root root
42964399
# our .zshrc has a dedicated root prompt, so root runs zsh too
42974400
try "root login shell -> zsh" ensure_zsh_shell root
4401+
# root does not go through refresh_user, so it needs its own linger
4402+
enable_ssh_agent_for root
42984403

42994404
if [[ "$SKIP_TOOLS" == 1 ]]; then
43004405
log "skipping oh-my-zsh/mise/moshcode (--skip-tools)"

0 commit comments

Comments
 (0)