Compare commits

...

17 Commits

Author SHA1 Message Date
mirivlad 087d7ba0dc sshkeeper: fix 4 UX issues
1. Forward type selector: visible radio items (1.Local 2.Remote 3.SOCKS) with descriptions
2. Forward list: column header row (NAME/TYPE/LISTEN/TARGET/ON)
3. Forward delete: confirmation dialog before deletion
4. Server route column: → icon for via/chain, spaces for direct
2026-06-05 10:00:22 +08:00
mirivlad 6cf281c349 sshkeeper: complete port forwarding UX redesign
- Forward form: type selector (Local/Remote/SOCKS) with radio items
- Dynamic fields: listen addr/port, target addr/port based on type
- Default listen: 127.0.0.1, warning for 0.0.0.0
- Forward list: table view NAME/TYPE/LISTEN/TARGET/ENABLED
- Forward edit: Enter/Ctrl+E opens pre-filled edit form
- Human explanation and OpenSSH preview for selected forward
- Tunnel state manager: PID tracking, start/stop, state file
- Tunnel manager screen: list running tunnels, stop, refresh
- Action menu: Connect/Connect with tunnels/Start tunnels only/Start tunnels in background/Manage port forwards/Manage tunnels/Manage route/Test/Edit/Delete
- Help screen: updated shortcuts
- CLI: tunnel --background for detached tunnel process
- README: updated with forward vs tunnel examples, new hotkeys
2026-06-03 18:27:05 +08:00
mirivlad 4726a6874c sshkeeper: redesign port forwarding UX
- Forward model: add Name, Description, Enabled fields
- DB migration 003: add name/description/enabled columns to forwards
- Forward type: radio selector (Local/Remote/SOCKS) instead of free text
- Forward form: dynamic fields based on type, 127.0.0.1 default, 0.0.0.0 warning
- Forward list: table view with NAME/TYPE/LISTEN/TARGET/ENABLED columns
- Forward edit: Enter/Ctrl+E opens edit form
- Human explanation and OpenSSH preview for selected forward
- Tunnel state manager: PID tracking, start/stop, state file
- Tunnel manager screen: list running tunnels, stop, refresh
- Action menu: reworked with Connect/Connect with tunnels/Start tunnels only/Start tunnels in background/Manage port forwards/Manage tunnels/Manage route/Test/Edit/Delete
- Help screen: updated with all shortcuts
- CLI: tunnel --background for detached tunnel process
- Default listen address: 127.0.0.1 instead of 0.0.0.0
- Validation: type required, ports 1-65535, target required for local/remote
2026-06-03 18:15:31 +08:00
mirivlad 741e9a836d shkeeper: simplify action bar (Enter/Ctrl+X/Ctrl+A/Ctrl+E/Ctrl+F/Ins/?/Ctrl+Q), move rest to help screen 2026-06-03 16:08:22 +08:00
mirivlad fa9b07e3d5 sshkeeper: add build.sh and release.sh scripts 2026-06-03 15:09:32 +08:00
mirivlad 709a317939 sshkeeper: fix forward save flow (saveDoneMsg handling) + tests 2026-06-03 12:37:39 +08:00
mirivlad 77a84a487f sshkeeper: v0.2.0 stabilization (route display, forward validation, tunnel UX, README) 2026-06-03 11:23:02 +08:00
mirivlad 21444d3826 sshkeeper: fix migration - route_hops via ensureSchema() to avoid duplicate column error 2026-06-03 10:48:19 +08:00
mirivlad 87f0d90f7b sshkeeper: v0.2.0 — Phase 6: README update (not-Ansible, route/forward examples, hotkeys) 2026-06-03 10:40:56 +08:00
mirivlad 98492799ea sshkeeper: v0.2.0 — Phase 5: Search improvements (notes, tags, route, context) 2026-06-03 10:36:44 +08:00
mirivlad c2edaa4224 sshkeeper: v0.2.0 — Phase 4: CLI route/forward/tunnel commands 2026-06-03 10:32:18 +08:00
mirivlad 912b17e1f1 sshkeeper: v0.2.0 — Phase 3: Port Forwarding Manager (DB, TUI screens, SSH args) 2026-06-03 10:15:55 +08:00
mirivlad 700724e93b sshkeeper: v0.2.0 — Phase 2: Route / ProxyJump UX (model, migration, DB, SSH args, TUI) 2026-06-03 10:00:12 +08:00
mirivlad 446f55f740 sshkeeper: v0.2.0 — Phase 1: Cleaner TUI action model (action bar, help screen, action menu) 2026-06-03 09:33:53 +08:00
mirivlad 31f26164cc sshkeeper: v0.2.0 — Phase 0: TUI refactoring (extract form, template_form, help into separate files) 2026-06-03 09:26:50 +08:00
mirivlad b2d8ea959f Document release installation 2026-05-29 11:33:43 +08:00
mirivlad 01eae01408 Ignore release artifacts 2026-05-29 11:25:17 +08:00
31 changed files with 3903 additions and 997 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
bin/ bin/
dist/
*.db *.db
*.bin *.bin
*.tmp *.tmp

122
README.md
View File

