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
```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 <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`,
`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

View File

@ -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)
}

View File

@ -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{

View File

@ -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{

View File

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

View File

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

View File

@ -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) {

View File

@ -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))

View File

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

View File

@ -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,
}

View File

@ -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"},