feat: sftp, scp, and named port forwards

Add netssh sftp and netssh scp. Both resolve the host like a connection
does (cache, shortcuts and rewrites, instance/host, default user and
port, SSH options and rules, --jump/--no-jump, --dry-run) and get shell
completion. The per-client flag tables handle the differences from ssh:
the port is -P, -l is a bandwidth limit (the default user goes in as
-o User=), and IPv6 addresses are bracketed in host:path. All remote
scp operands must name the same host.

Add named port forwards under ssh.forwards (global, per instance, per
rule; a more specific layer replaces a forward of the same name),
netssh tunnel HOST NAME|SPEC... (ssh -N with ExitOnForwardFailure) and
netssh --forward NAME. Specs such as 8080:localhost:80 work without
configuration. Forwards are editable in netssh configure and completed
in the shell; the TUI opens SFTP with ctrl+o and a tunnel with ctrl+t.

Host resolution moves out of runSSHWrapper into resolveTarget, shared
by ssh, sftp, scp, tunnel, and the TUI.

Options added by netssh are now inserted before the first operand or
"--": after "--", ssh read them as the destination, and scp stops
reading options at the first operand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 23:05:07 +01:00
1 parent 878701aba7
commit b7fa71fe45
19 files changed
+1389 -141

No files matched your search