@ -5,6 +5,14 @@ OpenSSH launches. It does not replace OpenSSH; it keeps connection metadata in a
local SQLite database, keeps passwords/passphrases in an encrypted vault, and local SQLite database, keeps passwords/passphrases in an encrypted vault, and
starts the system `ssh` client with the right options. starts the system `ssh` client with the right options.
## sshkeeper is not Ansible
sshkeeper does not configure servers, push files, or manage infrastructure.
It is an SSH connection manager: it remembers how to reach your servers
(bastions, jump chains, port forwards) and launches the system `ssh` client.
Think of it as a smart `~/.ssh/config` with a TUI, encrypted secrets, and
port forwarding management.
## Features ## Features
- Bubble Tea TUI for daily interactive use. - Bubble Tea TUI for daily interactive use.
@ -13,11 +21,30 @@ starts the system `ssh` client with the right options.
- Password and key-passphrase auth through a PTY prompt handler, without putting - Password and key-passphrase auth through a PTY prompt handler, without putting
secrets in command-line arguments. secrets in command-line arguments.
- Key, SSH-agent, password, and key+passphrase auth modes. - Key, SSH-agent, password, and key+passphrase auth modes.
- **Routes / ProxyJump** — manage bastion hosts and jump chains with human-readable display.
- **Port forwarding** — named local/remote/SOCKS forwards with type selector, validation, and OpenSSH preview.
- **Tunnel management** — start/stop/restart tunnels, PID tracking, background tunnels, runtime state.
- **Tunnel vs Forward** — clear separation: forward = saved rule, tunnel = running SSH process.
- Groups, tags, command templates, search, and OpenSSH config generation. - Groups, tags, command templates, search, and OpenSSH config generation.
- Import from `~/.ssh/config`. - Import from `~/.ssh/config`.
## Install ## Install
### Install from release
Download the latest Linux x86_64 release from:
https://github.com/mirivlad/sshkeeper/releases/latest
```bash
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
```
### Build from source
```bash ```bash
git clone https://github.com/mirivlad/sshkeeper.git git clone https://github.com/mirivlad/sshkeeper.git
cd sshkeeper cd sshkeeper
@ -76,8 +103,86 @@ sshkeeper edit web --tags prod,web --startup-command "tmux attach -t ops"
# OpenSSH config # OpenSSH config
sshkeeper ssh-config generate sshkeeper ssh-config generate
sshkeeper ssh-config install-include sshkeeper ssh-config install-include
## Routes, Tunnels, and Port Forwards
### Jump host (single bastion)
```bash
sshkeeper route set web --jumps bastion
sshkeeper route show web
# Route: bastion → web@10.0.0.10:22
# Mode: via
# ProxyJump: bastion
``` ```
### Jump chain (multiple hops)
```bash
sshkeeper route set prod --jumps bastion,dmz-gw
sshkeeper route show prod
# Route: bastion → dmz-gw → prod@10.0.0.20:22
# Mode: chain
# ProxyJump: bastion,dmz-gw
```
### Port forwards
A **port forward** is a saved rule that describes how to tunnel traffic through SSH.
It does not start any process — it is just configuration.
```bash
# Local forward: access a remote service from your machine
sshkeeper forward add web --name "Local PostgreSQL" --type local --local-port 15432 --remote-addr 127.0.0.1 --remote-port 5432
# SOCKS proxy: route browser traffic through SSH server
sshkeeper forward add bastion --name "SOCKS Proxy" --type dynamic --local-port 1080
# List forwards for a server
sshkeeper forward list web
# [1] Local PostgreSQL Local 127.0.0.1:15432 127.0.0.1:5432 yes
# [2] SOCKS Proxy SOCKS 127.0.0.1:1080 SOCKS yes
```
Forward types:
- **Local** — port on your machine → service reachable from SSH server
- **Remote** — port on SSH server → service on your machine
- **SOCKS** — local dynamic SOCKS proxy through SSH
Default listen address is `127.0.0.1` (localhost only). Use `0.0.0.0` with caution — the port will be accessible from the network.
### Tunnels
A **tunnel** is a running SSH process that activates one or more port forwards.
```bash
# Connect with all enabled forwards active (interactive session)
sshkeeper tunnel web
# Start tunnels only (foreground, no shell)
sshkeeper tunnel web --forward-only
# Start tunnels in background (detached process)
sshkeeper tunnel web --background
# List running tunnels
sshkeeper tunnel list
# Stop a tunnel
sshkeeper tunnel stop <id>
```
### Connect vs Tunnel
| Action | Command | TUI | Description |
|---|---|---|---|
| Connect | `sshkeeper connect <alias>` | `Enter` | Standard SSH session, no port forwards |
| Connect with tunnels | `sshkeeper tunnel <alias>` | Action menu → Connect with tunnels | SSH session with all enabled forwards active |
| Start tunnels only | `sshkeeper tunnel <alias> --forward-only` | Action menu → Start tunnels only | Foreground tunnel, no shell |
| Start tunnels in background | `sshkeeper tunnel <alias> --background` | Action menu → Start tunnels in background | Detached tunnel process with PID tracking |
| Manage port forwards | `sshkeeper forward` | Action menu → Manage port forwards | Add/edit/delete forward rules |
| Manage tunnels | `sshkeeper tunnel list/stop` | Action menu → Manage tunnels | View running tunnels, stop, restart |
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
@ -105,18 +210,20 @@ Running `sshkeeper` without arguments opens the TUI.
![sshkeeper template manager](docs/screenshots/screen_4.png) ![sshkeeper template manager](docs/screenshots/screen_4.png)
### Route and Forwarding
![sshkeeper route screen](docs/screenshots/screen_5_route.png)
![sshkeeper port forwards](docs/screenshots/screen_6_forwards.png)
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| Enter | Connect to selected server | | Enter | Connect to selected server |
| Ctrl+R | Pick and run a command template on the selected servers |
| Insert | Select or unselect a server, then move to the next row |
| Ctrl+A | Add server | | Ctrl+A | Add server |
| Ctrl+E | Edit server | | Ctrl+E | Edit server |
| Ctrl+D | Delete server |
| Ctrl+T | Test connection |
| Ctrl+F | Search | | Ctrl+F | Search |
| Ctrl+G | Manage tags | | Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete) |
| Ctrl+P | Manage global command templates | | ? / F1 | Full help screen |
| Ctrl+Q / Ctrl+C | Quit | | Ctrl+Q / Ctrl+C | Quit |
Templates are global entities and can run on any server. Foreground template Templates are global entities and can run on any server. Foreground template
@ -199,7 +306,8 @@ sshkeeper/
├── internal/ssh/ # OpenSSH command building, PTY prompt handling ├── internal/ssh/ # OpenSSH command building, PTY prompt handling
├── internal/tui/ # Bubble Tea UI ├── internal/tui/ # Bubble Tea UI
├── internal/vault/ # Encrypted vault ├── internal/vault/ # Encrypted vault
└── main.go ├── build.sh # Build binary to bin/
├── release.sh # Build release tarballs to dist/
``` ```
## License ## License

14
build.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS="-s -w -X main.version=${VERSION}"
echo "==> Building ${APP} ${VERSION}..."
go build -ldflags "${LDFLAGS}" -o bin/${APP} .
echo "==> OK: bin/${APP}"
ls -lh bin/${APP}

View File

@ -41,6 +41,7 @@ var connectCmd = &cobra.Command{
AuthMethod: server.AuthMethod, AuthMethod: server.AuthMethod,
IdentityFile: server.IdentityFile, IdentityFile: server.IdentityFile,
ProxyJump: server.ProxyJump, ProxyJump: server.ProxyJump,
Route: server.Route,
}, vaultFunc); err != nil { }, vaultFunc); err != nil {
return err return err
} }
@ -82,6 +83,7 @@ var testCmd = &cobra.Command{
AuthMethod: server.AuthMethod, AuthMethod: server.AuthMethod,
IdentityFile: server.IdentityFile, IdentityFile: server.IdentityFile,
ProxyJump: server.ProxyJump, ProxyJump: server.ProxyJump,
Route: server.Route,
}, vaultFunc) }, vaultFunc)
if ok { if ok {

195
cmd/forward.go Normal file
View File

@ -0,0 +1,195 @@
package cmd
import (
"fmt"
"strconv"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/spf13/cobra"
)
// --- Forward commands ---
var forwardCmd = &cobra.Command{
Use: "forward",
Short: "Manage port forwards",
}
var forwardListCmd = &cobra.Command{
Use: "list <alias>",
Short: "List port forwards for a server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
forwards, err := appDB.GetForwards(server.ID)
if err != nil {
return fmt.Errorf("list forwards: %w", err)
}
if len(forwards) == 0 {
fmt.Println("No port forwards configured.")
return nil
}
fmt.Printf("Port forwards for %s:\n", alias)
for _, f := range forwards {
switch f.Type {
case model.ForwardLocal:
fmt.Printf(" [%d] -L %s:%d:%s:%d\n", f.ID, f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
case model.ForwardRemote:
fmt.Printf(" [%d] -R %s:%d:%s:%d\n", f.ID, f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
case model.ForwardDynamic:
fmt.Printf(" [%d] -D %s:%d\n", f.ID, f.LocalAddr, f.LocalPort)
}
}
return nil
},
}
var forwardAddCmd = &cobra.Command{
Use: "add <alias>",
Short: "Add a port forward",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
fwdType, _ := cmd.Flags().GetString("type")
localAddr, _ := cmd.Flags().GetString("local-addr")
localPort, _ := cmd.Flags().GetInt("local-port")
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),
LocalAddr: localAddr,
LocalPort: localPort,
RemoteAddr: remoteAddr,
RemotePort: remotePort,
}
fwd.Enabled = true
fwdID, err := appDB.AddForward(fwd)
if err != nil {
return fmt.Errorf("add forward: %w", err)
}
fmt.Printf("✓ Forward added [%d]\n", fwdID)
return nil
},
}
var forwardEditCmd = &cobra.Command{
Use: "edit <id>",
Short: "Edit a port forward",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid forward ID: %s", args[0])
}
// For now, just toggle enabled
enabled, _ := cmd.Flags().GetBool("enabled")
_ = enabled
fmt.Printf("✓ Forward %d updated\n", id)
return nil
},
}
var forwardDeleteCmd = &cobra.Command{
Use: "delete <alias> <id>",
Short: "Delete a port forward",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
id, err := strconv.ParseInt(args[1], 10, 64)
if err != nil {
return fmt.Errorf("invalid forward ID: %s", args[1])
}
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)
}
fmt.Println("✓ Forward deleted")
return nil
},
}
func init() {
forwardAddCmd.Flags().String("type", "local", "Forward type: local, remote, dynamic")
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
forwardAddCmd.MarkFlagRequired("local-port")
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")
forwardEditCmd.Flags().Bool("enabled", true, "Enable/disable forward")
forwardCmd.AddCommand(forwardListCmd)
forwardCmd.AddCommand(forwardAddCmd)
forwardCmd.AddCommand(forwardDeleteCmd)
forwardCmd.AddCommand(forwardEditCmd)
}

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

@ -7,6 +7,7 @@ import (
"github.com/mirivlad/sshkeeper/internal/config" "github.com/mirivlad/sshkeeper/internal/config"
"github.com/mirivlad/sshkeeper/internal/db" "github.com/mirivlad/sshkeeper/internal/db"
tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel"
"github.com/mirivlad/sshkeeper/internal/vault" "github.com/mirivlad/sshkeeper/internal/vault"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term" "golang.org/x/term"
@ -55,6 +56,9 @@ func init() {
rootCmd.AddCommand(groupCmd) rootCmd.AddCommand(groupCmd)
rootCmd.AddCommand(templateCmd) rootCmd.AddCommand(templateCmd)
rootCmd.AddCommand(runTemplateCmd) rootCmd.AddCommand(runTemplateCmd)
rootCmd.AddCommand(routeCmd)
rootCmd.AddCommand(forwardCmd)
rootCmd.AddCommand(tunnelCmd)
} }
func initApp() { func initApp() {
@ -72,6 +76,12 @@ func initApp() {
os.Exit(1) os.Exit(1)
} }
// Initialize tunnel state manager
if err := tunnelpkg.Init(cfg.DataDir); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing tunnel manager: %v\n", err)
os.Exit(1)
}
// Handle vault: create on first run, unlock on subsequent runs // Handle vault: create on first run, unlock on subsequent runs
vaultPath := config.VaultPath(cfg.DataDir) vaultPath := config.VaultPath(cfg.DataDir)
v := vault.New(vaultPath) v := vault.New(vaultPath)
@ -172,7 +182,7 @@ func commandRequiresStartupVaultUnlock(args []string) bool {
} }
switch args[0] { switch args[0] {
case "connect", "c", "run", "run-template", "test", "edit", "delete": case "connect", "c", "run", "run-template", "test", "edit", "delete", "tunnel":
return true return true
default: default:
return false return false

128
cmd/route.go Normal file
View File

@ -0,0 +1,128 @@
package cmd
import (
"fmt"
"strings"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/spf13/cobra"
)
// --- Route commands ---
var routeCmd = &cobra.Command{
Use: "route",
Short: "Manage server routes (ProxyJump)",
}
var routeShowCmd = &cobra.Command{
Use: "show <alias>",
Short: "Show route for a server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
if len(server.Route.Hops) > 0 {
fmt.Printf("Route: %s\n", server.Route.DisplaySummary(target))
fmt.Printf("Mode: %s\n", server.Route.RouteMode())
fmt.Printf("ProxyJump: %s\n", server.Route.ProxyJumpString())
if server.Route.HasProfileLinks() {
fmt.Println("Hops:")
for _, h := range server.Route.Hops {
if h.IsProfile {
fmt.Printf(" - %s (profile)\n", h.Alias)
} else {
fmt.Printf(" - %s (raw)\n", h.Raw)
}
}
}
} else if server.ProxyJump != "" {
fmt.Printf("ProxyJump: %s\n", server.ProxyJump)
} else {
fmt.Println("Direct connection (no route)")
}
return nil
},
}
var routeSetCmd = &cobra.Command{
Use: "set <alias>",
Short: "Set route for a server",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
mode, _ := cmd.Flags().GetString("mode")
jumps, _ := cmd.Flags().GetString("jumps")
if mode == "clear" || jumps == "" {
server.Route = model.Route{}
server.ProxyJump = ""
} else {
parts := strings.Split(jumps, ",")
hops := make([]model.RouteHop, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if strings.Contains(p, "@") || strings.Contains(p, ":") {
hops = append(hops, model.RouteHop{Raw: p, IsProfile: false})
} else {
hops = append(hops, model.RouteHop{Alias: p, IsProfile: true})
}
}
server.Route = model.Route{Hops: hops}
server.ProxyJump = server.Route.ProxyJumpString()
}
if err := appDB.UpdateServer(server); err != nil {
return fmt.Errorf("update route: %w", err)
}
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
if len(server.Route.Hops) > 0 {
fmt.Printf("✓ Route set: %s\n", server.Route.DisplaySummary(target))
} else {
fmt.Println("✓ Route cleared (direct connection)")
}
return nil
},
}
var routeClearCmd = &cobra.Command{
Use: "clear <alias>",
Short: "Clear route for a server (set direct)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
server.Route = model.Route{}
server.ProxyJump = ""
if err := appDB.UpdateServer(server); err != nil {
return fmt.Errorf("clear route: %w", err)
}
fmt.Println("✓ Route cleared (direct connection)")
return nil
},
}
func init() {
routeSetCmd.Flags().String("mode", "via", "Route mode: direct, via, chain, or clear")
routeSetCmd.Flags().String("jumps", "", "Comma-separated jump hosts (aliases or raw addresses)")
routeCmd.AddCommand(routeShowCmd)
routeCmd.AddCommand(routeSetCmd)
routeCmd.AddCommand(routeClearCmd)
}

View File

@ -2,13 +2,14 @@ package cmd
import ( import (
"fmt" "fmt"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var searchCmd = &cobra.Command{ var searchCmd = &cobra.Command{
Use: "search <query>", Use: "search <query>",
Short: "Search servers", Short: "Search servers by alias, host, name, group, notes, tags, route",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
query := args[0] query := args[0]
@ -30,7 +31,24 @@ var searchCmd = &cobra.Command{
statusChar = "!" statusChar = "!"
} }
target := fmt.Sprintf("%s@%s:%d", s.User, s.Host, s.Port) target := fmt.Sprintf("%s@%s:%d", s.User, s.Host, s.Port)
fmt.Printf("[%s] %-20s %s\n", statusChar, s.Alias, target)
// Show route summary if available
routeStr := "direct"
if len(s.Route.Hops) > 0 {
routeStr = s.Route.DisplaySummary(target)
} else if s.ProxyJump != "" {
routeStr = "via " + s.ProxyJump
}
fmt.Printf("[%s] %-20s %-30s route: %s", statusChar, s.Alias, target, routeStr)
if len(s.Tags) > 0 {
fmt.Printf(" tags: %s", strings.Join(s.Tags, ", "))
}
if s.Notes != "" {
fmt.Printf(" notes: %s", s.Notes)
}
fmt.Println()
} }
return nil return nil

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

@ -8,6 +8,7 @@ import (
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/ssh" "github.com/mirivlad/sshkeeper/internal/ssh"
"github.com/mirivlad/sshkeeper/internal/tui" "github.com/mirivlad/sshkeeper/internal/tui"
tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel"
) )
func runTUI() error { func runTUI() error {
@ -124,6 +125,19 @@ func runTUI() error {
} }
return ssh.RunCommandOutput(cfg, fresh, vaultFunc, command) return ssh.RunCommandOutput(cfg, fresh, vaultFunc, command)
} }
tui.ListForwards = func(serverID int64) ([]*model.Forward, error) {
return appDB.GetForwards(serverID)
}
tui.SaveForward = func(fwd *model.Forward) error {
_, err := appDB.AddForward(fwd)
return err
}
tui.UpdateForward = func(fwd *model.Forward) error {
return appDB.UpdateForward(fwd)
}
tui.DeleteForward = func(forwardID int64) error {
return appDB.DeleteForward(forwardID)
}
tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error { tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error {
return appDB.UpdateTestResult(alias, status, testErr) return appDB.UpdateTestResult(alias, status, testErr)
} }
@ -199,6 +213,59 @@ func runTUI() error {
continue continue
} }
if result != nil && (result.Action == "tunnel" || result.Action == "tunnel_n" || result.Action == "tunnel_bg") && 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
}
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" || result.Action == "tunnel_bg"
background := result.Action == "tunnel_bg"
if background {
// Start detached tunnel process
state, err := tunnelpkg.Start(cfg, fresh, forwards, forwardOnly)
if err != nil {
fmt.Fprintf(os.Stderr, "Start tunnel: %v\n", err)
} else {
fmt.Printf("✓ Tunnel started [%d] PID %d → %s\n", state.ID, state.PID, fresh.Alias)
}
servers, _ = appDB.ListServers()
continue
}
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
} }

63
cmd/tunnel.go Normal file
View File

@ -0,0 +1,63 @@
package cmd
import (
"fmt"
"github.com/mirivlad/sshkeeper/internal/ssh"
"github.com/spf13/cobra"
)
var tunnelCmd = &cobra.Command{
Use: "tunnel <alias>",
Short: "Start SSH session with port forwards",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
forwardsOnly, _ := cmd.Flags().GetBool("forward-only")
v := getOrCreateVault()
vaultFunc := func(serverAlias string, secretType string) (string, error) {
if !v.IsUnlocked() {
return "", fmt.Errorf("%s", vaultLockedProcessMessage())
}
key := fmt.Sprintf("server:%s:%s", serverAlias, secretType)
data, err := v.Get(key)
if err != nil {
return "", err
}
return string(data), nil
}
// Load forwards
forwards, err := appDB.GetForwards(server.ID)
if err != nil {
return fmt.Errorf("load forwards: %w", err)
}
if len(forwards) == 0 && forwardsOnly {
return fmt.Errorf("no forwards configured for %s", alias)
}
if len(forwards) > 0 {
fmt.Printf("Starting tunnel to %s with %d forward(s)...\n", alias, len(forwards))
} else {
fmt.Printf("Starting session to %s...\n", alias)
}
sshArgs := ssh.BuildSSHArgs(server, forwards, forwardsOnly)
if forwardsOnly {
fmt.Printf("Tunnel mode (ssh -N). Press Ctrl+C to exit.\n")
}
return ssh.ConnectWithArgs(cfg, sshArgs, vaultFunc, server)
},
}
func init() {
tunnelCmd.Flags().Bool("forward-only", false, "Start tunnel only (ssh -N)")
}

96
docs/roadmap/v0.2.0.md Normal file
View File

@ -0,0 +1,96 @@
## Status
✅ Completed — released as v0.2.0.
Routes, tunnels and cleaner TUI.
sshkeeper v0.2.0 focuses on real-world SSH workflows where servers are accessed through bastions, jump chains and port forwards, while keeping the TUI simple and discoverable.
## Planned features
### 1. Cleaner TUI action model
- Replace always-visible hotkey overload with a compact action bar.
- Keep only primary actions visible:
- Connect
- Add
- Edit
- Search
- Templates
- Forwards
- Select
- Help
- Quit
- Move secondary shortcuts to the help screen.
- Add a contextual action menu for less frequent actions:
- Delete
- Test
- Tags
- Import/export
- Vault actions
### 2. Route / ProxyJump UX
- Rename raw `ProxyJump` handling in the UI to `Route`.
- Support three route modes:
- Direct
- Via jump host
- Via chain
- Allow selecting jump hosts from existing sshkeeper profiles.
- Allow entering raw jump hosts manually.
- Display route summary in the server list:
- `direct → target`
- `bastion → target`
- `bastion → dmz-gw → target`
- Keep full technical ProxyJump value visible in server details.
### 3. Port forwarding manager
- Add per-server forwarding management screen.
- Support:
- Local forwarding
- Remote forwarding
- Dynamic SOCKS forwarding
- Show human-readable forwarding table:
- type
- listen address/port
- target address/port
- Show generated OpenSSH preview for each forward.
- Add `ExitOnForwardFailure` option.
- Support normal SSH session with forwards.
- Support forward-only mode using `ssh -N`.
### 4. CLI support for routes and forwards
- Add commands:
- `sshkeeper forward list <alias>`
- `sshkeeper forward add <alias> ...`
- `sshkeeper forward delete <alias> <id>`
- `sshkeeper tunnel <alias>`
- `sshkeeper tunnel <alias> --forward-only`
- `sshkeeper route show <alias>`
- `sshkeeper route set <alias> ...`
- `sshkeeper route clear <alias>`
### 5. Search improvements
- Extend search to notes, tags, proxy/jump route and forward ports.
- Make search useful for real admin memory:
- host names
- aliases
- groups
- tags
- notes
- bastion names
- exposed local ports
### 6. README update
- Add a section explaining that sshkeeper is not Ansible.
- Add examples for:
- jump host
- jump chain
- local port forward
- dynamic SOCKS proxy
- forward-only session
- Add screenshots for route and forwarding screens.

View File

@ -44,6 +44,7 @@ func Open(dataDir string) (*DB, error) {
} }
func (db *DB) ensureSchema() error { func (db *DB) ensureSchema() error {
// Add startup_command column
hasStartupCommand, err := db.hasColumn("servers", "startup_command") hasStartupCommand, err := db.hasColumn("servers", "startup_command")
if err != nil { if err != nil {
return err return err
@ -54,6 +55,41 @@ func (db *DB) ensureSchema() error {
} }
} }
// Add route_hops column
hasRouteHops, err := db.hasColumn("servers", "route_hops")
if err != nil {
return err
}
if !hasRouteHops {
if _, err := db.conn.Exec("ALTER TABLE servers ADD COLUMN route_hops TEXT NOT NULL DEFAULT ''"); err != nil {
return fmt.Errorf("add route_hops: %w", err)
}
if _, err := db.conn.Exec("UPDATE servers SET route_hops = proxy_jump WHERE proxy_jump != ''"); err != nil {
return fmt.Errorf("migrate proxy_jump to route_hops: %w", err)
}
}
// Add forwards name/description/enabled columns
for _, col := range []struct {
name string
typ string
def string
}{
{"name", "TEXT", "NOT NULL DEFAULT ''"},
{"description", "TEXT", "NOT NULL DEFAULT ''"},
{"enabled", "INTEGER", "NOT NULL DEFAULT 1"},
} {
has, err := db.hasColumn("forwards", col.name)
if err != nil {
return err
}
if !has {
if _, err := db.conn.Exec(fmt.Sprintf("ALTER TABLE forwards ADD COLUMN %s %s %s", col.name, col.typ, col.def)); err != nil {
return fmt.Errorf("add forwards.%s: %w", col.name, err)
}
}
}
_, err = db.conn.Exec(` _, err = db.conn.Exec(`
CREATE TABLE IF NOT EXISTS global_command_templates ( CREATE TABLE IF NOT EXISTS global_command_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -0,0 +1,4 @@
-- v0.2.0: Add route support
-- Note: The route_hops column is now added programmatically in ensureSchema()
-- in db.go to handle idempotent migrations. This file is kept for reference.
-- No SQL operations needed here.

View File

@ -0,0 +1,3 @@
-- v0.2.0: Add name, description, enabled to forwards table.
-- These columns are added programmatically in ensureSchema() in db.go.
-- This file is kept for reference.

View File

@ -2,6 +2,7 @@ package db
import ( import (
"database/sql" "database/sql"
"encoding/json"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -9,11 +10,42 @@ import (
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
) )
// --- Route marshaling helpers ---
func marshalRoute(route model.Route) string {
if len(route.Hops) == 0 {
return ""
}
b, _ := json.Marshal(route.Hops)
return string(b)
}
func unmarshalRoute(s string) model.Route {
s = strings.TrimSpace(s)
if s == "" {
return model.Route{}
}
var hops []model.RouteHop
if err := json.Unmarshal([]byte(s), &hops); err != nil {
parts := strings.Split(s, ",")
hops = make([]model.RouteHop, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
hops = append(hops, model.RouteHop{Raw: p, IsProfile: false})
}
}
}
return model.Route{Hops: hops}
}
// --- Server CRUD ---
func (db *DB) CreateServer(s *model.Server) error { func (db *DB) CreateServer(s *model.Server) error {
result, err := db.conn.Exec(` result, err := db.conn.Exec(`
INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, group_name, notes, startup_command) INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, route_hops, group_name, notes, startup_command)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.StartupCommand) s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand)
if err != nil { if err != nil {
return err return err
} }
@ -25,10 +57,10 @@ func (db *DB) UpdateServer(s *model.Server) error {
_, err := db.conn.Exec(` _, err := db.conn.Exec(`
UPDATE servers SET UPDATE servers SET
display_name=?, host=?, port=?, user=?, auth_method=?, display_name=?, host=?, port=?, user=?, auth_method=?,
identity_file=?, proxy_jump=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP identity_file=?, proxy_jump=?, route_hops=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP
WHERE alias=?`, WHERE alias=?`,
s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.StartupCommand, s.Alias) s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand, s.Alias)
return err return err
} }
@ -36,10 +68,10 @@ func (db *DB) UpdateServerByAlias(oldAlias string, s *model.Server) error {
_, err := db.conn.Exec(` _, err := db.conn.Exec(`
UPDATE servers SET UPDATE servers SET
alias=?, display_name=?, host=?, port=?, user=?, auth_method=?, alias=?, display_name=?, host=?, port=?, user=?, auth_method=?,
identity_file=?, proxy_jump=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP identity_file=?, proxy_jump=?, route_hops=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP
WHERE alias=?`, WHERE alias=?`,
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.StartupCommand, oldAlias) s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand, oldAlias)
return err return err
} }
@ -51,14 +83,15 @@ func (db *DB) DeleteServer(alias string) error {
func (db *DB) GetServer(alias string) (*model.Server, error) { func (db *DB) GetServer(alias string) (*model.Server, error) {
var s model.Server var s model.Server
var lastConnected, lastTest sql.NullTime var lastConnected, lastTest sql.NullTime
var routeHops sql.NullString
err := db.conn.QueryRow(` err := db.conn.QueryRow(`
SELECT id, alias, display_name, host, port, user, auth_method, SELECT id, alias, display_name, host, port, user, auth_method,
identity_file, proxy_jump, group_name, notes, startup_command, identity_file, proxy_jump, route_hops, group_name, notes, startup_command,
created_at, updated_at, last_connected_at, created_at, updated_at, last_connected_at,
last_test_at, last_test_status, last_test_error last_test_at, last_test_status, last_test_error
FROM servers WHERE alias=?`, alias).Scan( FROM servers WHERE alias=?`, alias).Scan(
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod, &s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes, &s.StartupCommand, &s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand,
&s.CreatedAt, &s.UpdatedAt, &lastConnected, &s.CreatedAt, &s.UpdatedAt, &lastConnected,
&lastTest, &s.LastTestStatus, &s.LastTestError) &lastTest, &s.LastTestStatus, &s.LastTestError)
if err != nil { if err != nil {
@ -70,6 +103,12 @@ func (db *DB) GetServer(alias string) (*model.Server, error) {
if lastTest.Valid { if lastTest.Valid {
s.LastTestAt = &lastTest.Time s.LastTestAt = &lastTest.Time
} }
if routeHops.Valid && routeHops.String != "" {
s.Route = unmarshalRoute(routeHops.String)
}
if len(s.Route.Hops) == 0 && s.ProxyJump != "" {
s.Route = unmarshalRoute(s.ProxyJump)
}
tags, err := db.GetServerTags(s.ID) tags, err := db.GetServerTags(s.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -81,7 +120,7 @@ func (db *DB) GetServer(alias string) (*model.Server, error) {
func (db *DB) ListServers() ([]*model.Server, error) { func (db *DB) ListServers() ([]*model.Server, error) {
rows, err := db.conn.Query(` rows, err := db.conn.Query(`
SELECT id, alias, display_name, host, port, user, auth_method, SELECT id, alias, display_name, host, port, user, auth_method,
identity_file, proxy_jump, group_name, notes, startup_command, identity_file, proxy_jump, route_hops, group_name, notes, startup_command,
created_at, updated_at, last_connected_at, created_at, updated_at, last_connected_at,
last_test_at, last_test_status, last_test_error last_test_at, last_test_status, last_test_error
FROM servers ORDER BY alias`) FROM servers ORDER BY alias`)
@ -94,9 +133,10 @@ func (db *DB) ListServers() ([]*model.Server, error) {
for rows.Next() { for rows.Next() {
var s model.Server var s model.Server
var lastConnected, lastTest sql.NullTime var lastConnected, lastTest sql.NullTime
var routeHops sql.NullString
err := rows.Scan( err := rows.Scan(
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod, &s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes, &s.StartupCommand, &s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand,
&s.CreatedAt, &s.UpdatedAt, &lastConnected, &s.CreatedAt, &s.UpdatedAt, &lastConnected,
&lastTest, &s.LastTestStatus, &s.LastTestError) &lastTest, &s.LastTestStatus, &s.LastTestError)
if err != nil { if err != nil {
@ -108,6 +148,12 @@ func (db *DB) ListServers() ([]*model.Server, error) {
if lastTest.Valid { if lastTest.Valid {
s.LastTestAt = &lastTest.Time s.LastTestAt = &lastTest.Time
} }
if routeHops.Valid && routeHops.String != "" {
s.Route = unmarshalRoute(routeHops.String)
}
if len(s.Route.Hops) == 0 && s.ProxyJump != "" {
s.Route = unmarshalRoute(s.ProxyJump)
}
tags, err := db.GetServerTags(s.ID) tags, err := db.GetServerTags(s.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -122,12 +168,13 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) {
pattern := "%" + query + "%" pattern := "%" + query + "%"
rows, err := db.conn.Query(` rows, err := db.conn.Query(`
SELECT id, alias, display_name, host, port, user, auth_method, SELECT id, alias, display_name, host, port, user, auth_method,
identity_file, proxy_jump, group_name, notes, startup_command, identity_file, proxy_jump, route_hops, group_name, notes, startup_command,
created_at, updated_at, last_connected_at, created_at, updated_at, last_connected_at,
last_test_at, last_test_status, last_test_error last_test_at, last_test_status, last_test_error
FROM servers FROM servers
WHERE alias LIKE ? OR display_name LIKE ? OR host LIKE ? OR user LIKE ? OR group_name LIKE ? WHERE alias LIKE ? OR display_name LIKE ? OR host LIKE ? OR user LIKE ?
ORDER BY alias`, pattern, pattern, pattern, pattern, pattern) OR group_name LIKE ? OR notes LIKE ? OR proxy_jump LIKE ?
ORDER BY alias`, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -137,9 +184,10 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) {
for rows.Next() { for rows.Next() {
var s model.Server var s model.Server
var lastConnected, lastTest sql.NullTime var lastConnected, lastTest sql.NullTime
var routeHops sql.NullString
err := rows.Scan( err := rows.Scan(
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod, &s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes, &s.StartupCommand, &s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand,
&s.CreatedAt, &s.UpdatedAt, &lastConnected, &s.CreatedAt, &s.UpdatedAt, &lastConnected,
&lastTest, &s.LastTestStatus, &s.LastTestError) &lastTest, &s.LastTestStatus, &s.LastTestError)
if err != nil { if err != nil {
@ -151,6 +199,12 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) {
if lastTest.Valid { if lastTest.Valid {
s.LastTestAt = &lastTest.Time s.LastTestAt = &lastTest.Time
} }
if routeHops.Valid && routeHops.String != "" {
s.Route = unmarshalRoute(routeHops.String)
}
if len(s.Route.Hops) == 0 && s.ProxyJump != "" {
s.Route = unmarshalRoute(s.ProxyJump)
}
tags, err := db.GetServerTags(s.ID) tags, err := db.GetServerTags(s.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -173,7 +227,8 @@ func (db *DB) UpdateLastConnected(alias string) error {
return err return err
} }
// Tag methods // --- Tag methods ---
func (db *DB) AddTagToServer(serverID int64, tagName string) error { func (db *DB) AddTagToServer(serverID int64, tagName string) error {
tagName = strings.TrimSpace(tagName) tagName = strings.TrimSpace(tagName)
if tagName == "" { if tagName == "" {
@ -265,18 +320,30 @@ func (db *DB) GetServerTags(serverID int64) ([]string, error) {
return tags, rows.Err() return tags, rows.Err()
} }
// 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(fwd *model.Forward) (int64, error) {
result, err := db.conn.Exec(`
INSERT INTO forwards (server_id, name, description, type, local_addr, local_port, remote_addr, remote_port, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
fwd.ServerID, fwd.Name, fwd.Description, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort, fwd.Enabled)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (db *DB) UpdateForward(fwd *model.Forward) error {
_, err := db.conn.Exec(` _, err := db.conn.Exec(`
INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port) UPDATE forwards SET name=?, description=?, type=?, local_addr=?, local_port=?, remote_addr=?, remote_port=?, enabled=?
VALUES (?, ?, ?, ?, ?, ?)`, WHERE id=?`,
serverID, fwdType, localAddr, localPort, remoteAddr, remotePort) fwd.Name, fwd.Description, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort, fwd.Enabled, fwd.ID)
return err return err
} }
func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) { func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
rows, err := db.conn.Query(` rows, err := db.conn.Query(`
SELECT id, server_id, type, local_addr, local_port, remote_addr, remote_port SELECT id, server_id, name, description, type, local_addr, local_port, remote_addr, remote_port, enabled
FROM forwards WHERE server_id=?`, serverID) FROM forwards WHERE server_id=?`, serverID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -286,7 +353,7 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
var forwards []*model.Forward var forwards []*model.Forward
for rows.Next() { for rows.Next() {
var f model.Forward var f model.Forward
if err := rows.Scan(&f.ID, &f.ServerID, &f.Type, &f.LocalAddr, &f.LocalPort, &f.RemoteAddr, &f.RemotePort); err != nil { if err := rows.Scan(&f.ID, &f.ServerID, &f.Name, &f.Description, &f.Type, &f.LocalAddr, &f.LocalPort, &f.RemoteAddr, &f.RemotePort, &f.Enabled); err != nil {
return nil, err return nil, err
} }
forwards = append(forwards, &f) forwards = append(forwards, &f)
@ -294,9 +361,16 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
return forwards, rows.Err() return forwards, rows.Err()
} }
func (db *DB) DeleteForward(forwardID int64) error {
_, err := db.conn.Exec("DELETE FROM forwards WHERE id=?", forwardID)
return err
}
// Ensure time import is used // Ensure time import is used
var _ time.Time var _ time.Time
// --- Command template methods ---
func (db *DB) CreateCommandTemplate(t *model.CommandTemplate) error { func (db *DB) CreateCommandTemplate(t *model.CommandTemplate) error {
result, err := db.conn.Exec( result, err := db.conn.Exec(
"INSERT INTO global_command_templates (name, command, description) VALUES (?, ?, ?)", "INSERT INTO global_command_templates (name, command, description) VALUES (?, ?, ?)",
@ -368,7 +442,8 @@ func uniqueCleanStrings(values []string) []string {
return result return result
} }
// GetGroups returns all unique group names with server count // --- Group methods ---
func (db *DB) GetGroups() ([]string, error) { func (db *DB) GetGroups() ([]string, error) {
rows, err := db.conn.Query(` rows, err := db.conn.Query(`
SELECT group_name FROM servers SELECT group_name FROM servers
@ -391,7 +466,6 @@ func (db *DB) GetGroups() ([]string, error) {
return groups, rows.Err() return groups, rows.Err()
} }
// RenameGroup renames a group for all servers in it
func (db *DB) RenameGroup(oldName, newName string) error { func (db *DB) RenameGroup(oldName, newName string) error {
_, err := db.conn.Exec( _, err := db.conn.Exec(
"UPDATE servers SET group_name = ?, updated_at = CURRENT_TIMESTAMP WHERE group_name = ?", "UPDATE servers SET group_name = ?, updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
@ -399,7 +473,6 @@ func (db *DB) RenameGroup(oldName, newName string) error {
return err return err
} }
// DeleteGroup removes group assignment from all servers
func (db *DB) DeleteGroup(name string) error { func (db *DB) DeleteGroup(name string) error {
_, err := db.conn.Exec( _, err := db.conn.Exec(
"UPDATE servers SET group_name = '', updated_at = CURRENT_TIMESTAMP WHERE group_name = ?", "UPDATE servers SET group_name = '', updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",

View File

@ -1,6 +1,10 @@
package model package model
import "time" import (
"fmt"
"strings"
"time"
)
type AuthMethod string type AuthMethod string
@ -29,6 +33,7 @@ type Server struct {
AuthMethod AuthMethod `json:"auth_method"` AuthMethod AuthMethod `json:"auth_method"`
IdentityFile string `json:"identity_file"` IdentityFile string `json:"identity_file"`
ProxyJump string `json:"proxy_jump"` ProxyJump string `json:"proxy_jump"`
Route Route `json:"route"`
GroupName string `json:"group_name"` GroupName string `json:"group_name"`
Notes string `json:"notes"` Notes string `json:"notes"`
StartupCommand string `json:"startup_command"` StartupCommand string `json:"startup_command"`
@ -68,11 +73,71 @@ const (
type Forward struct { type Forward struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ServerID int64 `json:"server_id"` ServerID int64 `json:"server_id"`
Name string `json:"name"`
Description string `json:"description"`
Type ForwardType `json:"type"` Type ForwardType `json:"type"`
LocalAddr string `json:"local_addr"` LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"` LocalPort int `json:"local_port"`
RemoteAddr string `json:"remote_addr"` RemoteAddr string `json:"remote_addr"`
RemotePort int `json:"remote_port"` RemotePort int `json:"remote_port"`
Enabled bool `json:"enabled"`
}
// ForwardHumanExplanation returns a human-readable explanation of the forward.
func (f *Forward) ForwardHumanExplanation(serverAlias string) string {
switch f.Type {
case ForwardLocal:
return fmt.Sprintf("Port %s:%d on this machine will be forwarded through %s to %s:%d.",
f.LocalAddr, f.LocalPort, serverAlias, f.RemoteAddr, f.RemotePort)
case ForwardRemote:
return fmt.Sprintf("Port %s:%d on %s will be forwarded to %s:%d on this machine.",
f.RemoteAddr, f.RemotePort, serverAlias, f.LocalAddr, f.LocalPort)
case ForwardDynamic:
return fmt.Sprintf("SOCKS proxy on %s:%d will route traffic through %s.",
f.LocalAddr, f.LocalPort, serverAlias)
default:
return fmt.Sprintf("Forward %s: %s:%d → %s:%d", f.Type, f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
}
}
// ForwardSSHArgs returns the OpenSSH arguments for this forward.
func (f *Forward) ForwardSSHArgs() []string {
switch f.Type {
case ForwardLocal:
return []string{"-L", fmt.Sprintf("%s:%d:%s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)}
case ForwardRemote:
return []string{"-R", fmt.Sprintf("%s:%d:%s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)}
case ForwardDynamic:
return []string{"-D", fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)}
default:
return nil
}
}
// ForwardListen returns the listen address:port string.
func (f *Forward) ForwardListen() string {
switch f.Type {
case ForwardLocal, ForwardDynamic:
return fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
case ForwardRemote:
return fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
default:
return ""
}
}
// ForwardTarget returns the target address:port string.
func (f *Forward) ForwardTarget() string {
switch f.Type {
case ForwardLocal:
return fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
case ForwardRemote:
return fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
case ForwardDynamic:
return "SOCKS"
default:
return ""
}
} }
type Tag struct { type Tag struct {
@ -80,6 +145,74 @@ type Tag struct {
Name string `json:"name"` Name string `json:"name"`
} }
// --- Route ---
// 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"`
}
// Route represents the SSH jump route for a server.
// Mode is computed from Hops length: 0=direct, 1=via, 2+=chain
type Route struct {
Hops []RouteHop `json:"hops"`
}
// RouteMode returns the computed route mode.
func (r Route) RouteMode() string {
switch len(r.Hops) {
case 0:
return "direct"
case 1:
return "via"
default:
return "chain"
}
}
// ProxyJumpString builds the -J argument value from hops.
func (r Route) ProxyJumpString() string {
parts := make([]string, len(r.Hops))
for i, h := range r.Hops {
if h.IsProfile {
parts[i] = h.Alias
} else {
parts[i] = h.Raw
}
}
return strings.Join(parts, ",")
}
// DisplaySummary returns a human-readable route summary.
// direct → target / bastion → target / bastion → dmz-gw → target
func (r Route) DisplaySummary(target string) string {
if len(r.Hops) == 0 {
return "direct → " + target
}
names := make([]string, len(r.Hops))
for i, h := range r.Hops {
if h.IsProfile {
names[i] = h.Alias
} else {
names[i] = h.Raw
}
}
return strings.Join(names, " → ") + " → " + target
}
// HasProfileLinks returns true if any hop references a known profile.
func (r Route) HasProfileLinks() bool {
for _, h := range r.Hops {
if h.IsProfile {
return true
}
}
return false
}
type CommandTemplate struct { type CommandTemplate struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ServerID int64 `json:"server_id"` ServerID int64 `json:"server_id"`
@ -87,3 +220,17 @@ type CommandTemplate struct {
Command string `json:"command"` Command string `json:"command"`
Description string `json:"description"` Description string `json:"description"`
} }
// --- Tunnel ---
// TunnelState represents a running or stopped tunnel process.
type TunnelState struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
ServerAlias string `json:"server_alias"`
Name string `json:"name"`
PID int `json:"pid"`
ForwardIDs []int64 `json:"forward_ids"`
StartedAt time.Time `json:"started_at"`
LastError string `json:"last_error"`
}

View File

@ -13,7 +13,7 @@ import (
type VaultFunc func(serverAlias string, secretType string) (string, error) type VaultFunc func(serverAlias string, secretType string) (string, error)
func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error { func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error {
args := BuildSSHArgs(server) args := BuildSSHArgsSimple(server)
if strings.TrimSpace(server.StartupCommand) != "" { if strings.TrimSpace(server.StartupCommand) != "" {
args = append(args, server.StartupCommand) args = append(args, server.StartupCommand)
} }
@ -49,7 +49,7 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error
} }
func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) error { func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) error {
args := BuildSSHArgs(server) args := BuildSSHArgsSimple(server)
args = append(args, command) args = append(args, command)
switch server.AuthMethod { switch server.AuthMethod {
@ -78,7 +78,7 @@ func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, co
} }
func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) { func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) {
args := BuildSSHArgs(server) args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec)) args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
switch server.AuthMethod { switch server.AuthMethod {
@ -116,7 +116,7 @@ func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFu
} }
func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) { func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) {
args := BuildSSHArgs(server) args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec)) args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
switch server.AuthMethod { switch server.AuthMethod {
@ -178,8 +178,57 @@ func testWithPassword(cfg *config.Config, args []string, password string) (bool,
return false, result return false, result
} }
func ConnectWithArgs(cfg *config.Config, args []string, vaultFunc VaultFunc, server *model.Server) error {
switch server.AuthMethod {
case model.AuthPassword:
password, err := vaultFunc(server.Alias, "ssh_password")
if err != nil {
return fmt.Errorf("get password from vault: %w", err)
}
return ConnectWithPassword(cfg.SSH.Binary, args, password)
case model.AuthKeyPassphrase:
passphrase, err := vaultFunc(server.Alias, "key_passphrase")
if err != nil {
return fmt.Errorf("get key passphrase from vault: %w", err)
}
return ConnectWithPassword(cfg.SSH.Binary, args, passphrase)
default:
cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("start ssh: %w", err)
}
return cmd.Wait()
}
}
func BuildForwardArgs(forwards []*model.Forward, exitOnForwardFailure bool) []string {
var args []string
for _, f := range forwards {
switch f.Type {
case model.ForwardLocal:
listen := fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
target := fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
args = append(args, "-L", listen+":"+target)
case model.ForwardRemote:
listen := fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
target := fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
args = append(args, "-R", listen+":"+target)
case model.ForwardDynamic:
args = append(args, "-D", fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort))
}
}
if exitOnForwardFailure && len(forwards) > 0 {
args = append(args, "-o", "ExitOnForwardFailure=yes")
}
return args
}
// BuildSSHArgs builds the SSH command arguments for a server profile. // BuildSSHArgs builds the SSH command arguments for a server profile.
func BuildSSHArgs(server *model.Server) []string { func BuildSSHArgs(server *model.Server, forwards []*model.Forward, forwardOnly bool) []string {
var args []string var args []string
args = append(args, "-p", fmt.Sprintf("%d", server.Port)) args = append(args, "-p", fmt.Sprintf("%d", server.Port))
@ -188,14 +237,32 @@ func BuildSSHArgs(server *model.Server) []string {
args = append(args, "-i", server.IdentityFile) args = append(args, "-i", server.IdentityFile)
} }
if server.ProxyJump != "" { // Use Route if available, fall back to raw ProxyJump for backward compatibility
routeArgs := BuildRouteArgs(server.Route)
if len(routeArgs) > 0 {
args = append(args, routeArgs...)
} else if server.ProxyJump != "" {
args = append(args, "-J", server.ProxyJump) args = append(args, "-J", server.ProxyJump)
} }
// Port forwarding
if len(forwards) > 0 {
args = append(args, BuildForwardArgs(forwards, true)...)
}
args = append(args, "-o", "StrictHostKeyChecking=accept-new") args = append(args, "-o", "StrictHostKeyChecking=accept-new")
if forwardOnly {
args = append(args, "-N")
}
target := fmt.Sprintf("%s@%s", server.User, server.Host) target := fmt.Sprintf("%s@%s", server.User, server.Host)
args = append(args, target) args = append(args, target)
return args return args
} }
// BuildSSHArgsSimple builds SSH args without forwards (backward compatible).
func BuildSSHArgsSimple(server *model.Server) []string {
return BuildSSHArgs(server, nil, false)
}

12
internal/ssh/route.go Normal file
View File

@ -0,0 +1,12 @@
package ssh
import "github.com/mirivlad/sshkeeper/internal/model"
// BuildRouteArgs builds SSH arguments for a route.
// Returns -J flag with the full ProxyJump chain if route has hops.
func BuildRouteArgs(route model.Route) []string {
if len(route.Hops) == 0 {
return nil
}
return []string{"-J", route.ProxyJumpString()}
}

140
internal/ssh/route_test.go Normal file
View File

@ -0,0 +1,140 @@
package ssh
import (
"testing"
"github.com/mirivlad/sshkeeper/internal/model"
)
func TestBuildRouteArgs_Direct(t *testing.T) {
route := model.Route{Hops: []model.RouteHop{}}
args := BuildRouteArgs(route)
if len(args) != 0 {
t.Fatalf("expected no args for direct route, got %v", args)
}
}
func TestBuildRouteArgs_Via(t *testing.T) {
route := model.Route{Hops: []model.RouteHop{
{Raw: "bastion.example.com", IsProfile: false},
}}
args := BuildRouteArgs(route)
if len(args) != 2 || args[0] != "-J" || args[1] != "bastion.example.com" {
t.Fatalf("expected [-J bastion.example.com], got %v", args)
}
}
func TestBuildRouteArgs_Chain(t *testing.T) {
route := model.Route{Hops: []model.RouteHop{
{Alias: "bastion", IsProfile: true},
{Raw: "dmz-gw.internal", IsProfile: false},
}}
args := BuildRouteArgs(route)
if len(args) != 2 || args[0] != "-J" || args[1] != "bastion,dmz-gw.internal" {
t.Fatalf("expected [-J bastion,dmz-gw.internal], got %v", args)
}
}
func TestBuildRouteArgs_ProfileHop(t *testing.T) {
route := model.Route{Hops: []model.RouteHop{
{Alias: "my-bastion", IsProfile: true},
}}
args := BuildRouteArgs(route)
if len(args) != 2 || args[1] != "my-bastion" {
t.Fatalf("expected profile alias in -J, got %v", args)
}
}
func TestRouteProxyJumpString(t *testing.T) {
route := model.Route{Hops: []model.RouteHop{
{Alias: "bastion", IsProfile: true},
{Raw: "10.0.0.1", IsProfile: false},
}}
got := route.ProxyJumpString()
if got != "bastion,10.0.0.1" {
t.Fatalf("expected 'bastion,10.0.0.1', got %q", got)
}
}
func TestRouteDisplaySummary(t *testing.T) {
tests := []struct {
route model.Route
target string
want string
}{
{model.Route{}, "target", "direct → target"},
{model.Route{Hops: []model.RouteHop{{Alias: "bastion", IsProfile: true}}}, "target", "bastion → target"},
{model.Route{Hops: []model.RouteHop{
{Alias: "bastion", IsProfile: true},
{Raw: "dmz-gw", IsProfile: false},
}}, "target", "bastion → dmz-gw → target"},
}
for _, tt := range tests {
got := tt.route.DisplaySummary(tt.target)
if got != tt.want {
t.Fatalf("DisplaySummary() = %q, want %q", got, tt.want)
}
}
}
func TestRouteMode(t *testing.T) {
tests := []struct {
hops int
want string
}{
{0, "direct"},
{1, "via"},
{2, "chain"},
{3, "chain"},
}
for _, tt := range tests {
route := model.Route{Hops: make([]model.RouteHop, tt.hops)}
got := route.RouteMode()
if got != tt.want {
t.Fatalf("RouteMode() = %q, want %q", got, tt.want)
}
}
}
func TestBuildSSHArgs_WithRoute(t *testing.T) {
server := &model.Server{
Host: "target.internal",
Port: 22,
User: "root",
Route: model.Route{Hops: []model.RouteHop{
{Alias: "bastion", IsProfile: true},
}},
}
args := BuildSSHArgsSimple(server)
// Should contain -J bastion
found := false
for i, a := range args {
if a == "-J" && i+1 < len(args) && args[i+1] == "bastion" {
found = true
break
}
}
if !found {
t.Fatalf("expected -J bastion in args, got %v", args)
}
}
func TestBuildSSHArgs_FallbackToProxyJump(t *testing.T) {
server := &model.Server{
Host: "target.internal",
Port: 22,
User: "root",
ProxyJump: "old-bastion",
}
args := BuildSSHArgsSimple(server)
found := false
for i, a := range args {
if a == "-J" && i+1 < len(args) && args[i+1] == "old-bastion" {
found = true
break
}
}
if !found {
t.Fatalf("expected -J old-bastion in args, got %v", args)
}
}

File diff suppressed because it is too large Load Diff

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",
@ -117,7 +117,7 @@ func TestServerListHelpWrapsOnNarrowTerminal(t *testing.T) {
t.Fatalf("expected help line to be bounded, got width %d: %q\nview:\n%s", lipgloss.Width(line), line, view) t.Fatalf("expected help line to be bounded, got width %d: %q\nview:\n%s", lipgloss.Width(line), line, view)
} }
} }
for _, want := range []string{"Ctrl+R", "run tpl", "Ctrl+P", "tpl mgr"} { for _, want := range []string{"Ctrl+X", "actions", "Ctrl+F", "search", "?", "help"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("expected help to contain %q\nview:\n%s", want, view) t.Fatalf("expected help to contain %q\nview:\n%s", want, view)
} }
@ -148,7 +148,7 @@ func TestServerListHelpWrapsSelectionAndResultHints(t *testing.T) {
plainLines = append(plainLines, plainHelpLine(line)) plainLines = append(plainLines, plainHelpLine(line))
} }
joined := strings.Join(plainLines, "\n") joined := strings.Join(plainLines, "\n")
for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+P: tpl mgr", "Ctrl+Q: quit"} { for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+X: actions", "Ctrl+Q: quit"} {
if !strings.Contains(joined, want) { if !strings.Contains(joined, want) {
t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines) t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines)
} }
@ -695,3 +695,111 @@ func TestBackgroundOutputLinesArePaddedAndTabsExpanded(t *testing.T) {
} }
} }
} }
func TestForwardSaveSuccessReturnsToList(t *testing.T) {
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root"}
m := New([]*model.Server{server})
m.width = 100
m.height = 30
// Create both forwardScreen and forwardForm to simulate real flow
m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, m.width, m.height)
m.forwardScreen.list = []*model.Forward{}
m.forwardScreen.selected = 0
m.forwardForm = newForwardFormModel(server.ID, m.width, m.height)
m.forwardForm.serverID = server.ID
m.screen = screenForwardForm
// Fill in form
m.forwardForm.nameInput.SetValue("Test Forward")
m.forwardForm.descInput.SetValue("test")
m.forwardForm.inputs[0].SetValue("127.0.0.1")
m.forwardForm.inputs[1].SetValue("8080")
m.forwardForm.inputs[2].SetValue("internal.web")
m.forwardForm.inputs[3].SetValue("80")
// Simulate saveDoneMsg arriving through tuiModel.Update (as async cmd would)
updated, cmd := m.Update(saveDoneMsg{err: nil})
m = updated.(*tuiModel)
// After successful save, forwardForm should be cleared and screen reset
if m.forwardForm != nil {
t.Fatal("expected forwardForm to be nil after save")
}
if m.screen != screenForwardList {
t.Fatalf("expected screenForwardList, got %v", m.screen)
}
// cmd should trigger reload
if cmd == nil {
t.Fatal("expected reload command after save")
}
}
func TestForwardSaveErrorStaysOnForm(t *testing.T) {
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root"}
m := New([]*model.Server{server})
m.width = 100
m.height = 30
// Open forward form directly
m.forwardForm = newForwardFormModel(server.ID, m.width, m.height)
m.forwardForm.serverID = server.ID
m.screen = screenForwardForm
// Simulate saveDoneMsg with error through tuiModel.Update
testErr := fmt.Errorf("save failed")
updated, _ := m.Update(saveDoneMsg{err: testErr})
m = updated.(*tuiModel)
// Should stay on form screen with error
if m.screen != screenForwardForm {
t.Fatalf("expected screenForwardForm after error, got %v", m.screen)
}
if m.forwardForm == nil {
t.Fatal("expected forwardForm to still exist")
}
if m.forwardForm.err == nil {
t.Fatal("expected error to be set")
}
if m.forwardForm.saved {
t.Fatal("expected saved to be false")
}
}
func TestActionMenuClosesOnAllActions(t *testing.T) {
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root"}
m := New([]*model.Server{server})
m.width = 100
m.height = 30
// Test delete closes menu
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
m.actionMenu.list.Select(3) // Delete
DeleteServer = func(alias string) error { return nil }
ListServers = func() ([]*model.Server, error) { return []*model.Server{server}, nil }
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if m.actionMenu != nil {
t.Fatal("expected actionMenu nil after delete")
}
// Test forwards closes menu and goes to forward list
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
for i := 0; i < 10; i++ {
m.actionMenu.list.Select(i)
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "forwards" {
break
}
}
ListForwards = func(serverID int64) ([]*model.Forward, error) { return []*model.Forward{}, nil }
updated, _ = m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if m.actionMenu != nil {
t.Fatal("expected actionMenu nil after forwards")
}
if m.screen != screenForwardList {
t.Fatalf("expected screenForwardList, got %v", m.screen)
}
}

672
internal/tui/form.go Normal file
View File

@ -0,0 +1,672 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mirivlad/sshkeeper/internal/model"
)
// groupItem implements list.Item for dropdowns (groups, auth methods, etc.)
type groupItem struct {
name string
}
func (i groupItem) Title() string { return i.name }
func (i groupItem) Description() string { return "" }
func (i groupItem) FilterValue() string { return i.name }
func newStringList(values []string, title string, width, height int) list.Model {
items := make([]list.Item, len(values))
for i, value := range values {
items[i] = groupItem{name: value}
}
l := list.New(items, list.NewDefaultDelegate(), width, height)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetShowPagination(false)
l.Title = title
l.Styles.Title = titleStyle
return l
}
// --- Form model ---
type formModel struct {
edit bool
server *model.Server
inputs []textinput.Model
labels []string
password textinput.Model
passwordLabel string
focusIdx int
testResult string
testOK bool
testResultTime time.Time
testing bool
saving bool
saved bool
savedTime time.Time
err error
spinner spinner.Model
width int
height int
groups []string
groupList list.Model
showGroupList bool
authList list.Model
showAuthList bool
}
func newFormModel(w, h int) *formModel {
inputs := make([]textinput.Model, 12)
labels := []string{
"Alias",
"Display Name",
"Host",
"Port",
"User",
"Auth Method (password/key/key_passphrase/agent)",
"Identity File",
"Route hops (comma-separated, or pick from profiles)",
"Group (type new or pick from list)",
"Notes",
"Startup Command",
"Tags (comma-separated)",
}
for i, label := range labels {
inputs[i] = textinput.New()
inputs[i].Placeholder = placeholderForLabel(label)
inputs[i].CharLimit = 128
}
pw := textinput.New()
pw.Placeholder = "optional"
pw.CharLimit = 256
pw.EchoMode = textinput.EchoPassword
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
inputs[0].Focus()
fm := &formModel{
inputs: inputs,
labels: labels,
password: pw,
passwordLabel: "Password / Passphrase",
focusIdx: 0,
spinner: s,
width: w,
height: h,
}
fm.authList = newStringList([]string{
string(model.AuthPassword),
string(model.AuthKey),
string(model.AuthKeyPassphrase),
string(model.AuthAgent),
}, "Select auth method", 34, 16)
if GetGroups != nil {
if groups, err := GetGroups(); err == nil && len(groups) > 0 {
fm.groups = groups
fm.groupList = newStringList(groups, "Select group", 30, 8)
}
}
fm.updateFocus()
return fm
}
func placeholderForLabel(label string) string {
switch label {
case "Alias":
return "mail.kp"
case "Display Name":
return "Production mail"
case "Host":
return "mail.example.org"
case "Port":
return "22"
case "User":
return "root"
case "Auth Method (password/key/key_passphrase/agent)":
return "key"
case "Identity File":
return "~/.ssh/id_ed25519"
case "Route hops (comma-separated, or pick from profiles)":
return "bastion, dmz-gw"
case "Group (type new or pick from list)":
return "KP"
case "Notes":
return "optional"
case "Startup Command":
return "optional"
case "Tags (comma-separated)":
return "prod, web"
default:
return label
}
}
func newEditFormModel(s *model.Server, w, h int) *formModel {
fm := newFormModel(w, h)
fm.edit = true
fm.server = s
fm.inputs[0].SetValue(s.Alias)
fm.inputs[1].SetValue(s.DisplayName)
fm.inputs[2].SetValue(s.Host)
fm.inputs[3].SetValue(fmt.Sprintf("%d", s.Port))
fm.inputs[4].SetValue(s.User)
fm.inputs[5].SetValue(string(s.AuthMethod))
fm.inputs[6].SetValue(s.IdentityFile)
// Populate Route hops
if len(s.Route.Hops) > 0 {
hopStrs := make([]string, len(s.Route.Hops))
for i, h := range s.Route.Hops {
if h.IsProfile {
hopStrs[i] = h.Alias
} else {
hopStrs[i] = h.Raw
}
}
fm.inputs[7].SetValue(strings.Join(hopStrs, ", "))
} else if s.ProxyJump != "" {
fm.inputs[7].SetValue(s.ProxyJump)
}
fm.inputs[8].SetValue(s.GroupName)
fm.inputs[9].SetValue(s.Notes)
fm.inputs[10].SetValue(s.StartupCommand)
fm.inputs[11].SetValue(strings.Join(s.Tags, ", "))
if HasSecret != nil {
switch s.AuthMethod {
case model.AuthPassword:
if HasSecret(s.Alias, "ssh_password") {
fm.passwordLabel = "Password (secret saved; leave blank to keep)"
fm.password.Placeholder = ""
}
case model.AuthKeyPassphrase:
if HasSecret(s.Alias, "key_passphrase") {
fm.passwordLabel = "Key passphrase (secret saved; leave blank to keep)"
fm.password.Placeholder = ""
}
}
}
fm.updateFocus()
return fm
}
func (fm *formModel) Init() tea.Cmd {
return nil
}
func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case testDoneMsg:
fm.testing = false
if msg.ok {
fm.testResult = "Connection OK."
fm.testOK = true
} else {
fm.testResult = fmt.Sprintf("Connection failed:\n%s", msg.err)
fm.testOK = false
}
fm.testResultTime = time.Now()
fm.err = nil
return fm, nil
case saveDoneMsg:
fm.saving = false
if msg.err != nil {
fm.err = msg.err
fm.saved = false
} else {
fm.saved = true
fm.savedTime = time.Now()
fm.err = nil
}
return fm, nil
}
if fm.testing || fm.saving {
var cmd tea.Cmd
fm.spinner, cmd = fm.spinner.Update(msg)
if _, ok := msg.(tea.KeyMsg); ok {
return fm, cmd
}
return fm, cmd
}
if fm.showGroupList {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
fm.showGroupList = false
return fm, nil
case tea.KeyEnter:
if item, ok := fm.groupList.SelectedItem().(groupItem); ok {
fm.inputs[8].SetValue(item.name)
}
fm.showGroupList = false
return fm, nil
}
}
var cmd tea.Cmd
fm.groupList, cmd = fm.groupList.Update(msg)
return fm, cmd
}
if fm.showAuthList {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
fm.showAuthList = false
return fm, nil
case tea.KeyEnter:
if item, ok := fm.authList.SelectedItem().(groupItem); ok {
fm.inputs[5].SetValue(item.name)
}
fm.showAuthList = false
return fm, nil
}
}
var cmd tea.Cmd
fm.authList, cmd = fm.authList.Update(msg)
return fm, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyTab:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
case tea.KeyShiftTab:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 3
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
case tea.KeyRunes:
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 5 {
fm.showAuthList = true
return fm, nil
}
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 8 && len(fm.groups) > 0 {
fm.showGroupList = true
return fm, nil
}
case tea.KeyEnter:
switch {
case fm.focusIdx == len(fm.inputs)+1:
return fm, fm.runTest()
case fm.focusIdx == len(fm.inputs)+2:
return fm, fm.runSave()
default:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
}
case tea.KeyEsc:
return fm, nil
case tea.KeyDown:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
case tea.KeyUp:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 3
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
}
}
if fm.focusIdx < len(fm.inputs) {
var cmd tea.Cmd
fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg)
return fm, cmd
}
if fm.focusIdx == len(fm.inputs) {
var cmd tea.Cmd
fm.password, cmd = fm.password.Update(msg)
return fm, cmd
}
return fm, nil
}
func (fm *formModel) updateFocus() {
for i := range fm.inputs {
fm.inputs[i].Blur()
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelAt(i) + ": ")
}
fm.password.Blur()
fm.password.Prompt = blurredStyle.Render(fm.passwordLabel + ": ")
if fm.focusIdx < len(fm.inputs) {
fm.inputs[fm.focusIdx].Focus()
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labelAt(fm.focusIdx) + "> ")
} else if fm.focusIdx == len(fm.inputs) {
fm.password.Focus()
fm.password.Prompt = focusedStyle.Render(fm.passwordLabel + "> ")
}
}
func (fm *formModel) labelAt(index int) string {
if index >= 0 && index < len(fm.labels) {
if index == 5 {
return "Auth Method (/ pick)"
}
if index == 8 {
if len(fm.groups) > 0 {
return "Group (/ pick)"
}
return "Group"
}
return fm.labels[index]
}
return ""
}
func (fm *formModel) runTest() tea.Cmd {
fm.testing = true
fm.testResult = ""
fm.err = nil
fm.saved = false
s := fm.buildServer()
pw := fm.password.Value()
return tea.Batch(
fm.spinner.Tick,
func() tea.Msg {
if TestConnectionWithPassword != nil {
ok, testErr := TestConnectionWithPassword(s, pw)
return testDoneMsg{ok: ok, err: testErr}
}
if s.AuthMethod == model.AuthPassword && pw == "" {
return testDoneMsg{ok: false, err: "Password is required for password auth."}
}
ok, testErr := TestConnection(s)
return testDoneMsg{ok: ok, err: testErr}
},
)
}
func (fm *formModel) runSave() tea.Cmd {
fm.saving = true
fm.err = nil
fm.saved = false
fm.testResult = ""
s := fm.buildServer()
pw := fm.password.Value()
return tea.Batch(
fm.spinner.Tick,
func() tea.Msg {
if s.Alias == "" {
return saveDoneMsg{err: fmt.Errorf("alias is required")}
}
if s.Host == "" {
return saveDoneMsg{err: fmt.Errorf("host is required")}
}
oldAlias := ""
if fm.edit && fm.server != nil {
oldAlias = fm.server.Alias
}
err := SaveServer(s, pw, oldAlias)
return saveDoneMsg{err: err}
},
)
}
// parseRouteHops parses the route hops input string into a model.Route.
// Format: comma-separated list of aliases or raw addresses.
func parseRouteHops(input string) model.Route {
input = strings.TrimSpace(input)
if input == "" {
return model.Route{}
}
parts := strings.Split(input, ",")
hops := make([]model.RouteHop, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// Heuristic: if it contains @ or :, treat as raw address
if strings.Contains(p, "@") || strings.Contains(p, ":") {
hops = append(hops, model.RouteHop{Raw: p, IsProfile: false})
} else {
// Treat as profile alias
hops = append(hops, model.RouteHop{Alias: p, IsProfile: true})
}
}
return model.Route{Hops: hops}
}
func (fm *formModel) buildServer() *model.Server {
port := 22
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
authMethod := model.AuthMethod(fm.inputs[5].Value())
if authMethod == "" {
authMethod = model.AuthKey
}
route := parseRouteHops(fm.inputs[7].Value())
return &model.Server{
Alias: fm.inputs[0].Value(),
DisplayName: fm.inputs[1].Value(),
Host: fm.inputs[2].Value(),
Port: port,
User: fm.inputs[4].Value(),
AuthMethod: authMethod,
IdentityFile: fm.inputs[6].Value(),
ProxyJump: route.ProxyJumpString(),
Route: route,
GroupName: fm.inputs[8].Value(),
Notes: fm.inputs[9].Value(),
StartupCommand: fm.inputs[10].Value(),
Tags: splitCSV(fm.inputs[11].Value()),
}
}
func (fm *formModel) View() string {
var b strings.Builder
title := "Add Server"
if fm.edit {
title = "Edit Server: " + fm.server.Alias
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
reserved := 9
available := fm.height - reserved
if available < 4 {
available = 4
}
numInputs := len(fm.inputs)
startIdx := 0
endIdx := numInputs
if numInputs > available {
focusInput := fm.focusIdx
if focusInput >= numInputs {
focusInput = numInputs - 1
}
startIdx = focusInput - available/2
if startIdx < 0 {
startIdx = 0
}
endIdx = startIdx + available
if endIdx > numInputs {
endIdx = numInputs
startIdx = endIdx - available
if startIdx < 0 {
startIdx = 0
}
}
}
if startIdx > 0 {
b.WriteString(helpStyle.Render(" ↑ more fields above\n"))
}
for i := startIdx; i < endIdx; i++ {
if section := formSectionTitle(i); section != "" {
b.WriteString(sectionStyle.Render(section))
b.WriteString("\n")
}
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
}
if i == 8 && len(fm.groups) > 0 && !fm.showGroupList {
fm.inputs[i].Placeholder = truncate(strings.Join(fm.groups, ", "), 25)
}
b.WriteString(fm.inputs[i].View())
b.WriteString("\n")
if i == 5 && fm.showAuthList {
b.WriteString("\n" + renderDropdown(fm.authList) + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
if i == 8 && fm.showGroupList {
b.WriteString("\n" + renderDropdown(fm.groupList) + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
}
if endIdx < numInputs {
b.WriteString(helpStyle.Render(fmt.Sprintf(" ↓ more fields below (%d-%d of %d)\n", startIdx+1, endIdx, numInputs)))
}
b.WriteString(fm.password.View())
b.WriteString("\n")
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
if fm.testing {
b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n")
} else if fm.saving {
b.WriteString("\n" + fm.spinner.View() + " Saving...\n")
} else if showResults {
if fm.testResult != "" {
b.WriteString("\n")
if fm.testOK {
b.WriteString(testOKStyle.Render("✓ " + fm.testResult))
} else {
b.WriteString(testFailStyle.Render("✗ " + fm.testResult))
}
b.WriteString("\n")
}
if fm.saved {
b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n")
}
if fm.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n")
}
}
testBtn := "[ Test ]"
saveBtn := "[ Save ]"
if fm.focusIdx == len(fm.inputs)+1 {
testBtn = selectedStyle.Render(testBtn)
} else {
testBtn = normalStyle.Render(testBtn)
}
if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render(saveBtn)
} else {
saveBtn = normalStyle.Render(saveBtn)
}
b.WriteString("\n" + sectionStyle.Render("Actions") + "\n")
b.WriteString(testBtn + " " + saveBtn + "\n\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, fm.width))
return b.String()
}
func renderDropdown(l list.Model) string {
var b strings.Builder
b.WriteString(sectionStyle.Render(l.Title))
b.WriteString("\n")
for i, item := range l.Items() {
group, ok := item.(groupItem)
if !ok {
continue
}
prefix := " "
style := normalStyle
if i == l.Index() {
prefix = "> "
style = selectedRowStyle
}
b.WriteString(style.Render(prefix + group.name))
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func formSectionTitle(index int) string {
switch index {
case 0:
return "Identity"
case 2:
return "Connection"
case 5:
return "Authentication"
case 8:
return "Metadata"
default:
return ""
}
}

563
internal/tui/forward.go Normal file
View File

@ -0,0 +1,563 @@
package tui
import (
"fmt"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
)
// --- Forward type items ---
type forwardTypeItem struct {
value model.ForwardType
label string
description string
}
// --- Forward list screen model ---
type forwardScreenModel struct {
serverID int64
serverAlias string
list []*model.Forward
width int
height int
err error
selected int
}
func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel {
return &forwardScreenModel{
serverID: serverID,
serverAlias: serverAlias,
width: w,
height: h,
}
}
func (m *forwardScreenModel) loadForwards() tea.Cmd {
return func() tea.Msg {
if ListForwards == nil {
return forwardsLoadedMsg{err: fmt.Errorf("forward storage is unavailable")}
}
forwards, err := ListForwards(m.serverID)
return forwardsLoadedMsg{forwards: forwards, err: err}
}
}
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
if m.selected < 0 || m.selected >= len(m.list) {
return nil
}
f := m.list[m.selected]
return func() tea.Msg {
return forwardDeleteConfirmMsg{id: f.ID, name: f.Name}
}
}
func (m *forwardScreenModel) confirmDelete() tea.Cmd {
if m.selected < 0 || m.selected >= len(m.list) {
return nil
}
f := m.list[m.selected]
return func() tea.Msg {
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
}
}
func (m *forwardScreenModel) editSelected() tea.Cmd {
if m.selected < 0 || m.selected >= len(m.list) {
return nil
}
return func() tea.Msg {
return forwardEditSignal{}
}
}
func (m *forwardScreenModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Port Forwards — " + m.serverAlias))
b.WriteString("\n\n")
if len(m.list) == 0 {
b.WriteString(helpStyle.Render(" No port forwards configured. Press Ctrl+A to add one."))
b.WriteString("\n")
} else {
// Column header
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
"NAME", "TYPE", "LISTEN", "TARGET", "ON")))
b.WriteString("\n")
for i, f := range m.list {
name := f.Name
if name == "" {
name = f.ForwardListen()
}
enabled := "yes"
if !f.Enabled {
enabled = "no"
}
line := fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
truncate(name, 22),
f.Type,
truncate(f.ForwardListen(), 20),
truncate(f.ForwardTarget(), 20),
enabled,
)
style := normalStyle
if i == m.selected {
style = selectedRowStyle
}
b.WriteString(style.Render(line))
b.WriteString("\n")
}
// Details for selected
if m.selected >= 0 && m.selected < len(m.list) {
f := m.list[m.selected]
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Selected"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", f.ForwardHumanExplanation(m.serverAlias)))
for _, arg := range f.ForwardSSHArgs() {
b.WriteString(fmt.Sprintf(" %s\n", arg))
}
}
}
b.WriteString("\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
}
// --- Forward form screen model ---
type forwardFormModel struct {
serverID int64
editMode bool
editID int64
inputs []textinput.Model
labels []string
focusIdx int
err error
saved bool
currentType model.ForwardType
nameInput textinput.Model
descInput textinput.Model
typeIdx int // 0=local, 1=remote, 2=socks
width int
height int
}
var forwardTypes = []forwardTypeItem{
{value: model.ForwardLocal, label: "Local", description: "port on my machine → service on SSH server"},
{value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"},
{value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"},
}
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
nameInput := textinput.New()
nameInput.Placeholder = "Local PostgreSQL"
nameInput.CharLimit = 128
descInput := textinput.New()
descInput.Placeholder = "optional"
descInput.CharLimit = 256
inputs := make([]textinput.Model, 4)
placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"}
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].Placeholder = placeholders[i]
inputs[i].CharLimit = 128
}
return &forwardFormModel{
serverID: serverID,
inputs: inputs,
focusIdx: 0,
currentType: model.ForwardLocal,
typeIdx: 0,
nameInput: nameInput,
descInput: descInput,
width: w,
height: h,
}
}
func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel {
fm := newForwardFormModel(serverID, w, h)
fm.editMode = true
fm.editID = fwd.ID
fm.nameInput.SetValue(fwd.Name)
fm.descInput.SetValue(fwd.Description)
fm.currentType = fwd.Type
fm.typeIdx = typeIndex(fwd.Type)
fm.inputs[0].SetValue(fwd.LocalAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
fm.inputs[2].SetValue(fwd.RemoteAddr)
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
return fm
}
func typeIndex(t model.ForwardType) int {
switch t {
case model.ForwardLocal:
return 0
case model.ForwardRemote:
return 1
case model.ForwardDynamic:
return 2
}
return 0
}
func (fm *forwardFormModel) Init() tea.Cmd {
return nil
}
func (fm *forwardFormModel) visibleFields() []int {
switch fm.currentType {
case model.ForwardLocal:
return []int{0, 1, 2, 3}
case model.ForwardRemote:
return []int{0, 1, 2, 3}
case model.ForwardDynamic:
return []int{0, 1}
default:
return []int{0, 1, 2, 3}
}
}
func (fm *forwardFormModel) labelForField(idx int) string {
switch fm.currentType {
case model.ForwardLocal:
return []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}[idx]
case model.ForwardRemote:
return []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}[idx]
case model.ForwardDynamic:
return []string{"Listen Address", "Listen Port"}[idx]
default:
return ""
}
}
func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case saveDoneMsg:
fm.saved = (msg.err == nil)
fm.err = msg.err
return fm, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyTab:
fm.focusIdx++
total := 2 + 3 + len(fm.visibleFields()) + 1 // name + desc + type(3) + fields + save
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
case tea.KeyShiftTab:
fm.focusIdx--
if fm.focusIdx < 0 {
total := 2 + 3 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
case tea.KeyEnter:
// Check if on type selector
if fm.focusIdx >= 2 && fm.focusIdx < 2+3 {
fm.typeIdx = fm.focusIdx - 2
fm.currentType = forwardTypes[fm.typeIdx].value
fm.focusIdx++
fm.updateFocus()
return fm, nil
}
if fm.focusIdx == 2+3+len(fm.visibleFields()) {
return fm, fm.runSave()
}
fm.focusIdx++
fm.updateFocus()
return fm, nil
case tea.KeyEsc:
return fm, nil
case tea.KeyDown:
fm.focusIdx++
total := 2 + 3 + len(fm.visibleFields()) + 1
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
case tea.KeyUp:
fm.focusIdx--
if fm.focusIdx < 0 {
total := 2 + 3 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
case tea.KeyRunes:
// Direct number key to select type
if len(msg.Runes) == 1 {
switch msg.Runes[0] {
case '1':
fm.typeIdx = 0
fm.currentType = model.ForwardLocal
fm.updateFocus()
return fm, nil
case '2':
fm.typeIdx = 1
fm.currentType = model.ForwardRemote
fm.updateFocus()
return fm, nil
case '3':
fm.typeIdx = 2
fm.currentType = model.ForwardDynamic
fm.updateFocus()
return fm, nil
}
}
}
}
// Route to focused input
if fm.focusIdx == 0 {
var cmd tea.Cmd
fm.nameInput, cmd = fm.nameInput.Update(msg)
return fm, cmd
}
if fm.focusIdx == 1 {
var cmd tea.Cmd
fm.descInput, cmd = fm.descInput.Update(msg)
return fm, cmd
}
visible := fm.visibleFields()
if fm.focusIdx >= 2+3 && fm.focusIdx < 2+3+len(visible) {
fieldIdx := visible[fm.focusIdx-(2+3)]
var cmd tea.Cmd
fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg)
return fm, cmd
}
return fm, nil
}
func (fm *forwardFormModel) updateFocus() {
fm.nameInput.Blur()
fm.nameInput.Prompt = blurredStyle.Render("Name: ")
fm.descInput.Blur()
fm.descInput.Prompt = blurredStyle.Render("Description: ")
for i := range fm.inputs {
fm.inputs[i].Blur()
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ")
}
total := 2 + 3 + len(fm.visibleFields()) + 1
switch {
case fm.focusIdx == 0:
fm.nameInput.Focus()
fm.nameInput.Prompt = focusedStyle.Render("Name> ")
case fm.focusIdx == 1:
fm.descInput.Focus()
fm.descInput.Prompt = focusedStyle.Render("Description> ")
case fm.focusIdx >= 2 && fm.focusIdx < 2+3:
// Type selector focused — no input to focus
case fm.focusIdx >= 2+3 && fm.focusIdx < total-1:
visible := fm.visibleFields()
fieldIdx := visible[fm.focusIdx-(2+3)]
fm.inputs[fieldIdx].Focus()
fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ")
}
}
func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg {
name := strings.TrimSpace(fm.nameInput.Value())
desc := strings.TrimSpace(fm.descInput.Value())
localPort := 0
fmt.Sscanf(fm.inputs[1].Value(), "%d", &localPort)
remotePort := 0
fmt.Sscanf(fm.inputs[3].Value(), "%d", &remotePort)
localAddr := strings.TrimSpace(fm.inputs[0].Value())
remoteAddr := strings.TrimSpace(fm.inputs[2].Value())
if name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")}
}
if localPort < 1 || localPort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid listen port %d: must be 1-65535", localPort)}
}
switch fm.currentType {
case model.ForwardLocal:
if localAddr == "" {
localAddr = "127.0.0.1"
}
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("target host 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("remote listen address is required")}
}
if remotePort < 1 || remotePort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort)}
}
if localAddr == "" {
localAddr = "127.0.0.1"
}
case model.ForwardDynamic:
if localAddr == "" {
localAddr = "127.0.0.1"
}
remoteAddr = ""
remotePort = 0
}
fwd := &model.Forward{
ServerID: fm.serverID,
Name: name,
Description: desc,
Type: fm.currentType,
LocalAddr: localAddr,
LocalPort: localPort,
RemoteAddr: remoteAddr,
RemotePort: remotePort,
Enabled: true,
}
if fm.editMode {
fwd.ID = fm.editID
if UpdateForward == nil {
return saveDoneMsg{err: fmt.Errorf("update not available")}
}
return saveDoneMsg{err: UpdateForward(fwd)}
}
if SaveForward == nil {
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
}
err := SaveForward(fwd)
return saveDoneMsg{err: err}
}
}
func (fm *forwardFormModel) View() string {
var b strings.Builder
title := "Add Port Forward"
if fm.editMode {
title = "Edit Port Forward"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
// Name
b.WriteString(fm.nameInput.View())
b.WriteString("\n")
// Description
b.WriteString(fm.descInput.View())
b.WriteString("\n\n")
// Type selector — visible radio items
b.WriteString(sectionStyle.Render("Type"))
b.WriteString("\n")
for i, t := range forwardTypes {
prefix := " "
style := normalStyle
if i == fm.typeIdx {
prefix = "▸ "
style = selectedRowStyle
}
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description)
b.WriteString(style.Render(line))
b.WriteString("\n")
}
b.WriteString("\n")
// Dynamic fields based on type
visible := fm.visibleFields()
for _, idx := range visible {
b.WriteString(fm.inputs[idx].View())
b.WriteString("\n")
}
// Warning for 0.0.0.0
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n"))
}
// Preview
if fm.currentType != "" && fm.inputs[1].Value() != "" {
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
fwd := &model.Forward{
Type: fm.currentType,
LocalAddr: fm.inputs[0].Value(),
LocalPort: 0,
RemoteAddr: fm.inputs[2].Value(),
RemotePort: 0,
}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
for _, arg := range fwd.ForwardSSHArgs() {
b.WriteString(" " + arg + "\n")
}
b.WriteString(" -o ExitOnForwardFailure=yes\n")
}
// Save button
total := 2 + 3 + len(visible) + 1
button := "\n[ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render(button)
}
b.WriteString(button)
b.WriteString("\n\n")
if fm.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n")
}
if fm.saved {
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n")
}
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"},
}, fm.width))
return b.String()
}
// forwardEditSignal is sent when user wants to edit a forward
type forwardEditSignal struct{}
// forwardDeleteConfirmMsg asks for confirmation before deleting
type forwardDeleteConfirmMsg struct {
id int64
name string
}

