package setup import ( "errors" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "charm.land/huh/v2" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" ) // 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 { return cancelled(err) } work := prepare(*cfg, sources) if err := runMenu(&work); err != nil { return cancelled(err) } if err := save(work); err != nil { return err } loaded, err := config.Load() if err != nil { return err } c := cache.New(loaded.CachePath(), loaded.Cache.TTL).WithStatic(loaded.Hosts) if err := c.Load(); err != nil { return fmt.Errorf("updating aliases: %w", err) } if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil { return err } if loaded.Shell.Alias != "" { shell := filepath.Base(os.Getenv("SHELL")) profile, err := hook.ProfilePath(shell) if err != nil { fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n") return nil } if _, err := hook.Install(profile, shell); err != nil { return err } fmt.Fprintln(os.Stderr, hook.ReloadNote(profile)) } 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, forwards", "ssh"), huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"), huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"), huh.NewOption(fmt.Sprintf("Multi-session — groups, tmux/zellij (%d groups)", len(cfg.Groups)), "multi"), 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 "hosts": var hosts []config.StaticHost if hosts, err = configureHosts(cfg.Hosts); err == nil { cfg.Hosts = hosts } case "multi": err = editMulti(cfg) 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 hostnameDomain := cfg.Resolver.HostnameDomain 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, hostname"). 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("Hostname domain"). Description("Only used by the hostname strategy: appended to NetBox names without a dot,\ne.g. example.com turns web01 into web01.example.com."). Value(&hostnameDomain), 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.Resolver.HostnameDomain = strings.Trim(strings.TrimSpace(hostnameDomain), ".") 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 rewrites := formatRewrites(cfg.Shortcuts.Rewrites) 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.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."). 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.Shortcuts.Rewrites, _ = parseRewrites(rewrites) 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 } // 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 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, "interface_name": true, "hostname": true, } func validateStrategies(s string) error { if strings.TrimSpace(s) == "" { return errors.New("at least one strategy is required") } for _, name := range parseStrategies(s) { if !knownStrategies[name] { return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name, hostname", name) } } return nil } func parseStrategies(s string) []string { return splitList(s) } // Save validates cfg and writes it to the config file, replacing the old file. func Save(cfg config.Config) error { return save(cfg) } func save(cfg config.Config) error { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { return err } if err := cfg.ValidateNetBoxes(); err != nil { return err } if err := cfg.ValidateSSH(); err != nil { return err } if err := cfg.Shortcuts.Validate(); err != nil { return err } if err := cfg.ValidateHosts(); err != nil { return err } if err := cfg.ValidateMulti(); 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) } var b strings.Builder if len(cfg.NetBoxes) > 0 { b.WriteString("netboxes:\n") for _, source := range cfg.NetBoxes { fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion) if !sshOptionsEmpty(source.SSH) { b.WriteString(" ssh:\n") writeSSHOptions(&b, " ", source.SSH) } } } else if cfg.NetBox.URL != "" || len(cfg.Hosts) == 0 { b.WriteString("netbox:\n") fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) if !sshOptionsEmpty(cfg.NetBox.SSH) { b.WriteString(" ssh:\n") writeSSHOptions(&b, " ", cfg.NetBox.SSH) } } b.WriteString("\nresolver:\n") b.WriteString(" strategies:\n") for _, s := range cfg.Resolver.Strategies { fmt.Fprintf(&b, " - %s\n", s) } if len(cfg.Resolver.ManagementSubnets) > 0 { b.WriteString(" management_subnets:\n") for _, s := range cfg.Resolver.ManagementSubnets { fmt.Fprintf(&b, " - %s\n", s) } } if cfg.Resolver.InterfaceName != "" { fmt.Fprintf(&b, " interface_name: %q\n", cfg.Resolver.InterfaceName) } if cfg.Resolver.HostnameDomain != "" { fmt.Fprintf(&b, " hostname_domain: %q\n", cfg.Resolver.HostnameDomain) } b.WriteString("\ncache:\n") fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL) if cfg.Cache.Path != "" { fmt.Fprintf(&b, " path: %q\n", cfg.Cache.Path) } b.WriteString("\nssh:\n") if cfg.SSH.DefaultUser != "" { fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser) } if cfg.SSH.DefaultPort > 0 { fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort) } writeSSHOptions(&b, " ", cfg.SSH.SSHOptions) if len(cfg.SSH.Rules) > 0 { b.WriteString(" rules:\n") for _, rule := range cfg.SSH.Rules { b.WriteString(" - match:\n") for _, field := range [][2]string{{"tag", rule.Match.Tag}, {"instance", rule.Match.Instance}, {"kind", rule.Match.Kind}, {"name", rule.Match.Name}} { if field[1] != "" { fmt.Fprintf(&b, " %s: %q\n", field[0], field[1]) } } writeSSHOptions(&b, " ", rule.SSHOptions) } } if len(cfg.Hosts) > 0 { b.WriteString("\nhosts:\n") for _, host := range cfg.Hosts { fmt.Fprintf(&b, " - name: %q\n address: %q\n", host.Name, host.Address) if host.User != "" { fmt.Fprintf(&b, " user: %q\n", host.User) } if host.Port > 0 { fmt.Fprintf(&b, " port: %d\n", host.Port) } if len(host.Tags) > 0 { b.WriteString(" tags:\n") for _, tag := range host.Tags { fmt.Fprintf(&b, " - %q\n", tag) } } if !sshOptionsEmpty(host.SSH) { b.WriteString(" ssh:\n") writeSSHOptions(&b, " ", host.SSH) } } } if len(cfg.Groups) > 0 { b.WriteString("\ngroups:\n") names := make([]string, 0, len(cfg.Groups)) for name := range cfg.Groups { names = append(names, name) } sort.Strings(names) for _, name := range names { quoted := make([]string, 0, len(cfg.Groups[name])) for _, selector := range cfg.Groups[name] { quoted = append(quoted, strconv.Quote(selector)) } fmt.Fprintf(&b, " %s: [%s]\n", name, strings.Join(quoted, ", ")) } } if cfg.Multi.Multiplexer != "" || cfg.Multi.PanesPerWindow > 0 { b.WriteString("\nmulti:\n") if cfg.Multi.Multiplexer != "" { fmt.Fprintf(&b, " multiplexer: %s\n", cfg.Multi.Multiplexer) } if cfg.Multi.PanesPerWindow > 0 { fmt.Fprintf(&b, " panes_per_window: %d\n", cfg.Multi.PanesPerWindow) } } if cfg.Completion.MaxResults > 0 { fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) } if cfg.Shortcuts.Enabled() { b.WriteString("\nshortcuts:\n") if len(cfg.Shortcuts.Domains) > 0 { b.WriteString(" domains:\n") for _, d := range cfg.Shortcuts.Domains { fmt.Fprintf(&b, " - %s\n", d) } } 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 != "" { fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias) } if err := storage.WriteFile(path, []byte(strings.TrimLeft(b.String(), "\n")), 0o600); err != nil { return fmt.Errorf("writing config: %w", err) } fmt.Printf("\nConfig saved → %s\n", path) return nil } func sshOptionsEmpty(o config.SSHOptions) bool { 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. func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) { for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} { if field[1] != "" { fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1]) } } if len(o.ExtraArgs) > 0 { fmt.Fprintf(b, "%sextra_args:\n", indent) for _, arg := range o.ExtraArgs { fmt.Fprintf(b, "%s - %q\n", indent, arg) } } if len(o.Options) > 0 { keys := make([]string, 0, len(o.Options)) for key := range o.Options { keys = append(keys, key) } sort.Strings(keys) fmt.Fprintf(b, "%soptions:\n", indent) for _, key := range keys { 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]) } } } } }