@@ -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
3744func (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