Skip to content

Commit 66f3d3c

Browse files
feat: support SSH key auth and load env file for connector
- Allow Linux VM shell/SFTP to fallback to SSH key if password is missing - Use resolved credential type for upstream SSH auth and audit logging - Load connector.env file to ensure config persists when spawned by OS URL handlers
1 parent 9ee988d commit 66f3d3c

7 files changed

Lines changed: 384 additions & 30 deletions

File tree

apps/api/internal/handlers/sessions.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,15 @@ func (h *SessionsHandler) Launch(w http.ResponseWriter, r *http.Request) {
499499
if asset.Type == assets.TypeLinuxVM && (req.Action == access.ActionShell || req.Action == access.ActionSFTP) {
500500
cred, err := h.credentialsService.ResolveForAsset(r.Context(), asset.ID, credentials.TypePassword)
501501
if err != nil {
502-
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve linux credential"})
503-
return
502+
if !errors.Is(err, credentials.ErrCredentialNotFound) {
503+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve linux credential"})
504+
return
505+
}
506+
cred, err = h.credentialsService.ResolveForAsset(r.Context(), asset.ID, credentials.TypeSSHKey)
507+
if err != nil {
508+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve linux credential"})
509+
return
510+
}
504511
}
505512
upstreamUsername := strings.TrimSpace(cred.Username)
506513
if req.Action == access.ActionShell {

apps/api/internal/sshproxy/server.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,22 +389,41 @@ func (s *Server) handleConn(rawConn net.Conn, cfg *ssh.ServerConfig) {
389389
}
390390

391391
func (s *Server) connectUpstream(ctx context.Context, launch sessions.LaunchContext) (*ssh.Client, *ssh.Session, string, error) {
392+
credType := credentials.TypePassword
392393
cred, err := s.credSvc.ResolveForAsset(ctx, launch.AssetID, credentials.TypePassword)
393394
if err != nil {
394-
return nil, nil, "", fmt.Errorf("resolve password credential: %w", err)
395+
if !errors.Is(err, credentials.ErrCredentialNotFound) {
396+
return nil, nil, "", fmt.Errorf("resolve password credential: %w", err)
397+
}
398+
cred, err = s.credSvc.ResolveForAsset(ctx, launch.AssetID, credentials.TypeSSHKey)
399+
if err != nil {
400+
return nil, nil, "", fmt.Errorf("resolve ssh credential: %w", err)
401+
}
402+
credType = credentials.TypeSSHKey
395403
}
396404
launch.UpstreamUsername = strings.TrimSpace(cred.Username)
397-
if err := s.sessionsSvc.RecordCredentialUsage(ctx, launch, credentials.TypePassword, "proxy_upstream_auth", launch.RequestID); err != nil {
405+
if err := s.sessionsSvc.RecordCredentialUsage(ctx, launch, credType, "proxy_upstream_auth", launch.RequestID); err != nil {
398406
s.logger.Warn("failed to write credential usage audit", "session_id", launch.SessionID, "request_id", launch.RequestID, "error", err)
399407
}
400408
if strings.TrimSpace(cred.Username) == "" {
401409
return nil, nil, "", fmt.Errorf("credential username is required for ssh")
402410
}
403411

412+
var authMethod ssh.AuthMethod
413+
if credType == credentials.TypeSSHKey {
414+
signer, parseErr := ssh.ParsePrivateKey([]byte(cred.Secret))
415+
if parseErr != nil {
416+
return nil, nil, "", fmt.Errorf("parse ssh private key: %w", parseErr)
417+
}
418+
authMethod = ssh.PublicKeys(signer)
419+
} else {
420+
authMethod = ssh.Password(cred.Secret)
421+
}
422+
404423
clientCfg := &ssh.ClientConfig{
405424
User: cred.Username,
406425
Auth: []ssh.AuthMethod{
407-
ssh.Password(cred.Secret),
426+
authMethod,
408427
},
409428
HostKeyCallback: s.hostKeyCallback,
410429
Timeout: 10 * time.Second,

apps/connector/cmd/connector/main.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,60 @@ func spawnBackgroundServe() error {
457457
return err
458458
}
459459
cmd := exec.Command(exePath, "serve")
460-
cmd.Env = os.Environ()
460+
// Load env file so the spawned server inherits config even when this process
461+
// was launched by the OS URL handler (Chrome/Windows) with a minimal environment
462+
// that has no ACCESSD_* variables set (e.g. MSI-registered URL handler on Windows).
463+
cmd.Env = envWithConnectorEnvFile()
461464
return cmd.Start()
462465
}
463466

467+
// envWithConnectorEnvFile returns os.Environ() merged with values from the
468+
// connector env file (~/.config/accessd/connector.env). Env file values fill
469+
// in missing variables but do not override values already present in the
470+
// process environment.
471+
func envWithConnectorEnvFile() []string {
472+
home, err := os.UserHomeDir()
473+
if err != nil || home == "" {
474+
return os.Environ()
475+
}
476+
envFilePath := home + "/.config/accessd/connector.env"
477+
data, err := os.ReadFile(envFilePath)
478+
if err != nil {
479+
return os.Environ()
480+
}
481+
482+
// Index current environment so we can fill gaps without overwriting.
483+
current := os.Environ()
484+
set := make(map[string]struct{}, len(current))
485+
for _, kv := range current {
486+
if i := strings.Index(kv, "="); i > 0 {
487+
set[kv[:i]] = struct{}{}
488+
}
489+
}
490+
491+
extra := current
492+
for _, line := range strings.Split(string(data), "\n") {
493+
line = strings.TrimSpace(line)
494+
if line == "" || strings.HasPrefix(line, "#") {
495+
continue
496+
}
497+
i := strings.Index(line, "=")
498+
if i <= 0 {
499+
continue
500+
}
501+
key := strings.TrimSpace(line[:i])
502+
if key == "" {
503+
continue
504+
}
505+
if _, already := set[key]; already {
506+
continue // don't override env vars already set
507+
}
508+
extra = append(extra, line)
509+
set[key] = struct{}{}
510+
}
511+
return extra
512+
}
513+
464514
func withLogging(next http.Handler) http.Handler {
465515
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
466516
start := time.Now()

0 commit comments

Comments
 (0)