Skip to content
Open
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
60 changes: 48 additions & 12 deletions internal/hetzner/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package hetzner

import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/ghostwright/specter/internal/config"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)

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

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

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

signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
return nil, fmt.Errorf("error parsing SSH key: %w", err)
// Fall back to raw key files for unprotected keys
home, homeErr := os.UserHomeDir()
if homeErr != nil {
diagErrors = append(diagErrors, fmt.Sprintf("could not find home directory: %v", homeErr))
} else {
for _, name := range []string{"id_ed25519", "id_rsa"} {
keyPath := filepath.Join(home, ".ssh", name)
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
continue
}
signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
var passErr *ssh.PassphraseMissingError
if errors.As(err, &passErr) {
continue // passphrase-protected, skip — agent handles these
}
diagErrors = append(diagErrors, fmt.Sprintf("failed to parse %s: %v", keyPath, err))
continue
}
Comment on lines +313 to +321
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback key parsing currently continues on any ssh.ParsePrivateKey error, not just the expected “passphrase missing” case. This can hide real problems (corrupted key file, unsupported format, etc.) and lead to a confusing “no SSH auth available” error. Consider only skipping when the error is a passphrase-missing/encrypted-key error (e.g., via errors.As), and otherwise propagate or include the parse error in the returned error when no auth methods are usable.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

authMethods = append(authMethods, ssh.PublicKeys(signer))
}
}

if len(authMethods) == 0 {
msg := "no SSH auth available: set SSH_AUTH_SOCK or provide an unprotected key at ~/.ssh/id_ed25519 or ~/.ssh/id_rsa"
if len(diagErrors) > 0 {
msg += "\ndetails:\n " + strings.Join(diagErrors, "\n ")
}
return nil, fmt.Errorf("%s", msg)
}

config := &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
Auth: authMethods,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}

client, err := ssh.Dial("tcp", ip+":22", config)
if err != nil {
if agentConn != nil {
agentConn.Close()
}
return nil, err
}
return client, nil
Expand Down