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:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 22:42:13 +01:00
1 parent ac713ac97b
commit d51c040d9f
8 files changed
+630 -174

No files matched your search

+232 -159
View File
@@ -17,163 +17,19 @@ import (
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
// 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 {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0)
}
return err
return cancelled(err)
}
defaultUser := cfg.SSH.DefaultUser
defaultPort := ""
if cfg.SSH.DefaultPort > 0 {
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
}
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
stripHyphens := cfg.Shortcuts.StripHyphens
commandAlias := cfg.Shell.Alias
if strategiesRaw == "" {
strategiesRaw = "primary_ip"
}
if cacheTTL == "0" {
cacheTTL = "3600"
work := prepare(*cfg, sources)
if err := runMenu(&work); err != nil {
return cancelled(err)
}
form := 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"),
huh.NewGroup(
huh.NewInput().
Title("Resolver strategies").
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
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("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
}),
).Title("Resolver & cache"),
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.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"),
)
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0)
}
return err
}
strategies := parseStrategies(strategiesRaw)
ttl, _ := strconv.Atoi(cacheTTL)
port := 0
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
port = p
}
var subnetList []string
for _, s := range strings.Split(subnets, ",") {
if s = strings.TrimSpace(s); s != "" {
subnetList = append(subnetList, s)
}
}
var domainList []string
for _, s := range strings.Split(shortcutDomains, ",") {
if s = strings.TrimSpace(s); s != "" {
if !strings.HasPrefix(s, ".") {
s = "." + s
}
domainList = append(domainList, s)
}
}
out := config.Config{
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
NetBoxes: sources,
SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser),
DefaultPort: port,
},
Resolver: config.ResolverConfig{
Strategies: strategies,
ManagementSubnets: subnetList,
InterfaceName: strings.TrimSpace(interfaceName),
},
Cache: config.CacheConfig{
TTL: ttl,
Path: cfg.Cache.Path,
},
Shortcuts: config.ShortcutsConfig{
Domains: domainList,
StripHyphens: stripHyphens,
},
}
if err := save(out); err != nil {
if err := save(work); err != nil {
return err
}
loaded, err := config.Load()
@@ -202,6 +58,228 @@ func RunWizard(cfg *config.Config) error {
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", "ssh"),
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
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 "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
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").
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("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.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
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.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.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
}
// 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,
@@ -220,15 +298,7 @@ func validateStrategies(s string) error {
return nil
}
func parseStrategies(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if name := strings.TrimSpace(part); name != "" {
out = append(out, name)
}
}
return out
}
func parseStrategies(s string) []string { return splitList(s) }
func save(cfg config.Config) error {
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
@@ -237,6 +307,9 @@ func save(cfg config.Config) error {
if err := cfg.ValidateNetBoxes(); err != nil {
return err
}
if err := cfg.ValidateSSH(); 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)