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