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) // // Must return tea.ExecProcess directly (NOT wrapped in another closure) // so Bubble Tea can execute the process command correctly. func sshConnectCmd(host *models.Host, dataDir string) tea.Cmd { port := host.Port if port == 0 { port = 22 } portStr := strconv.Itoa(port) target := fmt.Sprintf("%s@%s", host.Username, host.Hostname) ctrlSock := fmt.Sprintf("/tmp/hk-%s", host.ID) env := os.Environ() // Common SSH args sshArgs := []string{ "-p", portStr, "-o", "StrictHostKeyChecking=accept-new", "-o", "ServerAliveInterval=60", "-o", "ServerAliveCountMax=3", "-S", ctrlSock, "-o", "ControlMaster=auto", } cleanup := func() { exec.Command("ssh", "-S", ctrlSock, "-O", "exit", target).Run() } switch host.Auth.Type { case "password": allArgs := append([]string{"-e", "ssh"}, sshArgs...) allArgs = append(allArgs, target) cmd := exec.Command("sshpass", allArgs...) cmd.Env = append(env, "SSHPASS="+host.Auth.Password) return tea.ExecProcess(cmd, func(err error) tea.Msg { cleanup() return sshExitMsg{err: err} }) case "key": keyContent, err := loadKeyContent(host, dataDir) if err != nil { return errorCmd(fmt.Errorf("load key: %w", err)) } tmpFile, err := os.CreateTemp("", "hk-key-*") if err != nil { return errorCmd(fmt.Errorf("create temp key: %w", err)) } tmpPath := tmpFile.Name() if _, err := tmpFile.Write([]byte(keyContent)); err != nil { tmpFile.Close() os.Remove(tmpPath) return errorCmd(fmt.Errorf("write temp key: %w", err)) } tmpFile.Close() os.Chmod(tmpPath, 0600) keyArgs := append([]string{"-i", tmpPath}, sshArgs...) keyArgs = append(keyArgs, target) cmd := exec.Command("ssh", keyArgs...) 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"}, keyArgs...) cmd = exec.Command(setsid, newArgs...) } } } } cmd.Env = env return tea.ExecProcess(cmd, func(err error) tea.Msg { os.Remove(tmpPath) cleanup() return sshExitMsg{err: err} }) default: return errorCmd(fmt.Errorf("unsupported auth type: %s", host.Auth.Type)) } } // errorCmd returns a Cmd that sends an sshExitMsg with the given error. func errorCmd(err error) tea.Cmd { return func() tea.Msg { 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 }