Skip to content

root-ubuntu: run one ssh-agent per user under systemd - #35

Merged
ralyodio merged 1 commit into
masterfrom
root-ubuntu-ssh-agent
Aug 30, 2026
Merged

root-ubuntu: run one ssh-agent per user under systemd#35
ralyodio merged 1 commit into
masterfrom
root-ubuntu-ssh-agent

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Boxes provisioned by root-ubuntu.sh had no ssh-agent, so anything leaning on one — git over ssh, rsync to a third host, diskpush — prompted for a passphrase every connection or just failed.

What it adds

  • /etc/systemd/user/ssh-agent.service, enabled --global. One agent per user on $XDG_RUNTIME_DIR/ssh-agent.socket.
  • /etc/profile.d/ssh-agent.sh — points login shells at that socket.
  • linger per account, via refresh_user (and root separately).

Why a unit and not a line in .zshrc

The shell-snippet version starts a new agent per shell: every tmux pane and every reconnect gets its own, a key added in one is invisible to the next, and the dead ones pile up until reboot. systemd gives exactly one and restarts it if it dies.

/etc/systemd/user + --global rather than seeding each ~/.config/systemd/user: one file to update, and accounts created later pick it up without a re-run. It stays a default rather than a policy — systemctl --user mask ssh-agent outranks the global enable.

Two details worth reviewing

The profile snippet is not redundant with the unit. Environment=SSH_AUTH_SOCK= only reaches processes systemd starts; an sshd login shell is not one, so the shell has to be told separately. It is POSIX because Debian sources /etc/profile.d/*.sh from zsh too, under emulate sh.

It sets SSH_AUTH_SOCK only when there is not already a working agent — an inherited one is usually forwarded (ssh -A), and overwriting it would swap the keys you brought with you for the ones on the box. A set-but-dead socket (reattached tmux) is replaced.

Linger is load-bearing, not a nicety. Without it the user manager exits with the last session and takes the agent with it, so a key added in one ssh session is gone by the next — most of the point of an agent. It also keeps detached tmux alive after logout.

No key is ever loaded. Every key worth having is passphrased and an unattended root run is the last thing that should be asking. ssh-add on first login, or AddKeysToAgent in your own ~/.ssh/config.

Verified

On a real box, against real systemd and a real ssh-agent — not just by reading the generated files:

check result
unit starts, ssh-add -l answers The agent has no identities.
ExecStartPre recovers from a stale socket file ✅ active after restart
snippet resolves the socket under dash
snippet resolves it under zsh -c "emulate sh"
inherited live agent left alone ✅ kept
inherited dead socket replaced
systemd-analyze verify ✅ rc=0
second install_ssh_agent run writes nothing

One thing I did not change

write_if_changed reports success when its install fails — it does not check the exit status, so a failed write still returns "changed" and the caller prints a note. I hit this when /etc/profile.d was missing in a test sandbox. I worked around it inside the new function (install -d first) rather than touching a helper with ~15 call sites, but it is worth a separate look — during provisioning a silent write failure is exactly the kind that bites later.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EhwDgBphuy2UKenqMKkezQ

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhwDgBphuy2UKenqMKkezQ
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

16 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 2 | LOW: 9

Severity Rule Location
HIGH sh-remote-script-execution root-ubuntu.sh:135
HIGH sh-remote-script-execution root-ubuntu.sh:2598
HIGH sh-remote-script-execution root-ubuntu.sh:2602
HIGH sh-remote-script-execution root-ubuntu.sh:2751
HIGH sh-remote-script-execution root-ubuntu.sh:3780
MEDIUM redos-nested-quantifier src/domain-free.ts:56
MEDIUM redos-nested-quantifier src/porkbun.ts:506
LOW secret-generic-credential src/credentials.ts:36
LOW insecure-temp-file test/blog.test.ts:73
LOW insecure-temp-file test/blog.test.ts:74
LOW insecure-temp-file test/credentials.test.ts:43
LOW insecure-temp-file test/credentials.test.ts:44
LOW secret-generic-api-key test/credentials.test.ts:208
LOW insecure-temp-file test/download.test.ts:99
LOW insecure-temp-file test/download.test.ts:100
LOW secret-generic-credential test/shorten.test.ts:36

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 2aa390b into master Aug 30, 2026
5 checks passed
ralyodio added a commit that referenced this pull request Aug 30, 2026
Eight commits since 0.15.0, one of them a new command, so the minor moves.

- sysupdate: apt lists, apt packages and snaps in one command, reachable
  from the pit as /update (#37)
- root-ubuntu.sh: a 2G swapfile on a box with no swap at all, plus
  vm.swappiness=10 (#36)
- root-ubuntu.sh: one ssh-agent per user under systemd (#35)
- root-ubuntu.sh: lm-sensors, smartmontools and the rest of the hardware
  inventory set, with sensor detection on bare metal
- companions: diskpush, and installers that are not npm (#34)
- porkbun: Porkbun's own pre-flight, structured refusals, and the fact that
  registration spends prepaid credit rather than a card (#32, #33)


Claude-Session: https://claude.ai/code/session_013TerE4nvNU3jvS51nRR6Pd

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant