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>
This commit is contained in:
1 parent
ac713ac97b
commit
d51c040d9f
8 files changed
+630
-174
No files matched your search
@@ -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, " ") + "]"
|
||||
}
|
||||
Reference in new issue
Block a user