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>
230 lines
7.1 KiB
Go
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, " ") + "]"
|
|
}
|