+117 -17
View File
@@ -22,13 +22,22 @@ import (
// SelectedHost is returned when the user confirms a host in the TUI.
type SelectedHost struct {
Kind string
Name string
IP string
User string // empty = use config default
Port string // empty = use default port
Kind string
Name string
IP string
User string // empty = use config default
Port string // empty = use default port
Action string // ActionSSH, ActionSFTP, or ActionTunnel
Forwards []string // forward names or specs for ActionTunnel
}
// Actions the user can start on a host.
const (
ActionSSH = ""
ActionSFTP = "sftp"
ActionTunnel = "tunnel"
)
// --- bubbletea messages ---
type debounceMsg struct {
@@ -145,6 +154,9 @@ type Model struct {
input textinput.Model
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
editInput textinput.Model // user@host:port inline editor
forwardInput textinput.Model // forward names/specs, ctrl+t
forwardHost *hostItem
forwardsFor func(name, kind string, tags []string) []string
list list.Model
client *netbox.Client
cache *cache.Cache
@@ -160,6 +172,7 @@ type Model struct {
recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item
forwardMode bool // Ctrl+T on the selected item
}
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
@@ -174,6 +187,10 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
ei := textinput.New()
ei.Prompt = ""
fwd := textinput.New()
fwd.Prompt = ""
fwd.Placeholder = "web, 8080:localhost:80"
l := list.New(nil, compactDelegate{}, 0, 0)
l.SetShowHelp(false)
l.SetShowTitle(false)
@@ -189,16 +206,17 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
ni.CharLimit = 0
ni.SetHeight(10)
return &Model{
noteInput: ni,
defaultPort: port,
input: ti,
filterInput: fi,
editInput: ei,
list: l,
client: client,
cache: c,
defaultUser: defaultUser,
recentMode: true,
noteInput: ni,
defaultPort: port,
input: ti,
filterInput: fi,
editInput: ei,
forwardInput: fwd,
list: l,
client: client,
cache: c,
defaultUser: defaultUser,
recentMode: true,
}
}
@@ -281,6 +299,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.noteInput, cmd = m.noteInput.Update(msg)
return m, cmd
}
// --- Forward mode ---
if m.forwardMode {
switch msg.String() {
case "esc":
m.forwardMode = false
m.forwardInput.Blur()
m.input.Focus()
m.err = nil
return m, nil
case "enter":
item := *m.forwardHost
forwards, err := parseForwards(m.forwardInput.Value(), m.availableForwards(item))
if err != nil {
m.err = err
return m, nil
}
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionTunnel, Forwards: forwards}
return m, tea.Quit
}
var cmd tea.Cmd
m.forwardInput, cmd = m.forwardInput.Update(msg)
return m, cmd
}
// --- Edit mode ---
if m.editMode {
switch msg.String() {
@@ -355,6 +397,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case "ctrl+o":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionSFTP}
return m, tea.Quit
}
case "ctrl+t":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.forwardMode = true
m.forwardHost = &item
m.err = nil
m.input.Blur()
m.forwardInput.SetValue("")
m.forwardInput.Focus()
return m, nil
}
case "ctrl+n", "ctrl+b":
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
m.noteMode = true
@@ -509,13 +568,23 @@ func (m *Model) viewContent() string {
sb.WriteString(m.list.View())
}
if m.editMode {
if m.forwardMode {
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
sb.WriteString("\n" + dim.Render("Forward via "+m.forwardHost.name+": "))
sb.WriteString(m.forwardInput.View() + "\n")
available := "none configured — enter specs such as 8080:localhost:80"
if names := m.availableForwards(*m.forwardHost); len(names) > 0 {
available = strings.Join(names, ", ")
}
sb.WriteString(dim.Render(" forwards: "+available) + "\n")
sb.WriteString(dim.Render(" enter start tunnel esc cancel") + "\n")
} else if m.editMode {
sb.WriteString("\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else {
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
}
@@ -654,3 +723,34 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
}
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
// SetForwards provides the forward names configured for a host (ctrl+t).
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
m.forwardsFor = forwardsFor
}
func (m *Model) availableForwards(item hostItem) []string {
if m.forwardsFor == nil {
return nil
}
return m.forwardsFor(item.name, item.kind, item.tags)
}
// parseForwards accepts configured names and specs containing a colon,
// separated by commas or spaces.
func parseForwards(input string, available []string) ([]string, error) {
known := make(map[string]bool, len(available))
for _, name := range available {
known[strings.ToLower(name)] = true
}
fields := strings.FieldsFunc(input, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' })
if len(fields) == 0 {
return nil, fmt.Errorf("enter a forward name or a spec such as 8080:localhost:80")
}
for _, field := range fields {
if !known[strings.ToLower(field)] && !strings.Contains(field, ":") {
return nil, fmt.Errorf("unknown forward %q", field)
}
}
return fields, nil
}
+52
View File
@@ -1,6 +1,8 @@
package tui
import (
"reflect"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
@@ -70,3 +72,53 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
t.Fatalf("edited host changed: %+v", m.selected)
}
}
func TestCtrlOSelectsSFTP(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1"}})
m.Update(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
if m.selected == nil || m.selected.Action != ActionSFTP || m.selected.Name != "web01" {
t.Fatalf("got %+v", m.selected)
}
}
func TestCtrlTStartsTunnelWithForwards(t *testing.T) {
m := New(nil, nil, "")
m.SetForwards(func(name, kind string, tags []string) []string {
if name != "web01" || len(tags) != 1 {
t.Errorf("forwards asked for %s %v", name, tags)
}
return []string{"web"}
})
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1", Tags: []string{"dmz"}}})
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
if !m.forwardMode || m.selected != nil {
t.Fatal("forward input not opened")
}
if !strings.Contains(m.viewContent(), "forwards: web") {
t.Fatalf("configured forwards not shown:\n%s", m.viewContent())
}
m.forwardInput.SetValue("nope")
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if m.selected != nil || m.err == nil {
t.Fatal("unknown forward accepted")
}
m.forwardInput.SetValue("WEB, 9000:localhost:9000")
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
want := []string{"WEB", "9000:localhost:9000"}
if m.selected == nil || m.selected.Action != ActionTunnel || !reflect.DeepEqual(m.selected.Forwards, want) {
t.Fatalf("got %+v", m.selected)
}
}
func TestForwardInputEscCancels(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "web01"}})
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if m.forwardMode || m.selected != nil {
t.Fatal("esc did not cancel")
}
}