diff options
| author | Clawd <ai@clawd.bot> | 2026-02-15 22:03:09 -0800 |
|---|---|---|
| committer | Clawd <ai@clawd.bot> | 2026-02-15 22:03:09 -0800 |
| commit | 29ad8c336116c01b10cacdd72b36e31cd3aa08f7 (patch) | |
| tree | 49e6a620ea6877bd561e5d9d1dbff25f57e08384 /cmd/ship/host_v2.go | |
| parent | 385577a14de35dcf70996ccfee6508d54e090c16 (diff) | |
feat: auto-generate SSH key and guide auth setup in host init
- ensureSSHKey() generates ed25519 key if none exists
- If SSH auth fails, returns JSON with pubkey and setup instructions
- Provides ssh-copy-id command for easy key deployment
Diffstat (limited to 'cmd/ship/host_v2.go')
| -rw-r--r-- | cmd/ship/host_v2.go | 84 |
1 files changed, 82 insertions, 2 deletions
diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go index 6e1850c..b19c376 100644 --- a/cmd/ship/host_v2.go +++ b/cmd/ship/host_v2.go | |||
| @@ -1,7 +1,11 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | ||
| 4 | "fmt" | 5 | "fmt" |
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 5 | "strings" | 9 | "strings" |
| 6 | 10 | ||
| 7 | "github.com/bdw/ship/internal/output" | 11 | "github.com/bdw/ship/internal/output" |
| @@ -38,10 +42,27 @@ func runHostInitV2(cmd *cobra.Command, args []string) error { | |||
| 38 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) | 42 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) |
| 39 | } | 43 | } |
| 40 | 44 | ||
| 41 | // Connect | 45 | // Ensure SSH key exists |
| 46 | keyPath, pubkey, err := ensureSSHKey() | ||
| 47 | if err != nil { | ||
| 48 | output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) | ||
| 49 | } | ||
| 50 | |||
| 51 | // Try to connect first (to verify key is authorized) | ||
| 42 | client, err := ssh.Connect(host) | 52 | client, err := ssh.Connect(host) |
| 43 | if err != nil { | 53 | if err != nil { |
| 44 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | 54 | // Connection failed - provide helpful error with pubkey |
| 55 | resp := map[string]interface{}{ | ||
| 56 | "status": "error", | ||
| 57 | "code": "SSH_AUTH_FAILED", | ||
| 58 | "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", | ||
| 59 | "public_key": pubkey, | ||
| 60 | "key_path": keyPath, | ||
| 61 | "host": host, | ||
| 62 | "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), | ||
| 63 | } | ||
| 64 | printJSON(resp) | ||
| 65 | os.Exit(output.ExitSSHFailed) | ||
| 45 | } | 66 | } |
| 46 | defer client.Close() | 67 | defer client.Close() |
| 47 | 68 | ||
| @@ -363,3 +384,62 @@ func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Ho | |||
| 363 | hostState.GitSetup = true | 384 | hostState.GitSetup = true |
| 364 | return nil | 385 | return nil |
| 365 | } | 386 | } |
| 387 | |||
| 388 | // ensureSSHKey checks for an existing SSH key or generates a new one. | ||
| 389 | // Returns the key path, public key contents, and any error. | ||
| 390 | func ensureSSHKey() (keyPath string, pubkey string, err error) { | ||
| 391 | home, err := os.UserHomeDir() | ||
| 392 | if err != nil { | ||
| 393 | return "", "", err | ||
| 394 | } | ||
| 395 | |||
| 396 | // Check common key locations | ||
| 397 | keyPaths := []string{ | ||
| 398 | filepath.Join(home, ".ssh", "id_ed25519"), | ||
| 399 | filepath.Join(home, ".ssh", "id_rsa"), | ||
| 400 | filepath.Join(home, ".ssh", "id_ecdsa"), | ||
| 401 | } | ||
| 402 | |||
| 403 | for _, kp := range keyPaths { | ||
| 404 | pubPath := kp + ".pub" | ||
| 405 | if _, err := os.Stat(kp); err == nil { | ||
| 406 | if _, err := os.Stat(pubPath); err == nil { | ||
| 407 | // Key exists, read public key | ||
| 408 | pub, err := os.ReadFile(pubPath) | ||
| 409 | if err != nil { | ||
| 410 | continue | ||
| 411 | } | ||
| 412 | return kp, strings.TrimSpace(string(pub)), nil | ||
| 413 | } | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | // No key found, generate one | ||
| 418 | keyPath = filepath.Join(home, ".ssh", "id_ed25519") | ||
| 419 | sshDir := filepath.Dir(keyPath) | ||
| 420 | |||
| 421 | // Ensure .ssh directory exists | ||
| 422 | if err := os.MkdirAll(sshDir, 0700); err != nil { | ||
| 423 | return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) | ||
| 424 | } | ||
| 425 | |||
| 426 | // Generate key | ||
| 427 | cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") | ||
| 428 | if err := cmd.Run(); err != nil { | ||
| 429 | return "", "", fmt.Errorf("failed to generate SSH key: %w", err) | ||
| 430 | } | ||
| 431 | |||
| 432 | // Read public key | ||
| 433 | pub, err := os.ReadFile(keyPath + ".pub") | ||
| 434 | if err != nil { | ||
| 435 | return "", "", fmt.Errorf("failed to read public key: %w", err) | ||
| 436 | } | ||
| 437 | |||
| 438 | return keyPath, strings.TrimSpace(string(pub)), nil | ||
| 439 | } | ||
| 440 | |||
| 441 | // printJSON outputs a value as JSON to stdout | ||
| 442 | func printJSON(v interface{}) { | ||
| 443 | enc := json.NewEncoder(os.Stdout) | ||
| 444 | enc.Encode(v) | ||
| 445 | } | ||