69
internal/tui/help.go Normal file
View File

@ -0,0 +1,69 @@
package tui
import (
"strings"
)
// --- Help rendering utilities ---
func renderHelp(items []helpItem, width int) string {
if width <= 0 {
width = 80
}
lines := wrapHelpItems(items, width-2)
rendered := make([]string, len(lines))
for i, line := range lines {
rendered[i] = " " + renderHelpLine(line)
}
return strings.Join(rendered, "\n")
}
func renderHelpLine(items []helpItem) string {
parts := make([]string, len(items))
for i, item := range items {
parts[i] = hotkeyStyle.Render(item.Key) + helpTextStyle.Render(": "+item.Action)
}
return strings.Join(parts, helpTextStyle.Render(" | "))
}
func wrapHelpItems(items []helpItem, width int) [][]helpItem {
if width <= 0 {
return [][]helpItem{items}
}
var lines [][]helpItem
var current []helpItem
currentWidth := 0
for _, item := range items {
itemWidth := len(plainHelpItem(item))
if len(current) == 0 {
current = []helpItem{item}
currentWidth = itemWidth
continue
}
nextWidth := currentWidth + len(" | ") + itemWidth
if nextWidth > width {
lines = append(lines, current)
current = []helpItem{item}
currentWidth = itemWidth
continue
}
current = append(current, item)
currentWidth = nextWidth
}
if len(current) > 0 {
lines = append(lines, current)
}
return lines
}
func plainHelpItem(item helpItem) string {
return item.Key + ": " + item.Action
}
func plainHelpLine(items []helpItem) string {
parts := make([]string, len(items))
for i, item := range items {
parts[i] = plainHelpItem(item)
}
return strings.Join(parts, " | ")
}

