Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 63 additions & 5 deletions internal/pods/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
package pods

import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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 }

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions pods/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
6 changes: 6 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading