SFTP, SCP und benannte Port-Forwards #32

Merged
Sebi merged 1 commits from feat/sftp-forwarding into main 2026-11-13 22:09:03 +00:00
19 changed files with 1389 additions and 141 deletions

No files matched your search

+4
View File
@@ -7,6 +7,10 @@
- The configurator validates SSH options and rules before writing the config.
- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`.
- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches.
- Add `netssh sftp` and `netssh scp`: host lookup, shortcuts, instances, default user and port, SSH options and rules, jump host, `--dry-run`, and completion work as for ssh. IPv6 addresses are bracketed for host:path.
- Add named port forwards (`ssh.forwards` globally, per instance, and per rule), `netssh tunnel HOST NAME|SPEC...` (ssh -N with ExitOnForwardFailure), and `netssh --forward NAME`. Forwards are editable in `netssh configure` and completed in the shell.
- TUI: `Ctrl+O` opens SFTP, `Ctrl+T` starts a tunnel with configured or ad hoc forwards.
- Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination.
## v0.0.22
+45 -1
View File
@@ -33,6 +33,8 @@ netssh -p 2222 admin@app-server-03 uptime
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
- **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh
- **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
@@ -112,7 +114,7 @@ A menu then gives access to every other setting; open only the sections you want
| Section | Settings |
|---|---|
| SSH defaults | `ssh.default_user`, `ssh.default_port` |
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line) |
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) |
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file |
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
@@ -327,6 +329,46 @@ With `target: hostname`, ssh resolves the NetBox name itself (DNS or
Switching to `name` stores new known_hosts entries under the device name, so
ssh asks once to confirm each host key.
### SFTP and SCP
`netssh sftp` and `netssh scp` look up the host exactly like a connection does: cache, shortcuts and rewrite rules, `instance/host`, default user and port, SSH options and rules, `--jump`/`--no-jump`, and `--dry-run`.
```sh
netssh sftp fsnbx1 # interactive sftp
netssh sftp prod/web01:/var/log # start in a directory
netssh scp ./app.tar fsnbx1:/tmp/ # upload
netssh scp -r web01:/etc/nginx ./nginx # download
netssh scp --dry-run app.tar web01:/tmp/ # print the scp command
```
sftp and scp options are passed through. Mind their differences from ssh: the port is `-P`, and `-l` is a bandwidth limit, so the default user is passed as `-o User=`. `extra_args` and `forwards` contain ssh flags and apply to ssh only. All remote operands of one `scp` call must name the same host; copy between two NetBox hosts in two steps. Tab completion offers `host:` for scp and local files otherwise.
### Port forwarding
Plain `-L`, `-R`, and `-D` flags are passed to ssh as usual (`netssh -L 8080:localhost:80 web01`). For tunnels you use often, name them under `ssh.forwards`, globally, per NetBox instance (`netboxes[].ssh.forwards`), or per rule. A more specific layer replaces a forward of the same name.
```yaml
ssh:
forwards:
web: {local: "8080:localhost:80"} # -L
socks: {dynamic: "1080"} # -D, SOCKS proxy
back: {remote: "9000:localhost:9000"} # -R
rules:
- match: {tag: postgres}
forwards:
db: {local: "5432:localhost:5432"}
```
```sh
netssh tunnel fsnbx1 web socks # only forwards: ssh -N, stops with Ctrl+C
netssh tunnel prod/db01 db 6379:localhost:6379 # names and ad hoc specs mixed
netssh --forward web fsnbx1 # shell plus forward (repeatable)
```
A value that is no configured name but a port spec (`[bind:]port:host:hostport`) becomes a local forward. `netssh tunnel` adds `-o ExitOnForwardFailure=yes`, so a port already in use fails instead of leaving a silent session. Without forward names it lists the forwards configured for the host. Forward names are completed after the host (`netssh tunnel fsnbx1 <Tab>`) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards.
`sftp`, `scp`, and `tunnel` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`.
### Default username
Set `ssh.default_user` in the config to avoid typing a username every time:
@@ -444,6 +486,8 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search
| `Tab` | autocomplete top result into the search field |
| `↑` / `↓` | navigate results |
| `Enter` | connect to selected host |
| `Ctrl+O` | open an SFTP session to the selected host |
| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) |
| `Ctrl+E` | open inline editor to override user/port before connecting |
| `Ctrl+N` | edit local notes for the selected server |
| `Ctrl+B` | edit NetBox comments for the selected server |
+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()
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"sort"
"strings"
"github.com/spf13/cobra"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
)
func sftpCmd() *cobra.Command {
return transferCmd(internalssh.SFTP, "sftp [--dry-run] [--jump HOST|--no-jump] [sftp options] HOST[:PATH]",
"Open an SFTP session to a NetBox host",
`Resolve HOST like netssh does for ssh (cache, shortcuts, instance/host,
SSH options and rules, jump host) and run sftp. sftp options are passed
through; note that sftp uses -P for the port.
netssh sftp fsnbx1
netssh sftp -P 2222 prod/web01:/var/log`)
}
func scpCmd() *cobra.Command {
return transferCmd(internalssh.SCP, "scp [--dry-run] [--jump HOST|--no-jump] [scp options] SOURCE... TARGET",
"Copy files to or from a NetBox host",
`Resolve the host in HOST:PATH operands like netssh does for ssh and run scp.
All remote operands must name the same host.
netssh scp ./app.tar fsnbx1:/tmp/
netssh scp -r prod/web01:/etc/nginx ./nginx`)
}
func transferCmd(client internalssh.Client, use, short, long string) *cobra.Command {
return &cobra.Command{
Use: use,
Short: short,
Long: long,
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_ = cmd.Help()
return
}
flags, args, err := client.ExtractWrapperFlags(args)
if err != nil {
fatalf("%v", err)
}
if len(flags.Forwards) > 0 {
fatalf("--forward is only available for ssh and netssh tunnel")
}
runClient(client, flags, args)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if client.Name == internalssh.SFTP.Name && client.Parse(args) != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if strings.HasPrefix(toComplete, "-") {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if client.Name == internalssh.SCP.Name {
if strings.Contains(toComplete, ":") {
return nil, cobra.ShellCompDirectiveNoFileComp // remote paths are not completed
}
// HOST: for remote operands; without a matching host, local files.
hosts := completeHosts(toComplete)
if len(hosts) == 0 {
return nil, cobra.ShellCompDirectiveDefault
}
for i := range hosts {
hosts[i] += ":"
}
return hosts, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
},
}
}
func tunnelCmd() *cobra.Command {
return &cobra.Command{
Use: "tunnel [--dry-run] [--jump HOST|--no-jump] [ssh options] HOST FORWARD...",
Short: "Open port forwards to a NetBox host without a shell",
Long: `Run ssh -N with the named forwards configured under ssh.forwards (globally,
per NetBox instance, or per rule). A FORWARD that is no configured name but a
port spec such as 8080:localhost:80 is used as a local forward (-L).
netssh tunnel fsnbx1 web socks
netssh tunnel prod/db01 5432:localhost:5432`,
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_ = cmd.Help()
return
}
flags, args, err := internalssh.SSH.ExtractWrapperFlags(args)
if err != nil {
fatalf("%v", err)
}
parsed := internalssh.SSH.Parse(args)
if parsed == nil {
fatalf("usage: netssh tunnel HOST FORWARD...")
}
// Everything after the host names forwards instead of a remote command.
flags.Forwards = append(flags.Forwards, args[parsed.DestIdx+1:]...)
flags.Tunnel = true
runClient(internalssh.SSH, flags, args[:parsed.DestIdx+1])
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
parsed := internalssh.SSH.Parse(args)
if parsed == nil {
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
}
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
var names []string
if entry, ok := cachedHost(cfg, parsed.Host); ok {
names = forwardNames(hostOptions(cfg, resolvedHost{Name: entry.Name, Kind: entry.Kind, Tags: entry.Tags}))
} else {
names = allForwardNames(cfg)
}
return filterPrefix(names, toComplete, args[parsed.DestIdx+1:]), cobra.ShellCompDirectiveNoFileComp
},
}
}
// completeHosts returns the cached hosts for a completion request.
func completeHosts(toComplete string) []string {
cfg, err := config.Load()
if err != nil {
return nil
}
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]
}
return hosts
}
// cachedHost finds a host for completion by name or shortcut, without NetBox.
func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
return cache.Entry{}, false
}
if entry, _ := c.Get(host); entry.Name != "" {
return entry, true
}
if !cfg.Shortcuts.Enabled() {
return cache.Entry{}, false
}
entry, found, _, err := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, strings.Contains(host, "/")))
return entry, found && err == nil
}
// allForwardNames lists every configured forward name, for when the host is unknown.
func allForwardNames(cfg *config.Config) []string {
seen := map[string]bool{}
add := func(o config.SSHOptions) {
for name := range o.Forwards {
seen[strings.ToLower(name)] = true
}
}
add(cfg.SSH.SSHOptions)
add(cfg.NetBox.SSH)
for _, source := range cfg.NetBoxes {
add(source.SSH)
}
for _, rule := range cfg.SSH.Rules {
add(rule.SSHOptions)
}
names := make([]string, 0, len(seen))
for name := range seen {
names = append(names, name)
}
sort.Strings(names)
return names
}
// filterPrefix keeps the names starting with prefix that were not given yet.
func filterPrefix(names []string, prefix string, given []string) []string {
used := map[string]bool{}
for _, name := range given {
used[strings.ToLower(name)] = true
}
var out []string
for _, name := range names {
if strings.HasPrefix(name, strings.ToLower(prefix)) && !used[name] {
out = append(out, name)
}
}
return out
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// TestNetsshHelper runs main() with the arguments in NETSSH_TEST_ARGS.
func TestNetsshHelper(t *testing.T) {
if args := os.Getenv("NETSSH_TEST_ARGS"); args != "" {
os.Args = append([]string{"netssh"}, strings.Split(args, "\x1f")...)
main()
os.Exit(0)
}
}
// prepareTransferTest writes a config whose only host is cached; NetBox is
// unreachable, so every result must come from cache, shortcuts, and options.
func prepareTransferTest(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := `netbox:
url: http://127.0.0.1:1
token: t
ssh:
default_user: ops
default_port: 2222
extra_args: ["-A"]
forwards:
web: {local: "8080:localhost:80"}
rules:
- match: {tag: dmz}
proxy_jump: jh.example.com
forwards:
socks: {dynamic: "1080"}
shortcuts:
domains: [.hoster.de]
rewrites:
- pattern: '^([a-z]+)[0-9]+-'
replace: '$1'
`
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), 3600)
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device", Tags: []string{"dmz"}})
if err := c.Save(); err != nil {
t.Fatal(err)
}
}
func runNetssh(t *testing.T, args ...string) (string, error) {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestNetsshHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_ARGS="+strings.Join(args, "\x1f"))
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func TestTransferAndTunnelCommands(t *testing.T) {
prepareTransferTest(t)
for _, tc := range []struct {
args []string
want string
}{
// sftp: -P and -o User, rule jump host, path kept, no ssh-only extra_args.
{[]string{"sftp", "--dry-run", "fsnbx1:/var/log"}, "sftp -P 2222 -o User=ops -J jh.example.com 10.0.0.9:/var/log"},
{[]string{"sftp", "--dry-run", "-P", "22", "root@fsnbx1"}, "sftp -P 22 -J jh.example.com root@10.0.0.9"},
{[]string{"scp", "--dry-run", "-r", "a.txt", "fsn1-bx1.hoster.de:/tmp/"}, "scp -P 2222 -o User=ops -r -J jh.example.com a.txt 10.0.0.9:/tmp/"},
{[]string{"scp", "--dry-run", "--no-jump", "fsnbx1:/etc/hosts", "."}, "scp -P 2222 -o User=ops -o ProxyJump=none 10.0.0.9:/etc/hosts ."},
// Forwards come from the global options and the matching rule.
{[]string{"tunnel", "--dry-run", "fsnbx1", "web", "socks", "5432:db:5432"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 -D 1080 -L 5432:db:5432 -o ExitOnForwardFailure=yes -N 10.0.0.9"},
{[]string{"--dry-run", "--forward", "web", "fsnbx1"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 10.0.0.9"},
} {
out, err := runNetssh(t, tc.args...)
if err != nil || out != tc.want {
t.Errorf("%v:\n got %s (%v)\n want %s", tc.args, out, err, tc.want)
}
}
for _, tc := range []struct {
args []string
want string
}{
{[]string{"tunnel", "--dry-run", "fsnbx1"}, "forwards for fsn1-bx1.hoster.de: socks, web"},
{[]string{"tunnel", "--dry-run", "fsnbx1", "db"}, `unknown forward "db"; available: socks, web`},
{[]string{"sftp", "--forward", "web", "fsnbx1"}, "only available for ssh"},
{[]string{"scp", "fsnbx1:a", "other:b"}, "not supported"},
} {
out, err := runNetssh(t, tc.args...)
if err == nil || !strings.Contains(out, tc.want) {
t.Errorf("%v: got %s (%v), want error containing %q", tc.args, out, err, tc.want)
}
}
}
func TestTransferCompletion(t *testing.T) {
prepareTransferTest(t)
for _, tc := range []struct {
args []string
want string
}{
{[]string{"__complete", "sftp", "fsnb"}, "fsnbx1\n"},
{[]string{"__complete", "scp", "a.txt", "fsnb"}, "fsnbx1:\n"},
{[]string{"__complete", "tunnel", "fsnbx1", ""}, "socks\nweb\n"},
{[]string{"__complete", "tunnel", "fsnbx1", "web", ""}, "socks\n"},
{[]string{"__complete", "--forward", ""}, "socks\nweb\n"},
} {
out, err := runNetssh(t, tc.args...)
if err != nil || !strings.HasPrefix(out+"\n", tc.want) {
t.Errorf("%v: got %q (%v), want prefix %q", tc.args, out, err, tc.want)
}
}
}
+58
View File
@@ -22,6 +22,48 @@ type SSHOptions struct {
ExtraArgs []string `mapstructure:"extra_args"`
// Options are passed as -o Key=Value.
Options map[string]string `mapstructure:"options"`
// Forwards are named port forwards, selected with --forward or netssh tunnel.
Forwards map[string]Forward `mapstructure:"forwards"`
}
// Forward is one port forward; exactly one of the fields is set.
type Forward struct {
Local string `mapstructure:"local"` // -L [bind:]port:host:hostport
Remote string `mapstructure:"remote"` // -R [bind:]port:host:hostport
Dynamic string `mapstructure:"dynamic"` // -D [bind:]port (SOCKS proxy)
}
// Flag returns the ssh arguments for the forward, e.g. -L 8080:localhost:80.
func (f Forward) Flag() []string {
switch {
case f.Local != "":
return []string{"-L", f.Local}
case f.Remote != "":
return []string{"-R", f.Remote}
case f.Dynamic != "":
return []string{"-D", f.Dynamic}
}
return nil
}
// String renders the forward as its ssh flag, e.g. "-L 8080:localhost:80".
func (f Forward) String() string { return strings.Join(f.Flag(), " ") }
func (f Forward) validate() error {
set := 0
for _, spec := range []string{f.Local, f.Remote, f.Dynamic} {
if spec == "" {
continue
}
set++
if strings.HasPrefix(spec, "-") || strings.ContainsAny(spec, " \t\n\r") {
return fmt.Errorf("invalid forward %q", spec)
}
}
if set != 1 {
return fmt.Errorf("set exactly one of local, remote, dynamic")
}
return nil
}
// SSHRule applies SSH options to hosts that match every non-empty field.
@@ -113,6 +155,14 @@ func (o SSHOptions) Validate(scope string) error {
return fmt.Errorf("%s.options: invalid option %q", scope, key)
}
}
for name, forward := range o.Forwards {
if !instanceName.MatchString(name) {
return fmt.Errorf("%s.forwards: name %q must contain only letters, digits, underscores, or hyphens", scope, name)
}
if err := forward.validate(); err != nil {
return fmt.Errorf("%s.forwards.%s: %v", scope, name, err)
}
}
return nil
}
@@ -147,6 +197,14 @@ func (o SSHOptions) merge(override SSHOptions) SSHOptions {
for key, value := range override.Options {
out.Options[strings.ToLower(key)] = value
}
// Forwards override per name, so a rule can redefine a global "web" forward.
out.Forwards = make(map[string]Forward, len(o.Forwards)+len(override.Forwards))
for name, forward := range o.Forwards {
out.Forwards[strings.ToLower(name)] = forward
}
for name, forward := range override.Forwards {
out.Forwards[strings.ToLower(name)] = forward
}
if override.ProxyJump != "" {
out.ProxyJump = override.ProxyJump
}
+47
View File
@@ -88,3 +88,50 @@ func TestSSHOptionsValidation(t *testing.T) {
}
}
}
func TestForwardsLayering(t *testing.T) {
cfg, err := loadYAML(t, `
netboxes:
- name: prod
url: https://prod.example
token: t
ssh:
forwards:
db: {local: "5432:localhost:5432"}
ssh:
forwards:
web: {local: "8080:localhost:80"}
socks: {dynamic: "1080"}
rules:
- match: {tag: dmz}
forwards:
web: {local: "8443:localhost:443"}
`)
if err != nil {
t.Fatal(err)
}
got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards
want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" {
t.Fatalf("other instance: %+v", got)
}
if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" {
t.Fatal(f.String())
}
}
func TestForwardsValidation(t *testing.T) {
for _, forwards := range []string{
`web: {local: "8080:a:80", dynamic: "1080"}`,
`web: {local: "-oProxyCommand=x"}`,
`web: {local: "8080 a:80"}`,
`"bad name": {local: "8080:a:80"}`,
} {
if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") {
t.Errorf("%s: got %v", forwards, err)
}
}
}
+55
View File
@@ -22,6 +22,7 @@ func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error
addressFamily := o.AddressFamily
extraArgs := strings.Join(o.ExtraArgs, "\n")
options := formatOptions(o.Options)
forwards := formatForwards(o.Forwards)
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
@@ -58,13 +59,25 @@ func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error
_, err := parseOptions(s)
return err
}),
huh.NewText().
Title("Port forwards").
Description("One per line: NAME L|R|D SPEC, e.g. web L 8080:localhost:80 or socks D 1080.\n"+
"Use with netssh tunnel HOST NAME or netssh --forward NAME HOST.").
Lines(3).
Value(&forwards).
Validate(func(s string) error {
_, err := parseForwards(s)
return err
}),
).Title(title)).Run()
if err != nil {
return o, err
}
parsed, _ := parseOptions(options)
parsedForwards, _ := parseForwards(forwards)
return config.SSHOptions{
Forwards: parsedForwards,
ProxyJump: strings.TrimSpace(proxyJump),
HostKeyChecking: hostKeyChecking,
Target: target,
@@ -105,6 +118,48 @@ func parseOptions(s string) (map[string]string, error) {
return out, nil
}
// forwardKinds maps the letters used in the form to the ssh flags.
var forwardKinds = map[string]func(spec string) config.Forward{
"L": func(spec string) config.Forward { return config.Forward{Local: spec} },
"R": func(spec string) config.Forward { return config.Forward{Remote: spec} },
"D": func(spec string) config.Forward { return config.Forward{Dynamic: spec} },
}
// parseForwards reads "NAME L|R|D SPEC" lines into ssh.forwards.
func parseForwards(s string) (map[string]config.Forward, error) {
var out map[string]config.Forward
for _, line := range parseLines(s) {
fields := strings.Fields(line)
if len(fields) != 3 || forwardKinds[strings.ToUpper(fields[1])] == nil {
return nil, fmt.Errorf("%q: expected NAME L|R|D SPEC", line)
}
if out == nil {
out = make(map[string]config.Forward)
}
out[strings.ToLower(fields[0])] = forwardKinds[strings.ToUpper(fields[1])](fields[2])
}
if err := (config.SSHOptions{Forwards: out}).Validate("ssh"); err != nil {
return nil, err
}
return out, nil
}
func formatForwards(forwards map[string]config.Forward) string {
names := make([]string, 0, len(forwards))
for name := range forwards {
names = append(names, name)
}
sort.Strings(names)
lines := make([]string, 0, len(names))
for _, name := range names {
flag := forwards[name].Flag()
if len(flag) == 2 {
lines = append(lines, name+" "+strings.TrimPrefix(flag[0], "-")+" "+flag[1])
}
}
return strings.Join(lines, "\n")
}
func formatOptions(options map[string]string) string {
keys := make([]string, 0, len(options))
for key := range options {
+20
View File
@@ -70,3 +70,23 @@ func TestRuleValidation(t *testing.T) {
t.Fatalf("valid match rejected: %v", err)
}
}
func TestParseForwardsRoundtrip(t *testing.T) {
text := "Web L 8080:localhost:80\n\nsocks d 1080\nback R 9000:localhost:9000\n"
got, err := parseForwards(text)
if err != nil {
t.Fatal(err)
}
want := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if formatForwards(got) != "back R 9000:localhost:9000\nsocks D 1080\nweb L 8080:localhost:80" {
t.Fatalf("format: %q", formatForwards(got))
}
for _, bad := range []string{"web 8080:localhost:80", "web X 8080", "web L -oFoo", "bad/name L 1:a:2"} {
if _, err := parseForwards(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
+18 -2
View File
@@ -88,7 +88,7 @@ func runMenu(cfg *config.Config) error {
Description("Choose a section to edit, then save.").
Options(
huh.NewOption("SSH defaults — user, port", "defaults"),
huh.NewOption("SSH options — jump host, host keys, target, -o", "ssh"),
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
huh.NewOption("Resolver & cache", "resolver"),
huh.NewOption("Shortcuts & command alias", "shortcuts"),
@@ -452,7 +452,7 @@ func save(cfg config.Config) error {
}
func sshOptionsEmpty(o config.SSHOptions) bool {
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0 && len(o.Forwards) == 0
}
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
@@ -479,4 +479,20 @@ func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
}
}
if len(o.Forwards) > 0 {
names := make([]string, 0, len(o.Forwards))
for name := range o.Forwards {
names = append(names, name)
}
sort.Strings(names)
fmt.Fprintf(b, "%sforwards:\n", indent)
for _, name := range names {
f := o.Forwards[name]
for _, field := range [][2]string{{"local", f.Local}, {"remote", f.Remote}, {"dynamic", f.Dynamic}} {
if field[1] != "" {
fmt.Fprintf(b, "%s %s:\n%s %s: %q\n", indent, name, indent, field[0], field[1])
}
}
}
}
}
+26
View File
@@ -542,3 +542,29 @@ func TestSaveShortcutRewritesRoundtrip(t *testing.T) {
t.Fatalf("got %+v", loaded.Shortcuts.Rewrites)
}
}
func TestSaveForwardsRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
global := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", SSH: config.SSHOptions{Forwards: map[string]config.Forward{"db": {Local: "5432:localhost:5432"}}}}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
SSH: config.SSHConfig{
SSHOptions: config.SSHOptions{Forwards: global},
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{Forwards: map[string]config.Forward{"back": {Remote: "9000:localhost:9000"}}}}},
},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.SSH.Forwards, global) ||
!reflect.DeepEqual(loaded.NetBoxes[0].SSH.Forwards, cfg.NetBoxes[0].SSH.Forwards) ||
!reflect.DeepEqual(loaded.SSH.Rules[0].Forwards, cfg.SSH.Rules[0].Forwards) {
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
}
}
+56 -33
View File
@@ -12,15 +12,19 @@ var flagsWithArg = map[byte]bool{
// ParsedArgs holds the result of parsing SSH arguments.
type ParsedArgs struct {
Host string // hostname without the user@ prefix
Host string // hostname without the user@ prefix or :path
User string // empty if not specified
Path string // sftp only: the remote path after host:
DestIdx int // index in Args where [user@]host sits
Args []string
}
// Parse scans SSH arguments and extracts the destination ([user@]host).
// Returns nil if no destination is found.
func Parse(args []string) *ParsedArgs {
func Parse(args []string) *ParsedArgs { return SSH.Parse(args) }
// Parse extracts the destination: [user@]host, or [user@]host[:path] for sftp.
func (c Client) Parse(args []string) *ParsedArgs {
i := 0
for i < len(args) {
arg := args[i]
@@ -33,64 +37,83 @@ func Parse(args []string) *ParsedArgs {
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
for j := 1; j < len(arg); j++ {
if flagsWithArg[arg[j]] {
if j == len(arg)-1 {
i++
}
break
}
if c.takesSeparateValue(arg) {
i++
}
i++
continue
}
// first non-flag argument is the destination
host, user := splitUserHost(arg)
return &ParsedArgs{
Host: host,
User: user,
DestIdx: i,
Args: args,
}
return c.parsed(args, i)
}
// handle arguments after "--"
if i < len(args) {
host, user := splitUserHost(args[i])
return &ParsedArgs{
Host: host,
User: user,
DestIdx: i,
Args: args,
}
return c.parsed(args, i)
}
return nil
}
func (c Client) parsed(args []string, i int) *ParsedArgs {
dest, path := args[i], ""
if c.paths {
if d, p, remote := splitRemote(dest); remote {
dest, path = d, p
}
}
host, user := splitUserHost(dest)
return &ParsedArgs{Host: host, User: user, Path: path, DestIdx: i, Args: args}
}
// ReplaceHost returns a copy of args with the destination replaced by newHost,
// preserving any user@ prefix.
func ReplaceHost(args []string, destIdx int, newHost string) []string {
return SSH.replaceHost(args, []int{destIdx}, newHost)
}
// replaceHost keeps user@ and, for sftp and scp, :path. Those clients read
// host:path, so an IPv6 address must be bracketed.
func (c Client) replaceHost(args []string, dests []int, newHost string) []string {
result := make([]string, len(args))
copy(result, args)
original := args[destIdx]
if at := strings.Index(original, "@"); at != -1 {
result[destIdx] = original[:at+1] + newHost
} else {
result[destIdx] = newHost
if c.paths && strings.Contains(newHost, ":") {
newHost = "[" + newHost + "]"
}
for _, destIdx := range dests {
original := args[destIdx]
prefix, suffix := "", ""
if at := strings.Index(original, "@"); at != -1 {
prefix, original = original[:at+1], original[at+1:]
}
if c.paths {
if _, path, remote := splitRemote(original); remote {
suffix = ":" + path
}
}
result[destIdx] = prefix + newHost + suffix
}
return result
}
// HasPortFlag reports whether a port was explicitly supplied.
func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") }
func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) }
// HasUserFlag reports whether a login user was explicitly supplied.
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
func HasUserFlag(args []string) bool { return SSH.HasUserFlag(args) }
// HasPortFlag reports whether a port was explicitly supplied.
func (c Client) HasPortFlag(args []string) bool { return c.hasOption(args, c.port, "Port") }
// HasUserFlag reports whether a login user was explicitly supplied.
func (c Client) HasUserFlag(args []string) bool { return c.hasOption(args, c.user, "User") }
func hasOption(args []string, wanted byte, keyword string) bool {
return SSH.hasOption(args, wanted, keyword)
}
func (c Client) hasOption(args []string, wanted byte, keyword string) bool {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
@@ -98,7 +121,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
}
for j := 1; j < len(arg); j++ {
flag := arg[j]
if !flagsWithArg[flag] {
if !c.withArg[flag] {
continue
}
value := arg[j+1:]
@@ -109,7 +132,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
}
value = args[i]
}
if flag == wanted {
if wanted != 0 && flag == wanted {
return true
}
if flag == 'o' {
+137
View File
@@ -0,0 +1,137 @@
package ssh
import (
"fmt"
"strings"
)
// Client describes one OpenSSH program netssh can run. The programs share
// ssh's connection options but differ in flags and destination syntax.
type Client struct {
Name string // binary name: ssh, sftp, or scp
withArg map[byte]bool // short flags that consume a value
port byte // flag for the port: -p (ssh) or -P (sftp, scp)
user byte // flag for the login user; 0 when only user@ or -o User work
paths bool // destinations are host[:path]
}
var (
// SSH is the default client; its flag table is flagsWithArg.
SSH = Client{Name: "ssh", withArg: flagsWithArg, port: 'p', user: 'l'}
// SFTP follows sftp(1): -l is a bandwidth limit, -P the port.
SFTP = Client{Name: "sftp", withArg: flagSet("BbcDFiJloPRSsX"), port: 'P', paths: true}
// SCP follows scp(1); unlike sftp, -B and -R take no value.
SCP = Client{Name: "scp", withArg: flagSet("cDFiJloPSX"), port: 'P', paths: true}
)
func flagSet(flags string) map[byte]bool {
set := make(map[byte]bool, len(flags))
for i := 0; i < len(flags); i++ {
set[flags[i]] = true
}
return set
}
// PortArgs returns the arguments that set port for this client.
func (c Client) PortArgs(port string) []string { return []string{"-" + string(c.port), port} }
// UserArgs returns the arguments that set the login user for this client.
func (c Client) UserArgs(user string) []string {
if c.user == 0 {
return []string{"-o", "User=" + user}
}
return []string{"-" + string(c.user), user}
}
// Operand is a remote scp argument such as user@host:/path.
type Operand struct {
Idx int
User string
Host string
Path string
}
// Operands returns the remote operands of scp arguments. All remote operands
// must name the same host, because one set of options applies per call.
func (c Client) Operands(args []string) ([]Operand, error) {
start := c.optionsEnd(args)
var out []Operand
for i := start; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") {
return nil, fmt.Errorf("%q: use host:path instead of a URI", arg)
}
dest, path, remote := splitRemote(arg)
if !remote {
continue
}
host, user := splitUserHost(dest)
if host == "" {
continue
}
if len(out) > 0 && !strings.EqualFold(out[0].Host, host) {
return nil, fmt.Errorf("scp between two remote hosts (%s, %s) is not supported; copy in two steps", out[0].Host, host)
}
out = append(out, Operand{Idx: i, User: user, Host: host, Path: path})
}
return out, nil
}
// optionsEnd returns the index of the first operand; scp, like ssh, stops
// option parsing at the first non-option or after "--".
func (c Client) optionsEnd(args []string) int {
end := c.insertionPoint(args)
if end < len(args) && args[end] == "--" {
end++
}
return end
}
// insertionPoint is where added options still count as options: before the
// first operand or before "--".
func (c Client) insertionPoint(args []string) int {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return i
}
if !strings.HasPrefix(arg, "-") || len(arg) < 2 {
return i
}
if c.takesSeparateValue(arg) {
i++
}
}
return len(args)
}
// splitRemote mirrors scp's colon(): an argument is remote when a ':' comes
// before any '/'. A leading "[" starts a bracketed IPv6 address.
func splitRemote(arg string) (dest, path string, remote bool) {
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") {
return "", "", false
}
inBracket := false
for i := 0; i < len(arg); i++ {
switch arg[i] {
case '[':
inBracket = true
case ']':
inBracket = false
case '/':
if !inBracket {
return "", "", false
}
case ':':
if !inBracket {
return arg[:i], arg[i+1:], true
}
}
}
return "", "", false
}
// HasForwardFlag reports whether -L, -R, or -D (or the -o equivalents) was given.
func (c Client) HasForwardFlag(args []string) bool {
return c.hasOption(args, 'L', "LocalForward") || c.hasOption(args, 'R', "RemoteForward") || c.hasOption(args, 'D', "DynamicForward")
}
+156
View File
@@ -0,0 +1,156 @@
package ssh
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestSFTPParse(t *testing.T) {
// -l is a bandwidth limit and -s takes a value for sftp, unlike ssh.
parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"})
if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 {
t.Fatalf("got %+v", parsed)
}
if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" {
t.Fatalf("instance-qualified host: %+v", p)
}
if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) {
t.Fatal("sftp port flag is -P")
}
if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) {
t.Fatal("sftp has no -l user flag")
}
}
func TestSCPOperands(t *testing.T) {
ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"})
if err != nil {
t.Fatal(err)
}
want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}}
if !reflect.DeepEqual(ops, want) {
t.Fatalf("got %+v", ops)
}
if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 {
t.Fatalf("local copy has remote operands: %+v", ops)
}
if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil {
t.Fatal("two remote hosts accepted")
}
if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil {
t.Fatal("URI accepted")
}
}
func TestSFTPApply(t *testing.T) {
args := []string{"-P", "2222", "ops@web01:/var/log"}
opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}}
got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args
want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want)
}
}
func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) {
args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"}
got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args
want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
// sftp needs brackets even without a path; ssh never does.
if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" {
t.Fatalf("sftp: %v", got)
}
if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" {
t.Fatalf("ssh: %v", got.Args)
}
}
func TestSFTPTargetName(t *testing.T) {
got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
}
func TestClientUserAndPortArgs(t *testing.T) {
if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) {
t.Fatal(got)
}
if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) {
t.Fatal(got)
}
if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) {
t.Fatal(got)
}
}
func TestClientExtractWrapperFlags(t *testing.T) {
// For scp, -X takes a value; for ssh it does not.
flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"})
if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"})
if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} {
if _, _, err := ExtractWrapperFlags(args); err == nil {
t.Errorf("%v accepted", args)
}
}
}
func TestForwardArgs(t *testing.T) {
available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available)
want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("got %v %v", got, err)
}
if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") {
t.Fatalf("unknown name: %v", err)
}
if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") {
t.Fatalf("no forwards: %v", err)
}
}
func TestApplyForwardsAndTunnel(t *testing.T) {
flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}}
if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) {
t.Fatalf("forward: %v", got)
}
flags.Tunnel = true
result := apply([]string{"web"}, config.SSHOptions{}, flags)
want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"}
if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") {
t.Fatalf("tunnel: %v %q", result.Args, result.Notice)
}
// The user's own ExitOnForwardFailure wins.
if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") {
t.Fatalf("overrode user option: %v", got)
}
}
func TestHasForwardFlag(t *testing.T) {
for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} {
if got := SSH.HasForwardFlag(strings.Fields(args)); got != want {
t.Errorf("%q: got %v", args, got)
}
}
}
func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args
if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
+8 -5
View File
@@ -9,11 +9,14 @@ import (
// Exec replaces the current process with the native ssh client via syscall.Exec.
// All existing SSH configs, keys, and agent forwarding remain intact.
func Exec(args []string) error {
sshPath, err := exec.LookPath("ssh")
func Exec(args []string) error { return SSH.Exec(args) }
// Exec replaces the current process with the client's binary.
func (c Client) Exec(args []string) error {
path, err := exec.LookPath(c.Name)
if err != nil {
return fmt.Errorf("ssh not found in PATH: %w", err)
return fmt.Errorf("%s not found in PATH: %w", c.Name, err)
}
argv := append([]string{"ssh"}, args...)
return syscall.Exec(sshPath, argv, os.Environ())
argv := append([]string{c.Name}, args...)
return syscall.Exec(path, argv, os.Environ())
}
+99 -16
View File
@@ -12,14 +12,24 @@ import (
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
// so these never collide with flags meant for ssh.
type WrapperFlags struct {
DryRun bool
Jump string // --jump HOST overrides the configured jump host
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
DryRun bool
Jump string // --jump HOST overrides the configured jump host
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
Forwards []string // --forward NAME|SPEC, repeatable (ssh only)
// ForwardArgs are Forwards resolved by ForwardArgs; Apply inserts them.
ForwardArgs []string
// Tunnel runs ssh without a remote command (-N), for netssh tunnel.
Tunnel bool
}
// ExtractWrapperFlags removes netssh's long options that appear before the
// destination. Anything after the destination belongs to the remote command.
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
return SSH.ExtractWrapperFlags(args)
}
// ExtractWrapperFlags removes netssh's long options before the destination.
func (c Client) ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
var flags WrapperFlags
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
@@ -37,6 +47,14 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
flags.Jump = args[i]
case strings.HasPrefix(arg, "--jump="):
flags.Jump = strings.TrimPrefix(arg, "--jump=")
case arg == "--forward":
if i+1 >= len(args) {
return flags, nil, fmt.Errorf("--forward requires a name or spec")
}
i++
flags.Forwards = append(flags.Forwards, args[i])
case strings.HasPrefix(arg, "--forward="):
flags.Forwards = append(flags.Forwards, strings.TrimPrefix(arg, "--forward="))
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
// Destination or end of options: keep the rest unchanged.
return flags, append(out, args[i:]...), validateJump(flags)
@@ -45,7 +63,7 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
default:
out = append(out, arg)
// Keep the value of an ssh option such as -l USER together with it.
if takesSeparateValue(arg) && i+1 < len(args) {
if c.takesSeparateValue(arg) && i+1 < len(args) {
i++
out = append(out, args[i])
}
@@ -64,14 +82,48 @@ func validateJump(flags WrapperFlags) error {
if strings.HasPrefix(flags.Jump, "-") {
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
}
for _, forward := range flags.Forwards {
if forward == "" || strings.HasPrefix(forward, "-") || strings.ContainsAny(forward, " \t\n") {
return fmt.Errorf("--forward: invalid value %q", forward)
}
}
return nil
}
var adHocForward = regexp.MustCompile(`^([^\s:]+:)?[0-9]+(:[^\s]+:[0-9]+)?$`)
// ForwardArgs resolves forward names against the configured forwards. A value
// that is no configured name but looks like a port or [bind:]port:host:port
// is used as a local forward (-L).
func ForwardArgs(names []string, available map[string]config.Forward) ([]string, error) {
var out []string
for _, name := range names {
if forward, ok := available[strings.ToLower(name)]; ok {
out = append(out, forward.Flag()...)
continue
}
if strings.Contains(name, ":") && adHocForward.MatchString(name) {
out = append(out, "-L", name)
continue
}
known := make([]string, 0, len(available))
for key := range available {
known = append(known, key)
}
sort.Strings(known)
if len(known) == 0 {
return nil, fmt.Errorf("unknown forward %q; no forwards are configured for this host (ssh.forwards), or pass a spec such as 8080:localhost:80", name)
}
return nil, fmt.Errorf("unknown forward %q; available: %s", name, strings.Join(known, ", "))
}
return out, nil
}
// takesSeparateValue reports whether a short option cluster ends with a flag
// whose value is the next argument (e.g. "-l" or "-vp").
func takesSeparateValue(arg string) bool {
func (c Client) takesSeparateValue(arg string) bool {
for j := 1; j < len(arg); j++ {
if flagsWithArg[arg[j]] {
if c.withArg[arg[j]] {
return j == len(arg)-1
}
}
@@ -96,9 +148,17 @@ var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])
// options directly before it. Explicit options given by the user come first,
// and ssh uses the first -o value it obtains, so the user's choice wins.
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
return SSH.Apply(args, []int{destIdx}, host, opts, flags)
}
// Apply replaces the host in every destination (several only for scp) and
// inserts the configured options before the first one. extra_args and
// forwards are ssh flags and apply to ssh only.
func (c Client) Apply(args []string, dests []int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
destIdx := dests[0]
var inserted []string
add := func(keyword, value string) {
if !hasOption(args[:destIdx], 0, keyword) {
if !c.hasOption(args[:destIdx], 0, keyword) {
inserted = append(inserted, "-o", keyword+"="+value)
}
}
@@ -114,7 +174,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
}
}
userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand")
userJump := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.hasOption(args[:destIdx], 0, "ProxyCommand")
switch {
case userJump:
case flags.NoJump:
@@ -140,7 +200,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
}
}
if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') {
if opts.AddressFamily != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.hasBoolFlag(args[:destIdx], '6') {
add("AddressFamily", opts.AddressFamily)
}
@@ -153,19 +213,42 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
add(key, opts.Options[key])
}
var extra []string
if c.Name == SSH.Name {
extra = opts.ExtraArgs
inserted = append(inserted, flags.ForwardArgs...)
if flags.Tunnel {
add("ExitOnForwardFailure", "yes")
inserted = append(inserted, "-N")
forwards := strings.Join(flags.ForwardArgs, " ")
if forwards == "" {
forwards = "the given ports"
}
tunnel := fmt.Sprintf("forwarding %s via %s, press Ctrl+C to stop", forwards, host.Name)
if notice == "" {
notice = tunnel
} else {
notice += "\nnetssh: " + tunnel
}
}
}
// Extra arguments go first: short flags such as -4 or -A take the last
// value given, so the user's own flags later on the line still win.
replaced := ReplaceHost(args, destIdx, dest)
out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted))
out = append(out, opts.ExtraArgs...)
out = append(out, replaced[:destIdx]...)
// Options go before the first operand: scp stops reading options there,
// and after "--" they would be taken as the destination.
at := c.insertionPoint(args)
replaced := c.replaceHost(args, dests, dest)
out := make([]string, 0, len(extra)+len(replaced)+len(inserted))
out = append(out, extra...)
out = append(out, replaced[:at]...)
out = append(out, inserted...)
out = append(out, replaced[destIdx:]...)
out = append(out, replaced[at:]...)
return Result{Args: out, Notice: notice}
}
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
func hasBoolFlag(args []string, wanted byte) bool {
func (c Client) hasBoolFlag(args []string, wanted byte) bool {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
@@ -175,7 +258,7 @@ func hasBoolFlag(args []string, wanted byte) bool {
if arg[j] == wanted {
return true
}
if flagsWithArg[arg[j]] {
if c.withArg[arg[j]] {
if j == len(arg)-1 {
i++
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestExtractWrapperFlags(t *testing.T) {
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
} {
flags, rest, err := ExtractWrapperFlags(tc.args)
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
if err != nil || !reflect.DeepEqual(flags, tc.flags) || !reflect.DeepEqual(rest, tc.rest) {
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
}
}
+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")
}
}