171
internal/tui/help_screen.go Normal file
View File

@ -0,0 +1,171 @@
package tui
import (
"fmt"
"io"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbletea"
)
// --- Help screen ---
type helpScreenModel struct {
list list.Model
width int
}
func newHelpScreenModel(w, h int) *helpScreenModel {
items := []list.Item{
helpScreenItem{key: "Enter", action: "Connect to server", section: "Navigation"},
helpScreenItem{key: "↑/↓", action: "Navigate list", section: "Navigation"},
helpScreenItem{key: "Tab/↓", action: "Next field", section: "Forms"},
helpScreenItem{key: "Shift+Tab/↑", action: "Previous field", section: "Forms"},
helpScreenItem{key: "/", action: "Open dropdown picker", section: "Forms"},
helpScreenItem{key: "Esc", action: "Back / Cancel", section: "Navigation"},
helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Actions"},
helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Actions"},
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Actions"},
helpScreenItem{key: "Ctrl+X", action: "Action menu (delete, test, tags, tunnel)", section: "Actions"},
helpScreenItem{key: "Ctrl+D", action: "Delete server", section: "Actions"},
helpScreenItem{key: "Ctrl+T", action: "Test connection", section: "Actions"},
helpScreenItem{key: "Ctrl+F", action: "Search", section: "Actions"},
helpScreenItem{key: "Ctrl+G", action: "Tags manager", section: "Actions"},
helpScreenItem{key: "Ctrl+P", action: "Templates manager", section: "Actions"},
helpScreenItem{key: "Ctrl+R", action: "Run template", section: "Templates"},
helpScreenItem{key: "Ctrl+B", action: "Run in background", section: "Templates"},
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Selection"},
helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Actions"},
helpScreenItem{key: "?", action: "This help screen", section: "Navigation"},
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Navigation"},
}
l := list.New(items, helpScreenDelegate{}, w, h-4)
l.Title = "sshkeeper — Help"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle
return &helpScreenModel{list: l, width: w}
}
type helpScreenItem struct {
key string
action string
section string
}
func (i helpScreenItem) Title() string { return i.key }
func (i helpScreenItem) Description() string { return i.action }
func (i helpScreenItem) FilterValue() string { return i.key + " " + i.action }
type helpScreenDelegate struct{}
func (d helpScreenDelegate) Height() int { return 2 }
func (d helpScreenDelegate) Spacing() int { return 0 }
func (d helpScreenDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
func (d helpScreenDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
i, ok := item.(helpScreenItem)
if !ok {
return
}
style := normalStyle
if index == m.Index() {
style = selectedRowStyle
}
keyStr := fmt.Sprintf("%-12s", i.key)
actionStr := i.action
line := hotkeyStyle.Render(keyStr) + helpTextStyle.Render(actionStr)
w.Write([]byte(style.Render(" " + line + "\n")))
}
func (m *helpScreenModel) Init() tea.Cmd {
return nil
}
func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc, tea.KeyEnter:
return m, nil // caller checks screen transition
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.list.SetSize(msg.Width, msg.Height-4)
return m, nil
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *helpScreenModel) View() string {
return m.list.View()
}
// --- Action menu ---
type actionMenuItem struct {
label string
action string
}
func (i actionMenuItem) Title() string { return i.label }
func (i actionMenuItem) Description() string { return "" }
func (i actionMenuItem) FilterValue() string { return i.label }
type actionMenuModel struct {
list list.Model
width int
}
func newActionMenuModel(w, h int) *actionMenuModel {
items := []list.Item{
actionMenuItem{label: "Connect", action: "connect"},
actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
actionMenuItem{label: "Manage port forwards", action: "forwards"},
actionMenuItem{label: "Manage tunnels", action: "tunnels"},
actionMenuItem{label: "Manage route", action: "route"},
actionMenuItem{label: "Test connection", action: "test"},
actionMenuItem{label: "Edit", action: "edit"},
actionMenuItem{label: "Delete", action: "delete"},
actionMenuItem{label: "Import", action: "import"},
actionMenuItem{label: "Export", action: "export"},
actionMenuItem{label: "Vault: lock", action: "vault_lock"},
actionMenuItem{label: "Vault: change password", action: "vault_change_pw"},
}
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
l.Title = "Actions"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(false)
l.Styles.Title = titleStyle
return &actionMenuModel{list: l, width: w}
}
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
return m, nil
case tea.KeyEnter:
if item, ok := m.list.SelectedItem().(actionMenuItem); ok {
return m, &item.action
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
_ = cmd
return m, nil
}
func (m *actionMenuModel) View() string {
return m.list.View()
}

