From a26e96cd329bf942b5c4d9bf1073cee2f6741b2f Mon Sep 17 00:00:00 2001 From: Sebi Date: Wed, 28 Oct 2026 10:54:26 +0000 Subject: [PATCH 1/6] fix(deps): update module charm.land/bubbletea/v2 to v2.1.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d04c6c8..65482a4 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( charm.land/bubbles/v2 v2.2.1 - charm.land/bubbletea/v2 v2.1.0 + charm.land/bubbletea/v2 v2.1.1 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 8647fb0..97ea624 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= -charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= -charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= +charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU= +charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= From b3f40ac3ad897b60b64b6e0d627fa111252a0048 Mon Sep 17 00:00:00 2001 From: Sebi Date: Mon, 9 Nov 2026 13:44:02 +0000 Subject: [PATCH 2/6] fix(deps): update module charm.land/lipgloss/v2 to v2.1.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d04c6c8..198ba7c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/huh/v2 v2.0.3 - charm.land/lipgloss/v2 v2.0.6 + charm.land/lipgloss/v2 v2.1.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 golang.org/x/sys v0.49.0 diff --git a/go.sum b/go.sum index 8647fb0..15e4162 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= -charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +charm.land/lipgloss/v2 v2.1.0 h1:RLCNLhSQST+NPOmvUSbRWZ3UFOXsf3ZXFmJ5E/2tHEU= +charm.land/lipgloss/v2 v2.1.0/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= From bbc04723b476965d9924b369ed8f06c46ec02d82 Mon Sep 17 00:00:00 2001 From: Sebi Date: Tue, 10 Nov 2026 17:45:15 +0000 Subject: [PATCH 3/6] chore(deps): update go module directive to v1.27.4 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d04c6c8..1741401 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.zb-server.de/Sebi/ssh-netbox-wrapper -go 1.27.3 +go 1.27.4 require ( charm.land/bubbles/v2 v2.2.1 From d51c040d9f3be7c59767608cfc83b1f8d740f62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Fri, 13 Nov 2026 22:42:13 +0100 Subject: [PATCH 4/6] feat(configure): edit every config option in the configurator After the NetBox instances, netssh configure now opens a menu with all sections: SSH defaults, global SSH options (jump host, host key checking, target, address family, extra args, -o options), SSH rules, resolver & cache (including the cache file), shortcuts & command alias, and completion.max_results. SSH options can also be set per instance. The wizard rebuilt the config from its own fields, so re-running it dropped global SSH options, ssh.rules and completion.max_results from the file. It now edits a copy of the loaded config, and save validates SSH options and rules before writing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 15 +- internal/config/ssh.go | 36 +++- internal/setup/instances.go | 12 +- internal/setup/ssh.go | 229 ++++++++++++++++++++ internal/setup/ssh_test.go | 72 +++++++ internal/setup/wizard.go | 391 ++++++++++++++++++++-------------- internal/setup/wizard_test.go | 43 ++++ 8 files changed, 630 insertions(+), 174 deletions(-) create mode 100644 internal/setup/ssh.go create mode 100644 internal/setup/ssh_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ffee5b7..ab37c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.0.23 + +- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list. +- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving. +- The configurator validates SSH options and rules before writing the config. + ## v0.0.22 - Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names. diff --git a/README.md b/README.md index 1b157ea..024e352 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,19 @@ Re-run it at any time to change settings without editing the file manually: netssh configure ``` -The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias, -then saves to `~/.config/netssh.yaml`. +The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. +A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**: + +| 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 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, `shell.alias` | +| Completion | `completion.max_results` | + +The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing. ### Manual config diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 0e55e09..df8a905 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -50,32 +50,46 @@ var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`) // ValidateSSH rejects values that would be passed to ssh in an unexpected form. func (c *Config) ValidateSSH() error { - if err := c.SSH.SSHOptions.validate("ssh"); err != nil { + if err := c.SSH.SSHOptions.Validate("ssh"); err != nil { return err } for i, rule := range c.SSH.Rules { - if err := rule.SSHOptions.validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil { + if err := rule.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil { return err } - if rule.Match == (SSHMatch{}) { - return fmt.Errorf("ssh.rules[%d]: match must set at least one of tag, instance, kind, name", i) - } - if _, err := path.Match(rule.Match.Name, ""); err != nil { - return fmt.Errorf("ssh.rules[%d]: invalid name pattern %q", i, rule.Match.Name) - } } - if err := c.NetBox.SSH.validate("netbox.ssh"); err != nil { + if err := c.NetBox.SSH.Validate("netbox.ssh"); err != nil { return err } for _, source := range c.NetBoxes { - if err := source.SSH.validate("netboxes." + source.Name + ".ssh"); err != nil { + if err := source.SSH.Validate("netboxes." + source.Name + ".ssh"); err != nil { return err } } return nil } -func (o SSHOptions) validate(scope string) error { +// Validate checks the match and options of a single rule. +func (r SSHRule) Validate(scope string) error { + if err := r.SSHOptions.Validate(scope); err != nil { + return err + } + return r.Match.Validate(scope) +} + +// Validate requires at least one selector and a well-formed name pattern. +func (m SSHMatch) Validate(scope string) error { + if m == (SSHMatch{}) { + return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope) + } + if _, err := path.Match(m.Name, ""); err != nil { + return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name) + } + return nil +} + +// Validate rejects values that ssh would not accept or would misinterpret. +func (o SSHOptions) Validate(scope string) error { switch o.HostKeyChecking { case "", "strict", "accept-new", "off": default: diff --git a/internal/setup/instances.go b/internal/setup/instances.go index a4a4b8e..2540506 100644 --- a/internal/setup/instances.go +++ b/internal/setup/instances.go @@ -30,6 +30,7 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) { var options []huh.Option[string] for _, source := range sources { options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name)) + options = append(options, huh.NewOption("SSH options for "+source.Name, "ssh:"+source.Name)) options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name)) } options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done")) @@ -53,13 +54,20 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) { if source.Name != name { continue } - if verb == "edit" { + switch verb { + case "edit": edited, err := editInstance(source, sources, name) if err != nil { return nil, err } sources[i] = edited - } else { + case "ssh": + options, err := editSSHOptions("SSH options for "+name, source.SSH) + if err != nil { + return nil, err + } + sources[i].SSH = options + default: remove := false if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil { return nil, err diff --git a/internal/setup/ssh.go b/internal/setup/ssh.go new file mode 100644 index 0000000..1122b44 --- /dev/null +++ b/internal/setup/ssh.go @@ -0,0 +1,229 @@ +package setup + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "charm.land/huh/v2" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides. +func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") } + +// editSSHOptions edits one SSH option layer: global, per instance, or per rule. +func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) { + proxyJump := o.ProxyJump + hostKeyChecking := o.HostKeyChecking + target := o.Target + addressFamily := o.AddressFamily + extraArgs := strings.Join(o.ExtraArgs, "\n") + options := formatOptions(o.Options) + + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Jump host"). + Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere."). + Value(&proxyJump). + Validate(func(s string) error { + return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh") + }), + huh.NewSelect[string](). + Title("Host key checking"). + Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")). + Value(&hostKeyChecking), + huh.NewSelect[string](). + Title("Connection target"). + Description("What is passed to ssh as destination."). + Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")). + Value(&target), + huh.NewSelect[string](). + Title("Address family"). + Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")). + Value(&addressFamily), + huh.NewText(). + Title("Extra arguments"). + Description("One argument per line, inserted before the destination, e.g. -A"). + Lines(3). + Value(&extraArgs), + huh.NewText(). + Title("SSH options"). + Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60"). + Lines(4). + Value(&options). + Validate(func(s string) error { + _, err := parseOptions(s) + return err + }), + ).Title(title)).Run() + if err != nil { + return o, err + } + + parsed, _ := parseOptions(options) + return config.SSHOptions{ + ProxyJump: strings.TrimSpace(proxyJump), + HostKeyChecking: hostKeyChecking, + Target: target, + AddressFamily: addressFamily, + ExtraArgs: parseLines(extraArgs), + Options: parsed, + }, nil +} + +// parseLines returns the non-empty, trimmed lines of s. +func parseLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} + +// parseOptions reads Key=Value lines into the map stored under ssh.options. +func parseOptions(s string) (map[string]string, error) { + var out map[string]string + for _, line := range parseLines(s) { + key, value, ok := strings.Cut(line, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("%q: expected Key=Value", line) + } + if out == nil { + out = make(map[string]string) + } + out[key] = strings.TrimSpace(value) + } + if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil { + return nil, err + } + return out, nil +} + +func formatOptions(options map[string]string) string { + keys := make([]string, 0, len(options)) + for key := range options { + keys = append(keys, key) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, key+"="+options[key]) + } + return strings.Join(lines, "\n") +} + +// configureRules edits a copy of the rules; the caller decides whether to keep it. +func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) { + rules = append([]config.SSHRule(nil), rules...) + for { + action := "done" + var options []huh.Option[string] + for i, rule := range rules { + index := strconv.Itoa(i) + options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index)) + options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index)) + } + options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done")) + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("SSH rules"). + Description("Rules apply SSH options to hosts matching every set field. Later rules win."). + Options(options...). + Value(&action))).Run() + if err != nil { + return nil, err + } + if action == "done" { + return rules, nil + } + if action == "add" { + rule, err := editRule(config.SSHRule{}, sources) + if err != nil { + return nil, err + } + rules = append(rules, rule) + continue + } + verb, index, _ := strings.Cut(action, ":") + i, _ := strconv.Atoi(index) + if verb == "edit" { + rule, err := editRule(rules[i], sources) + if err != nil { + return nil, err + } + rules[i] = rule + continue + } + remove := false + if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil { + return nil, err + } + if remove { + rules = append(rules[:i], rules[i+1:]...) + } + } +} + +func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) { + match := rule.Match + instances := []huh.Option[string]{huh.NewOption("(any)", "")} + known := false + for _, source := range sources { + instances = append(instances, huh.NewOption(source.Name, source.Name)) + known = known || strings.EqualFold(source.Name, match.Instance) + } + if match.Instance != "" && !known { + instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance)) + } + + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Tag"). + Description("NetBox tag name or slug, e.g. dmz"). + Value(&match.Tag), + huh.NewSelect[string](). + Title("Instance"). + Options(instances...). + Value(&match.Instance), + huh.NewSelect[string](). + Title("Kind"). + Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")). + Value(&match.Kind), + huh.NewInput(). + Title("Name pattern"). + Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com"). + Value(&match.Name). + Validate(func(s string) error { + candidate := match + candidate.Tag = strings.TrimSpace(candidate.Tag) + candidate.Name = strings.TrimSpace(s) + return candidate.Validate("rule") + }), + ).Title("Rule match")).Run() + if err != nil { + return rule, err + } + match.Tag = strings.TrimSpace(match.Tag) + match.Name = strings.TrimSpace(match.Name) + + options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions) + if err != nil { + return rule, err + } + return config.SSHRule{Match: match, SSHOptions: options}, nil +} + +func describeMatch(m config.SSHMatch) string { + var parts []string + for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} { + if field[1] != "" { + parts = append(parts, field[0]+"="+field[1]) + } + } + return "[" + strings.Join(parts, " ") + "]" +} diff --git a/internal/setup/ssh_test.go b/internal/setup/ssh_test.go new file mode 100644 index 0000000..c8ce079 --- /dev/null +++ b/internal/setup/ssh_test.go @@ -0,0 +1,72 @@ +package setup + +import ( + "reflect" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func TestParseLines(t *testing.T) { + got := parseLines(" -A \n\n\t-C\n \n") + if want := []string{"-A", "-C"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + if got := parseLines(" \n "); got != nil { + t.Fatalf("blank input: got %q, want nil", got) + } +} + +func TestParseOptionsRoundtrip(t *testing.T) { + text := "ServerAliveInterval = 60\n\nForwardAgent=yes\nLocalCommand=echo a=b\n" + got, err := parseOptions(text) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"ServerAliveInterval": "60", "ForwardAgent": "yes", "LocalCommand": "echo a=b"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + again, err := parseOptions(formatOptions(got)) + if err != nil || !reflect.DeepEqual(again, want) { + t.Fatalf("roundtrip: got %v (%v)", again, err) + } + if formatOptions(got) != "ForwardAgent=yes\nLocalCommand=echo a=b\nServerAliveInterval=60" { + t.Fatalf("formatOptions not sorted: %q", formatOptions(got)) + } +} + +func TestParseOptionsEmpty(t *testing.T) { + got, err := parseOptions("\n \n") + if err != nil || got != nil { + t.Fatalf("got %v, %v; want nil, nil", got, err) + } +} + +func TestParseOptionsInvalid(t *testing.T) { + for _, text := range []string{"ServerAliveInterval", "=60", "Bad-Key=1", "1Key=1"} { + if _, err := parseOptions(text); err == nil { + t.Errorf("%q: expected error", text) + } + } +} + +func TestDescribeMatch(t *testing.T) { + got := describeMatch(config.SSHMatch{Tag: "dmz", Name: "*.dmz"}) + if got != "[tag=dmz name=*.dmz]" { + t.Fatalf("got %q", got) + } +} + +func TestRuleValidation(t *testing.T) { + if err := (config.SSHMatch{}).Validate("rule"); err == nil || !strings.Contains(err.Error(), "at least one") { + t.Fatalf("empty match: got %v", err) + } + if err := (config.SSHMatch{Name: "[web"}).Validate("rule"); err == nil { + t.Fatal("invalid glob accepted") + } + if err := (config.SSHMatch{Kind: "vm"}).Validate("rule"); err != nil { + t.Fatalf("valid match rejected: %v", err) + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 9b78b26..9c5f25e 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -17,163 +17,19 @@ import ( "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" ) -// RunWizard runs the interactive setup form, pre-filled with any existing cfg values. +// RunWizard runs the interactive configurator, pre-filled with any existing cfg values. // It saves the result to the config file on success. func RunWizard(cfg *config.Config) error { sources, err := configureInstances(cfg) if err != nil { - if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(os.Stderr, "Setup cancelled.") - os.Exit(0) - } - return err + return cancelled(err) } - defaultUser := cfg.SSH.DefaultUser - defaultPort := "" - if cfg.SSH.DefaultPort > 0 { - defaultPort = strconv.Itoa(cfg.SSH.DefaultPort) - } - strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") - subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") - interfaceName := cfg.Resolver.InterfaceName - cacheTTL := strconv.Itoa(cfg.Cache.TTL) - shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") - stripHyphens := cfg.Shortcuts.StripHyphens - commandAlias := cfg.Shell.Alias - - if strategiesRaw == "" { - strategiesRaw = "primary_ip" - } - if cacheTTL == "0" { - cacheTTL = "3600" + work := prepare(*cfg, sources) + if err := runMenu(&work); err != nil { + return cancelled(err) } - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Default SSH user"). - Description("Leave empty to use your system user ($USER)."). - Value(&defaultUser), - huh.NewInput(). - Title("Default SSH port"). - Description("Leave empty to use the standard port (22)."). - Placeholder("22"). - Value(&defaultPort). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - n, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil || n < 1 || n > 65535 { - return errors.New("must be a port number between 1 and 65535") - } - return nil - }), - ).Title("SSH defaults"), - - huh.NewGroup( - huh.NewInput(). - Title("Resolver strategies"). - Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name"). - Placeholder("primary_ip, management_subnet"). - Value(&strategiesRaw). - Validate(validateStrategies), - huh.NewInput(). - Title("Management subnets"). - Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active."). - Value(&subnets), - huh.NewInput(). - Title("Interface name"). - Description("Only used when interface_name strategy is active."). - Placeholder("eth0"). - Value(&interfaceName), - huh.NewInput(). - Title("Cache TTL (seconds)"). - Value(&cacheTTL). - Validate(func(s string) error { - if _, err := strconv.Atoi(s); err != nil { - return errors.New("must be a number") - } - return nil - }), - ).Title("Resolver & cache"), - - huh.NewGroup( - huh.NewInput(). - Title("Domain suffixes"). - Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'."). - Placeholder(".example.com"). - Value(&shortcutDomains), - huh.NewConfirm(). - Title("Strip hyphens"). - Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache."). - Value(&stripHyphens), - huh.NewInput(). - Title("Command alias"). - Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). - Placeholder("j"). - Value(&commandAlias). - Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }), - ).Title("Shortcuts"), - ) - - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(os.Stderr, "Setup cancelled.") - os.Exit(0) - } - return err - } - - strategies := parseStrategies(strategiesRaw) - - ttl, _ := strconv.Atoi(cacheTTL) - - port := 0 - if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil { - port = p - } - - var subnetList []string - for _, s := range strings.Split(subnets, ",") { - if s = strings.TrimSpace(s); s != "" { - subnetList = append(subnetList, s) - } - } - - var domainList []string - for _, s := range strings.Split(shortcutDomains, ",") { - if s = strings.TrimSpace(s); s != "" { - if !strings.HasPrefix(s, ".") { - s = "." + s - } - domainList = append(domainList, s) - } - } - - out := config.Config{ - Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)}, - NetBoxes: sources, - SSH: config.SSHConfig{ - DefaultUser: strings.TrimSpace(defaultUser), - DefaultPort: port, - }, - Resolver: config.ResolverConfig{ - Strategies: strategies, - ManagementSubnets: subnetList, - InterfaceName: strings.TrimSpace(interfaceName), - }, - Cache: config.CacheConfig{ - TTL: ttl, - Path: cfg.Cache.Path, - }, - Shortcuts: config.ShortcutsConfig{ - Domains: domainList, - StripHyphens: stripHyphens, - }, - } - - if err := save(out); err != nil { + if err := save(work); err != nil { return err } loaded, err := config.Load() @@ -202,6 +58,228 @@ func RunWizard(cfg *config.Config) error { return nil } +func cancelled(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(os.Stderr, "Setup cancelled.") + os.Exit(0) + } + return err +} + +// prepare starts from the loaded config so settings without a menu entry survive +// a rewrite, migrates a legacy single instance, and fills in usable defaults. +func prepare(cfg config.Config, sources []config.NetBoxConfig) config.Config { + cfg.NetBox = config.NetBoxConfig{} + cfg.NetBoxes = sources + if len(cfg.Resolver.Strategies) == 0 { + cfg.Resolver.Strategies = []string{"primary_ip"} + } + if cfg.Cache.TTL == 0 { + cfg.Cache.TTL = 3600 + } + return cfg +} + +func runMenu(cfg *config.Config) error { + for { + action := "save" + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("netssh configuration"). + 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(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"), + huh.NewOption("Completion", "completion"), + huh.NewOption("Save and exit", "save"), + ). + Value(&action))).Run() + if err != nil { + return err + } + switch action { + case "defaults": + err = editSSHDefaults(cfg) + case "ssh": + cfg.SSH.SSHOptions, err = editSSHOptions("SSH options (all hosts)", cfg.SSH.SSHOptions) + case "rules": + var rules []config.SSHRule + if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil { + cfg.SSH.Rules = rules + } + case "resolver": + err = editResolverCache(cfg) + case "shortcuts": + err = editShortcuts(cfg) + case "completion": + err = editCompletion(cfg) + case "save": + return nil + } + if err != nil { + return err + } + } +} + +func editSSHDefaults(cfg *config.Config) error { + defaultUser := cfg.SSH.DefaultUser + defaultPort := "" + if cfg.SSH.DefaultPort > 0 { + defaultPort = strconv.Itoa(cfg.SSH.DefaultPort) + } + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Default SSH user"). + Description("Leave empty to use your system user ($USER)."). + Value(&defaultUser), + huh.NewInput(). + Title("Default SSH port"). + Description("Leave empty to use the standard port (22)."). + Placeholder("22"). + Value(&defaultPort). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 || n > 65535 { + return errors.New("must be a port number between 1 and 65535") + } + return nil + }), + ).Title("SSH defaults")).Run() + if err != nil { + return err + } + cfg.SSH.DefaultUser = strings.TrimSpace(defaultUser) + cfg.SSH.DefaultPort, _ = strconv.Atoi(strings.TrimSpace(defaultPort)) + return nil +} + +func editResolverCache(cfg *config.Config) error { + strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") + subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") + interfaceName := cfg.Resolver.InterfaceName + cacheTTL := strconv.Itoa(cfg.Cache.TTL) + cachePath := cfg.Cache.Path + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Resolver strategies"). + Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name"). + Placeholder("primary_ip, management_subnet"). + Value(&strategiesRaw). + Validate(validateStrategies), + huh.NewInput(). + Title("Management subnets"). + Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active."). + Value(&subnets), + huh.NewInput(). + Title("Interface name"). + Description("Only used when interface_name strategy is active."). + Placeholder("eth0"). + Value(&interfaceName), + huh.NewInput(). + Title("Cache TTL (seconds)"). + Value(&cacheTTL). + Validate(func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return errors.New("must be a number") + } + return nil + }), + huh.NewInput(). + Title("Cache file"). + Description("Where resolved hosts are stored. Leave empty for the default location."). + Value(&cachePath), + ).Title("Resolver & cache")).Run() + if err != nil { + return err + } + cfg.Resolver.Strategies = parseStrategies(strategiesRaw) + cfg.Resolver.ManagementSubnets = splitList(subnets) + cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName) + cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL) + cfg.Cache.Path = strings.TrimSpace(cachePath) + return nil +} + +func editShortcuts(cfg *config.Config) error { + shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") + stripHyphens := cfg.Shortcuts.StripHyphens + commandAlias := cfg.Shell.Alias + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Domain suffixes"). + Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'."). + Placeholder(".example.com"). + Value(&shortcutDomains), + huh.NewConfirm(). + Title("Strip hyphens"). + Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache."). + Value(&stripHyphens), + huh.NewInput(). + Title("Command alias"). + Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). + Placeholder("j"). + Value(&commandAlias). + Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }), + ).Title("Shortcuts")).Run() + if err != nil { + return err + } + cfg.Shortcuts.Domains = nil + for _, s := range splitList(shortcutDomains) { + if !strings.HasPrefix(s, ".") { + s = "." + s + } + cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s) + } + cfg.Shortcuts.StripHyphens = stripHyphens + cfg.Shell.Alias = strings.TrimSpace(commandAlias) + return nil +} + +func editCompletion(cfg *config.Config) error { + maxResults := "" + if cfg.Completion.MaxResults > 0 { + maxResults = strconv.Itoa(cfg.Completion.MaxResults) + } + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Maximum completion results"). + Description("Limits the hostnames offered per Tab completion. Leave empty for unlimited."). + Value(&maxResults). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + if n, err := strconv.Atoi(strings.TrimSpace(s)); err != nil || n < 0 { + return errors.New("must be a non-negative number") + } + return nil + }), + ).Title("Completion")).Run() + if err != nil { + return err + } + cfg.Completion.MaxResults, _ = strconv.Atoi(strings.TrimSpace(maxResults)) + return nil +} + +// splitList returns the non-empty, trimmed entries of a comma-separated list. +func splitList(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + var knownStrategies = map[string]bool{ "primary_ip": true, "management_subnet": true, @@ -220,15 +298,7 @@ func validateStrategies(s string) error { return nil } -func parseStrategies(s string) []string { - var out []string - for _, part := range strings.Split(s, ",") { - if name := strings.TrimSpace(part); name != "" { - out = append(out, name) - } - } - return out -} +func parseStrategies(s string) []string { return splitList(s) } func save(cfg config.Config) error { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { @@ -237,6 +307,9 @@ func save(cfg config.Config) error { if err := cfg.ValidateNetBoxes(); err != nil { return err } + if err := cfg.ValidateSSH(); err != nil { + return err + } path := config.Path() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating config dir: %w", err) diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index 4469300..d2a3ead 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -455,3 +455,46 @@ func TestSaveKeepsSSHOptions(t *testing.T) { t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH) } } + +// Re-running the configurator must keep settings that have no form field of +// their own or were not touched in the menu. +func TestPrepareKeepsUnmanagedSettings(t *testing.T) { + cfg := config.Config{ + NetBox: config.NetBoxConfig{URL: "https://legacy.example", Token: "t"}, + SSH: config.SSHConfig{ + DefaultUser: "root", + SSHOptions: config.SSHOptions{ProxyJump: "jh.example.com", Options: map[string]string{"forwardagent": "yes"}}, + Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}}, + }, + Completion: config.CompletionConfig{MaxResults: 50}, + Cache: config.CacheConfig{Path: "/var/cache/netssh/hosts.json"}, + } + sources := []config.NetBoxConfig{{Name: "default", URL: "https://legacy.example", Token: "t"}} + got := prepare(cfg, sources) + + if !reflect.DeepEqual(got.SSH, cfg.SSH) || got.Completion != cfg.Completion || got.Cache.Path != cfg.Cache.Path { + t.Fatalf("settings lost: %+v", got) + } + if got.NetBox.URL != "" || !reflect.DeepEqual(got.NetBoxes, sources) { + t.Fatalf("legacy instance not migrated: %+v / %+v", got.NetBox, got.NetBoxes) + } + if !reflect.DeepEqual(got.Resolver.Strategies, []string{"primary_ip"}) || got.Cache.TTL != 3600 { + t.Fatalf("defaults missing: %+v %+v", got.Resolver, got.Cache) + } +} + +func TestSaveRejectsInvalidSSHOptions(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + SSH: config.SSHConfig{Rules: []config.SSHRule{{SSHOptions: config.SSHOptions{ProxyJump: "none"}}}}, + } + if err := save(cfg); err == nil { + t.Fatal("rule without match was saved") + } + if _, err := os.Stat(config.Path()); !os.IsNotExist(err) { + t.Fatalf("config file written despite error: %v", err) + } +} From 878701aba70decf24ae22cae0dbd589a9616d91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Fri, 13 Nov 2026 22:42:13 +0100 Subject: [PATCH 5/6] feat(shortcuts): regex rewrite rules for host shortcuts shortcuts.rewrites applies regex replacements after the domain suffix is stripped, e.g. ^([a-z]+)[0-9]+- -> $1 turns fsn1-bx1.hoster.de into fsnbx1. The shortcut works for connecting (cached hosts), shell aliases, and Tab completion, where it is offered when the full name does not match the typed text. Rules are editable in netssh configure, and netssh alias --test HOSTNAME shows a name's shortcut and the cached hosts it matches. The shortcut lookup only ran when domains or strip_hyphens were set; it now also runs for rewrite-only configurations. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 20 ++++++- cmd/netssh/instances.go | 62 ++++++++++++++++++-- cmd/netssh/instances_test.go | 84 +++++++++++++++++++++++++++- cmd/netssh/main.go | 41 ++++++++++++-- internal/config/config.go | 33 ++++++++++- internal/config/config_test.go | 19 +++++++ internal/hook/aliases_test.go | 11 ++++ internal/setup/wizard.go | 48 +++++++++++++++- internal/setup/wizard_test.go | 44 +++++++++++++++ internal/shortcuts/shortcuts.go | 28 +++++++++- internal/shortcuts/shortcuts_test.go | 37 ++++++++++++ 12 files changed, 408 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab37c59..49a37cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list. - Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving. - 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. ## v0.0.22 diff --git a/README.md b/README.md index 024e352..6bf2069 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ netssh -p 2222 admin@app-server-03 uptime - **Shell completion** — install without sudo via `netssh completion install` - **Default SSH user** — set a fallback username once in config instead of typing it every time - **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line -- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding +- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`) - **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` @@ -115,7 +115,7 @@ A menu then gives access to every other setting; open only the sections you want | SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` 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, `shell.alias` | +| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` | | Completion | `completion.max_results` | The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing. @@ -366,13 +366,26 @@ shortcuts: - .example.com - .example.de strip_hyphens: false # optional + rewrites: # optional, applied in order + - pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1 + replace: '$1' ``` Resolution order: 1. **Exact match** — the input is looked up as-is in the cache. 2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name. -3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first. +3. **Shortcut match** — the input and every cached hostname are shortened the same way and compared: lowercase, strip the domain suffix, apply each rewrite rule in order, then remove hyphens if `strip_hyphens` is set (`fsn1web01` → `fsn1-web01.example.com`, `fsnbx1` → `fsn1-bx1.hoster.de`). This only works when the host is already cached; run `netssh cache refresh` first. + +**Rewrite rules** use Go regular expressions ([syntax](https://pkg.go.dev/regexp/syntax)); `replace` may refer to groups as `$1` or `${1}`. Every match in the name is replaced. Because the typed shortcut is rewritten too, write rules that leave an already shortened name unchanged: `^([a-z]+)[0-9]+-` matches `fsn1-bx1` but not `fsnbx1`. Shortcuts also become shell aliases and are offered by Tab completion when the full name does not match what you typed (`fsnb` → `fsnbx1`). + +Check what a hostname is shortened to, and whether the shortcut is unique in the cache: + +```sh +$ netssh alias --test fsn1-bx1.hoster.de +fsn1-bx1.hoster.de → fsnbx1 +matches fsn1-bx1.hoster.de +``` Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names. @@ -380,6 +393,7 @@ Domains may be configured with or without a leading dot. If several cached hosts |-------|--------|-------------| | `web01` | `domains: [.example.com]` | `web01.example.com` | | `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) | +| `fsnbx1` | `domains: [.hoster.de]`, rewrite `^([a-z]+)[0-9]+-` → `$1` | `fsn1-bx1.hoster.de` (cache only) | ### Shell aliases diff --git a/cmd/netssh/instances.go b/cmd/netssh/instances.go index 2d23f70..206de3a 100644 --- a/cmd/netssh/instances.go +++ b/cmd/netssh/instances.go @@ -41,7 +41,9 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st // all instances is offered bare ("web01"), so it completes like with a single // NetBox; connecting to it still checks every instance for ambiguity. // Names present in several instances stay qualified ("prod/web01"). -func hostCompletions(entries []cache.Entry, toComplete string) []string { +// Hosts whose full name does not match are offered by their configured +// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com). +func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string { prefix := strings.ToLower(toComplete) qualifiedInput := strings.Contains(prefix, "/") instances := make(map[string]int) @@ -49,21 +51,69 @@ func hostCompletions(entries []cache.Entry, toComplete string) []string { _, name := netbox.SplitTarget(e.Name) instances[strings.ToLower(name)]++ } - var out []string + var unmatched []cache.Entry + var names []string for _, e := range entries { source, name := netbox.SplitTarget(e.Name) target := strings.ToLower(e.Name) switch { case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix): if instances[strings.ToLower(name)] == 1 { - out = append(out, name) + names = append(names, name) } else { - out = append(out, e.Name) + names = append(names, e.Name) } case strings.HasPrefix(target, prefix): - out = append(out, e.Name) + names = append(names, e.Name) + default: + unmatched = append(unmatched, e) + } + } + if sc.Enabled() { + names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...) + } + sort.Strings(names) + return names +} + +// shortcutCompletions offers the shortcut of each unmatched host when it starts +// with prefix and selects exactly one host, qualified by instance if needed. +func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string { + bare := make(map[string]int) + qualified := make(map[string]int) + short := func(e cache.Entry) (string, string) { + source, name := netbox.SplitTarget(e.Name) + s := shortcuts.Normalize(name, sc) + if source == "" { + return s, s + } + return s, strings.ToLower(source) + "/" + s + } + for _, e := range all { + b, q := short(e) + bare[b]++ + qualified[q]++ + } + seen := make(map[string]bool) + var out []string + for _, e := range unmatched { + b, q := short(e) + candidate := "" + switch { + case strings.Contains(prefix, "/"): + if qualified[q] == 1 && strings.HasPrefix(q, prefix) { + candidate = q + } + case !strings.HasPrefix(b, prefix): + case bare[b] == 1: + candidate = b + case q != b && qualified[q] == 1: + candidate = q // like full names: zsh accepts it, bash/fish ignore it + } + if candidate != "" && !seen[candidate] { + seen[candidate] = true + out = append(out, candidate) } } - sort.Strings(out) return out } diff --git a/cmd/netssh/instances_test.go b/cmd/netssh/instances_test.go index 02955c6..69412b7 100644 --- a/cmd/netssh/instances_test.go +++ b/cmd/netssh/instances_test.go @@ -228,12 +228,92 @@ func TestHostCompletionsMatchTypedText(t *testing.T) { // An instance name typed without the slash completes qualified targets. {"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}}, } { - if got := hostCompletions(entries, tc.input); !reflect.DeepEqual(got, tc.want) { + if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !reflect.DeepEqual(got, tc.want) { t.Errorf("%q: got %v, want %v", tc.input, got, tc.want) } } // Single-instance caches are unchanged. - if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !reflect.DeepEqual(got, []string{"web01"}) { + if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) { t.Errorf("single instance: %v", got) } } + +var fsnRewrite = config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, +} + +func TestHostCompletionsOfferShortcuts(t *testing.T) { + entries := []cache.Entry{ + {Name: "prod/fsn1-bx1.hoster.de"}, + {Name: "prod/fsn1-db1.hoster.de"}, + {Name: "stg/fsn1-db1.hoster.de"}, + {Name: "prod/web01.hoster.de"}, + } + for _, tc := range []struct { + input string + want []string + }{ + {"fsnb", []string{"fsnbx1"}}, + // Shortcut shared by two instances stays qualified. + {"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}}, + {"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}}, + {"stg/fsnd", []string{"stg/fsndb1"}}, + // A matching full name is not repeated as shortcut. + {"web", []string{"web01.hoster.de"}}, + } { + if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%q: got %v, want %v", tc.input, got, tc.want) + } + } + // Two hosts of the same instance with one shortcut cannot be selected by it. + same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}} + if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 { + t.Errorf("ambiguous shortcut offered: %v", got) + } +} + +func TestShortcutTestOutput(t *testing.T) { + var out bytes.Buffer + printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite) + want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n" + if out.String() != want { + t.Fatalf("got:\n%s", out.String()) + } +} + +// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable, +// so only the cached host can answer. +func TestWrapperConnectsByRewriteShortcut(t *testing.T) { + 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:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n" + 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"}) + if err := c.Save(); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$") + cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "10.0.0.9\n") { + t.Fatalf("shortcut not resolved from cache: %s, %v", out, err) + } +} diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index b8af0aa..1ccf046 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -132,7 +132,7 @@ func runSSHWrapper(args []string) { // 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 - shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens + shortcutsEnabled := cfg.Shortcuts.Enabled() if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache { normalize := normalizeTarget(cfg.Shortcuts, source != "") entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) @@ -339,7 +339,7 @@ func rootCmd() *cobra.Command { c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() - hosts := hostCompletions(c.All(), toComplete) + hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts) if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { hosts = hosts[:limit] } @@ -645,13 +645,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry { } func aliasCmd() *cobra.Command { - var shell string + var shell, test string cmd := &cobra.Command{ Use: "alias", Short: "Print shell aliases for all cached hosts", Long: `Print shell alias definitions for all cached hosts. The alias name is the shortened form derived from the configured shortcuts -(domain suffixes stripped, hyphens optionally stripped). +(domain suffixes stripped, rewrite rules applied, hyphens optionally stripped). + +Check a rule with: netssh alias --test fsn1-bx1.example.com Source the output in your shell profile: bash/zsh: eval "$(netssh alias)" @@ -668,6 +670,10 @@ Or use in a script: if err := c.Load(); err != nil { return err } + if test != "" { + printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts) + return nil + } if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } @@ -694,9 +700,36 @@ Or use in a script: }, } cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)") + cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches") return cmd } +// printShortcutTest helps writing shortcut rules: it shows what a name is +// shortened to and which cached hosts share that shortcut. +func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) { + normalize := normalizeTarget(sc, strings.Contains(name, "/")) + short := normalize(name) + fmt.Fprintf(w, "%s → %s\n", name, short) + var matches []string + for _, e := range entries { + if normalize(e.Name) == short { + matches = append(matches, e.Name) + } + } + sort.Strings(matches) + switch len(matches) { + case 0: + fmt.Fprintln(w, "no cached host has this shortcut") + case 1: + fmt.Fprintf(w, "matches %s\n", matches[0]) + default: + fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches)) + for _, m := range matches { + fmt.Fprintf(w, " %s\n", m) + } + } +} + // shellInitCmd is called at shell startup to update alias files and trigger a // background cache refresh when stale. Intentionally silent — never disrupts the prompt. func shellInitCmd() *cobra.Command { diff --git a/internal/config/config.go b/internal/config/config.go index c498b9c..04d7ea5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,8 +57,34 @@ type CompletionConfig struct { } type ShortcutsConfig struct { - Domains []string `mapstructure:"domains"` - StripHyphens bool `mapstructure:"strip_hyphens"` + Domains []string `mapstructure:"domains"` + StripHyphens bool `mapstructure:"strip_hyphens"` + Rewrites []ShortcutRewrite `mapstructure:"rewrites"` +} + +// ShortcutRewrite replaces every match of Pattern (Go regexp syntax) with +// Replace, which may reference groups as $1. Applied after domain stripping. +type ShortcutRewrite struct { + Pattern string `mapstructure:"pattern"` + Replace string `mapstructure:"replace"` +} + +// Enabled reports whether any shortcut normalization is configured. +func (s ShortcutsConfig) Enabled() bool { + return len(s.Domains) > 0 || s.StripHyphens || len(s.Rewrites) > 0 +} + +// Validate rejects rewrite rules that cannot be compiled. +func (s ShortcutsConfig) Validate() error { + for i, rewrite := range s.Rewrites { + if rewrite.Pattern == "" { + return fmt.Errorf("shortcuts.rewrites[%d]: pattern is required", i) + } + if _, err := regexp.Compile(rewrite.Pattern); err != nil { + return fmt.Errorf("shortcuts.rewrites[%d]: invalid pattern %q: %v", i, rewrite.Pattern, err) + } + } + return nil } // Path returns the canonical config file path. @@ -128,6 +154,9 @@ func Load() (*Config, error) { if err := cfg.ValidateSSH(); err != nil { return nil, err } + if err := cfg.Shortcuts.Validate(); err != nil { + return nil, err + } for i := range cfg.NetBoxes { source := &cfg.NetBoxes[i] source.Name = strings.ToLower(source.Name) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a5594a4..f9e6997 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -177,3 +178,21 @@ func TestCommandAliasEnvironmentAndValidation(t *testing.T) { } } } + +func TestLoad_ShortcutRewrites(t *testing.T) { + dir := writeConfig(t, "shortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n") + cfg := loadFromDir(t, dir) + want := []ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}} + if len(cfg.Shortcuts.Rewrites) != 1 || cfg.Shortcuts.Rewrites[0] != want[0] || !cfg.Shortcuts.Enabled() { + t.Fatalf("rewrites: %+v", cfg.Shortcuts) + } +} + +func TestLoad_InvalidShortcutRewrite(t *testing.T) { + for _, rule := range []string{"pattern: '[a-'", "replace: x"} { + t.Setenv("XDG_CONFIG_HOME", writeConfig(t, "shortcuts:\n rewrites:\n - "+rule+"\n")) + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "shortcuts.rewrites[0]") { + t.Errorf("%s: got %v", rule, err) + } + } +} diff --git a/internal/hook/aliases_test.go b/internal/hook/aliases_test.go index 2facfa2..e7f4b3c 100644 --- a/internal/hook/aliases_test.go +++ b/internal/hook/aliases_test.go @@ -242,3 +242,14 @@ func TestAliasesIncludeInstanceName(t *testing.T) { t.Fatalf("alias lost source: %+v", entries) } } + +func TestAliasesUseRewriteRules(t *testing.T) { + cfg := config.Config{Shortcuts: config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, + }} + entries := hook.BuildAliasEntries([]cache.Entry{{Name: "fsn1-bx1.hoster.de"}}, cfg) + if len(entries) != 1 || entries[0].Definition("bash") != "alias fsnbx1='netssh fsn1-bx1.hoster.de'" { + t.Fatalf("entries: %+v", entries) + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 9c5f25e..7cb5a5e 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -209,6 +209,7 @@ func editResolverCache(cfg *config.Config) error { func editShortcuts(cfg *config.Config) error { shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") stripHyphens := cfg.Shortcuts.StripHyphens + rewrites := formatRewrites(cfg.Shortcuts.Rewrites) commandAlias := cfg.Shell.Alias err := huh.NewForm(huh.NewGroup( huh.NewInput(). @@ -220,6 +221,17 @@ func editShortcuts(cfg *config.Config) error { Title("Strip hyphens"). Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache."). Value(&stripHyphens), + huh.NewText(). + Title("Rewrite rules"). + Description("One rule per line: REGEX => REPLACEMENT, applied after the domain suffix is removed.\n"+ + "Example: ^([a-z]+)[0-9]+- => $1 turns fsn1-bx1.example.com into fsnbx1.\n"+ + "Check rules with: netssh alias --test HOSTNAME"). + Lines(3). + Value(&rewrites). + Validate(func(s string) error { + _, err := parseRewrites(s) + return err + }), huh.NewInput(). Title("Command alias"). Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). @@ -238,6 +250,7 @@ func editShortcuts(cfg *config.Config) error { cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s) } cfg.Shortcuts.StripHyphens = stripHyphens + cfg.Shortcuts.Rewrites, _ = parseRewrites(rewrites) cfg.Shell.Alias = strings.TrimSpace(commandAlias) return nil } @@ -269,6 +282,30 @@ func editCompletion(cfg *config.Config) error { return nil } +// parseRewrites reads "REGEX => REPLACEMENT" lines; the replacement may be empty. +func parseRewrites(s string) ([]config.ShortcutRewrite, error) { + var out []config.ShortcutRewrite + for _, line := range parseLines(s) { + pattern, replace, ok := strings.Cut(line, "=>") + if !ok { + return nil, fmt.Errorf("%q: expected REGEX => REPLACEMENT", line) + } + out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)}) + } + if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil { + return nil, err + } + return out, nil +} + +func formatRewrites(rewrites []config.ShortcutRewrite) string { + lines := make([]string, 0, len(rewrites)) + for _, r := range rewrites { + lines = append(lines, r.Pattern+" => "+r.Replace) + } + return strings.Join(lines, "\n") +} + // splitList returns the non-empty, trimmed entries of a comma-separated list. func splitList(s string) []string { var out []string @@ -310,6 +347,9 @@ func save(cfg config.Config) error { if err := cfg.ValidateSSH(); err != nil { return err } + if err := cfg.Shortcuts.Validate(); err != nil { + return err + } path := config.Path() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating config dir: %w", err) @@ -380,7 +420,7 @@ func save(cfg config.Config) error { fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) } - if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens { + if cfg.Shortcuts.Enabled() { b.WriteString("\nshortcuts:\n") if len(cfg.Shortcuts.Domains) > 0 { b.WriteString(" domains:\n") @@ -391,6 +431,12 @@ func save(cfg config.Config) error { if cfg.Shortcuts.StripHyphens { b.WriteString(" strip_hyphens: true\n") } + if len(cfg.Shortcuts.Rewrites) > 0 { + b.WriteString(" rewrites:\n") + for _, r := range cfg.Shortcuts.Rewrites { + fmt.Fprintf(&b, " - pattern: %q\n replace: %q\n", r.Pattern, r.Replace) + } + } } if cfg.Shell.Alias != "" { diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index d2a3ead..c02778d 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -498,3 +498,47 @@ func TestSaveRejectsInvalidSSHOptions(t *testing.T) { t.Fatalf("config file written despite error: %v", err) } } + +func TestParseRewrites(t *testing.T) { + text := `^([a-z]+)[0-9]+- => $1 + +\.lab$ => +` + got, err := parseRewrites(text) + if err != nil { + t.Fatal(err) + } + want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v", got) + } + if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) { + t.Fatalf("roundtrip: %+v", again) + } + for _, bad := range []string{"no arrow", "[a- => x", " => x"} { + if _, err := parseRewrites(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestSaveShortcutRewritesRoundtrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}} + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + Shortcuts: config.ShortcutsConfig{Rewrites: rewrites}, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) { + t.Fatalf("got %+v", loaded.Shortcuts.Rewrites) + } +} diff --git a/internal/shortcuts/shortcuts.go b/internal/shortcuts/shortcuts.go index bfae38c..f1d5ffb 100644 --- a/internal/shortcuts/shortcuts.go +++ b/internal/shortcuts/shortcuts.go @@ -1,13 +1,30 @@ package shortcuts import ( + "regexp" "strings" + "sync" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" ) -// Normalize strips configured domain suffixes and optionally hyphens from a hostname. -// The result is lowercased for case-insensitive comparison. +// compiled caches rewrite patterns: Normalize runs for every cache entry on each lookup. +var compiled sync.Map // pattern → *regexp.Regexp + +func rewritePattern(pattern string) *regexp.Regexp { + if re, ok := compiled.Load(pattern); ok { + return re.(*regexp.Regexp) + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil // rejected by config.Load already + } + compiled.Store(pattern, re) + return re +} + +// Normalize strips configured domain suffixes, applies rewrite rules in order, and +// optionally strips hyphens. The result is lowercased for case-insensitive comparison. func Normalize(name string, cfg config.ShortcutsConfig) string { s := strings.ToLower(name) for _, domain := range cfg.Domains { @@ -20,6 +37,11 @@ func Normalize(name string, cfg config.ShortcutsConfig) string { break } } + for _, rewrite := range cfg.Rewrites { + if re := rewritePattern(rewrite.Pattern); re != nil { + s = re.ReplaceAllString(s, rewrite.Replace) + } + } if cfg.StripHyphens { s = strings.ReplaceAll(s, "-", "") } @@ -34,7 +56,7 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string { } // AliasName returns a shell-safe alias name for the given host. -// It normalizes the name (strips configured domains and optionally hyphens) then +// It normalizes the name (strips configured domains, applies rewrites, optionally strips hyphens) then // replaces any remaining dots with underscores so the result is a valid identifier. func AliasName(name string, cfg config.ShortcutsConfig) string { s := Normalize(name, cfg) diff --git a/internal/shortcuts/shortcuts_test.go b/internal/shortcuts/shortcuts_test.go index 2ae5aa4..51e96c0 100644 --- a/internal/shortcuts/shortcuts_test.go +++ b/internal/shortcuts/shortcuts_test.go @@ -78,3 +78,40 @@ func TestAliasName_NoConfig(t *testing.T) { t.Errorf("AliasName = %q, want %q", got, want) } } + +func TestNormalizeRewrites(t *testing.T) { + cfg := config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, + } + for input, want := range map[string]string{ + "fsn1-bx1.hoster.de": "fsnbx1", + "FSN1-BX1.hoster.de": "fsnbx1", + "fsn1-bx1": "fsnbx1", + "fsnbx1": "fsnbx1", // the shortcut itself is left alone + "web01.hoster.de": "web01", + } { + if got := shortcuts.Normalize(input, cfg); got != want { + t.Errorf("Normalize(%q) = %q, want %q", input, got, want) + } + } + if got := shortcuts.AliasName("fsn1-bx1.hoster.de", cfg); got != "fsnbx1" { + t.Errorf("AliasName = %q", got) + } +} + +func TestNormalizeRewritesOrder(t *testing.T) { + // Rules run in order after domain stripping, before hyphen stripping. + cfg := config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + StripHyphens: true, + Rewrites: []config.ShortcutRewrite{ + {Pattern: `\.hoster\.de$`, Replace: "-never"}, + {Pattern: `^fsn`, Replace: "f"}, + {Pattern: `^f1`, Replace: "falkenstein"}, + }, + } + if got := shortcuts.Normalize("fsn1-bx-1.hoster.de", cfg); got != "falkensteinbx1" { + t.Fatalf("got %q", got) + } +} From b7fa71fe4581e2f23cdaae084916e555947b7c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Fri, 13 Nov 2026 23:05:07 +0100 Subject: [PATCH 6/6] 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 --- CHANGELOG.md | 4 + README.md | 46 ++++++- cmd/netssh/main.go | 228 ++++++++++++++++++++++++---------- cmd/netssh/transfer.go | 198 +++++++++++++++++++++++++++++ cmd/netssh/transfer_test.go | 130 +++++++++++++++++++ internal/config/ssh.go | 58 +++++++++ internal/config/ssh_test.go | 47 +++++++ internal/setup/ssh.go | 55 ++++++++ internal/setup/ssh_test.go | 20 +++ internal/setup/wizard.go | 20 ++- internal/setup/wizard_test.go | 26 ++++ internal/ssh/args.go | 89 ++++++++----- internal/ssh/client.go | 137 ++++++++++++++++++++ internal/ssh/client_test.go | 156 +++++++++++++++++++++++ internal/ssh/exec.go | 13 +- internal/ssh/options.go | 115 ++++++++++++++--- internal/ssh/options_test.go | 2 +- internal/tui/model.go | 134 +++++++++++++++++--- internal/tui/model_test.go | 52 ++++++++ 19 files changed, 1389 insertions(+), 141 deletions(-) create mode 100644 cmd/netssh/transfer.go create mode 100644 cmd/netssh/transfer_test.go create mode 100644 internal/ssh/client.go create mode 100644 internal/ssh/client_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a37cc..85fbfed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6bf2069..4be7a66 100644 --- a/README.md +++ b/README.md @@ -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 `) 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 | diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 1ccf046..4540fd2 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -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() diff --git a/cmd/netssh/transfer.go b/cmd/netssh/transfer.go new file mode 100644 index 0000000..0653961 --- /dev/null +++ b/cmd/netssh/transfer.go @@ -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 +} diff --git a/cmd/netssh/transfer_test.go b/cmd/netssh/transfer_test.go new file mode 100644 index 0000000..5da6e97 --- /dev/null +++ b/cmd/netssh/transfer_test.go @@ -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) + } + } +} diff --git a/internal/config/ssh.go b/internal/config/ssh.go index df8a905..2cbc49f 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -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 } diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index a6780bc..4e8cf25 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -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) + } + } +} diff --git a/internal/setup/ssh.go b/internal/setup/ssh.go index 1122b44..ec6d569 100644 --- a/internal/setup/ssh.go +++ b/internal/setup/ssh.go @@ -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 { diff --git a/internal/setup/ssh_test.go b/internal/setup/ssh_test.go index c8ce079..c6c7853 100644 --- a/internal/setup/ssh_test.go +++ b/internal/setup/ssh_test.go @@ -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) + } + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 7cb5a5e..5293376 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -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]) + } + } + } + } } diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index c02778d..d968c8c 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -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) + } +} diff --git a/internal/ssh/args.go b/internal/ssh/args.go index f83916b..b36ea5a 100644 --- a/internal/ssh/args.go +++ b/internal/ssh/args.go @@ -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' { diff --git a/internal/ssh/client.go b/internal/ssh/client.go new file mode 100644 index 0000000..3ffb926 --- /dev/null +++ b/internal/ssh/client.go @@ -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") +} diff --git a/internal/ssh/client_test.go b/internal/ssh/client_test.go new file mode 100644 index 0000000..3825a0a --- /dev/null +++ b/internal/ssh/client_test.go @@ -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) + } +} diff --git a/internal/ssh/exec.go b/internal/ssh/exec.go index fd55bb5..2c7e169 100644 --- a/internal/ssh/exec.go +++ b/internal/ssh/exec.go @@ -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()) } diff --git a/internal/ssh/options.go b/internal/ssh/options.go index 0542140..34babb2 100644 --- a/internal/ssh/options.go +++ b/internal/ssh/options.go @@ -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++ } diff --git a/internal/ssh/options_test.go b/internal/ssh/options_test.go index d7ed209..fc749c9 100644 --- a/internal/ssh/options_test.go +++ b/internal/ssh/options_test.go @@ -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) } } diff --git a/internal/tui/model.go b/internal/tui/model.go index a8b903c..4b369b4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 10514f4..21127b2 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -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") + } +}