diff --git a/cmd/hostkeeper/main.go b/cmd/hostkeeper/main.go index cd9e68e..ed07bec 100644 --- a/cmd/hostkeeper/main.go +++ b/cmd/hostkeeper/main.go @@ -11,6 +11,13 @@ var ( ) func main() { + // SSH_ASKPASS support: hostkeeper askpass + // Called by the SSH_ASKPASS script we create for key passphrases. + if len(os.Args) == 2 && os.Args[1] == "askpass" { + fmt.Print(os.Getenv("HK_PASSPHRASE")) + return + } + if err := Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index 31c1579..1fa1ca8 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -58,7 +58,7 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) { if len(t.hosts) > 0 { host := t.hosts[t.selectedIndex] return t, func() tea.Msg { - return openSessionMsg{host: host} + return sshConnectToMsg{host: host} } } diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go index b1f459b..4acd9e4 100644 --- a/pkg/tui/messages.go +++ b/pkg/tui/messages.go @@ -12,16 +12,13 @@ import ( // quitMsg signals the TUI to exit type quitMsg struct{} -// openSessionMsg signals the TUI to open a new SSH session tab -type openSessionMsg struct { +// sshConnectToMsg signals the TUI to connect to a host via native SSH +type sshConnectToMsg struct { host *models.Host } -// sessionOutputMsg carries SSH output to the TUI renderer -type sessionOutputMsg string - -// sessionDoneMsg signals that a session has ended -type sessionDoneMsg struct { +// sshExitMsg signals that a native SSH session has ended +type sshExitMsg struct { err error } diff --git a/pkg/tui/session.go b/pkg/tui/session.go deleted file mode 100644 index 342e7e9..0000000 --- a/pkg/tui/session.go +++ /dev/null @@ -1,354 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "io" - "strings" - "sync" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - cryptossh "golang.org/x/crypto/ssh" - - "git.tukangketik.id/swanadiva/hostkeeper/internal/models" - sshclient "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh" -) - -// SessionTab represents an interactive SSH session as a tab -type SessionTab struct { - host *models.Host - dataDir string - - client *sshclient.Client - sess *cryptossh.Session - - width int - height int - - mu sync.Mutex - buffer strings.Builder - connected bool - done bool - err error - - stdinPipe io.WriteCloser - stdinMu sync.Mutex - outputCh chan sessionOutputMsg - doneCh chan sessionDoneMsg - windowCh chan struct{} - closeOnce sync.Once - closed chan struct{} - - cancel context.CancelFunc -} - -// NewSessionTab creates a new SSH session tab -func NewSessionTab(host *models.Host, dataDir string) *SessionTab { - return &SessionTab{ - host: host, - dataDir: dataDir, - width: 80, - height: 24, - - outputCh: make(chan sessionOutputMsg, 256), - doneCh: make(chan sessionDoneMsg, 1), - windowCh: make(chan struct{}, 8), - closed: make(chan struct{}), - } -} - -// Name returns the tab name -func (t *SessionTab) Name() string { - return t.host.Name -} - -// Init starts the SSH connection in the background -func (t *SessionTab) Init() tea.Cmd { - ctx, cancel := context.WithCancel(context.Background()) - t.cancel = cancel - - go t.connectAndStream(ctx) - return t.pollSession -} - -// pollSession polls for session output and returns it as a message to trigger re-render -func (t *SessionTab) pollSession() tea.Msg { - select { - case msg := <-t.outputCh: - t.mu.Lock() - t.buffer.WriteString(string(msg)) - t.mu.Unlock() - // Return the msg to trigger Update → View re-render - return msg - case done := <-t.doneCh: - return done - default: - return nil - } -} - -// connectAndStream connects to the host and streams output -func (t *SessionTab) connectAndStream(ctx context.Context) { - timeout := 30 * time.Second - client := sshclient.NewClient(t.host, timeout) - - if err := client.Connect(ctx); err != nil { - t.doneCh <- sessionDoneMsg{err: fmt.Errorf("connection failed: %w", err)} - return - } - - t.mu.Lock() - t.client = client - t.connected = true - t.mu.Unlock() - - sshClient := client.GetClient() - sess, err := sshClient.NewSession() - if err != nil { - client.Close() - t.doneCh <- sessionDoneMsg{err: fmt.Errorf("session creation failed: %w", err)} - return - } - - t.mu.Lock() - t.sess = sess - t.mu.Unlock() - - width := t.width - height := t.height - - modes := cryptossh.TerminalModes{ - cryptossh.ECHO: 1, - cryptossh.TTY_OP_ISPEED: 14400, - cryptossh.TTY_OP_OSPEED: 14400, - } - - if err := sess.RequestPty("xterm-256color", height, width, modes); err != nil { - client.Close() - t.doneCh <- sessionDoneMsg{err: fmt.Errorf("PTY request failed: %w", err)} - return - } - - stdin, err := sess.StdinPipe() - if err != nil { - client.Close() - t.doneCh <- sessionDoneMsg{err: fmt.Errorf("stdin pipe failed: %w", err)} - return - } - t.stdinPipe = stdin - - stdout, err := sess.StdoutPipe() - if err != nil { - client.Close() - t.doneCh <- sessionDoneMsg{err: fmt.Errorf("stdout pipe failed: %w", err)} - return - } - - stderr, err := sess.StderrPipe() - if err != nil { - client.Close() - t.doneCh <- sessionDoneMsg{err: fmt.Errorf("stderr pipe failed: %w", err)} - return - } - - if err := sess.Shell(); err != nil { - client.Close() - t.doneCh <- sessionDoneMsg{err: fmt.Errorf("shell start failed: %w", err)} - return - } - - // Stream stdout - go func() { - buf := make([]byte, 4096) - for { - n, err := stdout.Read(buf) - if n > 0 { - select { - case t.outputCh <- sessionOutputMsg(string(buf[:n])): - default: - } - } - if err != nil { - return - } - } - }() - - // Stream stderr - go func() { - buf := make([]byte, 4096) - for { - n, err := stderr.Read(buf) - if n > 0 { - select { - case t.outputCh <- sessionOutputMsg(string(buf[:n])): - default: - } - } - if err != nil { - return - } - } - }() - - // Handle window resize - go func() { - for { - select { - case <-t.windowCh: - t.mu.Lock() - w, h := t.width, t.height - t.mu.Unlock() - sess.WindowChange(h, w) - case <-t.closed: - return - case <-ctx.Done(): - return - } - } - }() - - sess.Wait() - client.Close() - t.doneCh <- sessionDoneMsg{err: nil} -} - -// Update handles messages for the session tab -func (t *SessionTab) Update(msg tea.Msg) (Tab, tea.Cmd) { - if t.done { - return t, nil - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - t.mu.Lock() - t.width = msg.Width - t.height = msg.Height - t.mu.Unlock() - select { - case t.windowCh <- struct{}{}: - default: - } - - case tea.KeyMsg: - data := keyMsgToBytes(msg) - if len(data) > 0 { - t.stdinMu.Lock() - if t.stdinPipe != nil { - t.stdinPipe.Write(data) - } - t.stdinMu.Unlock() - } - - case sessionOutputMsg: - // Buffer already updated in pollSession, but we return pollSession - // to keep the polling loop alive - - case sessionDoneMsg: - t.mu.Lock() - t.done = true - if msg.err != nil { - t.err = msg.err - } - t.mu.Unlock() - } - - return t, t.pollSession -} - -// View renders the session tab content -func (t *SessionTab) View() string { - t.mu.Lock() - defer t.mu.Unlock() - - if !t.connected { - return lipgloss.NewStyle().Height(t.height - 2).Render( - HighlightStyle.Render(fmt.Sprintf("Connecting to %s...", t.host.Name))) - } - - if t.done && t.err != nil { - return fmt.Sprintf("%s\n\n%s", - ErrorStyle.Render(fmt.Sprintf("Session ended: %v", t.err)), - SubtitleStyle.Render("Press Ctrl+Q to close this tab")) - } - - if t.done { - return fmt.Sprintf("%s\n\n%s", - SubtitleStyle.Render("Session ended"), - SubtitleStyle.Render("Press Ctrl+Q to close this tab")) - } - - content := t.buffer.String() - if content == "" { - return lipgloss.NewStyle().Height(t.height - 2).Render( - SubtitleStyle.Render("Connected. Waiting for output...")) - } - - lines := strings.Split(content, "\n") - const maxLines = 1000 - if len(lines) > maxLines { - lines = lines[len(lines)-maxLines:] - } - visible := strings.Join(lines, "\n") - - return lipgloss.NewStyle().Height(t.height - 3).Render(visible) -} - -// Close terminates the session -func (t *SessionTab) Close() { - t.closeOnce.Do(func() { - close(t.closed) - if t.cancel != nil { - t.cancel() - } - t.mu.Lock() - if t.sess != nil { - t.sess.Close() - } - if t.client != nil { - t.client.Close() - } - t.mu.Unlock() - }) -} - -// keyMsgToBytes converts a Bubble Tea key message to SSH terminal bytes -func keyMsgToBytes(msg tea.KeyMsg) []byte { - switch msg.Type { - case tea.KeyEnter: - return []byte("\r") - case tea.KeyBackspace: - return []byte("\x7f") - case tea.KeyTab: - return []byte("\t") - case tea.KeySpace: - return []byte(" ") - case tea.KeyUp: - return []byte("\x1b[A") - case tea.KeyDown: - return []byte("\x1b[B") - case tea.KeyRight: - return []byte("\x1b[C") - case tea.KeyLeft: - return []byte("\x1b[D") - case tea.KeyEscape: - return []byte("\x1b") - case tea.KeyDelete: - return []byte("\x1b[3~") - case tea.KeyHome: - return []byte("\x1b[H") - case tea.KeyEnd: - return []byte("\x1b[F") - case tea.KeyPgUp: - return []byte("\x1b[5~") - case tea.KeyPgDown: - return []byte("\x1b[6~") - default: - if len(msg.Runes) > 0 { - return []byte(string(msg.Runes)) - } - return []byte(msg.String()) - } -} diff --git a/pkg/tui/ssh.go b/pkg/tui/ssh.go new file mode 100644 index 0000000..717ee2e --- /dev/null +++ b/pkg/tui/ssh.go @@ -0,0 +1,131 @@ +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 +} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go index 06fde9d..223b3b3 100644 --- a/pkg/tui/styles.go +++ b/pkg/tui/styles.go @@ -2,37 +2,40 @@ package tui import "github.com/charmbracelet/lipgloss" -// Orange theme palette +// Gruvbox Material Dark Hard palette — warm, soft, easy on eyes var ( - OrangePrimary = lipgloss.Color("#FF6B00") - OrangeSecondary = lipgloss.Color("#FF9F43") - OrangeLight = lipgloss.Color("#FFB800") - DarkBg = lipgloss.Color("#1A1A1A") - LightBg = lipgloss.Color("#2D2D2D") - TextPrimary = lipgloss.Color("#FFFFFF") - TextSecondary = lipgloss.Color("#AAAAAA") - GreenSuccess = lipgloss.Color("#00FF88") - RedError = lipgloss.Color("#FF4444") - BlueInfo = lipgloss.Color("#44AAFF") + gbFg = lipgloss.Color("#d4be98") // primary text + gbFgMute = lipgloss.Color("#7c6f64") // secondary/hints + gbBgSel = lipgloss.Color("#45403d") // cursor row bg + gbRed = lipgloss.Color("#ea6962") // error/destructive + gbOrange = lipgloss.Color("#e78a4e") // section headers + gbYellow = lipgloss.Color("#d8a657") // accent/titles + gbGreen = lipgloss.Color("#a9b665") // active pane/success + gbAqua = lipgloss.Color("#89b482") // interactive keys + gbBlue = lipgloss.Color("#7daea3") + gbPurple = lipgloss.Color("#d3869b") + gbBorder = lipgloss.Color("#504945") // subtle border ) // Component styles var ( - TabActiveStyle = lipgloss.NewStyle().Background(OrangePrimary).Foreground(DarkBg).Bold(true).Padding(0, 2) - TabInactiveStyle = lipgloss.NewStyle().Background(LightBg).Foreground(TextSecondary).Padding(0, 2) - TabBarStyle = lipgloss.NewStyle().Background(DarkBg) - StatusBarStyle = lipgloss.NewStyle().Background(OrangePrimary).Foreground(DarkBg).Padding(0, 1) - AppTitleStyle = lipgloss.NewStyle().Foreground(OrangeSecondary).Bold(true) - HighlightStyle = lipgloss.NewStyle().Foreground(OrangePrimary).Bold(true) - SelectedStyle = lipgloss.NewStyle().Foreground(DarkBg).Background(OrangePrimary).Padding(0, 1) - ErrorStyle = lipgloss.NewStyle().Foreground(RedError).Bold(true) - SuccessStyle = lipgloss.NewStyle().Foreground(GreenSuccess).Bold(true) - InfoStyle = lipgloss.NewStyle().Foreground(BlueInfo) - SubtitleStyle = lipgloss.NewStyle().Foreground(TextSecondary) - HostNameStyle = lipgloss.NewStyle().Foreground(OrangeLight).Bold(true) - HostDetailStyle = lipgloss.NewStyle().Foreground(TextSecondary) - TagStyle = lipgloss.NewStyle().Foreground(GreenSuccess) + TabActiveStyle = lipgloss.NewStyle().Background(gbYellow).Foreground(lipgloss.Color("#1d2021")).Bold(true).Padding(0, 2) + TabInactiveStyle = lipgloss.NewStyle().Background(gbBorder).Foreground(gbFgMute).Padding(0, 2) + TabBarStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1d2021")) + StatusBarStyle = lipgloss.NewStyle().Background(gbGreen).Foreground(lipgloss.Color("#1d2021")).Padding(0, 1) + AppTitleStyle = lipgloss.NewStyle().Foreground(gbYellow).Bold(true) + HighlightStyle = lipgloss.NewStyle().Foreground(gbOrange).Bold(true) + SelectedStyle = lipgloss.NewStyle().Foreground(gbFg).Background(gbBgSel).Bold(true).Padding(0, 1) + ErrorStyle = lipgloss.NewStyle().Foreground(gbRed).Bold(true) + SuccessStyle = lipgloss.NewStyle().Foreground(gbGreen).Bold(true) + InfoStyle = lipgloss.NewStyle().Foreground(gbAqua) + SubtitleStyle = lipgloss.NewStyle().Foreground(gbFgMute) + HostNameStyle = lipgloss.NewStyle().Foreground(gbYellow).Bold(true) + HostDetailStyle = lipgloss.NewStyle().Foreground(gbFgMute) + TagStyle = lipgloss.NewStyle().Foreground(gbGreen) TitleStyle = AppTitleStyle + SectionStyle = lipgloss.NewStyle().Foreground(gbOrange).Bold(true) + BorderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(1, 2) ) // TabWidth returns the width of the tab bar content diff --git a/pkg/tui/tabs.go b/pkg/tui/tabs.go index 4382555..d4d55a2 100644 --- a/pkg/tui/tabs.go +++ b/pkg/tui/tabs.go @@ -196,6 +196,6 @@ func renderTabBar(tm *TabManager) string { // renderStatusBar renders the bottom status bar func renderStatusBar(tm *TabManager) string { - hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:select Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit" + hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit" return StatusBarStyle.Render(hints) } diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 3c8740b..3ac3bb6 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -61,10 +61,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Quit = true return m, tea.Quit - case openSessionMsg: - tab := NewSessionTab(msg.host, m.dataDir) - cmd := m.tabs.Add(tab) - return m, cmd + case sshConnectToMsg: + return m, sshConnectCmd(msg.host, m.dataDir) + + case sshExitMsg: + return m, nil case openHostFormMsg: var tab Tab @@ -175,11 +176,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case sessionDoneMsg: - if msg.err != nil { - m.Error = msg.err - } - return m, nil } cmd, err := m.tabs.Update(msg)