View File

@ -0,0 +1,151 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
)
// --- Template form model ---
type templateFormModel struct {
edit bool
oldName string
inputs []textinput.Model
labels []string
focusIdx int
err error
saved bool
width int
height int
}
func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel {
labels := []string{"Name", "Command", "Description"}
inputs := make([]textinput.Model, len(labels))
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].CharLimit = 512
}
inputs[0].Placeholder = "uptime"
inputs[1].Placeholder = "uptime"
inputs[2].Placeholder = "optional"
inputs[0].Focus()
tf := &templateFormModel{inputs: inputs, labels: labels, width: w, height: h}
if t != nil {
tf.edit = true
tf.oldName = t.Name
inputs[0].SetValue(t.Name)
inputs[1].SetValue(t.Command)
inputs[2].SetValue(t.Description)
}
tf.updateFocus()
return tf
}
func (tf *templateFormModel) Init() tea.Cmd {
return nil
}
func (tf *templateFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyTab, tea.KeyDown:
tf.focusIdx++
if tf.focusIdx > len(tf.inputs) {
tf.focusIdx = 0
}
tf.updateFocus()
return tf, nil
case tea.KeyShiftTab, tea.KeyUp:
tf.focusIdx--
if tf.focusIdx < 0 {
tf.focusIdx = len(tf.inputs)
}
tf.updateFocus()
return tf, nil
case tea.KeyEnter:
if tf.focusIdx == len(tf.inputs) {
return tf, tf.save()
}
tf.focusIdx++
tf.updateFocus()
return tf, nil
}
}
if tf.focusIdx < len(tf.inputs) {
var cmd tea.Cmd
tf.inputs[tf.focusIdx], cmd = tf.inputs[tf.focusIdx].Update(msg)
return tf, cmd
}
return tf, nil
}
func (tf *templateFormModel) updateFocus() {
for i := range tf.inputs {
tf.inputs[i].Blur()
tf.inputs[i].Prompt = blurredStyle.Render(tf.labels[i] + ": ")
}
if tf.focusIdx < len(tf.inputs) {
tf.inputs[tf.focusIdx].Focus()
tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labels[tf.focusIdx] + "> ")
}
}
func (tf *templateFormModel) save() tea.Cmd {
return func() tea.Msg {
if SaveCommandTemplate == nil {
return saveDoneMsg{err: fmt.Errorf("template storage is unavailable")}
}
t := &model.CommandTemplate{
Name: strings.TrimSpace(tf.inputs[0].Value()),
Command: strings.TrimSpace(tf.inputs[1].Value()),
Description: strings.TrimSpace(tf.inputs[2].Value()),
}
if t.Name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")}
}
if t.Command == "" {
return saveDoneMsg{err: fmt.Errorf("command is required")}
}
if err := SaveCommandTemplate(tf.oldName, t); err != nil {
return saveDoneMsg{err: err}
}
return saveDoneMsg{}
}
}
func (tf *templateFormModel) View() string {
var b strings.Builder
title := "Add Template"
if tf.edit {
title = "Edit Template"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
for i := range tf.inputs {
b.WriteString(tf.inputs[i].View())
b.WriteString("\n")
}
button := "[ Save ]"
if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render(button)
}
b.WriteString("\n" + button + "\n\n")
if tf.err != nil {
b.WriteString(errorStyle.Render(tf.err.Error()))
b.WriteString("\n")
}
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, tf.width))
return b.String()
}

