sshkeeper: v0.2.0 stabilization (route display, forward validation, tunnel UX, README)
This commit is contained in:
parent
21444d3826
commit
77a84a487f
10
README.md
10
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 <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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
43
cmd/tui.go
43
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
## Theme
|
||||
## Status
|
||||
|
||||
✅ Completed — released as v0.2.0.
|
||||
|
||||
Routes, tunnels and cleaner TUI.
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func TestServerListViewUsesDashboardLayout(t *testing.T) {
|
|||
"2 servers",
|
||||
"Vault",
|
||||
"NAME",
|
||||
"TARGET",
|
||||
"ROUTE",
|
||||
"AUTH",
|
||||
"GROUP",
|
||||
"STATUS",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Reference in New Issue