Skip to content

Commit fded82a

Browse files
committed
feat: add remote agent auth tunneling via SSH agent protocol extensions
Implement proxying of auth requests back through SSH agent forwarding, enabling users to SSH to a remote host and run `epithet agent fish` to get certificates from an internal CA while auth plugins (FIDO2, browser) run on the local machine. Uses SSH agent protocol extensions (message type 27) with a registry pattern for extensibility: - epithet-hello@epithet.dev — probe if socket is an epithet agent - epithet-auth@epithet.dev — request auth token from upstream Key components: - ProxyAgent: generic ExtendedAgent wrapper with extension handler registry - ProxyListener: per-connection upstream dialing for agent protocol proxying - Upstream client: ProbeUpstream() and RequestAuth() functions - Broker: WithUpstream option and Authenticate() helper (try upstream, fall back to local) - CLI: `epithet agent <shell>` wrapper mode mirroring ssh-agent's pattern Multi-hop chaining works naturally — each layer proxies to its upstream. No changes to CA or policy server required.
1 parent 80051ac commit fded82a

12 files changed

Lines changed: 1500 additions & 78 deletions

File tree

cmd/epithet/agent.go

Lines changed: 226 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@ import (
44
"context"
55
"crypto/sha256"
66
"encoding/hex"
7+
"errors"
78
"fmt"
89
"log/slog"
910
"os"
11+
"os/exec"
1012
"os/signal"
1113
"path/filepath"
1214
"strconv"
1315
"strings"
1416
"syscall"
1517
"time"
1618

19+
"github.com/epithet-ssh/epithet/pkg/agent"
1720
"github.com/epithet-ssh/epithet/pkg/broker"
1821
"github.com/epithet-ssh/epithet/pkg/caclient"
1922
"github.com/epithet-ssh/epithet/pkg/tlsconfig"
@@ -32,74 +35,243 @@ type AgentCLI struct {
3235
}
3336

3437
// AgentStartCLI is the default subcommand that starts the agent/broker.
35-
type AgentStartCLI struct{}
38+
// When Shell is provided, it runs in wrapper mode: wrapping a shell with an
39+
// epithet-aware agent proxy (mirroring ssh-agent's "ssh-agent bash" pattern).
40+
type AgentStartCLI struct {
41+
Shell string `arg:"" optional:"" help:"Shell to wrap with epithet agent (enables wrapper mode)"`
42+
}
3643

3744
func (s *AgentStartCLI) Run(parent *AgentCLI, logger *slog.Logger, tlsCfg tlsconfig.Config) error {
38-
// Validate required fields for start
45+
if s.Shell != "" {
46+
return s.runWrapper(parent, logger, tlsCfg)
47+
}
48+
return s.runDaemon(parent, logger, tlsCfg)
49+
}
50+
51+
// runDaemon runs the broker as a long-lived daemon (original behavior).
52+
func (s *AgentStartCLI) runDaemon(parent *AgentCLI, logger *slog.Logger, tlsCfg tlsconfig.Config) error {
53+
b, tempDir, brokerSock, agentDir, homeDir, err := setupBroker(parent, logger, tlsCfg)
54+
if err != nil {
55+
return err
56+
}
57+
58+
// Clean up temp directory on exit.
59+
defer func() {
60+
if err := os.RemoveAll(tempDir); err != nil {
61+
logger.Warn("failed to remove temp directory", "error", err, "path", tempDir)
62+
} else {
63+
logger.Debug("removed temp directory", "path", tempDir)
64+
}
65+
}()
66+
67+
// Set up context with cancellation on signals.
68+
ctx, cancel := context.WithCancel(context.Background())
69+
defer cancel()
70+
71+
sigChan := make(chan os.Signal, 1)
72+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
73+
go func() {
74+
<-sigChan
75+
logger.Info("received shutdown signal")
76+
cancel()
77+
}()
78+
79+
// Generate SSH config.
80+
sshConfigPath := filepath.Join(tempDir, "ssh-config.conf")
81+
if err := parent.generateSSHConfig(sshConfigPath, agentDir, brokerSock, homeDir); err != nil {
82+
logger.Warn("failed to generate SSH config", "error", err, "path", sshConfigPath)
83+
} else {
84+
includePattern := filepath.Join(homeDir, ".epithet", "run", "*", "ssh-config.conf")
85+
if err := checkSSHConfigInclude(homeDir, includePattern, logger); err != nil {
86+
logger.Warn(fmt.Sprintf("Add 'Include %s' to ~/.ssh/config", includePattern))
87+
}
88+
logger.Debug("generated SSH config", "path", sshConfigPath)
89+
}
90+
91+
// Start broker.
92+
logger.Info("starting broker", "socket", brokerSock)
93+
err = b.Serve(ctx)
94+
if err != nil && err != context.Canceled {
95+
return fmt.Errorf("broker serve error: %w", err)
96+
}
97+
98+
logger.Info("broker shutdown complete")
99+
return nil
100+
}
101+
102+
// runWrapper wraps a shell with an epithet-aware agent proxy. It probes the
103+
// current SSH_AUTH_SOCK for an upstream epithet agent, starts the broker, and
104+
// creates a proxy listener that handles epithet protocol extensions while
105+
// delegating standard SSH agent operations to the upstream agent.
106+
func (s *AgentStartCLI) runWrapper(parent *AgentCLI, logger *slog.Logger, tlsCfg tlsconfig.Config) error {
107+
upstreamSocket := os.Getenv("SSH_AUTH_SOCK")
108+
109+
// Probe for upstream epithet agent.
110+
var depth int
111+
var brokerOpts []broker.Option
112+
if upstreamSocket != "" {
113+
hello, err := agent.ProbeUpstream(upstreamSocket)
114+
if err != nil {
115+
logger.Warn("failed to probe upstream agent", "error", err)
116+
} else if hello != nil {
117+
depth = hello.ChainDepth + 1
118+
brokerOpts = append(brokerOpts, broker.WithUpstream(upstreamSocket))
119+
logger.Info("detected upstream epithet agent", "chain_depth", depth)
120+
} else {
121+
logger.Debug("upstream is vanilla ssh-agent")
122+
}
123+
} else {
124+
logger.Debug("no SSH_AUTH_SOCK set")
125+
}
126+
127+
b, tempDir, brokerSock, agentDir, homeDir, err := setupBrokerWithOptions(parent, logger, tlsCfg, brokerOpts...)
128+
if err != nil {
129+
return err
130+
}
131+
132+
// Clean up temp directory on exit.
133+
defer func() {
134+
if err := os.RemoveAll(tempDir); err != nil {
135+
logger.Warn("failed to remove temp directory", "error", err, "path", tempDir)
136+
}
137+
}()
138+
139+
// Set up context with cancellation.
140+
ctx, cancel := context.WithCancel(context.Background())
141+
defer cancel()
142+
143+
// Start broker in background.
144+
brokerErr := make(chan error, 1)
145+
go func() {
146+
brokerErr <- b.Serve(ctx)
147+
}()
148+
<-b.Ready()
149+
150+
// Generate SSH config.
151+
sshConfigPath := filepath.Join(tempDir, "ssh-config.conf")
152+
if err := parent.generateSSHConfig(sshConfigPath, agentDir, brokerSock, homeDir); err != nil {
153+
logger.Warn("failed to generate SSH config", "error", err, "path", sshConfigPath)
154+
} else {
155+
includePattern := filepath.Join(homeDir, ".epithet", "run", "*", "ssh-config.conf")
156+
if err := checkSSHConfigInclude(homeDir, includePattern, logger); err != nil {
157+
logger.Warn(fmt.Sprintf("Add 'Include %s' to ~/.ssh/config", includePattern))
158+
}
159+
}
160+
161+
// Create proxy listener if we have an upstream socket to proxy.
162+
if upstreamSocket != "" {
163+
proxySock := filepath.Join(tempDir, "proxy.sock")
164+
setup := func(p *agent.ProxyAgent) {
165+
p.RegisterExtension(agent.ExtensionHello, agent.HelloHandler(depth))
166+
p.RegisterExtension(agent.ExtensionAuth, agent.AuthHandler(func() (string, error) {
167+
return b.Authenticate(nil)
168+
}))
169+
}
170+
proxyListener := agent.NewProxyListener(logger, proxySock, upstreamSocket, setup)
171+
go func() {
172+
if err := proxyListener.Serve(ctx); err != nil && err != context.Canceled {
173+
logger.Error("proxy listener error", "error", err)
174+
}
175+
}()
176+
// Wait briefly for the listener to start.
177+
// TODO: add Ready() to ProxyListener like Broker has.
178+
upstreamSocket = proxySock
179+
logger.Info("proxy agent listening", "socket", proxySock)
180+
}
181+
182+
// Build child environment with updated SSH_AUTH_SOCK.
183+
childEnv := os.Environ()
184+
if upstreamSocket != "" {
185+
childEnv = replaceEnv(childEnv, "SSH_AUTH_SOCK", upstreamSocket)
186+
}
187+
188+
// Run the shell as a child process.
189+
cmd := exec.Command(s.Shell)
190+
cmd.Env = childEnv
191+
cmd.Stdin = os.Stdin
192+
cmd.Stdout = os.Stdout
193+
cmd.Stderr = os.Stderr
194+
195+
// Forward signals to child.
196+
sigChan := make(chan os.Signal, 1)
197+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
198+
go func() {
199+
for sig := range sigChan {
200+
if cmd.Process != nil {
201+
cmd.Process.Signal(sig)
202+
}
203+
}
204+
}()
205+
206+
logger.Info("starting shell", "shell", s.Shell)
207+
if err := cmd.Run(); err != nil {
208+
var exitErr *exec.ExitError
209+
if errors.As(err, &exitErr) {
210+
cancel()
211+
signal.Stop(sigChan)
212+
os.Exit(exitErr.ExitCode())
213+
}
214+
cancel()
215+
return fmt.Errorf("shell failed: %w", err)
216+
}
217+
218+
cancel()
219+
signal.Stop(sigChan)
220+
return nil
221+
}
222+
223+
// setupBroker creates the broker and supporting directories with no extra options.
224+
func setupBroker(parent *AgentCLI, logger *slog.Logger, tlsCfg tlsconfig.Config) (*broker.Broker, string, string, string, string, error) {
225+
return setupBrokerWithOptions(parent, logger, tlsCfg)
226+
}
227+
228+
// setupBrokerWithOptions creates the broker, temp directories, and resolves auth.
229+
// Returns (broker, tempDir, brokerSock, agentDir, homeDir, error).
230+
func setupBrokerWithOptions(parent *AgentCLI, logger *slog.Logger, tlsCfg tlsconfig.Config, opts ...broker.Option) (*broker.Broker, string, string, string, string, error) {
39231
if len(parent.CaURL) == 0 {
40-
return fmt.Errorf("--ca-url is required (at least one)")
232+
return nil, "", "", "", "", fmt.Errorf("--ca-url is required (at least one)")
41233
}
42234

43-
// Parse CA URLs into endpoints
44235
caEndpoints, err := caclient.ParseCAURLs(parent.CaURL)
45236
if err != nil {
46-
return fmt.Errorf("invalid CA URL: %w", err)
237+
return nil, "", "", "", "", fmt.Errorf("invalid CA URL: %w", err)
47238
}
48239

49-
logger.Debug("agent start command received", "ca_urls", parent.CaURL, "ca_timeout", parent.CaTimeout, "ca_cooldown", parent.CaCooldown)
240+
logger.Debug("agent command received", "ca_urls", parent.CaURL, "ca_timeout", parent.CaTimeout, "ca_cooldown", parent.CaCooldown)
50241

51-
// Validate all CA URLs require TLS (unless --insecure)
52242
for _, ep := range caEndpoints {
53243
if err := tlsCfg.ValidateURL(ep.URL); err != nil {
54-
return fmt.Errorf("CA URL %q: %w", ep.URL, err)
244+
return nil, "", "", "", "", fmt.Errorf("CA URL %q: %w", ep.URL, err)
55245
}
56246
}
57247

58-
// Get home directory
59248
homeDir, err := os.UserHomeDir()
60249
if err != nil {
61-
return fmt.Errorf("failed to get home directory: %w", err)
250+
return nil, "", "", "", "", fmt.Errorf("failed to get home directory: %w", err)
62251
}
63252

64-
// Create a unique temporary directory for this broker instance
65-
// Use a hash of the CA URLs to make it deterministic
66253
instanceID := hashString(fmt.Sprintf("%v", parent.CaURL))
67254
runDir := filepath.Join(homeDir, ".epithet", "run")
68255
tempDir := filepath.Join(runDir, instanceID)
69256

70-
// Clean up stale run directories from dead processes
71257
cleanupStaleRunDirs(runDir, logger)
72258

73-
// Clean up temp directory on exit
74-
defer func() {
75-
if err := os.RemoveAll(tempDir); err != nil {
76-
logger.Warn("failed to remove temp directory", "error", err, "path", tempDir)
77-
} else {
78-
logger.Debug("removed temp directory", "path", tempDir)
79-
}
80-
}()
81-
82-
// Create temp directory
83259
if err := os.MkdirAll(tempDir, 0700); err != nil {
84-
return fmt.Errorf("failed to create temp directory: %w", err)
260+
return nil, "", "", "", "", fmt.Errorf("failed to create temp directory: %w", err)
85261
}
86262

87-
// Write PID file for stale detection
88263
pidFile := filepath.Join(tempDir, "broker.pid")
89264
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0600); err != nil {
90-
return fmt.Errorf("failed to write PID file: %w", err)
265+
return nil, "", "", "", "", fmt.Errorf("failed to write PID file: %w", err)
91266
}
92267

93-
// Define paths within temp directory
94268
brokerSock := filepath.Join(tempDir, "broker.sock")
95269
agentDir := filepath.Join(tempDir, "agent")
96270

97-
// Create agent directory
98271
if err := os.MkdirAll(agentDir, 0700); err != nil {
99-
return fmt.Errorf("failed to create agent directory: %w", err)
272+
return nil, "", "", "", "", fmt.Errorf("failed to create agent directory: %w", err)
100273
}
101274

102-
// Create CA client
103275
caClientOpts := []caclient.Option{
104276
caclient.WithLogger(logger),
105277
caclient.WithTimeout(parent.CaTimeout),
@@ -108,81 +280,60 @@ func (s *AgentStartCLI) Run(parent *AgentCLI, logger *slog.Logger, tlsCfg tlscon
108280
}
109281
caClient, err := caclient.New(caEndpoints, caClientOpts...)
110282
if err != nil {
111-
return fmt.Errorf("failed to create CA client: %w", err)
283+
return nil, "", "", "", "", fmt.Errorf("failed to create CA client: %w", err)
112284
}
113285

114-
// Resolve auth command: use --auth if provided, otherwise discover from CA.
115286
authCommand := parent.Auth
116287
if authCommand == "" {
117288
logger.Debug("no --auth provided, discovering from CA")
118289

119-
// Fetch public key (also caches discovery URL).
120290
_, err := caClient.GetPublicKey(context.Background())
121291
if err != nil {
122-
return fmt.Errorf("failed to get CA public key: %w", err)
292+
return nil, "", "", "", "", fmt.Errorf("failed to get CA public key: %w", err)
123293
}
124294

125-
// Fetch unauthenticated discovery (auth config only, no token needed).
126295
discovery, err := caClient.GetDiscovery(context.Background(), "")
127296
if err != nil {
128-
return fmt.Errorf("failed to get discovery config (try --auth to specify manually): %w", err)
297+
return nil, "", "", "", "", fmt.Errorf("failed to get discovery config (try --auth to specify manually): %w", err)
129298
}
130299
if discovery == nil || discovery.Auth == nil {
131-
return fmt.Errorf("CA did not return auth config in discovery (try --auth to specify manually)")
300+
return nil, "", "", "", "", fmt.Errorf("CA did not return auth config in discovery (try --auth to specify manually)")
132301
}
133302

134-
// Convert auth config to command.
135303
authCommand, err = broker.AuthConfigToCommand(*discovery.Auth)
136304
if err != nil {
137-
return fmt.Errorf("failed to convert discovery auth config: %w", err)
305+
return nil, "", "", "", "", fmt.Errorf("failed to convert discovery auth config: %w", err)
138306
}
139307

140308
logger.Info("discovered auth config from CA", "type", discovery.Auth.Type, "issuer", discovery.Auth.Issuer)
141309
}
142310

143-
// Create broker
144-
b, err := broker.New(*logger, brokerSock, authCommand, caClient, agentDir)
311+
b, err := broker.New(*logger, brokerSock, authCommand, caClient, agentDir, opts...)
145312
if err != nil {
146-
return fmt.Errorf("failed to create broker: %w", err)
313+
return nil, "", "", "", "", fmt.Errorf("failed to create broker: %w", err)
147314
}
148315

149-
// Set up context with cancellation on signals
150-
ctx, cancel := context.WithCancel(context.Background())
151-
defer cancel()
152-
153-
sigChan := make(chan os.Signal, 1)
154-
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
155-
go func() {
156-
<-sigChan
157-
logger.Info("received shutdown signal")
158-
cancel()
159-
}()
160-
161-
// Generate SSH config file in the temp directory
162-
sshConfigPath := filepath.Join(tempDir, "ssh-config.conf")
163-
164-
if err := parent.generateSSHConfig(sshConfigPath, agentDir, brokerSock, homeDir); err != nil {
165-
logger.Warn("failed to generate SSH config", "error", err, "path", sshConfigPath)
166-
// Don't fail startup, just warn
167-
} else {
168-
// Check if ~/.ssh/config has the Include directive
169-
includePattern := filepath.Join(homeDir, ".epithet", "run", "*", "ssh-config.conf")
170-
if err := checkSSHConfigInclude(homeDir, includePattern, logger); err != nil {
316+
return b, tempDir, brokerSock, agentDir, homeDir, nil
317+
}
171318

172-
logger.Warn(fmt.Sprintf("Add 'Include %s' to ~/.ssh/config", includePattern))
319+
// replaceEnv returns a copy of environ with key set to value.
320+
// If key already exists, it is replaced; otherwise it is appended.
321+
func replaceEnv(environ []string, key, value string) []string {
322+
prefix := key + "="
323+
result := make([]string, 0, len(environ)+1)
324+
found := false
325+
for _, e := range environ {
326+
if strings.HasPrefix(e, prefix) {
327+
result = append(result, prefix+value)
328+
found = true
329+
} else {
330+
result = append(result, e)
173331
}
174-
logger.Debug("generated SSH config", "path", sshConfigPath)
175332
}
176-
177-
// Start broker
178-
logger.Info("starting broker", "socket", brokerSock)
179-
err = b.Serve(ctx)
180-
if err != nil && err != context.Canceled {
181-
return fmt.Errorf("broker serve error: %w", err)
333+
if !found {
334+
result = append(result, prefix+value)
182335
}
183-
184-
logger.Info("broker shutdown complete")
185-
return nil
336+
return result
186337
}
187338

188339
// generateSSHConfig writes an SSH config file for epithet

0 commit comments

Comments
 (0)