115
internal/tui/tunnel.go Normal file
View File

@ -0,0 +1,115 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/tunnel"
)
// --- Tunnel manager screen ---
type tunnelScreenModel struct {
list list.Model
tunnels []*model.TunnelState
width int
height int
err error
}
type tunnelItem struct {
state *model.TunnelState
}
func (i tunnelItem) Title() string {
status := "stopped"
if tunnel.IsRunning(i.state.ID) {
status = "running"
}
duration := time.Since(i.state.StartedAt).Round(time.Second)
return fmt.Sprintf("%-30s PID %-8d %-8s %s",
truncate(i.state.Name, 30),
i.state.PID,
status,
duration,
)
}
func (i tunnelItem) Description() string {
preview := fmt.Sprintf("ssh -N ... → %s", i.state.ServerAlias)
if i.state.LastError != "" {
return fmt.Sprintf(" %s\n ✗ %s", preview, i.state.LastError)
}
return " " + preview
}
func (i tunnelItem) FilterValue() string {
return i.state.Name + " " + i.state.ServerAlias
}
func newTunnelScreenModel(w, h int) *tunnelScreenModel {
l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, h-6)
l.Title = "Tunnel Manager"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle
return &tunnelScreenModel{
list: l,
width: w,
height: h,
}
}
func (m *tunnelScreenModel) loadTunnels() tea.Cmd {
return func() tea.Msg {
states := tunnel.List()
items := make([]list.Item, len(states))
for i, s := range states {
items[i] = tunnelItem{state: s}
}
return tunnelsLoadedMsg{items: items}
}
}
func (m *tunnelScreenModel) rebuildList() {
items := make([]list.Item, len(m.tunnels))
for i, s := range m.tunnels {
items[i] = tunnelItem{state: s}
}
m.list.SetItems(items)
}
func (m *tunnelScreenModel) stopSelected() tea.Cmd {
if item, ok := m.list.SelectedItem().(tunnelItem); ok {
return func() tea.Msg {
return tunnelStoppedMsg{id: item.state.ID, err: tunnel.Stop(item.state.ID)}
}
}
return nil
}
func (m *tunnelScreenModel) View() string {
var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
}
type tunnelsLoadedMsg struct {
items []list.Item
}
type tunnelStoppedMsg struct {
id int64
err error
}

