sshkeeper: v0.2.0 stabilization (route display, forward validation, tunnel UX, README)

This commit is contained in:
mirivlad 2026-06-03 11:23:02 +08:00
parent 21444d3826
commit 77a84a487f
12 changed files with 213 additions and 24 deletions

View File

@ -36,7 +36,7 @@ Download the latest Linux x86_64 release from:
https://github.com/mirivlad/sshkeeper/releases/latest https://github.com/mirivlad/sshkeeper/releases/latest
```bash ```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 chmod +x sshkeeper-linux-amd64
sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper
sshkeeper sshkeeper
@ -156,6 +156,14 @@ sshkeeper tunnel web
# Starts SSH session with all configured forwards active. # Starts SSH session with all configured forwards active.
``` ```
## Connect vs Tunnel
- **`sshkeeper connect <alias>`** (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 <alias>`** — start SSH session **with** all configured forwards active.
- **`sshkeeper tunnel <alias> --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`, Commands that only read profile metadata, such as `list`, `show`, `search`,
`config path`, `group list`, and `export`, do not require the master password. `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 Commands that need secrets ask for the master password in that process. Adding

View File

@ -65,6 +65,47 @@ var forwardAddCmd = &cobra.Command{
remoteAddr, _ := cmd.Flags().GetString("remote-addr") remoteAddr, _ := cmd.Flags().GetString("remote-addr")
remotePort, _ := cmd.Flags().GetInt("remote-port") 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{ fwd := &model.Forward{
ServerID: server.ID, ServerID: server.ID,
Type: model.ForwardType(fwdType), Type: model.ForwardType(fwdType),
@ -74,10 +115,11 @@ var forwardAddCmd = &cobra.Command{
RemotePort: remotePort, 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) return fmt.Errorf("add forward: %w", err)
} }
fmt.Printf("✓ Forward added [%d]\n", fwd.ID) fmt.Printf("✓ Forward added [%d]\n", fwdID)
return nil return nil
}, },
} }
@ -92,10 +134,25 @@ var forwardDeleteCmd = &cobra.Command{
if err != nil { if err != nil {
return fmt.Errorf("invalid forward ID: %s", args[1]) return fmt.Errorf("invalid forward ID: %s", args[1])
} }
// Verify server exists server, err := appDB.GetServer(alias)
if _, err := appDB.GetServer(alias); err != nil { if err != nil {
return fmt.Errorf("server not found: %s", alias) 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 { if err := appDB.DeleteForward(id); err != nil {
return fmt.Errorf("delete forward: %w", err) return fmt.Errorf("delete forward: %w", err)
} }

View File

@ -3,9 +3,9 @@ package cmd
import ( import (
"fmt" "fmt"
"github.com/spf13/cobra"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
"github.com/spf13/cobra"
) )
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{

View File

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/spf13/cobra"
"github.com/mirivlad/sshkeeper/internal/ssh" "github.com/mirivlad/sshkeeper/internal/ssh"
"github.com/spf13/cobra"
) )
var sshConfigCmd = &cobra.Command{ var sshConfigCmd = &cobra.Command{

View File

@ -128,7 +128,8 @@ func runTUI() error {
return appDB.GetForwards(serverID) return appDB.GetForwards(serverID)
} }
tui.SaveForward = func(fwd *model.Forward) error { 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 { tui.DeleteForward = func(forwardID int64) error {
return appDB.DeleteForward(forwardID) return appDB.DeleteForward(forwardID)
@ -208,6 +209,46 @@ func runTUI() error {
continue 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) // Normal quit (q or Esc)
return nil return nil
} }

View File

@ -1,4 +1,6 @@
## Theme ## Status
✅ Completed — released as v0.2.0.
Routes, tunnels and cleaner TUI. Routes, tunnels and cleaner TUI.

View File

@ -322,12 +322,15 @@ func (db *DB) GetServerTags(serverID int64) ([]string, error) {
// --- Forward methods --- // --- Forward methods ---
func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) error { func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) (int64, error) {
_, err := db.conn.Exec(` result, err := db.conn.Exec(`
INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port) INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
serverID, fwdType, localAddr, localPort, remoteAddr, remotePort) 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) { func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {

View File

@ -89,9 +89,9 @@ type Tag struct {
// RouteHop represents a single jump host in a route. // RouteHop represents a single jump host in a route.
// IsProfile: true = use Alias (references a sshkeeper profile), false = use Raw (literal address). // IsProfile: true = use Alias (references a sshkeeper profile), false = use Raw (literal address).
type RouteHop struct { type RouteHop struct {
Alias string `json:"alias"` Alias string `json:"alias"`
Raw string `json:"raw"` Raw string `json:"raw"`
IsProfile bool `json:"is_profile"` IsProfile bool `json:"is_profile"`
} }
// Route represents the SSH jump route for a server. // Route represents the SSH jump route for a server.

View File

@ -961,10 +961,34 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
if action != nil { if action != nil {
m.screen = screenList
m.actionMenu = nil
// Handle known actions // Handle known actions
switch *action { 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": case "delete":
if item, ok := m.list.SelectedItem().(serverItem); ok { if item, ok := m.list.SelectedItem().(serverItem); ok {
return m, func() tea.Msg { return m, func() tea.Msg {
@ -1069,7 +1093,7 @@ func (m *tuiModel) viewServerList() string {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(helpStyle.Render(fmt.Sprintf("Vault unlocked | %s", testSummary(m.servers)))) b.WriteString(helpStyle.Render(fmt.Sprintf("Vault unlocked | %s", testSummary(m.servers))))
b.WriteString("\n\n") 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") b.WriteString("\n")
if len(m.servers) == 0 { if len(m.servers) == 0 {
@ -1096,6 +1120,16 @@ func (m *tuiModel) viewServerList() string {
name = server.Alias name = server.Alias
} }
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) 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 group := server.GroupName
if group == "" { if group == "" {
group = "-" group = "-"
@ -1104,7 +1138,7 @@ func (m *tuiModel) viewServerList() string {
marker, marker,
truncate(name, 20), truncate(name, 20),
truncate(server.Alias, 20), truncate(server.Alias, 20),
truncate(target, 34), truncate(routeStr, 34),
authLabel(server.AuthMethod), authLabel(server.AuthMethod),
truncate(group, 10), truncate(group, 10),
testStatusLabel(server), 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(" Host: %s\n", server.Host))
b.WriteString(fmt.Sprintf(" Port: %d\n", server.Port)) b.WriteString(fmt.Sprintf(" Port: %d\n", server.Port))
b.WriteString(fmt.Sprintf(" User: %s\n", server.User)) 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))) b.WriteString(fmt.Sprintf(" Auth: %s\n", authLabel(server.AuthMethod)))
if len(server.Route.Hops) > 0 { 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))) b.WriteString(fmt.Sprintf(" Route: %s\n", server.Route.DisplaySummary(target)))
} else if server.ProxyJump != "" { } else if server.ProxyJump != "" {
b.WriteString(fmt.Sprintf(" ProxyJump: %s\n", server.ProxyJump)) b.WriteString(fmt.Sprintf(" ProxyJump: %s\n", server.ProxyJump))

View File

@ -45,7 +45,7 @@ func TestServerListViewUsesDashboardLayout(t *testing.T) {
"2 servers", "2 servers",
"Vault", "Vault",
"NAME", "NAME",
"TARGET", "ROUTE",
"AUTH", "AUTH",
"GROUP", "GROUP",
"STATUS", "STATUS",

View File

@ -281,19 +281,59 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value())) fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value()))
if fwdType == "" { 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 localPort := 0
fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort) fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort)
remotePort := 0 remotePort := 0
fmt.Sscanf(fm.inputs[4].Value(), "%d", &remotePort) 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{ fwd := &model.Forward{
ServerID: fm.serverID, ServerID: fm.serverID,
Type: fwdType, Type: fwdType,
LocalAddr: fm.inputs[1].Value(), LocalAddr: localAddr,
LocalPort: localPort, LocalPort: localPort,
RemoteAddr: fm.inputs[3].Value(), RemoteAddr: remoteAddr,
RemotePort: remotePort, RemotePort: remotePort,
} }

View File

@ -120,6 +120,9 @@ type actionMenuModel struct {
func newActionMenuModel(w, h int) *actionMenuModel { func newActionMenuModel(w, h int) *actionMenuModel {
items := []list.Item{ 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: "Delete", action: "delete"},
actionMenuItem{label: "Test connection", action: "test"}, actionMenuItem{label: "Test connection", action: "test"},
actionMenuItem{label: "Tags", action: "tags"}, actionMenuItem{label: "Tags", action: "tags"},