sshkeeper/internal/tui/forward.go

550 lines
14 KiB
Go

package tui
import (
"fmt"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
)
// --- Forward type selector items ---
type forwardTypeItem struct {
value model.ForwardType
label string
description string
}
func (i forwardTypeItem) Title() string { return i.label }
func (i forwardTypeItem) Description() string { return i.description }
func (i forwardTypeItem) FilterValue() string { return i.label }
// --- 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 forwardListItem struct {
forward *model.Forward
}
func (i forwardListItem) Title() string {
name := i.forward.Name
if name == "" {
name = i.forward.ForwardListen()
}
return fmt.Sprintf("%-20s %-8s %-20s %-20s %s",
truncate(name, 20),
i.forward.Type,
truncate(i.forward.ForwardListen(), 20),
truncate(i.forward.ForwardTarget(), 20),
map[bool]string{true: "yes", false: "no"}[i.forward.Enabled],
)
}
func (i forwardListItem) Description() string {
return i.forward.ForwardHumanExplanation("")
}
func (i forwardListItem) FilterValue() string {
return i.forward.Name + " " + string(i.forward.Type) + " " + i.forward.ForwardListen() + " " + i.forward.ForwardTarget()
}
func (m *forwardScreenModel) SetServerAlias(alias string) {
m.serverAlias = alias
m.list.Title = "Port Forwards — " + alias
}
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] = forwardListItem{forward: f}
}
m.list.SetItems(items)
}
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
if item, ok := m.list.SelectedItem().(forwardListItem); ok && DeleteForward != nil {
f := item.forward
return func() tea.Msg {
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
}
}
return nil
}
func (m *forwardScreenModel) editSelected() tea.Cmd {
// Return signal to open edit form
if _, ok := m.list.SelectedItem().(forwardListItem); ok {
return func() tea.Msg {
return forwardEditSignal{}
}
}
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+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
typeList list.Model
showList bool
currentType model.ForwardType
nameInput textinput.Model
descInput textinput.Model
width int
height int
}
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)
labels := []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}
placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"}
for i := range labels {
inputs[i] = textinput.New()
inputs[i].Placeholder = placeholders[i]
inputs[i].CharLimit = 128
}
typeItems := []list.Item{
forwardTypeItem{value: model.ForwardLocal, label: "Local", description: "port on my machine → service reachable from SSH server"},
forwardTypeItem{value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"},
forwardTypeItem{value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"},
}
typeList := list.New(typeItems, list.NewDefaultDelegate(), 50, 6)
typeList.Title = "Select forward type"
typeList.SetShowStatusBar(false)
typeList.SetShowHelp(false)
typeList.SetFilteringEnabled(false)
typeList.Styles.Title = titleStyle
return &forwardFormModel{
serverID: serverID,
inputs: inputs,
labels: labels,
focusIdx: 0,
typeList: typeList,
currentType: model.ForwardLocal,
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
switch fwd.Type {
case model.ForwardLocal:
fm.typeList.Select(0)
case model.ForwardRemote:
fm.typeList.Select(1)
case model.ForwardDynamic:
fm.typeList.Select(2)
}
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 (fm *forwardFormModel) Init() tea.Cmd {
return nil
}
func (fm *forwardFormModel) visibleFields() []int {
switch fm.currentType {
case model.ForwardLocal:
return []int{0, 1, 2, 3} // listen addr/port, target host/port
case model.ForwardRemote:
return []int{0, 1, 2, 3} // remote listen addr/port, local target host/port
case model.ForwardDynamic:
return []int{0, 1} // listen addr/port only
default:
return []int{0, 1, 2, 3}
}
}
func (fm *forwardFormModel) labelForField(idx int) string {
switch fm.currentType {
case model.ForwardLocal:
return fm.labels[idx]
case model.ForwardRemote:
labels := []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}
return labels[idx]
case model.ForwardDynamic:
return fm.labels[idx]
default:
return fm.labels[idx]
}
}
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().(forwardTypeItem); ok {
fm.currentType = item.value
}
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 := 2 + len(fm.visibleFields()) + 1 // name + desc + fields + save btn
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
case tea.KeyShiftTab:
fm.focusIdx--
if fm.focusIdx < 0 {
total := 2 + len(fm.visibleFields()) + 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 == 2+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 + 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 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1
}
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 && fm.focusIdx < 2+len(visible) {
fieldIdx := visible[fm.focusIdx-2]
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 + 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 < total-1:
visible := fm.visibleFields()
fieldIdx := visible[fm.focusIdx-2]
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")
// Type selector
typeLabel := fmt.Sprintf("Type: %s (/ to change)", fm.currentType)
if fm.focusIdx == 0 {
typeLabel = focusedStyle.Render("Type> " + fmt.Sprintf("%s (/ to change)", fm.currentType))
}
b.WriteString(typeLabel)
b.WriteString("\n")
if fm.showList {
b.WriteString("\n" + fm.typeList.View() + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
// Name
b.WriteString(fm.nameInput.View())
b.WriteString("\n")
// Description
b.WriteString(fm.descInput.View())
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 + 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: "/", Action: "change 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{}