From 54e553e50fd8bb6cef5126769f2ad49452fe61bd Mon Sep 17 00:00:00 2001 From: Christopher Jones <56381413+ntwrknrd@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:56:44 -0400 Subject: [PATCH] feat(inventory): qualify short provider names with group domain_suffix NetBox records many firewall cluster nodes under short names such as ACME-vPAN01. nssh only derives a domain from dotted names, so these devices could never be addressed. A provider group's single top-level domain_suffix now qualifies short, unaddressed objects assigned to it, keeping the short name as a lookup pattern. --- internal/config/example_config.yaml | 17 ++++++ internal/inventory/engine.go | 53 +++++++++++++++---- internal/inventory/engine_test.go | 43 +++++++++++++++ .../configuration-inventory-credentials.md | 5 ++ 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/internal/config/example_config.yaml b/internal/config/example_config.yaml index 98a87c1..4869470 100644 --- a/internal/config/example_config.yaml +++ b/internal/config/example_config.yaml @@ -140,6 +140,23 @@ agent: # WarnWeakCrypto: no-pq-kex # IdentityFile: # - ~/.ssh/work-ssh-key.pub +# firewall-nodes: +# # Devices whose NetBox name has no domain, such as fw01, become +# # fw01.fw.example.net. Applies only when one suffix is listed. +# domain_suffix: +# - .fw.example.net +# auth: +# mode: password +# credential_provider: op-work +# password_ref: op://Work/network-admin/password +# username: netops +# match: +# manufacturer: +# - Palo Alto +# role: +# - Firewall Cluster Node +# status: +# - active # hosts: # legacy-switch.example.net: # aliases: diff --git a/internal/inventory/engine.go b/internal/inventory/engine.go index 391458d..4d083cc 100644 --- a/internal/inventory/engine.go +++ b/internal/inventory/engine.go @@ -37,14 +37,11 @@ func Reconcile(objects []Object, selectors []config.InventoryGroupSelector, prov plan.Unmatched = append(plan.Unmatched, *obj) continue case 1: - selector := matches[0] - host := objectToProviderHost(obj, selector.Group) - host.AuthMode = groupAuthMode(selector.Group, groups) + host := groupProviderHost(obj, matches[0].Group, groups) desired[host.ObjectID] = host default: if selector, ok := BestGroupSelectorMatch(obj, matches); ok { - host := objectToProviderHost(obj, selector.Group) - host.AuthMode = groupAuthMode(selector.Group, groups) + host := groupProviderHost(obj, selector.Group, groups) desired[host.ObjectID] = host continue } @@ -91,13 +88,49 @@ func selectorGroups(selectors []config.InventoryGroupSelector) []string { return groups } +func groupProviderHost(obj *Object, group string, groups map[string]config.GroupConfig) *ProviderHost { + groupCfg := groupConfig(group, groups) + qualified := qualifyShortObject(*obj, groupCfg.DomainSuffix) + host := objectToProviderHost(&qualified, group) + host.AuthMode = groupAuthMode(group, groups) + return host +} + +func groupConfig(group string, groups map[string]config.GroupConfig) config.GroupConfig { + _, shortGroup, err := config.ParseInventoryGroupID(group) + if err != nil { + shortGroup = group + } + return groups[shortGroup] +} + +// qualifyShortObject appends a group's single domain suffix to an object whose +// name has no domain and whose provider supplied no other address, so short +// provider names become resolvable FQDN hosts. +func qualifyShortObject(obj Object, suffixes []string) Object { + if len(suffixes) != 1 { + return obj + } + suffix := strings.TrimSpace(suffixes[0]) + name := strings.TrimSpace(obj.Name) + if suffix == "" || name == "" || strings.Contains(name, ".") { + return obj + } + if host := strings.TrimSpace(obj.HostName); host != "" && host != name { + return obj + } + if !strings.HasPrefix(suffix, ".") { + suffix = "." + suffix + } + obj.Name = name + suffix + obj.FQDN = obj.Name + obj.HostName = obj.Name + return obj +} + func groupAuthMode(group string, groups map[string]config.GroupConfig) string { if groups != nil { - _, shortGroup, err := config.ParseInventoryGroupID(group) - if err != nil { - shortGroup = group - } - auth := groups[shortGroup].Auth + auth := groupConfig(group, groups).Auth auth.Normalize() if auth.Mode != "" { return auth.Mode diff --git a/internal/inventory/engine_test.go b/internal/inventory/engine_test.go index f74b132..e910be0 100644 --- a/internal/inventory/engine_test.go +++ b/internal/inventory/engine_test.go @@ -204,3 +204,46 @@ func TestReconcileChoosesMostSpecificWildcardDomainSuffixSelector(t *testing.T) t.Fatalf("group = %q, want netbox-prod/ldap", plan.Adds[0].Group) } } + +func TestReconcileQualifiesShortNamesWithGroupDomainSuffix(t *testing.T) { + selectors := []config.InventoryGroupSelector{{ + Group: "netbox-prod/vpan", + Provider: "netbox-prod", + Match: config.InventoryMatch{"manufacturer": {"Palo Alto"}}, + }} + groups := map[string]config.GroupConfig{"vpan": {DomainSuffix: []string{".custcbb.local"}}} + attrs := map[string][]string{"manufacturer": {"Palo Alto"}} + objects := []Object{ + {ObjectID: "1", Name: "ACME-vPAN01", HostName: "ACME-vPAN01", Attributes: attrs}, + {ObjectID: "2", Name: "acme-vpan.expedient.com", HostName: "acme-vpan.expedient.com", Attributes: attrs}, + {ObjectID: "3", Name: "lab-vpan", HostName: "10.0.0.5", Attributes: attrs}, + } + + plan := Reconcile(objects, selectors, "netbox-prod", nil, groups) + got := make(map[string]*ProviderHost) + for _, host := range plan.Adds { + got[host.ObjectID] = host + } + if host := got["1"]; host.Host != "ACME-vPAN01.custcbb.local" || host.HostName != "ACME-vPAN01.custcbb.local" || + !slices.Equal(host.Patterns, []string{"ACME-vPAN01.custcbb.local", "ACME-vPAN01"}) { + t.Fatalf("short host = %+v", host) + } + if host := got["2"]; host.HostName != "acme-vpan.expedient.com" { + t.Fatalf("dotted host changed: %+v", host) + } + if host := got["3"]; host.Host != "lab-vpan" || host.HostName != "10.0.0.5" { + t.Fatalf("addressed host changed: %+v", host) + } +} + +func TestReconcileLeavesShortNamesWithoutSingleGroupDomainSuffix(t *testing.T) { + selectors := []config.InventoryGroupSelector{{Group: "netbox-prod/fw", Provider: "netbox-prod"}} + objects := []Object{{ObjectID: "1", Name: "fw01", HostName: "fw01"}} + for _, suffixes := range [][]string{nil, {".a.local", ".b.local"}} { + groups := map[string]config.GroupConfig{"fw": {DomainSuffix: suffixes}} + plan := Reconcile(objects, selectors, "netbox-prod", nil, groups) + if len(plan.Adds) != 1 || plan.Adds[0].HostName != "fw01" { + t.Fatalf("suffixes %v: adds = %+v", suffixes, plan.Adds) + } + } +} diff --git a/skills/nssh/references/configuration-inventory-credentials.md b/skills/nssh/references/configuration-inventory-credentials.md index 0d8c6bf..09c5380 100644 --- a/skills/nssh/references/configuration-inventory-credentials.md +++ b/skills/nssh/references/configuration-inventory-credentials.md @@ -88,6 +88,11 @@ Current external providers: `jump_host`. Provider-owned group selectors assign discovered objects to canonical groups. +A provider group's top-level `domain_suffix` qualifies short object names: +when it lists exactly one suffix, an object assigned to that group whose name +has no domain and no separate address becomes ``, with the short +name kept as a lookup name. Selection still uses the object's original name, so +match such groups on fields other than `match.domain_suffix`. When resolved inventory auth mode is `password`, nssh renders OpenSSH options that force password-style authentication: `PreferredAuthentications=keyboard-interactive,password` and