package tui import ( "context" "fmt" "os" "os/exec" "strconv" tea "github.com/charmbracelet/bubbletea" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" ) // sshConnectCmd builds and runs a native SSH command via tea.ExecProcess. // Password auth: sshpass -e ssh user@host (SSHPASS env) // Key auth: ssh -i user@host (SSH_ASKPASS for passphrase) func sshConnectCmd(host *models.Host, dataDir string) tea.Cmd { return func() tea.Msg { port := host.Port if port == 0 { port = 22 } portStr := strconv.Itoa(port) target := fmt.Sprintf("%s@%s", host.Username, host.Hostname) var cmd *exec.Cmd env := os.Environ() ctrlSock := fmt.Sprintf("/tmp/hk-%s", host.ID) switch host.Auth.Type { case "password": args := []string{ "-e", "ssh", "-p", portStr, "-o", "StrictHostKeyChecking=accept-new", "-o", "ServerAliveInterval=60", "-o", "ServerAliveCountMax=3", "-S", ctrlSock, "-o", "ControlMaster=auto", target, } cmd = exec.Command("sshpass", args...) env = append(env, "SSHPASS="+host.Auth.Password) case "key": keyContent, err := loadKeyContent(host, dataDir) if err != nil { return sshExitMsg{err: err} } tmpFile, err := os.CreateTemp("", "hk-key-*") if err != nil { return sshExitMsg{err: fmt.Errorf("create temp key: %w", err)} } tmpPath := tmpFile.Name() if _, err := tmpFile.Write([]byte(keyContent)); err != nil { tmpFile.Close() os.Remove(tmpPath) return sshExitMsg{err: fmt.Errorf("write temp key: %w", err)} } tmpFile.Close() os.Chmod(tmpPath, 0600) args := []string{ "-i", tmpPath, "-p", portStr, "-o", "StrictHostKeyChecking=accept-new", "-o", "ServerAliveInterval=60", "-o", "ServerAliveCountMax=3", "-S", ctrlSock, "-o", "ControlMaster=auto", target, } cmd = exec.Command("ssh", args...) if host.Auth.Password != "" { self, err := os.Executable() if err == nil { script := fmt.Sprintf("#!/bin/sh\nexec %q askpass\n", self) f, err := os.CreateTemp("", "hk-askpass-*.sh") if err == nil { f.WriteString(script) f.Close() os.Chmod(f.Name(), 0700) env = append(env, "HK_PASSPHRASE="+host.Auth.Password, "SSH_ASKPASS="+f.Name(), "SSH_ASKPASS_REQUIRE=force", ) if os.Getenv("DISPLAY") == "" { env = append(env, "DISPLAY=:0") } if setsid, err := exec.LookPath("setsid"); err == nil { newArgs := append([]string{"ssh"}, args...) cmd = exec.Command(setsid, newArgs...) } } } } default: return sshExitMsg{err: fmt.Errorf("unsupported auth type: %s", host.Auth.Type)} } cmd.Env = env return tea.ExecProcess(cmd, func(err error) tea.Msg { // Cleanup ControlMaster socket exec.Command("ssh", "-S", ctrlSock, "-O", "exit", target).Run() return sshExitMsg{err: err} }) } } // loadKeyContent reads the private key content for a host func loadKeyContent(host *models.Host, dataDir string) (string, error) { if host.Auth.KeyID == "" { return "", fmt.Errorf("key auth requires key_id") } store, err := storage.NewJSONStorage(dataDir) if err != nil { return "", err } keyPair, err := store.GetKeyPair(context.Background(), host.Auth.KeyID) if err != nil { return "", fmt.Errorf("load key %s: %w", host.Auth.KeyID, err) } return keyPair.PrivateKey, nil }