sshkeeper: v0.2.0 — Phase 3: Port Forwarding Manager (DB, TUI screens, SSH args)

This commit is contained in:
mirivlad 2026-06-03 10:15:55 +08:00
parent 700724e93b
commit 912b17e1f1
7 changed files with 543 additions and 7 deletions

View File

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

View File

@ -124,6 +124,15 @@ func runTUI() error {
}
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 {
return appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort)
}
tui.DeleteForward = func(forwardID int64) error {
return appDB.DeleteForward(forwardID)
}
tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error {
return appDB.UpdateTestResult(alias, status, testErr)
}

View File

@ -349,6 +349,11 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
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
var _ time.Time

View File

@ -13,7 +13,7 @@ import (
type VaultFunc func(serverAlias string, secretType string) (string, error)
func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error {
args := BuildSSHArgs(server)
args := BuildSSHArgsSimple(server)
if strings.TrimSpace(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 {
args := BuildSSHArgs(server)
args := BuildSSHArgsSimple(server)
args = append(args, command)
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) {
args := BuildSSHArgs(server)
args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
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) {
args := BuildSSHArgs(server)
args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
switch server.AuthMethod {
@ -178,8 +178,31 @@ func testWithPassword(cfg *config.Config, args []string, password string) (bool,
return false, result
}
// BuildForwardArgs builds SSH port forwarding arguments.
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.
func BuildSSHArgs(server *model.Server) []string {
func BuildSSHArgs(server *model.Server, forwards []*model.Forward, forwardOnly bool) []string {
var args []string
args = append(args, "-p", fmt.Sprintf("%d", server.Port))
@ -196,10 +219,24 @@ func BuildSSHArgs(server *model.Server) []string {
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")
if forwardOnly {
args = append(args, "-N")
}
target := fmt.Sprintf("%s@%s", server.User, server.Host)
args = append(args, target)
return args
}
// BuildSSHArgsSimple builds SSH args without forwards (backward compatible).
func BuildSSHArgsSimple(server *model.Server) []string {
return BuildSSHArgs(server, nil, false)
}

View File

@ -105,7 +105,7 @@ func TestBuildSSHArgs_WithRoute(t *testing.T) {
{Alias: "bastion", IsProfile: true},
}},
}
args := BuildSSHArgs(server)
args := BuildSSHArgsSimple(server)
// Should contain -J bastion
found := false
for i, a := range args {
@ -126,7 +126,7 @@ func TestBuildSSHArgs_FallbackToProxyJump(t *testing.T) {
User: "root",
ProxyJump: "old-bastion",
}
args := BuildSSHArgs(server)
args := BuildSSHArgsSimple(server)
found := false
for i, a := range args {
if a == "-J" && i+1 < len(args) && args[i+1] == "old-bastion" {

View File

@ -84,6 +84,16 @@ type templateRunRequestMsg struct {
command string
}
type forwardsLoadedMsg struct {
forwards []*model.Forward
err error
}
type forwardDeletedMsg struct {
id int64
err error
}
// --- List items ---
type serverItem struct {
@ -144,6 +154,9 @@ var (
SaveCommandTemplate func(oldName string, template *model.CommandTemplate) error
DeleteCommandTemplate func(name string) error
RunTemplateBackground func(server *model.Server, command string) (string, error)
ListForwards func(serverID int64) ([]*model.Forward, error)
SaveForward func(fwd *model.Forward) error
DeleteForward func(forwardID int64) error
)
// --- Screen type ---
@ -163,6 +176,8 @@ const (
screenBackgroundResults
screenHelp
screenActionMenu
screenForwardList
screenForwardForm
)
// --- Result type — returned from TUI to caller ---
@ -201,6 +216,8 @@ type tuiModel struct {
result *TUIResult
helpScreen *helpScreenModel
actionMenu *actionMenuModel
forwardScreen *forwardScreenModel
forwardForm *forwardFormModel
}
func New(servers []*model.Server) *tuiModel {
@ -317,6 +334,24 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pendingTemplate = nil
return m, nil
case forwardsLoadedMsg:
if m.forwardScreen != nil {
if msg.err != nil {
m.forwardScreen.err = msg.err
} else {
m.forwardScreen.forwards = msg.forwards
m.forwardScreen.rebuildList()
}
}
return m, nil
case forwardDeletedMsg:
if m.forwardScreen != nil && msg.err == nil {
// Reload forwards
return m, m.forwardScreen.loadForwards()
}
return m, nil
case testDoneMsg:
if m.form != nil {
m.form.testing = false
@ -399,6 +434,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateHelp(msg)
case screenActionMenu:
return m.updateActionMenu(msg)
case screenForwardList:
return m.updateForwardList(msg)
case screenForwardForm:
return m.updateForwardForm(msg)
}
}
@ -496,6 +535,14 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.screen = screenHelp
return m, nil
case tea.KeyCtrlW:
// Open forward manager for selected server
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.forwardScreen = newForwardScreenModel(item.server.ID, item.server.Alias, m.width, m.height)
m.screen = screenForwardList
return m, m.forwardScreen.loadForwards()
}
case tea.KeyCtrlX:
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
@ -850,6 +897,16 @@ func (m *tuiModel) View() string {
if m.actionMenu != nil {
b.WriteString(m.actionMenu.View())
}
case screenForwardList:
if m.forwardScreen != nil {
b.WriteString(m.forwardScreen.View())
}
case screenForwardForm:
if m.forwardForm != nil {
b.WriteString(m.forwardForm.View())
}
}
if m.err != nil {
@ -931,6 +988,63 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
m.screen = screenList
m.forwardScreen = nil
return m, nil
case tea.KeyCtrlA:
// Add forward
if m.forwardScreen != nil {
m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height)
m.screen = screenForwardForm
return m, nil
}
case tea.KeyCtrlD:
if m.forwardScreen != nil {
return m, m.forwardScreen.deleteSelected()
}
case tea.KeyRunes:
switch msg.String() {
case "a", "A":
if m.forwardScreen != nil {
m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height)
m.screen = screenForwardForm
return m, nil
}
case "d", "D":
if m.forwardScreen != nil {
return m, m.forwardScreen.deleteSelected()
}
}
}
var cmd tea.Cmd
m.forwardScreen.list, cmd = m.forwardScreen.list.Update(msg)
return m, cmd
}
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyEsc {
m.screen = screenForwardList
m.forwardForm = nil
return m, nil
}
updated, cmd := m.forwardForm.Update(msg)
if fm, ok := updated.(*forwardFormModel); ok {
m.forwardForm = fm
if fm.saved {
m.screen = screenForwardList
m.forwardForm = nil
// Reload forward list
if m.forwardScreen != nil {
return m, m.forwardScreen.loadForwards()
}
}
}
return m, cmd
}
func (m *tuiModel) viewServerList() string {
var b strings.Builder
selectedAlias := ""
@ -1379,6 +1493,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
helpItem{Key: "Ctrl+F", Action: "search"},
helpItem{Key: "Ctrl+P", Action: "tmpl"},
helpItem{Key: "Ctrl+G", Action: "tags"},
helpItem{Key: "Ctrl+W", Action: "forwards"},
helpItem{Key: "Ins", Action: insAction},
helpItem{Key: "?", Action: "help"},
helpItem{Key: "Ctrl+Q", Action: "quit"},

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

@ -0,0 +1,368 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
)
// --- Forward list screen model ---
type forwardScreenModel struct {
serverID int64
serverAlias string
list list.Model
forwards []*model.Forward
width int
height int
err error
}
func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel {
l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, h-6)
l.Title = "Port Forwards — " + serverAlias
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle
return &forwardScreenModel{
serverID: serverID,
serverAlias: serverAlias,
list: l,
width: w,
height: h,
}
}
type forwardItem struct {
forward *model.Forward
}
func (i forwardItem) Title() string {
return fmt.Sprintf("[%s] %s", i.forward.Type, forwardSummary(i.forward))
}
func (i forwardItem) Description() string {
return forwardPreview(i.forward)
}
func (i forwardItem) FilterValue() string {
return string(i.forward.Type) + " " + fmt.Sprintf("%d", i.forward.LocalPort) + " " + i.forward.RemoteAddr
}
func forwardSummary(f *model.Forward) string {
switch f.Type {
case model.ForwardLocal:
return fmt.Sprintf("%s:%d → %s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
case model.ForwardRemote:
return fmt.Sprintf("%s:%d ← %s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
case model.ForwardDynamic:
return fmt.Sprintf("SOCKS %s:%d", f.LocalAddr, f.LocalPort)
default:
return fmt.Sprintf("%s %s:%d", f.Type, f.LocalAddr, f.LocalPort)
}
}
func forwardPreview(f *model.Forward) string {
switch f.Type {
case model.ForwardLocal:
return fmt.Sprintf("-L %s:%d:%s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
case model.ForwardRemote:
return fmt.Sprintf("-R %s:%d:%s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
case model.ForwardDynamic:
return fmt.Sprintf("-D %s:%d", f.LocalAddr, f.LocalPort)
default:
return string(f.Type)
}
}
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) rebuildList() {
items := make([]list.Item, len(m.forwards))
for i, f := range m.forwards {
items[i] = forwardItem{forward: f}
}
m.list.SetItems(items)
}
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
if item, ok := m.list.SelectedItem().(forwardItem); ok && DeleteForward != nil {
f := item.forward
return func() tea.Msg {
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
}
}
return nil
}
func (m *forwardScreenModel) View() string {
var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
}
// --- Forward form screen model ---
type forwardFormModel struct {
serverID int64
inputs []textinput.Model
labels []string
focusIdx int
err error
saved bool
typeList list.Model
showList bool
width int
height int
}
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
labels := []string{"Type (local/remote/dynamic)", "Listen Addr", "Listen Port", "Target Addr", "Target Port"}
inputs := make([]textinput.Model, len(labels))
for i, label := range labels {
inputs[i] = textinput.New()
inputs[i].Placeholder = forwardPlaceholder(label)
inputs[i].CharLimit = 128
}
inputs[0].Focus()
typeList := newStringList([]string{"local", "remote", "dynamic"}, "Select type", 30, 8)
return &forwardFormModel{
serverID: serverID,
inputs: inputs,
labels: labels,
focusIdx: 0,
typeList: typeList,
width: w,
height: h,
}
}
func forwardPlaceholder(label string) string {
switch label {
case "Type (local/remote/dynamic)":
return "local"
case "Listen Addr":
return "0.0.0.0"
case "Listen Port":
return "8080"
case "Target Addr":
return "internal.web"
case "Target Port":
return "80"
default:
return label
}
}
func (fm *forwardFormModel) Init() tea.Cmd {
return nil
}
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
}
if fm.showList {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
fm.showList = false
return fm, nil
case tea.KeyEnter:
if item, ok := fm.typeList.SelectedItem().(groupItem); ok {
fm.inputs[0].SetValue(item.name)
}
fm.showList = false
return fm, nil
}
}
var cmd tea.Cmd
fm.typeList, cmd = fm.typeList.Update(msg)
return fm, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyTab:
fm.focusIdx++
total := len(fm.inputs) + 1
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) + 1
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
case tea.KeyRunes:
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 0 {
fm.showList = true
return fm, nil
}
case tea.KeyEnter:
if fm.focusIdx == len(fm.inputs) {
return fm, fm.runSave()
}
fm.focusIdx++
fm.updateFocus()
return fm, nil
case tea.KeyEsc:
return fm, nil
case tea.KeyDown:
fm.focusIdx++
total := len(fm.inputs) + 1
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) + 1
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
}
return fm, nil
}
func (fm *forwardFormModel) updateFocus() {
for i := range fm.inputs {
fm.inputs[i].Blur()
fm.inputs[i].Prompt = blurredStyle.Render(fm.labels[i] + ": ")
}
if fm.focusIdx < len(fm.inputs) {
fm.inputs[fm.focusIdx].Focus()
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labels[fm.focusIdx] + "> ")
}
}
func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg {
fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value()))
if fwdType == "" {
fwdType = model.ForwardLocal
}
localPort := 0
fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort)
remotePort := 0
fmt.Sscanf(fm.inputs[4].Value(), "%d", &remotePort)
fwd := &model.Forward{
ServerID: fm.serverID,
Type: fwdType,
LocalAddr: fm.inputs[1].Value(),
LocalPort: localPort,
RemoteAddr: fm.inputs[3].Value(),
RemotePort: remotePort,
}
if SaveForward == nil {
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
}
if err := SaveForward(fwd); err != nil {
return saveDoneMsg{err: err}
}
return saveDoneMsg{}
}
}
func (fm *forwardFormModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Add Port Forward"))
b.WriteString("\n\n")
for i := range fm.inputs {
b.WriteString(fm.inputs[i].View())
b.WriteString("\n")
if i == 0 && fm.showList {
b.WriteString("\n" + renderDropdown(fm.typeList) + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
}
// Preview
fwdType := strings.TrimSpace(fm.inputs[0].Value())
localAddr := fm.inputs[1].Value()
localPort := fm.inputs[2].Value()
remoteAddr := fm.inputs[3].Value()
remotePort := fm.inputs[4].Value()
if fwdType != "" && localPort != "" {
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
switch fwdType {
case "local":
b.WriteString(fmt.Sprintf(" -L %s:%s:%s:%s\n", localAddr, localPort, remoteAddr, remotePort))
case "remote":
b.WriteString(fmt.Sprintf(" -R %s:%s:%s:%s\n", remoteAddr, remotePort, localAddr, localPort))
case "dynamic":
b.WriteString(fmt.Sprintf(" -D %s:%s\n", localAddr, localPort))
}
b.WriteString(" -o ExitOnForwardFailure=yes\n")
}
button := "\n[ Save ]"
if fm.focusIdx == len(fm.inputs) {
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: "/", Action: "pick type"},
{Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"},
}, fm.width))
return b.String()
}