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

+162 -66
View File
@@ -42,6 +42,9 @@ var managedSubcommands = map[string]bool{
"help": true,
"version": true,
"uninstall": true,
"sftp": true,
"scp": true,
"tunnel": true,
}
func main() {
@@ -91,47 +94,106 @@ func runSSHWrapper(args []string) {
if err != nil {
fatalf("%v", err)
}
runClient(internalssh.SSH, flags, args)
}
// destination is the NetBox host named in the arguments of a client.
type destination struct {
host string
user string
dests []int // argument indices naming the host; several only for scp
}
func findDestination(client internalssh.Client, args []string) (*destination, error) {
if client.Name == internalssh.SCP.Name {
operands, err := client.Operands(args)
if err != nil || len(operands) == 0 {
return nil, err
}
dest := &destination{host: operands[0].Host}
for _, operand := range operands {
dest.dests = append(dest.dests, operand.Idx)
if operand.User != "" {
dest.user = operand.User
}
}
return dest, nil
}
parsed := client.Parse(args)
if parsed == nil {
return nil, nil
}
return &destination{host: parsed.Host, user: parsed.User, dests: []int{parsed.DestIdx}}, nil
}
// runClient resolves the NetBox host in args and execs ssh, sftp, or scp.
func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) {
cfg := loadConfigOrSetup()
parsed := internalssh.Parse(args)
if parsed == nil {
// No destination found — pass arguments straight through to ssh.
execSSH(internalssh.Result{Args: args}, flags)
return
dest, err := findDestination(client, args)
if err != nil {
fatalf("%v", err)
}
connect := func(name, kind, ip string, tags []string) {
connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args)
if dest == nil {
if len(flags.Forwards) > 0 {
fatalf("--forward needs a destination host")
}
// No destination found — pass arguments straight through.
execClient(client, internalssh.Result{Args: args}, flags)
return
}
// Inject the configured default user if none was given on the command line.
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
parsed.DestIdx += 2
if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) {
args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests)
}
// Inject the configured default port if none was given on the command line.
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
parsed.DestIdx += 2
if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) {
args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), args, dest.dests)
}
connectHost(cfg, client, flags, resolveTarget(cfg, dest.host), dest.dests, args)
}
// prependArgs puts extra before args and shifts the destination indices.
func prependArgs(extra, args []string, dests []int) []string {
for i := range dests {
dests[i] += len(extra)
}
return append(append([]string(nil), extra...), args...)
}
// resolvedHost is a NetBox host with the address to connect to.
type resolvedHost struct {
Name string // cache/selector name, may carry an instance prefix
Kind string
IP string
Tags []string
}
// resolveTarget finds host in the cache (also by shortcut) or NetBox and
// resolves its address. It exits on failure.
func resolveTarget(cfg *config.Config, host string) resolvedHost {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
source, _ := netbox.SplitTarget(parsed.Host)
source, _ := netbox.SplitTarget(host)
useCache := len(cfg.NetBoxes) == 0 || source != ""
used := func(entry cache.Entry) resolvedHost {
c.MarkUsed(entry.Name)
_ = c.Save()
return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags}
}
// Cache hit with a fresh TTL — connect directly without querying NetBox.
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
c.MarkUsed(parsed.Host)
_ = c.Save()
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
return
if entry, fresh := c.Get(host); fresh && useCache {
return used(entry)
}
// Shortcut cache lookup: scan entries whose normalized name matches the input.
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
lookupHost := parsed.Host
lookupHost := host
shortcutsEnabled := cfg.Shortcuts.Enabled()
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
normalize := normalizeTarget(cfg.Shortcuts, source != "")
@@ -141,10 +203,7 @@ func runSSHWrapper(args []string) {
}
if found {
if fresh {
c.MarkUsed(entry.Name)
_ = c.Save()
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
return
return used(entry)
}
// Stale shortcut match — use canonical name for NetBox re-fetch.
lookupHost = entry.Name
@@ -161,7 +220,7 @@ func runSSHWrapper(args []string) {
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
entry, found, _, cacheErr := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, false))
if cacheErr != nil {
fatalf("%v", cacheErr)
}
@@ -174,10 +233,7 @@ func runSSHWrapper(args []string) {
}
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
c.MarkUsed(target.Target())
_ = c.Save()
connect(cached.Name, cached.Kind, cached.IP, cached.Tags)
return
return used(cached)
}
chain, err := resolver.New(cfg.Resolver)
@@ -187,14 +243,12 @@ func runSSHWrapper(args []string) {
ip, err := chain.Resolve(ctx, &target, nbClient)
if err != nil {
fatalf("IP resolution for %q: %v", parsed.Host, err)
fatalf("IP resolution for %q: %v", host, err)
}
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Target())
_ = c.Save()
connect(target.Target(), target.Kind, ip, target.Tags)
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
c.Upsert(entry)
return used(entry)
}
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
@@ -217,24 +271,43 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
}
// connectHost applies the SSH options configured for the host and execs ssh.
// target is the cache/selector name, which may carry an instance prefix.
func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) {
instance, name := netbox.SplitTarget(target)
opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags})
execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags)
// connectHost applies the SSH options configured for the host and execs the client.
func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) {
opts := hostOptions(cfg, host)
if len(flags.Forwards) > 0 {
forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards)
if err != nil {
fatalf("%v", err)
}
flags.ForwardArgs = forwardArgs
}
if flags.Tunnel && len(flags.ForwardArgs) == 0 && !client.HasForwardFlag(args) {
available := "none configured (ssh.forwards)"
if names := forwardNames(opts); len(names) > 0 {
available = strings.Join(names, ", ")
}
fatalf("name a forward or a spec such as 8080:localhost:80; forwards for %s: %s", host.Name, available)
}
_, name := netbox.SplitTarget(host.Name)
execClient(client, client.Apply(args, dests, internalssh.Host{Name: name, IP: host.IP}, opts, flags), flags)
}
// execSSH replaces the process with ssh, or prints the command for --dry-run.
func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
// hostOptions merges the global, instance, and rule SSH options for host.
func hostOptions(cfg *config.Config, host resolvedHost) config.SSHOptions {
instance, name := netbox.SplitTarget(host.Name)
return cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: host.Kind, Tags: host.Tags})
}
// execClient replaces the process with the client, or prints the command for --dry-run.
func execClient(client internalssh.Client, result internalssh.Result, flags internalssh.WrapperFlags) {
if flags.DryRun {
fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...)))
fmt.Println(internalssh.Quote(append([]string{client.Name}, result.Args...)))
return
}
if result.Notice != "" {
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
}
if err := internalssh.Exec(result.Args); err != nil {
if err := client.Exec(result.Args); err != nil {
fatalf("%v", err)
}
}
@@ -254,6 +327,9 @@ func runTUI() {
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
m.SetForwards(func(name, kind string, tags []string) []string {
return forwardNames(hostOptions(cfg, resolvedHost{Name: name, Kind: kind, Tags: tags}))
})
p := tea.NewProgram(m)
final, err := p.Run()
if err != nil {
@@ -297,31 +373,54 @@ func runTUI() {
}
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
var sshArgs []string
c.MarkUsed(host.Name)
_ = c.Save()
client, flags, args := tuiCommand(cfg, host)
connectHost(cfg, client, flags, resolvedHost{Name: host.Name, Kind: host.Kind, IP: host.IP, Tags: tags}, []int{len(args) - 1}, args)
}
// tuiCommand builds the client and arguments for the action chosen in the TUI.
func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, internalssh.WrapperFlags, []string) {
client := internalssh.SSH
var flags internalssh.WrapperFlags
switch host.Action {
case tui.ActionSFTP:
client = internalssh.SFTP
case tui.ActionTunnel:
flags = internalssh.WrapperFlags{Forwards: host.Forwards, Tunnel: true}
}
var args []string
// User override from TUI edit mode takes priority, then config default.
user := host.User
if user == "" {
user = cfg.SSH.DefaultUser
}
if user != "" {
sshArgs = append(sshArgs, "-l", user)
args = append(args, client.UserArgs(user)...)
}
port := host.Port
if port == "" && cfg.SSH.DefaultPort > 0 {
port = strconv.Itoa(cfg.SSH.DefaultPort)
}
if port != "" {
sshArgs = append(sshArgs, "-p", port)
args = append(args, client.PortArgs(port)...)
}
// Apply replaces the destination; a bare name cannot be mistaken for
// host:path by sftp, unlike an IPv6 address.
_, name := netbox.SplitTarget(host.Name)
return client, flags, append(args, name)
}
sshArgs = append(sshArgs, host.IP)
c.MarkUsed(host.Name)
_ = c.Save()
connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs)
// forwardNames lists the forwards configured for a host, sorted.
func forwardNames(opts config.SSHOptions) []string {
names := make([]string, 0, len(opts.Forwards))
for name := range opts.Forwards {
names = append(names, name)
}
sort.Strings(names)
return names
}
// --- cobra root + subcommands ---
@@ -332,17 +431,14 @@ func rootCmd() *cobra.Command {
Short: "SSH wrapper with NetBox hostname resolution",
DisableFlagParsing: true,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
hosts = hosts[:limit]
if len(args) > 0 && args[len(args)-1] == "--forward" {
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return filterPrefix(allForwardNames(cfg), toComplete, nil), cobra.ShellCompDirectiveNoFileComp
}
hosts := completeHosts(toComplete)
completions := make([]cobra.Completion, 0, len(hosts))
for _, host := range hosts {
completions = append(completions, cobra.Completion(host))
@@ -357,7 +453,7 @@ func rootCmd() *cobra.Command {
},
}
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()