Compare commits
17 Commits
d66e103c73
...
087d7ba0dc
| Author | SHA1 | Date |
|---|---|---|
|
|
087d7ba0dc | |
|
|
6cf281c349 | |
|
|
4726a6874c | |
|
|
741e9a836d | |
|
|
fa9b07e3d5 | |
|
|
709a317939 | |
|
|
77a84a487f | |
|
|
21444d3826 | |
|
|
87f0d90f7b | |
|
|
98492799ea | |
|
|
c2edaa4224 | |
|
|
912b17e1f1 | |
|
|
700724e93b | |
|
|
446f55f740 | |
|
|
31f26164cc | |
|
|
b2d8ea959f | |
|
|
01eae01408 |
|
|
@ -1,4 +1,5 @@
|
||||||
bin/
|
bin/
|
||||||
|
dist/
|
||||||
*.db
|
*.db
|
||||||
*.bin
|
*.bin
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
|
||||||
122
README.md
122
README.md
|
|
@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Route and Forwarding
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
| 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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
12
cmd/root.go
12
cmd/root.go
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
67
cmd/tui.go
67
cmd/tui.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,12 +442,13 @@ 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
|
||||||
WHERE group_name != ''
|
WHERE group_name != ''
|
||||||
GROUP BY group_name
|
GROUP BY group_name
|
||||||
ORDER BY group_name`)
|
ORDER BY group_name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -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 = ?",
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
@ -66,13 +71,73 @@ 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"`
|
||||||
Type ForwardType `json:"type"`
|
Name string `json:"name"`
|
||||||
LocalAddr string `json:"local_addr"`
|
Description string `json:"description"`
|
||||||
LocalPort int `json:"local_port"`
|
Type ForwardType `json:"type"`
|
||||||
RemoteAddr string `json:"remote_addr"`
|
LocalAddr string `json:"local_addr"`
|
||||||
RemotePort int `json:"remote_port"`
|
LocalPort int `json:"local_port"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1530
internal/tui/app.go
1530
internal/tui/app.go
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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, " | ")
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue