7c902cab3a
Release / release (push) Successful in 50s
- **Shortcuts**: Add hostname normalization with domain stripping and hyphen folding. Include alias generation for cached hosts. - **Shell Hook**: Automate 24h cache refresh trigger with shell startup hook. Add install/uninstall commands for bash, zsh, and fish. - **Wizard**: Extend setup wizard to configure shortcuts (domains, hyphen stripping) and default SSH port. - **Cache**: Add `GetByShortcut` for resolving hosts via normalized shortcuts. Implement `NeedsRefresh` / `SetRefreshed` logic for refresh timestamps. - **Tests**: Comprehensive unit tests for shortcuts, hook installation, cache refresh, and alias generation. - **Docs**: Update README with shortcuts, shell hook, and default SSH port configuration.
280 lines
7.6 KiB
Go
280 lines
7.6 KiB
Go
package setup
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/huh"
|
|
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
|
)
|
|
|
|
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
|
// It saves the result to the config file on success.
|
|
func RunWizard(cfg *config.Config) error {
|
|
url := cfg.NetBox.URL
|
|
token := cfg.NetBox.Token
|
|
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
|
|
|
|
if strategiesRaw == "" {
|
|
strategiesRaw = "primary_ip"
|
|
}
|
|
if cacheTTL == "0" {
|
|
cacheTTL = "3600"
|
|
}
|
|
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("NetBox URL").
|
|
Description("e.g. https://netbox.example.com").
|
|
Placeholder("https://").
|
|
Value(&url).
|
|
Validate(func(s string) error {
|
|
if strings.TrimSpace(s) == "" {
|
|
return errors.New("required")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewInput().
|
|
Title("NetBox API token").
|
|
EchoMode(huh.EchoModePassword).
|
|
Value(&token).
|
|
Validate(func(s string) error {
|
|
if strings.TrimSpace(s) == "" {
|
|
return errors.New("required")
|
|
}
|
|
return nil
|
|
}),
|
|
).Title("NetBox connection"),
|
|
|
|
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),
|
|
).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)
|
|
tokenVersion := netbox.TokenVersion(token)
|
|
if tokenVersion == 1 {
|
|
fmt.Fprintln(os.Stderr, "\nHinweis: Du verwendest einen Legacy-Token (v1). Erstelle in NetBox einen v2-Token (beginnt mit nbt_) für bessere Kompatibilität.")
|
|
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
|
}
|
|
|
|
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{
|
|
NetBox: config.NetBoxConfig{
|
|
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
|
Token: strings.TrimSpace(token),
|
|
TokenVersion: tokenVersion,
|
|
},
|
|
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,
|
|
},
|
|
Shortcuts: config.ShortcutsConfig{
|
|
Domains: domainList,
|
|
StripHyphens: stripHyphens,
|
|
},
|
|
}
|
|
|
|
return save(out)
|
|
}
|
|
|
|
var knownStrategies = map[string]bool{
|
|
"primary_ip": true,
|
|
"management_subnet": true,
|
|
"interface_name": 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", name)
|
|
}
|
|
}
|
|
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 save(cfg config.Config) error {
|
|
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
|
|
b.WriteString("netbox:\n")
|
|
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
|
|
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token))
|
|
fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion)
|
|
|
|
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)
|
|
}
|
|
|
|
b.WriteString("\ncache:\n")
|
|
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
|
|
|
|
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)
|
|
}
|
|
|
|
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
|
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 err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
|
return fmt.Errorf("writing config: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\nConfig saved → %s\n", path)
|
|
return nil
|
|
}
|