From 29ad8c336116c01b10cacdd72b36e31cd3aa08f7 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 22:03:09 -0800 Subject: 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 --- cmd/ship/host_v2.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file 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 @@ package main import ( + "encoding/json" "fmt" + "os" + "os/exec" + "path/filepath" "strings" "github.com/bdw/ship/internal/output" @@ -38,10 +42,27 @@ func runHostInitV2(cmd *cobra.Command, args []string) error { output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) } - // Connect + // Ensure SSH key exists + keyPath, pubkey, err := ensureSSHKey() + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) + } + + // Try to connect first (to verify key is authorized) client, err := ssh.Connect(host) if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) + // Connection failed - provide helpful error with pubkey + resp := map[string]interface{}{ + "status": "error", + "code": "SSH_AUTH_FAILED", + "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", + "public_key": pubkey, + "key_path": keyPath, + "host": host, + "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), + } + printJSON(resp) + os.Exit(output.ExitSSHFailed) } defer client.Close() @@ -363,3 +384,62 @@ func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Ho hostState.GitSetup = true return nil } + +// ensureSSHKey checks for an existing SSH key or generates a new one. +// Returns the key path, public key contents, and any error. +func ensureSSHKey() (keyPath string, pubkey string, err error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + + // Check common key locations + keyPaths := []string{ + filepath.Join(home, ".ssh", "id_ed25519"), + filepath.Join(home, ".ssh", "id_rsa"), + filepath.Join(home, ".ssh", "id_ecdsa"), + } + + for _, kp := range keyPaths { + pubPath := kp + ".pub" + if _, err := os.Stat(kp); err == nil { + if _, err := os.Stat(pubPath); err == nil { + // Key exists, read public key + pub, err := os.ReadFile(pubPath) + if err != nil { + continue + } + return kp, strings.TrimSpace(string(pub)), nil + } + } + } + + // No key found, generate one + keyPath = filepath.Join(home, ".ssh", "id_ed25519") + sshDir := filepath.Dir(keyPath) + + // Ensure .ssh directory exists + if err := os.MkdirAll(sshDir, 0700); err != nil { + return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) + } + + // Generate key + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") + if err := cmd.Run(); err != nil { + return "", "", fmt.Errorf("failed to generate SSH key: %w", err) + } + + // Read public key + pub, err := os.ReadFile(keyPath + ".pub") + if err != nil { + return "", "", fmt.Errorf("failed to read public key: %w", err) + } + + return keyPath, strings.TrimSpace(string(pub)), nil +} + +// printJSON outputs a value as JSON to stdout +func printJSON(v interface{}) { + enc := json.NewEncoder(os.Stdout) + enc.Encode(v) +} -- cgit v1.2.3