From 94ce79374c20943c45479720c5e1bde42a1f6f55 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:16:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(pods):=20SSH=20agent=20forwarding=20?= =?UTF-8?q?=E2=86=92=20git=20push=20from=20the=20pod=20with=20your=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code in your pod and push to git.profullstack.com using the SAME SSH key you signed in with — nothing is copied into the pod. When a member attaches with agent forwarding (ssh -A), agentbbs listens on a fresh unix socket in a per-user agent dir bind-mounted at /run/agentbbs-agent and proxies it back over the session; the pod shell gets SSH_AUTH_SOCK pointed at it. The pod image's ssh_config sends git@git.profullstack.com to Forgejo's SSH server (:2222), so `git clone git@git.profullstack.com:you/repo.git` just works. - pods.go: agentDir + startAgent (per-session socket, cleaned up on exit); Attach injects SSH_AUTH_SOCK when ssh.AgentRequested; ensure() bind-mounts the agent dir and self-heals idle pods missing it. No main.go change needed — charmbracelet/ssh sets AgentRequested from the session request loop. - pods/Containerfile: /etc/ssh/ssh_config.d entry (port 2222, user git, accept-new) so the conventional git@ URL reaches Forgejo. - setup.sh: keep using an already-built pod image if a later rebuild transient-fails, so a flaky deploy never downgrades pods to the base image. Build/vet/test/gofmt clean. Image rebuilt on the host; `ssh -G git.profullstack.com` resolves to port 2222 / user git. End-to-end push needs a live `ssh -A` session (validate after deploy). Co-Authored-By: Claude Opus 4.8 --- internal/pods/pods.go | 68 +++++++++++++++++++++++++++++++++++++++---- pods/Containerfile | 12 ++++++++ setup.sh | 6 ++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/internal/pods/pods.go b/internal/pods/pods.go index 3635575..a27e8fc 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -13,8 +13,11 @@ package pods import ( + "crypto/rand" + "encoding/hex" "fmt" "io" + "net" "os" "os/exec" "path/filepath" @@ -111,6 +114,45 @@ func (m *Manager) hasImage(name, image string) bool { return norm(got) == norm(image) } +// agentDir is the host directory bind-mounted into a pod at /run/agentbbs-agent, +// where Attach drops a forwarded SSH-agent socket. Derived as /agent/ +// (a sibling of the users dir). Empty — disabling agent forwarding — when the +// users dir isn't configured or the directory can't be created. +func (m *Manager) agentDir(user string) string { + if m.usersDir == "" { + return "" + } + d := filepath.Join(filepath.Dir(m.usersDir), "agent", unsafeName.ReplaceAllString(strings.ToLower(user), "-")) + if err := os.MkdirAll(d, 0o700); err != nil { + return "" + } + return d +} + +// startAgent forwards the connecting client's SSH agent into the member's pod: +// it listens on a fresh unix socket in the bind-mounted agent dir and proxies +// connections back over the SSH session. Returns the in-pod SSH_AUTH_SOCK path +// and a cleanup func, or "" when forwarding can't be set up (no agent dir / no +// socket). With this, `git push git@git.profullstack.com` inside the pod uses +// the member's own key — nothing is copied into the pod. +func (m *Manager) startAgent(s ssh.Session, user string) (sock string, cleanup func()) { + dir := m.agentDir(user) + if dir == "" { + return "", func() {} + } + var b [8]byte + _, _ = rand.Read(b[:]) + fname := "agent-" + hex.EncodeToString(b[:]) + ".sock" + hostSock := filepath.Join(dir, fname) + _ = os.Remove(hostSock) + l, err := net.Listen("unix", hostSock) + if err != nil { + return "", func() {} + } + go ssh.ForwardAgentConnections(l, s) + return "/run/agentbbs-agent/" + fname, func() { _ = l.Close(); _ = os.Remove(hostSock) } +} + // Engine reports the active container engine. func (m *Manager) Engine() string { return m.engine } @@ -126,6 +168,9 @@ func (m *Manager) ensure(user string) (string, error) { // Bind the host's public_html into the pod so a member's edits at // ~/public_html are exactly what Caddy serves at .. _, pubSpec := m.publicHTMLMount(user) + // Bind a per-user agent dir into the pod; Attach drops a forwarded SSH-agent + // socket here so `git push` uses the member's own key (see startAgent). + agentDir := m.agentDir(user) if m.engine == "docker" { // Under docker the pod runs as uid 1000 (never container root), so the // named home volume — and the bind-mounted public_html — must be owned @@ -159,7 +204,9 @@ func (m *Manager) ensure(user string) (string, error) { // running an out-of-date image (e.g. a new pod image with added tooling). // The home volume persists across rm, so member data is kept; a busy pod // heals on its next idle attach instead. - needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) || !m.hasImage(name, m.image)) + needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) || + (agentDir != "" && !m.hasMount(name, "/run/agentbbs-agent")) || + !m.hasImage(name, m.image)) if needsHeal { _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate } else { @@ -182,6 +229,9 @@ func (m *Manager) ensure(user string) (string, error) { if pubSpec != "" { args = append(args, "-v", pubSpec) } + if agentDir != "" { + args = append(args, "-v", agentDir+":/run/agentbbs-agent") + } if m.engine == "docker" { // Rootful docker: a breakout is host-root, so refuse to hand out // container root — run as uid 1000 with no caps and no privilege @@ -221,14 +271,22 @@ func (m *Manager) Attach(s ssh.Session, user string) error { return err } + // Forward the client's SSH agent (ssh -A) into the pod so git push uses the + // member's own key. No-op unless the client requested forwarding. + execEnv := []string{"-e", "TERM=" + ptyReq.Term} + if ssh.AgentRequested(s) { + if sock, cleanup := m.startAgent(s, user); sock != "" { + defer cleanup() + execEnv = append(execEnv, "-e", "SSH_AUTH_SOCK="+sock) + } + } + shell := env("AGENTBBS_POD_SHELL", "/bin/bash") - cmd := exec.Command(m.engine, "exec", "-it", - "-e", "TERM="+ptyReq.Term, - name, shell, "-l") + cmd := exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, shell, "-l")...) f, err := pty.Start(cmd) if err != nil { // busybox-ish images may lack bash - cmd = exec.Command(m.engine, "exec", "-it", "-e", "TERM="+ptyReq.Term, name, "/bin/sh", "-l") + cmd = exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, "/bin/sh", "-l")...) f, err = pty.Start(cmd) if err != nil { return fmt.Errorf("pods: attach failed: %w", err) diff --git a/pods/Containerfile b/pods/Containerfile index 67a7120..e679998 100644 --- a/pods/Containerfile +++ b/pods/Containerfile @@ -42,4 +42,16 @@ RUN printf '%s\n' \ && printf '[ -n "$PS1" ] && [ -r /etc/motd ] && cat /etc/motd\n' \ > /etc/profile.d/10-agentbbs-motd.sh +# Make `git@git.profullstack.com:...` reach Forgejo's SSH server (port 2222) and +# trust it on first use, so clones/pushes just work with a forwarded agent key. +RUN install -d -m 0755 /etc/ssh/ssh_config.d \ + && printf '%s\n' \ + 'Host git.profullstack.com' \ + ' Port 2222' \ + ' User git' \ + ' StrictHostKeyChecking accept-new' \ + > /etc/ssh/ssh_config.d/10-agentgit.conf \ + && grep -q 'ssh_config.d/\*.conf' /etc/ssh/ssh_config 2>/dev/null \ + || printf '\nInclude /etc/ssh/ssh_config.d/*.conf\n' >> /etc/ssh/ssh_config + CMD ["sleep", "infinity"] diff --git a/setup.sh b/setup.sh index 572d3d7..492569f 100755 --- a/setup.sh +++ b/setup.sh @@ -323,6 +323,12 @@ if [ -f "$SRC_DIR/pods/Containerfile" ]; then podman build -t localhost/agentbbs-pod:latest \ -f "$SRC_DIR/pods/Containerfile" "$SRC_DIR/pods" >/dev/null 2>&1; then POD_IMAGE="localhost/agentbbs-pod:latest" + elif sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \ + podman image exists localhost/agentbbs-pod:latest >/dev/null 2>&1; then + # A transient build failure (e.g. registry/network hiccup) must not downgrade + # pods back to the base image — keep using the previously built one. + POD_IMAGE="localhost/agentbbs-pod:latest" + warn "pod image rebuild failed — using the existing localhost/agentbbs-pod:latest" else warn "pod image build failed — keeping $POD_IMAGE (run: podman build -f $SRC_DIR/pods/Containerfile $SRC_DIR/pods)" fi