550 lines
14 KiB
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{}
|