Files
ssh-netbox-wrapper/internal/setup/ssh.go
Sebastian UnterschützandClaude Opus 5.5 d51c040d9f 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 <noreply@anthropic.com>
2026-11-13 22:42:13 +01:00

230 lines
7.1 KiB
Go

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, " ") + "]"
}