Skip to content

Commit ec9a176

Browse files
committed
fix: support SSH agent and passphrase-protected keys
SSHConnect failed with "this private key is passphrase protected" for any user whose default key has a passphrase. Now tries SSH agent first (via SSH_AUTH_SOCK), then falls back to raw key files for unprotected keys. - Only skip passphrase-protected keys (PassphraseMissingError), surface other parse errors (corrupted key, unsupported format) - Surface agent dial errors and UserHomeDir errors in diagnostics when no auth methods are available - Close agent socket on dial failure to prevent FD leaks in retry loop
1 parent 55bbf88 commit ec9a176

1 file changed

Lines changed: 48 additions & 12 deletions

File tree

internal/hetzner/client.go

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ package hetzner
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net"
78
"os"
89
"path/filepath"
910
"strconv"
11+
"strings"
1012
"time"
1113

1214
"github.com/ghostwright/specter/internal/config"
1315
"github.com/hetznercloud/hcloud-go/v2/hcloud"
1416
"golang.org/x/crypto/ssh"
17+
"golang.org/x/crypto/ssh/agent"
1518
)
1619

1720
type Client struct {
@@ -280,33 +283,66 @@ func (c *Client) ListServerTypes(ctx context.Context) ([]config.ServerTypeInfo,
280283
}
281284

282285
func SSHConnect(ip string) (*ssh.Client, error) {
283-
home, err := os.UserHomeDir()
284-
if err != nil {
285-
return nil, fmt.Errorf("could not find home directory: %w", err)
286-
}
286+
var authMethods []ssh.AuthMethod
287+
var diagErrors []string
287288

288-
keyBytes, err := os.ReadFile(filepath.Join(home, ".ssh", "id_ed25519"))
289-
if err != nil {
290-
keyBytes, err = os.ReadFile(filepath.Join(home, ".ssh", "id_rsa"))
289+
// Try SSH agent first (handles passphrase-protected keys)
290+
var agentConn net.Conn
291+
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
292+
conn, err := net.Dial("unix", sock)
291293
if err != nil {
292-
return nil, fmt.Errorf("no SSH key found at ~/.ssh/id_ed25519 or ~/.ssh/id_rsa")
294+
diagErrors = append(diagErrors, fmt.Sprintf("SSH agent dial failed: %v", err))
295+
} else {
296+
agentConn = conn
297+
agentClient := agent.NewClient(conn)
298+
authMethods = append(authMethods, ssh.PublicKeysCallback(agentClient.Signers))
293299
}
294300
}
295301

296-
signer, err := ssh.ParsePrivateKey(keyBytes)
297-
if err != nil {
298-
return nil, fmt.Errorf("error parsing SSH key: %w", err)
302+
// Fall back to raw key files for unprotected keys
303+
home, homeErr := os.UserHomeDir()
304+
if homeErr != nil {
305+
diagErrors = append(diagErrors, fmt.Sprintf("could not find home directory: %v", homeErr))
306+
} else {
307+
for _, name := range []string{"id_ed25519", "id_rsa"} {
308+
keyPath := filepath.Join(home, ".ssh", name)
309+
keyBytes, err := os.ReadFile(keyPath)
310+
if err != nil {
311+
continue
312+
}
313+
signer, err := ssh.ParsePrivateKey(keyBytes)
314+
if err != nil {
315+
var passErr *ssh.PassphraseMissingError
316+
if errors.As(err, &passErr) {
317+
continue // passphrase-protected, skip — agent handles these
318+
}
319+
diagErrors = append(diagErrors, fmt.Sprintf("failed to parse %s: %v", keyPath, err))
320+
continue
321+
}
322+
authMethods = append(authMethods, ssh.PublicKeys(signer))
323+
}
324+
}
325+
326+
if len(authMethods) == 0 {
327+
msg := "no SSH auth available: set SSH_AUTH_SOCK or provide an unprotected key at ~/.ssh/id_ed25519 or ~/.ssh/id_rsa"
328+
if len(diagErrors) > 0 {
329+
msg += "\ndetails:\n " + strings.Join(diagErrors, "\n ")
330+
}
331+
return nil, fmt.Errorf("%s", msg)
299332
}
300333

301334
config := &ssh.ClientConfig{
302335
User: "root",
303-
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
336+
Auth: authMethods,
304337
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
305338
Timeout: 10 * time.Second,
306339
}
307340

308341
client, err := ssh.Dial("tcp", ip+":22", config)
309342
if err != nil {
343+
if agentConn != nil {
344+
agentConn.Close()
345+
}
310346
return nil, err
311347
}
312348
return client, nil

0 commit comments

Comments
 (0)