Files
HostKeeper/pkg/tui/ssh.go
T
swanadiva 7b06793002 feat: native SSH delegation via tea.ExecProcess + Gruvbox palette
Phase A — Tamagosh-inspired redesign:
- Replace goroutine-based SSH (crypto/ssh) with tea.ExecProcess + native ssh binary
- Zero-lag SSH: native binary pegang TTY langsung, ga ada Go buffering
- Password auth via sshpass -e (SSHPASS env)
- Key auth via ssh -i <tmpfile> + SSH_ASKPASS for passphrase
- ControlMaster sockets for fast reconnect
- Add askpass subcommand for SSH_ASKPASS support
- Gruvbox Material Dark Hard palette for comfortable viewing
- Clean up old session messages (openSessionMsg, sessionOutputMsg, sessionDoneMsg)
- Remove 354-line session.go, replace with 150-line ssh.go
2026-06-23 15:36:40 +07:00

132 lines
3.4 KiB
Go

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 <tmpfile> 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
}