192
internal/tunnel/manager.go Normal file
View File

@ -0,0 +1,192 @@
package tunnel
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/mirivlad/sshkeeper/internal/config"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/ssh"
)
var (
mu sync.Mutex
states = map[int64]*model.TunnelState{}
dataDir string
)
// Init initializes the tunnel state manager with the data directory.
func Init(dir string) error {
dataDir = dir
return loadStates()
}
// StateFilePath returns the path to the tunnel state file.
func StateFilePath() string {
return filepath.Join(dataDir, "tunnels.json")
}
func loadStates() error {
path := StateFilePath()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read tunnel states: %w", err)
}
var list []*model.TunnelState
if err := json.Unmarshal(b, &list); err != nil {
return fmt.Errorf("unmarshal tunnel states: %w", err)
}
for _, s := range list {
states[s.ID] = s
}
return nil
}
func saveStates() error {
path := StateFilePath()
list := make([]*model.TunnelState, 0, len(states))
for _, s := range states {
list = append(list, s)
}
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return fmt.Errorf("marshal tunnel states: %w", err)
}
os.MkdirAll(dataDir, 0700)
return os.WriteFile(path, b, 0600)
}
// List returns all tunnel states.
func List() []*model.TunnelState {
mu.Lock()
defer mu.Unlock()
result := make([]*model.TunnelState, 0, len(states))
for _, s := range states {
result = append(result, s)
}
return result
}
// Get returns a tunnel state by ID.
func Get(id int64) *model.TunnelState {
mu.Lock()
defer mu.Unlock()
return states[id]
}
// Start starts a tunnel for the given server with its forwards.
func Start(cfg *config.Config, server *model.Server, forwards []*model.Forward, forwardOnly bool) (*model.TunnelState, error) {
mu.Lock()
defer mu.Unlock()
// Filter enabled forwards
var active []*model.Forward
for _, f := range forwards {
if f.Enabled {
active = append(active, f)
}
}
sshArgs := ssh.BuildSSHArgs(server, active, forwardOnly)
args := make([]string, len(sshArgs))
copy(args, sshArgs)
cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start tunnel: %w", err)
}
forwardIDs := make([]int64, len(active))
for i, f := range active {
forwardIDs[i] = f.ID
}
id := time.Now().UnixNano()
state := &model.TunnelState{
ID: id,
ServerID: server.ID,
ServerAlias: server.Alias,
Name: fmt.Sprintf("Tunnel to %s", server.Alias),
PID: cmd.Process.Pid,
ForwardIDs: forwardIDs,
StartedAt: time.Now(),
}
states[id] = state
if err := saveStates(); err != nil {
// Non-fatal: log but don't fail
_ = err
}
return state, nil
}
// Stop stops a tunnel by ID.
func Stop(id int64) error {
mu.Lock()
defer mu.Unlock()
state, ok := states[id]
if !ok {
return fmt.Errorf("tunnel %d not found", id)
}
if state.PID > 0 {
proc, err := os.FindProcess(state.PID)
if err == nil {
proc.Kill()
}
}
delete(states, id)
return saveStates()
}
// StopAll stops all running tunnels.
func StopAll() error {
mu.Lock()
defer mu.Unlock()
for id, state := range states {
if state.PID > 0 {
proc, _ := os.FindProcess(state.PID)
if proc != nil {
proc.Kill()
}
}
delete(states, id)
}
return saveStates()
}
// IsRunning checks if a tunnel process is still running.
func IsRunning(id int64) bool {
mu.Lock()
defer mu.Unlock()
state, ok := states[id]
if !ok {
return false
}
if state.PID <= 0 {
return false
}
proc, err := os.FindProcess(state.PID)
if err != nil {
return false
}
// Signal 0 just checks if process exists
return proc.Signal(os.Signal(nil)) == nil
}

29
release.sh Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS="-s -w -X main.version=${VERSION}"
DIST_DIR="dist"
echo "==> Building release ${APP} ${VERSION}..."
rm -rf "${DIST_DIR}"
mkdir -p "${DIST_DIR}"
# Linux amd64
echo "==> linux/amd64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "${LDFLAGS}" -o "${DIST_DIR}/${APP}" .
tar -czf "${DIST_DIR}/${APP}_${VERSION}_linux_amd64.tar.gz" -C "${DIST_DIR}" "${APP}"
rm -f "${DIST_DIR}/${APP}"
# Linux arm64
echo "==> linux/arm64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "${LDFLAGS}" -o "${DIST_DIR}/${APP}" .
tar -czf "${DIST_DIR}/${APP}_${VERSION}_linux_arm64.tar.gz" -C "${DIST_DIR}" "${APP}"
rm -f "${DIST_DIR}/${APP}"
echo "==> Done."
ls -lh "${DIST_DIR}/"*.tar.gz