From 77a84a487f5df2b87f3ac4637680f88b2954801b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 3 Jun 2026 11:23:02 +0800 Subject: [PATCH] sshkeeper: v0.2.0 stabilization (route display, forward validation, tunnel UX, README) --- README.md | 10 +++- cmd/forward.go | 65 +++++++++++++++++++-- cmd/list.go | 2 +- cmd/ssh_config.go | 2 +- cmd/tui.go | 43 +++++++++++++- Roadmap_v0.2.0.md => docs/roadmap/v0.2.0.md | 4 +- internal/db/servers.go | 9 ++- internal/model/server.go | 6 +- internal/tui/app.go | 45 ++++++++++++-- internal/tui/app_test.go | 2 +- internal/tui/forward.go | 46 ++++++++++++++- internal/tui/help_screen.go | 3 + 12 files changed, 213 insertions(+), 24 deletions(-) rename Roadmap_v0.2.0.md => docs/roadmap/v0.2.0.md (95%) diff --git a/README.md b/README.md index 7e8dde7..3982a8f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Download the latest Linux x86_64 release from: https://github.com/mirivlad/sshkeeper/releases/latest ```bash -tar -xzf sshkeeper_v0.1.0_linux_amd64.tar.gz +tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz chmod +x sshkeeper-linux-amd64 sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper sshkeeper @@ -156,6 +156,14 @@ sshkeeper tunnel web # Starts SSH session with all configured forwards active. ``` +## Connect vs Tunnel + +- **`sshkeeper connect `** (or `Enter` in TUI) — standard SSH session, no port forwards. +- **`sshkeeper forward`** (or `Ctrl+W` in TUI) — manage port forwards for a server. +- **`sshkeeper tunnel `** — start SSH session **with** all configured forwards active. +- **`sshkeeper tunnel --forward-only`** — start tunnel only (`ssh -N`), useful for background port forwarding. +- **TUI Action Menu** (`Ctrl+X`) — offers Connect, Start tunnel, and Tunnel mode for the selected server. + Commands that only read profile metadata, such as `list`, `show`, `search`, `config path`, `group list`, and `export`, do not require the master password. Commands that need secrets ask for the master password in that process. Adding diff --git a/cmd/forward.go b/cmd/forward.go index 92f687b..70102ce 100644 --- a/cmd/forward.go +++ b/cmd/forward.go @@ -65,6 +65,47 @@ var forwardAddCmd = &cobra.Command{ remoteAddr, _ := cmd.Flags().GetString("remote-addr") remotePort, _ := cmd.Flags().GetInt("remote-port") + // Validate type + if fwdType != "local" && fwdType != "remote" && fwdType != "dynamic" { + return fmt.Errorf("invalid forward type %q: must be local, remote, or dynamic", fwdType) + } + + // Validate ports + if localPort < 1 || localPort > 65535 { + return fmt.Errorf("invalid local port %d: must be 1-65535", localPort) + } + + // Validate fields based on type + switch fwdType { + case "local": + if localAddr == "" || localAddr == "0.0.0.0" { + localAddr = "0.0.0.0" + } + if remoteAddr == "" { + return fmt.Errorf("remote-addr is required for local forward") + } + if remotePort < 1 || remotePort > 65535 { + return fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort) + } + case "remote": + if remoteAddr == "" { + return fmt.Errorf("remote-addr is required for remote forward") + } + if remotePort < 1 || remotePort > 65535 { + return fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort) + } + if localAddr == "" { + localAddr = "0.0.0.0" + } + case "dynamic": + if localAddr == "" || localAddr == "0.0.0.0" { + localAddr = "0.0.0.0" + } + // dynamic doesn't use target fields — clear them + remoteAddr = "" + remotePort = 0 + } + fwd := &model.Forward{ ServerID: server.ID, Type: model.ForwardType(fwdType), @@ -74,10 +115,11 @@ var forwardAddCmd = &cobra.Command{ RemotePort: remotePort, } - if err := appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort); err != nil { + fwdID, err := appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort) + if err != nil { return fmt.Errorf("add forward: %w", err) } - fmt.Printf("✓ Forward added [%d]\n", fwd.ID) + fmt.Printf("✓ Forward added [%d]\n", fwdID) return nil }, } @@ -92,10 +134,25 @@ var forwardDeleteCmd = &cobra.Command{ if err != nil { return fmt.Errorf("invalid forward ID: %s", args[1]) } - // Verify server exists - if _, err := appDB.GetServer(alias); err != nil { + server, err := appDB.GetServer(alias) + if err != nil { return fmt.Errorf("server not found: %s", alias) } + // Verify forward belongs to this server + forwards, err := appDB.GetForwards(server.ID) + if err != nil { + return fmt.Errorf("load forwards: %w", err) + } + found := false + for _, f := range forwards { + if f.ID == id { + found = true + break + } + } + if !found { + return fmt.Errorf("forward %d does not belong to server %s", id, alias) + } if err := appDB.DeleteForward(id); err != nil { return fmt.Errorf("delete forward: %w", err) } diff --git a/cmd/list.go b/cmd/list.go index 8c075a0..536a458 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -3,9 +3,9 @@ package cmd import ( "fmt" - "github.com/spf13/cobra" "github.com/charmbracelet/lipgloss" "github.com/mirivlad/sshkeeper/internal/model" + "github.com/spf13/cobra" ) var listCmd = &cobra.Command{ diff --git a/cmd/ssh_config.go b/cmd/ssh_config.go index f4d0f15..b663d4b 100644 --- a/cmd/ssh_config.go +++ b/cmd/ssh_config.go @@ -4,8 +4,8 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "github.com/mirivlad/sshkeeper/internal/ssh" + "github.com/spf13/cobra" ) var sshConfigCmd = &cobra.Command{ diff --git a/cmd/tui.go b/cmd/tui.go index bfa2446..9d8a466 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -128,7 +128,8 @@ func runTUI() error { return appDB.GetForwards(serverID) } tui.SaveForward = func(fwd *model.Forward) error { - return appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort) + _, err := appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort) + return err } tui.DeleteForward = func(forwardID int64) error { return appDB.DeleteForward(forwardID) @@ -208,6 +209,46 @@ func runTUI() error { continue } + if result != nil && (result.Action == "tunnel" || result.Action == "tunnel_n") && result.Server != nil { + server := result.Server + fresh, err := appDB.GetServer(server.Alias) + if err != nil { + fmt.Fprintf(os.Stderr, "Server not found: %s\n", server.Alias) + servers, _ = appDB.ListServers() + continue + } + + // Load forwards + forwards, err := appDB.GetForwards(fresh.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "Load forwards: %v\n", err) + servers, _ = appDB.ListServers() + continue + } + + forwardOnly := result.Action == "tunnel_n" + if len(forwards) > 0 { + fmt.Printf("Starting tunnel to %s with %d forward(s)...\n", fresh.Alias, len(forwards)) + } else { + fmt.Printf("Starting session to %s...\n", fresh.Alias) + } + + sshArgs := ssh.BuildSSHArgs(fresh, forwards, forwardOnly) + if err := ssh.ConnectWithArgs(cfg, sshArgs, vaultFunc, fresh); err != nil { + fmt.Fprintf(os.Stderr, "Tunnel error: %v\n", err) + } else { + fmt.Println("Tunnel closed.") + } + appDB.UpdateLastConnected(fresh.Alias) + + fmt.Println("\n[Press Enter to return to sshkeeper]") + buf := make([]byte, 1) + os.Stdin.Read(buf) + + servers, _ = appDB.ListServers() + continue + } + // Normal quit (q or Esc) return nil } diff --git a/Roadmap_v0.2.0.md b/docs/roadmap/v0.2.0.md similarity index 95% rename from Roadmap_v0.2.0.md rename to docs/roadmap/v0.2.0.md index 2546979..1e40ad8 100644 --- a/Roadmap_v0.2.0.md +++ b/docs/roadmap/v0.2.0.md @@ -1,4 +1,6 @@ -## Theme +## Status + +✅ Completed — released as v0.2.0. Routes, tunnels and cleaner TUI. diff --git a/internal/db/servers.go b/internal/db/servers.go index 200b63c..2bd5fbd 100644 --- a/internal/db/servers.go +++ b/internal/db/servers.go @@ -322,12 +322,15 @@ func (db *DB) GetServerTags(serverID int64) ([]string, error) { // --- Forward methods --- -func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) error { - _, err := db.conn.Exec(` +func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) (int64, error) { + result, err := db.conn.Exec(` INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port) VALUES (?, ?, ?, ?, ?, ?)`, serverID, fwdType, localAddr, localPort, remoteAddr, remotePort) - return err + if err != nil { + return 0, err + } + return result.LastInsertId() } func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) { diff --git a/internal/model/server.go b/internal/model/server.go index 45b3858..eb0a19a 100644 --- a/internal/model/server.go +++ b/internal/model/server.go @@ -89,9 +89,9 @@ type Tag struct { // RouteHop represents a single jump host in a route. // IsProfile: true = use Alias (references a sshkeeper profile), false = use Raw (literal address). type RouteHop struct { - Alias string `json:"alias"` - Raw string `json:"raw"` - IsProfile bool `json:"is_profile"` + Alias string `json:"alias"` + Raw string `json:"raw"` + IsProfile bool `json:"is_profile"` } // Route represents the SSH jump route for a server. diff --git a/internal/tui/app.go b/internal/tui/app.go index 4d61beb..d84e4fb 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -961,10 +961,34 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if action != nil { - m.screen = screenList - m.actionMenu = nil // Handle known actions switch *action { + case "connect": + if item, ok := m.list.SelectedItem().(serverItem); ok { + m.screen = screenList + m.actionMenu = nil + return m, func() tea.Msg { + return connectRequestMsg{server: item.server} + } + } + case "tunnel": + if item, ok := m.list.SelectedItem().(serverItem); ok { + m.result = &TUIResult{ + Server: item.server, + Action: "tunnel", + Servers: []*model.Server{item.server}, + } + return m, tea.Quit + } + case "tunnel_n": + if item, ok := m.list.SelectedItem().(serverItem); ok { + m.result = &TUIResult{ + Server: item.server, + Action: "tunnel_n", + Servers: []*model.Server{item.server}, + } + return m, tea.Quit + } case "delete": if item, ok := m.list.SelectedItem().(serverItem); ok { return m, func() tea.Msg { @@ -1069,7 +1093,7 @@ func (m *tuiModel) viewServerList() string { b.WriteString("\n") b.WriteString(helpStyle.Render(fmt.Sprintf("Vault unlocked | %s", testSummary(m.servers)))) b.WriteString("\n\n") - b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-20s %-20s %-34s %-12s %-10s %s", "NAME", "ALIAS", "TARGET", "AUTH", "GROUP", "STATUS"))) + b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-20s %-20s %-34s %-12s %-10s %s", "NAME", "ALIAS", "ROUTE", "AUTH", "GROUP", "STATUS"))) b.WriteString("\n") if len(m.servers) == 0 { @@ -1096,6 +1120,16 @@ func (m *tuiModel) viewServerList() string { name = server.Alias } target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) + routeStr := server.Route.DisplaySummary(target) + // If too long, collapse middle hops + if len(routeStr) > 34 && len(server.Route.Hops) > 2 { + first := server.Route.Hops[0] + firstName := first.Alias + if !first.IsProfile { + firstName = first.Raw + } + routeStr = fmt.Sprintf("%s → … → %s", firstName, truncate(target, 34-len(firstName)-6)) + } group := server.GroupName if group == "" { group = "-" @@ -1104,7 +1138,7 @@ func (m *tuiModel) viewServerList() string { marker, truncate(name, 20), truncate(server.Alias, 20), - truncate(target, 34), + truncate(routeStr, 34), authLabel(server.AuthMethod), truncate(group, 10), testStatusLabel(server), @@ -1236,9 +1270,10 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string { b.WriteString(fmt.Sprintf(" Host: %s\n", server.Host)) b.WriteString(fmt.Sprintf(" Port: %d\n", server.Port)) b.WriteString(fmt.Sprintf(" User: %s\n", server.User)) + target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) + b.WriteString(fmt.Sprintf(" Target: %s\n", target)) b.WriteString(fmt.Sprintf(" Auth: %s\n", authLabel(server.AuthMethod))) if len(server.Route.Hops) > 0 { - target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) b.WriteString(fmt.Sprintf(" Route: %s\n", server.Route.DisplaySummary(target))) } else if server.ProxyJump != "" { b.WriteString(fmt.Sprintf(" ProxyJump: %s\n", server.ProxyJump)) diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index d821a71..8907631 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -45,7 +45,7 @@ func TestServerListViewUsesDashboardLayout(t *testing.T) { "2 servers", "Vault", "NAME", - "TARGET", + "ROUTE", "AUTH", "GROUP", "STATUS", diff --git a/internal/tui/forward.go b/internal/tui/forward.go index 013f3c8..c48849f 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -281,19 +281,59 @@ func (fm *forwardFormModel) runSave() tea.Cmd { return func() tea.Msg { fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value())) if fwdType == "" { - fwdType = model.ForwardLocal + return saveDoneMsg{err: fmt.Errorf("forward type is required (local/remote/dynamic)")} } + if fwdType != model.ForwardLocal && fwdType != model.ForwardRemote && fwdType != model.ForwardDynamic { + return saveDoneMsg{err: fmt.Errorf("invalid forward type %q", fwdType)} + } + localPort := 0 fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort) remotePort := 0 fmt.Sscanf(fm.inputs[4].Value(), "%d", &remotePort) + localAddr := strings.TrimSpace(fm.inputs[1].Value()) + remoteAddr := strings.TrimSpace(fm.inputs[3].Value()) + + if localPort < 1 || localPort > 65535 { + return saveDoneMsg{err: fmt.Errorf("invalid listen port %d: must be 1-65535", localPort)} + } + + switch fwdType { + case model.ForwardLocal: + if localAddr == "" { + localAddr = "0.0.0.0" + } + if remoteAddr == "" { + return saveDoneMsg{err: fmt.Errorf("target address is required for local forward")} + } + if remotePort < 1 || remotePort > 65535 { + return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)} + } + case model.ForwardRemote: + if remoteAddr == "" { + return saveDoneMsg{err: fmt.Errorf("target address is required for remote forward")} + } + if remotePort < 1 || remotePort > 65535 { + return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)} + } + if localAddr == "" { + localAddr = "0.0.0.0" + } + case model.ForwardDynamic: + if localAddr == "" { + localAddr = "0.0.0.0" + } + remoteAddr = "" + remotePort = 0 + } + fwd := &model.Forward{ ServerID: fm.serverID, Type: fwdType, - LocalAddr: fm.inputs[1].Value(), + LocalAddr: localAddr, LocalPort: localPort, - RemoteAddr: fm.inputs[3].Value(), + RemoteAddr: remoteAddr, RemotePort: remotePort, } diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index 46441f6..154100a 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -120,6 +120,9 @@ type actionMenuModel struct { func newActionMenuModel(w, h int) *actionMenuModel { items := []list.Item{ + actionMenuItem{label: "Connect", action: "connect"}, + actionMenuItem{label: "Start tunnel", action: "tunnel"}, + actionMenuItem{label: "Tunnel mode (-N)", action: "tunnel_n"}, actionMenuItem{label: "Delete", action: "delete"}, actionMenuItem{label: "Test connection", action: "test"}, actionMenuItem{label: "Tags", action: "tags"},