Merge pull request #53 from profullstack/feat/pod-agent-forward

feat(pods): SSH agent forwarding → git push from your pod with your key
This commit is contained in:
Anthony Ettinger 2026-06-23 02:17:05 -07:00 committed by GitHub
commit f5d3740a4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 81 additions and 5 deletions

View file

@ -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 <data>/agent/<user>
// (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 <name>.<host>.
_, 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)

View file

@ -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"]

View file

@ -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