From 53131f3cded0400a46d6aeff1880a9eed5dd5c16 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 01:45:29 -0400 Subject: [PATCH 01/11] chore(deps): add gofakeit/v7 for CommonDevice faker Pure-Go faker library used by the new internal/faker package to populate *model.CommonDevice with realistic hostnames, domains, and descriptions. Seeded via *rand.Rand for determinism. Signed-off-by: UncleSp1d3r --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 0f5d5e0..31dfb4d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/EvilBit-Labs/opnDossier v1.4.0 + github.com/brianvoe/gofakeit/v7 v7.14.1 github.com/charmbracelet/log v1.0.0 github.com/google/uuid v1.6.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 1d4b4f8..6e60843 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/EvilBit-Labs/opnDossier v1.4.0 h1:OhHmhtstx6vJgT4nbLXuyktf6T+Dc7/XwaC github.com/EvilBit-Labs/opnDossier v1.4.0/go.mod h1:9w9GOvuEP4KfRHWsPDvGItFnqJplH3Ik5i/9sGLbDQg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow= +github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= From d5d8cae5361097e46ded51343869925ae1f9d752 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 01:45:37 -0400 Subject: [PATCH 02/11] feat(faker): CommonDevice faker package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Produces a fully-populated *model.CommonDevice from functional options (WithSeed, WithVLANCount, WithFirewallRules, WithHostname, WithDomain). Files mirror the CommonDevice subsystem layout: system.go, network.go (WAN/LAN + VLANs), dhcp.go, firewall.go. Seed 0 is the "non-deterministic" sentinel; any non-zero seed produces byte-stable output. No XML, no opnsense schema knowledge in this package — the CommonDevice model is the only output. Covers R1 (zero-arg path), R2 (CommonDevice is the intermediate representation), and R5 (deterministic output). Signed-off-by: UncleSp1d3r --- internal/faker/device.go | 42 ++++++++++++ internal/faker/device_test.go | 59 +++++++++++++++++ internal/faker/dhcp.go | 36 ++++++++++ internal/faker/dhcp_test.go | 49 ++++++++++++++ internal/faker/firewall.go | 32 +++++++++ internal/faker/firewall_test.go | 45 +++++++++++++ internal/faker/network.go | 113 ++++++++++++++++++++++++++++++++ internal/faker/network_test.go | 72 ++++++++++++++++++++ internal/faker/options.go | 38 +++++++++++ internal/faker/rand.go | 25 +++++++ internal/faker/rand_test.go | 39 +++++++++++ internal/faker/system.go | 43 ++++++++++++ internal/faker/system_test.go | 59 +++++++++++++++++ 13 files changed, 652 insertions(+) create mode 100644 internal/faker/device.go create mode 100644 internal/faker/device_test.go create mode 100644 internal/faker/dhcp.go create mode 100644 internal/faker/dhcp_test.go create mode 100644 internal/faker/firewall.go create mode 100644 internal/faker/firewall_test.go create mode 100644 internal/faker/network.go create mode 100644 internal/faker/network_test.go create mode 100644 internal/faker/options.go create mode 100644 internal/faker/rand.go create mode 100644 internal/faker/rand_test.go create mode 100644 internal/faker/system.go create mode 100644 internal/faker/system_test.go diff --git a/internal/faker/device.go b/internal/faker/device.go new file mode 100644 index 0000000..7cb1e7b --- /dev/null +++ b/internal/faker/device.go @@ -0,0 +1,42 @@ +package faker + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" +) + +// NewCommonDevice returns a fully-populated *model.CommonDevice ready for +// serialization. All randomness is seeded from the Option set; with a fixed +// WithSeed value the function is deterministic. +func NewCommonDevice(opts ...Option) *model.CommonDevice { + cfg := &config{} + for _, opt := range opts { + opt(cfg) + } + + rng, f := newRand(cfg.seed) + + sys := fakeSystem(f) + if cfg.hostname != "" { + sys.Hostname = cfg.hostname + } + if cfg.domain != "" { + sys.Domain = cfg.domain + } + + net := fakeNetwork(rng, f, cfg.vlanCount) + dhcp := fakeDHCPScopes(net.Interfaces) + + var fwRules []model.FirewallRule + if cfg.firewallRules { + fwRules = fakeFirewallRules(f, net.Interfaces) + } + + return &model.CommonDevice{ + DeviceType: model.DeviceTypeOPNsense, + System: sys, + Interfaces: net.Interfaces, + VLANs: net.VLANs, + DHCP: dhcp, + FirewallRules: fwRules, + } +} diff --git a/internal/faker/device_test.go b/internal/faker/device_test.go new file mode 100644 index 0000000..f893fc0 --- /dev/null +++ b/internal/faker/device_test.go @@ -0,0 +1,59 @@ +package faker + +import ( + "testing" + + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCommonDeviceDefaults(t *testing.T) { + t.Parallel() + + dev := NewCommonDevice() + require.NotNil(t, dev) + assert.Equal(t, model.DeviceTypeOPNsense, dev.DeviceType) + assert.NotEmpty(t, dev.System.Hostname) + assert.NotEmpty(t, dev.System.Domain) + assert.Len(t, dev.Interfaces, 2, "default shape: WAN + LAN, no VLANs") + assert.Empty(t, dev.VLANs) + assert.Empty(t, dev.FirewallRules) + // LAN is the only static interface → one DHCP scope. + assert.Len(t, dev.DHCP, 1) +} + +func TestNewCommonDeviceDeterministic(t *testing.T) { + t.Parallel() + + a := NewCommonDevice(WithSeed(99), WithVLANCount(3)) + b := NewCommonDevice(WithSeed(99), WithVLANCount(3)) + assert.Equal(t, a, b, "same seed + options must produce equal devices") +} + +func TestNewCommonDeviceVLANCount(t *testing.T) { + t.Parallel() + + dev := NewCommonDevice(WithSeed(1), WithVLANCount(4)) + assert.Len(t, dev.VLANs, 4) + assert.Len(t, dev.Interfaces, 2+4, "WAN + LAN + 4 opt interfaces") + assert.Len(t, dev.DHCP, 5, "LAN + 4 opts each carry a DHCP scope") +} + +func TestNewCommonDeviceFirewallRulesOptIn(t *testing.T) { + t.Parallel() + + without := NewCommonDevice(WithSeed(1)) + assert.Empty(t, without.FirewallRules) + + with := NewCommonDevice(WithSeed(1), WithFirewallRules(true)) + assert.NotEmpty(t, with.FirewallRules) +} + +func TestNewCommonDeviceHostnameAndDomainOverride(t *testing.T) { + t.Parallel() + + dev := NewCommonDevice(WithSeed(1), WithHostname("gateway"), WithDomain("example.test")) + assert.Equal(t, "gateway", dev.System.Hostname) + assert.Equal(t, "example.test", dev.System.Domain) +} diff --git a/internal/faker/dhcp.go b/internal/faker/dhcp.go new file mode 100644 index 0000000..b7532f3 --- /dev/null +++ b/internal/faker/dhcp.go @@ -0,0 +1,36 @@ +package faker + +import ( + "fmt" + "net/netip" + + "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" + "github.com/EvilBit-Labs/opnDossier/pkg/model" +) + +// fakeDHCPScopes emits one DHCP scope per statically-addressed interface. +// WAN (type="dhcp") is excluded because DHCP servers run on downstream +// interfaces, not on the uplink. +func fakeDHCPScopes(interfaces []model.Interface) []model.DHCPScope { + scopes := make([]model.DHCPScope, 0, len(interfaces)) + for _, iface := range interfaces { + if iface.Type != "static" || iface.IPAddress == "" || iface.Subnet == "" { + continue + } + prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet)) + if err != nil { + continue + } + scopes = append(scopes, model.DHCPScope{ + Interface: iface.Name, + Enabled: true, + Range: model.DHCPRange{ + From: netutil.DHCPRangeStart(prefix).String(), + To: netutil.DHCPRangeEnd(prefix).String(), + }, + Gateway: iface.IPAddress, + DNSServer: iface.IPAddress, + }) + } + return scopes +} diff --git a/internal/faker/dhcp_test.go b/internal/faker/dhcp_test.go new file mode 100644 index 0000000..1ecc0b0 --- /dev/null +++ b/internal/faker/dhcp_test.go @@ -0,0 +1,49 @@ +package faker + +import ( + "net/netip" + "testing" + + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFakeDHCPScopesOnePerStaticInterface(t *testing.T) { + t.Parallel() + + interfaces := []model.Interface{ + {Name: "wan", Type: "dhcp"}, + {Name: "lan", Type: "static", IPAddress: "192.168.1.1", Subnet: "24"}, + {Name: "opt1", Type: "static", IPAddress: "10.0.0.1", Subnet: "24", Virtual: true}, + } + + scopes := fakeDHCPScopes(interfaces) + + require.Len(t, scopes, 2, "WAN excluded; LAN + opt1 each produce one scope") + names := []string{scopes[0].Interface, scopes[1].Interface} + assert.Contains(t, names, "lan") + assert.Contains(t, names, "opt1") + + for _, s := range scopes { + assert.True(t, s.Enabled) + assert.NotEmpty(t, s.Range.From) + assert.NotEmpty(t, s.Range.To) + from, err := netip.ParseAddr(s.Range.From) + require.NoError(t, err) + to, err := netip.ParseAddr(s.Range.To) + require.NoError(t, err) + assert.True(t, from.Less(to), "DHCP range.from must be < range.to") + } +} + +func TestFakeDHCPScopesSkipsWhenFieldsMissing(t *testing.T) { + t.Parallel() + + scopes := fakeDHCPScopes([]model.Interface{ + {Name: "lan", Type: "static", IPAddress: "", Subnet: "24"}, + {Name: "opt1", Type: "static", IPAddress: "10.0.0.1", Subnet: ""}, + {Name: "opt2", Type: "none"}, + }) + assert.Empty(t, scopes, "interfaces with missing fields produce no scope") +} diff --git a/internal/faker/firewall.go b/internal/faker/firewall.go new file mode 100644 index 0000000..71ec1b8 --- /dev/null +++ b/internal/faker/firewall.go @@ -0,0 +1,32 @@ +package faker + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/brianvoe/gofakeit/v7" +) + +// fakeFirewallRules emits a minimal default ruleset: one pass rule per +// non-WAN interface, sourcing from that interface's network to "any". This +// matches OPNsense's out-of-the-box LAN default and is the smallest ruleset +// that produces a semantically useful config.xml. +// +// The *gofakeit.Faker parameter is reserved for future diversification; the +// current ruleset is deterministic. +func fakeFirewallRules(_ *gofakeit.Faker, interfaces []model.Interface) []model.FirewallRule { + rules := make([]model.FirewallRule, 0, len(interfaces)) + for _, iface := range interfaces { + if iface.Name == "wan" { + continue + } + rules = append(rules, model.FirewallRule{ + Interfaces: []string{iface.Name}, + Type: model.RuleTypePass, + IPProtocol: model.IPProtocolInet, + Direction: model.DirectionIn, + Description: "Default allow " + iface.Name + " to any", + Source: model.RuleEndpoint{Address: iface.Name}, + Destination: model.RuleEndpoint{Address: "any"}, + }) + } + return rules +} diff --git a/internal/faker/firewall_test.go b/internal/faker/firewall_test.go new file mode 100644 index 0000000..940c315 --- /dev/null +++ b/internal/faker/firewall_test.go @@ -0,0 +1,45 @@ +package faker + +import ( + "testing" + + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFakeFirewallRulesDefaultAllowLAN(t *testing.T) { + t.Parallel() + + interfaces := []model.Interface{ + {Name: "wan", Type: "dhcp"}, + {Name: "lan", Type: "static"}, + } + _, f := newRand(1) + + rules := fakeFirewallRules(f, interfaces) + + require.Len(t, rules, 1, "WAN excluded, LAN emits one rule") + r := rules[0] + assert.Equal(t, model.RuleTypePass, r.Type) + assert.Equal(t, []string{"lan"}, r.Interfaces) + assert.Equal(t, "lan", r.Source.Address) + assert.Equal(t, "any", r.Destination.Address) + assert.Equal(t, model.IPProtocolInet, r.IPProtocol) + assert.Equal(t, model.DirectionIn, r.Direction) +} + +func TestFakeFirewallRulesNoInterfacesNoRules(t *testing.T) { + t.Parallel() + + _, f := newRand(1) + assert.Empty(t, fakeFirewallRules(f, nil)) +} + +func TestFakeFirewallRulesOnlyWANNoRules(t *testing.T) { + t.Parallel() + + _, f := newRand(1) + rules := fakeFirewallRules(f, []model.Interface{{Name: "wan"}}) + assert.Empty(t, rules) +} diff --git a/internal/faker/network.go b/internal/faker/network.go new file mode 100644 index 0000000..13eebcc --- /dev/null +++ b/internal/faker/network.go @@ -0,0 +1,113 @@ +package faker + +import ( + "fmt" + "math/rand/v2" + "net/netip" + "strconv" + + "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/brianvoe/gofakeit/v7" +) + +const ( + // parentPhysical carries all VLAN-tagged traffic. + parentPhysical = "igb0" + // wanPhysical is the upstream/DHCP-facing interface. + wanPhysical = "igb1" + // VLAN tag range per 802.1Q: 1 and 4095 are reserved; use [2, 4094]. + vlanTagMin = 2 + vlanTagMax = 4094 + // baseInterfaceCount counts the WAN + LAN pair emitted before any VLANs. + baseInterfaceCount = 2 +) + +// NetworkResult groups everything the network faker produces so callers +// stitch it into a CommonDevice in one step. +type NetworkResult struct { + Interfaces []model.Interface + VLANs []model.VLAN +} + +// fakeNetwork produces the standard WAN + LAN pair plus vlanCount opt +// interfaces, each backed by a unique VLAN tag and RFC 1918 /24 network. +func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) NetworkResult { + result := NetworkResult{ + Interfaces: make([]model.Interface, 0, baseInterfaceCount+vlanCount), + VLANs: make([]model.VLAN, 0, vlanCount), + } + + // WAN: DHCP from upstream. + result.Interfaces = append(result.Interfaces, model.Interface{ + Name: "wan", + PhysicalIf: wanPhysical, + Enabled: true, + Type: "dhcp", + }) + + // LAN: RFC 1918 /24, gateway .1. + lanNet := netutil.GenerateRandomNetwork(rng) + lanGW := netutil.GatewayIP(lanNet) + result.Interfaces = append(result.Interfaces, model.Interface{ + Name: "lan", + PhysicalIf: parentPhysical, + Enabled: true, + Type: "static", + IPAddress: lanGW.String(), + Subnet: strconv.Itoa(lanNet.Bits()), + }) + + usedTags := make(map[uint16]bool, vlanCount) + usedNets := map[string]bool{lanNet.String(): true} + + for i := range vlanCount { + tag := pickUniqueTag(rng, usedTags) + net := pickUniqueNet(rng, usedNets) + gw := netutil.GatewayIP(net) + + vlanIf := fmt.Sprintf("vlan0.%d", tag) + optName := fmt.Sprintf("opt%d", i+1) + descr := fmt.Sprintf("%s VLAN %d", f.BuzzWord(), tag) + + result.VLANs = append(result.VLANs, model.VLAN{ + VLANIf: vlanIf, + PhysicalIf: parentPhysical, + Tag: strconv.FormatUint(uint64(tag), 10), + Description: descr, + }) + result.Interfaces = append(result.Interfaces, model.Interface{ + Name: optName, + PhysicalIf: vlanIf, + Description: descr, + Enabled: true, + Type: "static", + IPAddress: gw.String(), + Subnet: strconv.Itoa(net.Bits()), + Virtual: true, + }) + } + + return result +} + +func pickUniqueTag(rng *rand.Rand, used map[uint16]bool) uint16 { + for { + //nolint:gosec // Fake data; IntN bounded to uint16 range below. + tag := uint16(vlanTagMin + rng.IntN(vlanTagMax-vlanTagMin+1)) + if !used[tag] { + used[tag] = true + return tag + } + } +} + +func pickUniqueNet(rng *rand.Rand, used map[string]bool) netip.Prefix { + for { + n := netutil.GenerateRandomNetwork(rng) + if !used[n.String()] { + used[n.String()] = true + return n + } + } +} diff --git a/internal/faker/network_test.go b/internal/faker/network_test.go new file mode 100644 index 0000000..0a8f28d --- /dev/null +++ b/internal/faker/network_test.go @@ -0,0 +1,72 @@ +package faker + +import ( + "net/netip" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFakeNetworkWANAndLANOnly(t *testing.T) { + t.Parallel() + + rng, f := newRand(100) + result := fakeNetwork(rng, f, 0) + + require.Len(t, result.Interfaces, 2, "WAN + LAN only when vlanCount == 0") + names := []string{result.Interfaces[0].Name, result.Interfaces[1].Name} + assert.Contains(t, names, "wan") + assert.Contains(t, names, "lan") + assert.Empty(t, result.VLANs) +} + +func TestFakeNetworkProducesRequestedVLANs(t *testing.T) { + t.Parallel() + + rng, f := newRand(101) + result := fakeNetwork(rng, f, 5) + + assert.Len(t, result.VLANs, 5) + assert.Len(t, result.Interfaces, 2+5, "WAN + LAN + one opt per VLAN") + + seenTags := map[string]bool{} + for _, v := range result.VLANs { + tag, err := strconv.Atoi(v.Tag) + require.NoError(t, err) + assert.GreaterOrEqual(t, tag, vlanTagMin) + assert.LessOrEqual(t, tag, vlanTagMax) + assert.Falsef(t, seenTags[v.Tag], "VLAN tag %s duplicated", v.Tag) + seenTags[v.Tag] = true + } +} + +func TestFakeNetworkLANIsRFC1918(t *testing.T) { + t.Parallel() + + rng, f := newRand(102) + result := fakeNetwork(rng, f, 0) + + for i := range result.Interfaces { + if result.Interfaces[i].Name != "lan" { + continue + } + addr, err := netip.ParseAddr(result.Interfaces[i].IPAddress) + require.NoError(t, err) + assert.Truef(t, addr.IsPrivate(), "LAN must be RFC 1918, got %s", addr) + assert.Equal(t, "24", result.Interfaces[i].Subnet) + return + } + t.Fatal("no lan interface in result") +} + +func TestFakeNetworkDeterministic(t *testing.T) { + t.Parallel() + + rngA, fa := newRand(42) + rngB, fb := newRand(42) + a := fakeNetwork(rngA, fa, 3) + b := fakeNetwork(rngB, fb, 3) + assert.Equal(t, a, b, "same seed must produce identical NetworkResult") +} diff --git a/internal/faker/options.go b/internal/faker/options.go new file mode 100644 index 0000000..ad07f80 --- /dev/null +++ b/internal/faker/options.go @@ -0,0 +1,38 @@ +package faker + +// Option configures the faker pipeline. +type Option func(*config) + +type config struct { + seed int64 + vlanCount int + firewallRules bool + hostname string + domain string +} + +// WithSeed sets a deterministic RNG seed. A seed of 0 is the sentinel for +// "non-deterministic": a fresh random stream per call. +func WithSeed(seed int64) Option { + return func(c *config) { c.seed = seed } +} + +// WithVLANCount requests exactly N VLANs beyond the default WAN/LAN pair. +func WithVLANCount(n int) Option { + return func(c *config) { c.vlanCount = n } +} + +// WithFirewallRules toggles emission of a default firewall ruleset. +func WithFirewallRules(on bool) Option { + return func(c *config) { c.firewallRules = on } +} + +// WithHostname overrides the faker-generated hostname. +func WithHostname(h string) Option { + return func(c *config) { c.hostname = h } +} + +// WithDomain overrides the faker-generated domain. +func WithDomain(d string) Option { + return func(c *config) { c.domain = d } +} diff --git a/internal/faker/rand.go b/internal/faker/rand.go new file mode 100644 index 0000000..c434fd2 --- /dev/null +++ b/internal/faker/rand.go @@ -0,0 +1,25 @@ +// Package faker produces realistic *model.CommonDevice values for the +// serializer pipeline. This package has no knowledge of XML or the opnsense +// schema; it only populates the opnDossier CommonDevice model. +package faker + +import ( + "math/rand/v2" + + "github.com/brianvoe/gofakeit/v7" +) + +// newRand builds a *rand.Rand and a *gofakeit.Faker sharing the same stream. +// A seed of 0 is the sentinel for "non-deterministic": a fresh random stream +// per call. Any non-zero seed produces a deterministic stream. +func newRand(seed int64) (*rand.Rand, *gofakeit.Faker) { + var rng *rand.Rand + if seed == 0 { + //nolint:gosec // Fake data generation; not security-sensitive. + rng = rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) + } else { + //nolint:gosec // Fake data generation; not security-sensitive. + rng = rand.New(rand.NewPCG(uint64(seed), 0)) + } + return rng, gofakeit.NewFaker(rng, false) +} diff --git a/internal/faker/rand_test.go b/internal/faker/rand_test.go new file mode 100644 index 0000000..94f3288 --- /dev/null +++ b/internal/faker/rand_test.go @@ -0,0 +1,39 @@ +package faker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRandDeterministic(t *testing.T) { + t.Parallel() + + a, fa := newRand(42) + b, fb := newRand(42) + require.NotNil(t, a) + require.NotNil(t, b) + require.NotNil(t, fa) + require.NotNil(t, fb) + + for range 5 { + assert.Equal(t, a.Uint64(), b.Uint64(), "same seed must produce identical PCG streams") + } +} + +func TestNewRandZeroSeedIsRandom(t *testing.T) { + t.Parallel() + + a, _ := newRand(0) + b, _ := newRand(0) + assert.NotEqual(t, a.Uint64(), b.Uint64(), "zero-seed streams must diverge") +} + +func TestNewRandGofakeitHonorsSeed(t *testing.T) { + t.Parallel() + + _, fa := newRand(17) + _, fb := newRand(17) + assert.Equal(t, fa.Name(), fb.Name(), "gofakeit sharing the rand stream must be deterministic") +} diff --git a/internal/faker/system.go b/internal/faker/system.go new file mode 100644 index 0000000..b9efd2e --- /dev/null +++ b/internal/faker/system.go @@ -0,0 +1,43 @@ +package faker + +import ( + "strings" + + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/brianvoe/gofakeit/v7" +) + +// fakerTimezones is a curated list; gofakeit does not ship a timezone picker +// that emits Region/City strings valid for OPNsense's schema. +var fakerTimezones = []string{ + "America/Denver", + "America/Los_Angeles", + "America/New_York", + "Europe/London", + "Europe/Berlin", + "UTC", +} + +// fakeSystem populates a model.System with schema-valid, realistic values. +// +// Hostname is derived from a domain-style token with dots collapsed to +// hyphens because the OPNsense schema validates System.Hostname with the +// `validate:"hostname"` tag (RFC 1123 label rules). Domain is a lowercased +// FQDN to satisfy `validate:"fqdn"`. +func fakeSystem(f *gofakeit.Faker) model.System { + host := strings.ToLower(f.DomainName()) + host = strings.ReplaceAll(host, ".", "-") + + domain := strings.ToLower(f.DomainName()) + + tz := fakerTimezones[f.IntRange(0, len(fakerTimezones)-1)] + + return model.System{ + Hostname: host, + Domain: domain, + Timezone: tz, + Language: "en_US", + DNSServers: []string{"1.1.1.1", "9.9.9.9"}, + TimeServers: []string{"0.pool.ntp.org", "1.pool.ntp.org"}, + } +} diff --git a/internal/faker/system_test.go b/internal/faker/system_test.go new file mode 100644 index 0000000..360e340 --- /dev/null +++ b/internal/faker/system_test.go @@ -0,0 +1,59 @@ +package faker + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFakeSystemDeterministic(t *testing.T) { + t.Parallel() + + _, fa := newRand(7) + _, fb := newRand(7) + + a := fakeSystem(fa) + b := fakeSystem(fb) + + assert.Equal(t, a, b, "same seed must produce identical System") +} + +func TestFakeSystemRequiredFields(t *testing.T) { + t.Parallel() + + _, f := newRand(7) + sys := fakeSystem(f) + + assert.NotEmpty(t, sys.Hostname, "Hostname must be populated (validate:hostname)") + assert.NotEmpty(t, sys.Domain, "Domain must be populated (validate:fqdn)") + assert.NotEmpty(t, sys.Timezone) + assert.NotEmpty(t, sys.DNSServers) + assert.NotEmpty(t, sys.TimeServers) + assert.Equal(t, "en_US", sys.Language) +} + +func TestFakeSystemHostnameIsDNSSafe(t *testing.T) { + t.Parallel() + + for _, seed := range []int64{1, 2, 3, 42, 100} { + _, f := newRand(seed) + sys := fakeSystem(f) + + for _, r := range sys.Hostname { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' + assert.Truef(t, ok, "seed %d: hostname char %q not DNS-label-safe in %q", seed, r, sys.Hostname) + } + } +} + +func TestFakeSystemDomainIsLowercaseFQDN(t *testing.T) { + t.Parallel() + + _, f := newRand(17) + sys := fakeSystem(f) + + for _, r := range sys.Domain { + assert.NotContainsf(t, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", string(r), "domain %q must be lowercase", sys.Domain) + } + assert.Contains(t, sys.Domain, ".", "domain must contain at least one dot") +} From 0460e3dd97bf1257f7964a1ef03169d6877679ac Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 01:45:48 -0400 Subject: [PATCH 03/11] feat(serializer/opnsense): CommonDevice to OpnSenseDocument serializer Implements the inverse of opnDossier's pkg/parser/opnsense.ConvertDocument. Serialize(*model.CommonDevice) returns a ready-to-marshal *opnsense.OpnSenseDocument with System, Interfaces, VLANs, Dhcpd, and Filter populated. Overlay layers generated content onto an existing base document, preserving fields Phase 1 does not own (NAT, VPN, OPNsense subsystem block, Theme, certificates, ...). One file per CommonDevice subsystem (system, interfaces, vlans, dhcp, firewall) so future subsystems plug in without restructuring. Package sits under internal/serializer/opnsense/ to reserve a sibling path for internal/serializer/pfsense/. Round-trip test exercises faker -> Serialize -> MarshalConfig -> ParseConfig -> ConvertDocument and asserts zero ConversionWarnings plus field-level parity. Covers R1, R2, R4 (overlay), R6 (package layout extensibility). Signed-off-by: UncleSp1d3r --- internal/serializer/opnsense/dhcp.go | 25 +++++++ internal/serializer/opnsense/dhcp_test.go | 40 +++++++++++ internal/serializer/opnsense/firewall.go | 62 +++++++++++++++++ internal/serializer/opnsense/firewall_test.go | 57 ++++++++++++++++ internal/serializer/opnsense/interfaces.go | 32 +++++++++ .../serializer/opnsense/interfaces_test.go | 66 +++++++++++++++++++ internal/serializer/opnsense/overlay.go | 34 ++++++++++ internal/serializer/opnsense/overlay_test.go | 45 +++++++++++++ internal/serializer/opnsense/serializer.go | 31 +++++++++ .../serializer/opnsense/serializer_test.go | 57 ++++++++++++++++ internal/serializer/opnsense/system.go | 31 +++++++++ internal/serializer/opnsense/system_test.go | 41 ++++++++++++ internal/serializer/opnsense/vlans.go | 20 ++++++ internal/serializer/opnsense/vlans_test.go | 33 ++++++++++ 14 files changed, 574 insertions(+) create mode 100644 internal/serializer/opnsense/dhcp.go create mode 100644 internal/serializer/opnsense/dhcp_test.go create mode 100644 internal/serializer/opnsense/firewall.go create mode 100644 internal/serializer/opnsense/firewall_test.go create mode 100644 internal/serializer/opnsense/interfaces.go create mode 100644 internal/serializer/opnsense/interfaces_test.go create mode 100644 internal/serializer/opnsense/overlay.go create mode 100644 internal/serializer/opnsense/overlay_test.go create mode 100644 internal/serializer/opnsense/serializer.go create mode 100644 internal/serializer/opnsense/serializer_test.go create mode 100644 internal/serializer/opnsense/system.go create mode 100644 internal/serializer/opnsense/system_test.go create mode 100644 internal/serializer/opnsense/vlans.go create mode 100644 internal/serializer/opnsense/vlans_test.go diff --git a/internal/serializer/opnsense/dhcp.go b/internal/serializer/opnsense/dhcp.go new file mode 100644 index 0000000..75d8101 --- /dev/null +++ b/internal/serializer/opnsense/dhcp.go @@ -0,0 +1,25 @@ +package opnsense + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" +) + +// SerializeDHCP maps CommonDevice DHCP scopes onto opnsense.Dhcpd keyed by +// interface name. +func SerializeDHCP(scopes []model.DHCPScope) opnsense.Dhcpd { + items := make(map[string]opnsense.DhcpdInterface, len(scopes)) + for _, s := range scopes { + out := opnsense.NewDhcpdInterface() + if s.Enabled { + out.Enable = "1" + } + out.Range = opnsense.Range{From: s.Range.From, To: s.Range.To} + out.Gateway = s.Gateway + out.Dnsserver = s.DNSServer + out.Ntpserver = s.NTPServer + out.Winsserver = s.WINSServer + items[s.Interface] = out + } + return opnsense.Dhcpd{Items: items} +} diff --git a/internal/serializer/opnsense/dhcp_test.go b/internal/serializer/opnsense/dhcp_test.go new file mode 100644 index 0000000..4f1354d --- /dev/null +++ b/internal/serializer/opnsense/dhcp_test.go @@ -0,0 +1,40 @@ +package opnsense_test + +import ( + "testing" + + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSerializeDHCPEnabled(t *testing.T) { + t.Parallel() + + in := []model.DHCPScope{{ + Interface: "lan", + Enabled: true, + Range: model.DHCPRange{From: "192.168.1.100", To: "192.168.1.200"}, + Gateway: "192.168.1.1", + DNSServer: "192.168.1.1", + }} + + out := serializer.SerializeDHCP(in) + + require.NotNil(t, out.Items) + lan, ok := out.Items["lan"] + require.True(t, ok) + assert.Equal(t, "1", lan.Enable) + assert.Equal(t, "192.168.1.100", lan.Range.From) + assert.Equal(t, "192.168.1.200", lan.Range.To) + assert.Equal(t, "192.168.1.1", lan.Gateway) + assert.Equal(t, "192.168.1.1", lan.Dnsserver) +} + +func TestSerializeDHCPDisabled(t *testing.T) { + t.Parallel() + + out := serializer.SerializeDHCP([]model.DHCPScope{{Interface: "lan", Enabled: false}}) + assert.Empty(t, out.Items["lan"].Enable) +} diff --git a/internal/serializer/opnsense/firewall.go b/internal/serializer/opnsense/firewall.go new file mode 100644 index 0000000..1bf39e8 --- /dev/null +++ b/internal/serializer/opnsense/firewall.go @@ -0,0 +1,62 @@ +package opnsense + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" +) + +// SerializeFilter maps CommonDevice firewall rules onto opnsense.Filter. +func SerializeFilter(rules []model.FirewallRule) opnsense.Filter { + out := opnsense.Filter{} + for _, r := range rules { + orule := opnsense.Rule{ + Type: string(r.Type), + Descr: r.Description, + Interface: opnsense.InterfaceList(r.Interfaces), + IPProtocol: string(r.IPProtocol), + Direction: string(r.Direction), + Protocol: r.Protocol, + Source: endpointToSource(r.Source), + Destination: endpointToDestination(r.Destination), + Log: opnsense.BoolFlag(r.Log), + Disabled: opnsense.BoolFlag(r.Disabled), + Tracker: r.Tracker, + } + out.Rule = append(out.Rule, orule) + } + return out +} + +// endpointToSource maps model.RuleEndpoint onto opnsense.Source. Address +// "any" becomes the presence-flag form (); a non-empty Network-style +// label or CIDR lands in Source.Network. +func endpointToSource(ep model.RuleEndpoint) opnsense.Source { + s := opnsense.Source{Port: ep.Port} + switch ep.Address { + case "", opnsense.NetworkAny: + empty := "" + s.Any = &empty + default: + s.Network = ep.Address + } + if ep.Negated { + s.Not = true + } + return s +} + +// endpointToDestination mirrors endpointToSource for the destination side. +func endpointToDestination(ep model.RuleEndpoint) opnsense.Destination { + d := opnsense.Destination{Port: ep.Port} + switch ep.Address { + case "", opnsense.NetworkAny: + empty := "" + d.Any = &empty + default: + d.Network = ep.Address + } + if ep.Negated { + d.Not = true + } + return d +} diff --git a/internal/serializer/opnsense/firewall_test.go b/internal/serializer/opnsense/firewall_test.go new file mode 100644 index 0000000..1cfb022 --- /dev/null +++ b/internal/serializer/opnsense/firewall_test.go @@ -0,0 +1,57 @@ +package opnsense_test + +import ( + "testing" + + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSerializeFilterPassRule(t *testing.T) { + t.Parallel() + + in := []model.FirewallRule{{ + Interfaces: []string{"lan"}, + Type: model.RuleTypePass, + Description: "Default allow LAN to any", + IPProtocol: model.IPProtocolInet, + Direction: model.DirectionIn, + Source: model.RuleEndpoint{Address: "lan"}, + Destination: model.RuleEndpoint{Address: "any"}, + }} + + out := serializer.SerializeFilter(in) + + require.Len(t, out.Rule, 1) + r := out.Rule[0] + assert.Equal(t, "pass", r.Type) + assert.Equal(t, "Default allow LAN to any", r.Descr) + assert.Equal(t, "inet", r.IPProtocol) + assert.Equal(t, "in", r.Direction) + assert.Equal(t, "lan", r.Source.Network) + require.NotNil(t, r.Destination.Any, "destination 'any' must produce a non-nil Any pointer") +} + +func TestSerializeFilterEmpty(t *testing.T) { + t.Parallel() + + assert.Empty(t, serializer.SerializeFilter(nil).Rule) +} + +func TestSerializeFilterEmptyEndpointDefaultsToAny(t *testing.T) { + t.Parallel() + + in := []model.FirewallRule{{ + Interfaces: []string{"lan"}, + Type: model.RuleTypePass, + Source: model.RuleEndpoint{}, + Destination: model.RuleEndpoint{Address: "10.0.0.0/24"}, + }} + + out := serializer.SerializeFilter(in) + require.Len(t, out.Rule, 1) + require.NotNil(t, out.Rule[0].Source.Any, "empty source address → ") + assert.Equal(t, "10.0.0.0/24", out.Rule[0].Destination.Network) +} diff --git a/internal/serializer/opnsense/interfaces.go b/internal/serializer/opnsense/interfaces.go new file mode 100644 index 0000000..6ed5dfc --- /dev/null +++ b/internal/serializer/opnsense/interfaces.go @@ -0,0 +1,32 @@ +package opnsense + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" +) + +// SerializeInterfaces maps a CommonDevice interface slice onto the OPNsense +// map-based Interfaces container, keyed by interface Name (wan, lan, opt*). +func SerializeInterfaces(in []model.Interface) opnsense.Interfaces { + items := make(map[string]opnsense.Interface, len(in)) + for _, iface := range in { + out := opnsense.Interface{ + If: iface.PhysicalIf, + Descr: iface.Description, + MTU: iface.MTU, + } + if iface.Enabled { + out.Enable = "1" + } + switch iface.Type { + case "dhcp": + out.IPAddr = "dhcp" + case "static": + out.IPAddr = iface.IPAddress + out.Subnet = iface.Subnet + out.Gateway = iface.Gateway + } + items[iface.Name] = out + } + return opnsense.Interfaces{Items: items} +} diff --git a/internal/serializer/opnsense/interfaces_test.go b/internal/serializer/opnsense/interfaces_test.go new file mode 100644 index 0000000..763405c --- /dev/null +++ b/internal/serializer/opnsense/interfaces_test.go @@ -0,0 +1,66 @@ +package opnsense_test + +import ( + "testing" + + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSerializeInterfacesKeyedByName(t *testing.T) { + t.Parallel() + + in := []model.Interface{ + {Name: "wan", PhysicalIf: "igb1", Enabled: true, Type: "dhcp"}, + {Name: "lan", PhysicalIf: "igb0", Enabled: true, Type: "static", IPAddress: "192.168.1.1", Subnet: "24"}, + { + Name: "opt1", + PhysicalIf: "vlan0.42", + Enabled: true, + Type: "static", + IPAddress: "10.42.0.1", + Subnet: "24", + Description: "IT", + }, + } + + out := serializer.SerializeInterfaces(in) + + require.NotNil(t, out.Items) + require.Len(t, out.Items, 3) + + wan, ok := out.Items["wan"] + require.True(t, ok) + assert.Equal(t, "1", wan.Enable) + assert.Equal(t, "dhcp", wan.IPAddr) + assert.Equal(t, "igb1", wan.If) + + lan, ok := out.Items["lan"] + require.True(t, ok) + assert.Equal(t, "192.168.1.1", lan.IPAddr) + assert.Equal(t, "24", lan.Subnet) + + opt1, ok := out.Items["opt1"] + require.True(t, ok) + assert.Equal(t, "IT", opt1.Descr) + assert.Equal(t, "vlan0.42", opt1.If) +} + +func TestSerializeInterfacesDisabled(t *testing.T) { + t.Parallel() + + out := serializer.SerializeInterfaces([]model.Interface{{Name: "lan", Enabled: false}}) + assert.Empty(t, out.Items["lan"].Enable) +} + +func TestSerializeInterfacesTypeNoneLeavesAddressingEmpty(t *testing.T) { + t.Parallel() + + out := serializer.SerializeInterfaces([]model.Interface{{ + Name: "opt1", Enabled: true, Type: "", IPAddress: "1.2.3.4", Subnet: "24", + }}) + assert.Empty(t, out.Items["opt1"].IPAddr) + assert.Empty(t, out.Items["opt1"].Subnet) +} diff --git a/internal/serializer/opnsense/overlay.go b/internal/serializer/opnsense/overlay.go new file mode 100644 index 0000000..3c5fbb2 --- /dev/null +++ b/internal/serializer/opnsense/overlay.go @@ -0,0 +1,34 @@ +package opnsense + +import ( + "errors" + + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" +) + +// ErrNilBase is returned when Overlay receives a nil base document. +var ErrNilBase = errors.New("serializer: base document is nil") + +// Overlay applies a serialized *model.CommonDevice onto an existing base +// *opnsense.OpnSenseDocument. Fields Phase 1 owns (System, Interfaces, +// VLANs, Dhcpd, Filter) are replaced wholesale. Everything else (Version, +// Theme, Nat, OpenVPN, OPNsense block, Certs, Syslog, ...) is preserved +// from the base. This is the --base-config path: "take this existing config +// and layer my generated content onto it.". +func Overlay(base *opnsense.OpnSenseDocument, device *model.CommonDevice) (*opnsense.OpnSenseDocument, error) { + if base == nil { + return nil, ErrNilBase + } + serialized, err := Serialize(device) + if err != nil { + return nil, err + } + out := *base + out.System = serialized.System + out.Interfaces = serialized.Interfaces + out.VLANs = serialized.VLANs + out.Dhcpd = serialized.Dhcpd + out.Filter = serialized.Filter + return &out, nil +} diff --git a/internal/serializer/opnsense/overlay_test.go b/internal/serializer/opnsense/overlay_test.go new file mode 100644 index 0000000..71bdc97 --- /dev/null +++ b/internal/serializer/opnsense/overlay_test.go @@ -0,0 +1,45 @@ +package opnsense_test + +import ( + "testing" + + "github.com/EvilBit-Labs/opnConfigGenerator/internal/faker" + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + opnschema "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOverlayPreservesBaseConfigUnrelatedFields(t *testing.T) { + t.Parallel() + + base := &opnschema.OpnSenseDocument{ + Version: "1.2.3", + Theme: "opnsense", + System: opnschema.System{Hostname: "base-host", Domain: "base.test"}, + } + device := faker.NewCommonDevice(faker.WithSeed(1), faker.WithVLANCount(2)) + + merged, err := serializer.Overlay(base, device) + require.NoError(t, err) + + assert.Equal(t, "opnsense", merged.Theme, "Theme must survive overlay") + assert.Equal(t, "1.2.3", merged.Version, "Version must survive overlay") + assert.Equal(t, device.System.Hostname, merged.System.Hostname, "System replaced from device") + assert.Len(t, merged.VLANs.VLAN, 2) +} + +func TestOverlayNilBase(t *testing.T) { + t.Parallel() + + _, err := serializer.Overlay(nil, faker.NewCommonDevice(faker.WithSeed(1))) + require.ErrorIs(t, err, serializer.ErrNilBase) +} + +func TestOverlayNilDeviceSurfacesSerializeError(t *testing.T) { + t.Parallel() + + base := &opnschema.OpnSenseDocument{Version: "1.0"} + _, err := serializer.Overlay(base, nil) + require.ErrorIs(t, err, serializer.ErrNilDevice) +} diff --git a/internal/serializer/opnsense/serializer.go b/internal/serializer/opnsense/serializer.go new file mode 100644 index 0000000..b7c009c --- /dev/null +++ b/internal/serializer/opnsense/serializer.go @@ -0,0 +1,31 @@ +// Package opnsense serializes a *model.CommonDevice into an +// *opnsense.OpnSenseDocument. This is the inverse of opnDossier's +// pkg/parser/opnsense.ConvertDocument and is the core purpose of +// opnConfigGenerator. +package opnsense + +import ( + "errors" + + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" +) + +// ErrNilDevice is returned when Serialize is called with a nil input. +var ErrNilDevice = errors.New("serializer: device is nil") + +// Serialize converts a *model.CommonDevice into an *opnsense.OpnSenseDocument. +// The returned document is ready for MarshalConfig. +func Serialize(device *model.CommonDevice) (*opnsense.OpnSenseDocument, error) { + if device == nil { + return nil, ErrNilDevice + } + return &opnsense.OpnSenseDocument{ + Version: "1.0", + System: SerializeSystem(device.System), + Interfaces: SerializeInterfaces(device.Interfaces), + VLANs: SerializeVLANs(device.VLANs), + Dhcpd: SerializeDHCP(device.DHCP), + Filter: SerializeFilter(device.FirewallRules), + }, nil +} diff --git a/internal/serializer/opnsense/serializer_test.go b/internal/serializer/opnsense/serializer_test.go new file mode 100644 index 0000000..e5c5c96 --- /dev/null +++ b/internal/serializer/opnsense/serializer_test.go @@ -0,0 +1,57 @@ +package opnsense_test + +import ( + "bytes" + "testing" + + "github.com/EvilBit-Labs/opnConfigGenerator/internal/faker" + "github.com/EvilBit-Labs/opnConfigGenerator/internal/opnsensegen" + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + opnsenseparser "github.com/EvilBit-Labs/opnDossier/pkg/parser/opnsense" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRoundTrip is the primary acceptance gate for R1. It exercises the +// full pipeline: faker → Serialize → MarshalConfig → ParseConfig → +// ConvertDocument. The CommonDevice that comes out must match the one that +// went in on the fields Phase 1 covers. +func TestRoundTrip(t *testing.T) { + t.Parallel() + + original := faker.NewCommonDevice( + faker.WithSeed(2026), + faker.WithVLANCount(3), + faker.WithFirewallRules(true), + ) + + doc, err := serializer.Serialize(original) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(doc, &buf)) + + parsed, err := opnsensegen.ParseConfig(buf.Bytes()) + require.NoError(t, err) + + roundTripped, warnings, err := opnsenseparser.ConvertDocument(parsed) + require.NoError(t, err) + assert.Emptyf(t, warnings, "ConvertDocument warnings: %+v", warnings) + + require.NotNil(t, roundTripped) + assert.Equal(t, model.DeviceTypeOPNsense, roundTripped.DeviceType) + assert.Equal(t, original.System.Hostname, roundTripped.System.Hostname) + assert.Equal(t, original.System.Domain, roundTripped.System.Domain) + assert.Len(t, roundTripped.VLANs, len(original.VLANs)) + assert.Len(t, roundTripped.Interfaces, len(original.Interfaces)) + assert.Len(t, roundTripped.DHCP, len(original.DHCP)) + assert.Len(t, roundTripped.FirewallRules, len(original.FirewallRules)) +} + +func TestSerializeNilDevice(t *testing.T) { + t.Parallel() + + _, err := serializer.Serialize(nil) + require.ErrorIs(t, err, serializer.ErrNilDevice) +} diff --git a/internal/serializer/opnsense/system.go b/internal/serializer/opnsense/system.go new file mode 100644 index 0000000..bae6a8e --- /dev/null +++ b/internal/serializer/opnsense/system.go @@ -0,0 +1,31 @@ +package opnsense + +import ( + "strings" + + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" +) + +// SerializeSystem maps model.System onto opnsense.System. Multi-value DNS +// and NTP server lists collapse to space-separated strings because OPNsense +// stores them that way at the XML level. +// +// WebGUI.Protocol and SSH.Group are set to schema-required defaults so the +// emitted config validates under opnsense.System's struct tags. +func SerializeSystem(sys model.System) opnsense.System { + return opnsense.System{ + Hostname: sys.Hostname, + Domain: sys.Domain, + Timezone: sys.Timezone, + Language: sys.Language, + DNSServer: strings.Join(sys.DNSServers, " "), + TimeServers: strings.Join(sys.TimeServers, " "), + WebGUI: opnsense.WebGUIConfig{ + Protocol: "https", + }, + SSH: opnsense.SSHConfig{ + Group: "wheel", + }, + } +} diff --git a/internal/serializer/opnsense/system_test.go b/internal/serializer/opnsense/system_test.go new file mode 100644 index 0000000..9c3950b --- /dev/null +++ b/internal/serializer/opnsense/system_test.go @@ -0,0 +1,41 @@ +package opnsense_test + +import ( + "testing" + + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" +) + +func TestSerializeSystemPopulatedFields(t *testing.T) { + t.Parallel() + + in := model.System{ + Hostname: "gw", + Domain: "example.test", + Timezone: "UTC", + Language: "en_US", + DNSServers: []string{"1.1.1.1", "9.9.9.9"}, + TimeServers: []string{"0.pool.ntp.org"}, + } + + out := serializer.SerializeSystem(in) + + assert.Equal(t, "gw", out.Hostname) + assert.Equal(t, "example.test", out.Domain) + assert.Equal(t, "UTC", out.Timezone) + assert.Equal(t, "en_US", out.Language) + assert.Equal(t, "1.1.1.1 9.9.9.9", out.DNSServer) + assert.Equal(t, "0.pool.ntp.org", out.TimeServers) + assert.Equal(t, "https", out.WebGUI.Protocol) + assert.Equal(t, "wheel", out.SSH.Group) +} + +func TestSerializeSystemEmptyStillHasDefaults(t *testing.T) { + t.Parallel() + + out := serializer.SerializeSystem(model.System{}) + assert.Equal(t, "https", out.WebGUI.Protocol) + assert.Equal(t, "wheel", out.SSH.Group) +} diff --git a/internal/serializer/opnsense/vlans.go b/internal/serializer/opnsense/vlans.go new file mode 100644 index 0000000..9c13a41 --- /dev/null +++ b/internal/serializer/opnsense/vlans.go @@ -0,0 +1,20 @@ +package opnsense + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" +) + +// SerializeVLANs maps CommonDevice VLAN entries onto opnsense.VLANs. +func SerializeVLANs(in []model.VLAN) opnsense.VLANs { + out := opnsense.VLANs{} + for _, v := range in { + out.VLAN = append(out.VLAN, opnsense.VLAN{ + If: v.PhysicalIf, + Tag: v.Tag, + Descr: v.Description, + Vlanif: v.VLANIf, + }) + } + return out +} diff --git a/internal/serializer/opnsense/vlans_test.go b/internal/serializer/opnsense/vlans_test.go new file mode 100644 index 0000000..b2d51dc --- /dev/null +++ b/internal/serializer/opnsense/vlans_test.go @@ -0,0 +1,33 @@ +package opnsense_test + +import ( + "testing" + + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/stretchr/testify/assert" +) + +func TestSerializeVLANs(t *testing.T) { + t.Parallel() + + in := []model.VLAN{ + {VLANIf: "vlan0.42", PhysicalIf: "igb0", Tag: "42", Description: "IT"}, + {VLANIf: "vlan0.100", PhysicalIf: "igb0", Tag: "100", Description: "Sales"}, + } + + out := serializer.SerializeVLANs(in) + + assert.Len(t, out.VLAN, 2) + assert.Equal(t, "42", out.VLAN[0].Tag) + assert.Equal(t, "igb0", out.VLAN[0].If) + assert.Equal(t, "vlan0.42", out.VLAN[0].Vlanif) + assert.Equal(t, "IT", out.VLAN[0].Descr) +} + +func TestSerializeVLANsEmpty(t *testing.T) { + t.Parallel() + + out := serializer.SerializeVLANs(nil) + assert.Empty(t, out.VLAN) +} From cd3b17977b421c64266b950588c8b47c163c9d80 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 01:46:00 -0400 Subject: [PATCH 04/11] refactor: transport-only opnsensegen; csvio consumes CommonDevice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/opnsensegen becomes transport-only (LoadBaseConfig, ParseConfig, MarshalConfig). The Inject* helpers are removed — generation lives in internal/faker, serialization in internal/serializer/opnsense. MarshalConfig post-processes the marshaled XML to sort children of map-backed sections (, ) alphabetically by tag name. Without this, opnDossier's map[string]T-backed MarshalXML emits children in Go's randomized map iteration order, breaking R5 (deterministic output under fixed seed). commondevice_test.go rewrites the consumer round-trip test to exercise the new pipeline. template_test.go drops tests for the deleted injection helpers. csvio.WriteVlanCSV now takes a *model.CommonDevice. IP range columns are derived by cross-referencing VLAN.VLANIf with Interface.PhysicalIf. Column order and German headers preserved. ReadVlanCSV dropped — unused after the CLI migration. Covers R2, R3 (CSV inspection path), R5 (determinism), R7 (no compatibility shims). Signed-off-by: UncleSp1d3r --- internal/csvio/csvio.go | 154 +++----------- internal/csvio/csvio_test.go | 146 ++++--------- internal/opnsensegen/commondevice_test.go | 92 +++------ internal/opnsensegen/template.go | 236 ++++++++++++---------- internal/opnsensegen/template_test.go | 156 ++------------ 5 files changed, 238 insertions(+), 546 deletions(-) diff --git a/internal/csvio/csvio.go b/internal/csvio/csvio.go index a98c805..2aa1e7a 100644 --- a/internal/csvio/csvio.go +++ b/internal/csvio/csvio.go @@ -1,4 +1,6 @@ -// Package csvio handles CSV reading and writing with German headers for VLAN data. +// Package csvio writes VLAN inspection CSVs derived from a *model.CommonDevice. +// The German column headers (VLAN, IP Range, Beschreibung, WAN) are preserved +// from the original tool for compatibility with downstream consumers. package csvio import ( @@ -6,40 +8,52 @@ import ( "errors" "fmt" "io" - "net/netip" - "strconv" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" + "github.com/EvilBit-Labs/opnDossier/pkg/model" ) -// German CSV headers matching the existing Rust implementation. +// vlanHeaders are the German column headers this package emits. var vlanHeaders = []string{"VLAN", "IP Range", "Beschreibung", "WAN"} -// UTF-8 BOM for Excel compatibility on Windows. +// utf8BOM is written first so Excel on Windows detects UTF-8 encoding. var utf8BOM = []byte{0xEF, 0xBB, 0xBF} -// WriteVlanCSV writes VLAN configurations to a writer in CSV format with German headers. -func WriteVlanCSV(w io.Writer, vlans []generator.VlanConfig) error { - // Write UTF-8 BOM for Windows/Excel compatibility. +// defaultWanAssignment preserves the column shape when the CommonDevice +// model has no concept of WAN assignment. The field is informational only. +const defaultWanAssignment = "1" + +// WriteVlanCSV writes the device's VLANs to w in the existing German-header +// CSV format. The column set is: VLAN tag, IP range (derived from the +// matching opt interface's IP and subnet), description, and WAN assignment +// (fixed at "1" — the CommonDevice model has no WAN assignment concept). +func WriteVlanCSV(w io.Writer, device *model.CommonDevice) error { + if device == nil { + return errors.New("csvio: device is nil") + } + if _, err := w.Write(utf8BOM); err != nil { return fmt.Errorf("write BOM: %w", err) } cw := csv.NewWriter(w) - // Write header row. if err := cw.Write(vlanHeaders); err != nil { return fmt.Errorf("write header: %w", err) } - // Write data rows. - for i, v := range vlans { - record := []string{ - strconv.FormatUint(uint64(v.VlanID), 10), - v.IPNetwork.String(), - v.Description, - strconv.FormatUint(uint64(v.WanAssignment), 10), + // Index interfaces by PhysicalIf so each VLAN row can pull its IP range + // from the matching opt interface. + byPhysical := make(map[string]model.Interface, len(device.Interfaces)) + for _, iface := range device.Interfaces { + byPhysical[iface.PhysicalIf] = iface + } + + for i, v := range device.VLANs { + ipRange := "" + if iface, ok := byPhysical[v.VLANIf]; ok && iface.IPAddress != "" && iface.Subnet != "" { + ipRange = fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet) } + record := []string{v.Tag, ipRange, v.Description, defaultWanAssignment} if err := cw.Write(record); err != nil { return fmt.Errorf("write row %d: %w", i, err) } @@ -48,109 +62,3 @@ func WriteVlanCSV(w io.Writer, vlans []generator.VlanConfig) error { cw.Flush() return cw.Error() } - -// ReadVlanCSV reads VLAN configurations from a CSV reader with German headers. -func ReadVlanCSV(r io.Reader) ([]generator.VlanConfig, error) { - cr := csv.NewReader(r) - - // Read and validate header. - header, err := cr.Read() - if err != nil { - return nil, fmt.Errorf("read header: %w", err) - } - - if err := validateHeader(header); err != nil { - return nil, err - } - - var vlans []generator.VlanConfig - for lineNum := 2; ; lineNum++ { - record, err := cr.Read() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return nil, fmt.Errorf("read line %d: %w", lineNum, err) - } - - vlan, err := parseVlanRecord(record, lineNum) - if err != nil { - return nil, err - } - - vlans = append(vlans, vlan) - } - - return vlans, nil -} - -func validateHeader(header []string) error { - if len(header) < len(vlanHeaders) { - return fmt.Errorf("CSV header has %d columns, expected %d", len(header), len(vlanHeaders)) - } - - // Strip UTF-8 BOM from first field if present (use local copy to avoid mutating caller). - first := header[0] - if len(first) >= 3 && first[:3] == string(utf8BOM) { - first = first[3:] - } - - for i, expected := range vlanHeaders { - actual := header[i] - if i == 0 { - actual = first - } - if actual != expected { - return fmt.Errorf("CSV header column %d: got %q, expected %q", i, header[i], expected) - } - } - - return nil -} - -func parseVlanRecord(record []string, lineNum int) (generator.VlanConfig, error) { - if len(record) < len(vlanHeaders) { - return generator.VlanConfig{}, fmt.Errorf( - "line %d: expected %d columns, got %d", - lineNum, - len(vlanHeaders), - len(record), - ) - } - - vlanID, err := strconv.ParseUint(record[0], 10, 16) - if err != nil { - return generator.VlanConfig{}, fmt.Errorf("line %d: invalid VLAN ID %q: %w", lineNum, record[0], err) - } - - if vlanID < generator.MinVlanID || vlanID > generator.MaxVlanID { - return generator.VlanConfig{}, fmt.Errorf("line %d: VLAN ID %d outside range %d-%d", - lineNum, vlanID, generator.MinVlanID, generator.MaxVlanID) - } - - network, err := netip.ParsePrefix(record[1]) - if err != nil { - return generator.VlanConfig{}, fmt.Errorf("line %d: invalid network %q: %w", lineNum, record[1], err) - } - - description := record[2] - if description == "" { - return generator.VlanConfig{}, fmt.Errorf("line %d: description cannot be empty", lineNum) - } - - wan, err := strconv.ParseUint(record[3], 10, 8) - if err != nil { - return generator.VlanConfig{}, fmt.Errorf("line %d: invalid WAN assignment %q: %w", lineNum, record[3], err) - } - - if wan < 1 || wan > 3 { - return generator.VlanConfig{}, fmt.Errorf("line %d: WAN assignment %d outside range 1-3", lineNum, wan) - } - - return generator.VlanConfig{ - VlanID: uint16(vlanID), - IPNetwork: network, - Description: description, - WanAssignment: uint8(wan), - }, nil -} diff --git a/internal/csvio/csvio_test.go b/internal/csvio/csvio_test.go index 239d5b0..8c46bc9 100644 --- a/internal/csvio/csvio_test.go +++ b/internal/csvio/csvio_test.go @@ -2,148 +2,80 @@ package csvio_test import ( "bytes" - "net/netip" "strings" "testing" "github.com/EvilBit-Labs/opnConfigGenerator/internal/csvio" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" + "github.com/EvilBit-Labs/opnConfigGenerator/internal/faker" + "github.com/EvilBit-Labs/opnDossier/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestWriteAndReadRoundTrip(t *testing.T) { +func TestWriteVlanCSVFromCommonDevice(t *testing.T) { t.Parallel() - vlans := []generator.VlanConfig{ - {VlanID: 42, IPNetwork: netip.MustParsePrefix("10.42.0.0/24"), Description: "IT VLAN 42", WanAssignment: 1}, - { - VlanID: 100, - IPNetwork: netip.MustParsePrefix("10.100.0.0/24"), - Description: "Sales VLAN 100", - WanAssignment: 2, - }, - { - VlanID: 200, - IPNetwork: netip.MustParsePrefix("172.16.5.0/24"), - Description: "Engineering VLAN 200", - WanAssignment: 3, - }, - } + dev := faker.NewCommonDevice(faker.WithSeed(7), faker.WithVLANCount(2)) var buf bytes.Buffer - err := csvio.WriteVlanCSV(&buf, vlans) - require.NoError(t, err) - - result, err := csvio.ReadVlanCSV(&buf) - require.NoError(t, err) - - assert.Len(t, result, 3) - for i, v := range result { - assert.Equal(t, vlans[i].VlanID, v.VlanID, "VLAN ID mismatch at %d", i) - assert.Equal(t, vlans[i].IPNetwork, v.IPNetwork, "network mismatch at %d", i) - assert.Equal(t, vlans[i].Description, v.Description, "description mismatch at %d", i) - assert.Equal(t, vlans[i].WanAssignment, v.WanAssignment, "WAN mismatch at %d", i) - } -} + require.NoError(t, csvio.WriteVlanCSV(&buf, dev)) -func TestWriteCSVGermanHeaders(t *testing.T) { - t.Parallel() - - vlans := []generator.VlanConfig{ - {VlanID: 42, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Description: "test", WanAssignment: 1}, - } - - var buf bytes.Buffer - err := csvio.WriteVlanCSV(&buf, vlans) - require.NoError(t, err) - - content := buf.String() - // Skip BOM (3 bytes). - if len(content) > 3 { - content = content[3:] - } - - assert.True(t, strings.HasPrefix(content, "VLAN,IP Range,Beschreibung,WAN\n"), - "should start with German headers, got: %q", content[:50]) + // Strip BOM. + content := strings.TrimPrefix(buf.String(), "\ufeff") + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + require.Len(t, lines, 3, "header + 2 data rows") + assert.Equal(t, "VLAN,IP Range,Beschreibung,WAN", lines[0]) } -func TestWriteCSVUTF8BOM(t *testing.T) { +func TestWriteVlanCSVNilDevice(t *testing.T) { t.Parallel() var buf bytes.Buffer err := csvio.WriteVlanCSV(&buf, nil) - require.NoError(t, err) - - data := buf.Bytes() - assert.Equal(t, byte(0xEF), data[0]) - assert.Equal(t, byte(0xBB), data[1]) - assert.Equal(t, byte(0xBF), data[2]) -} - -func TestReadCSVInvalidVlanID(t *testing.T) { - t.Parallel() - - input := "VLAN,IP Range,Beschreibung,WAN\n5,10.1.1.0/24,test,1\n" - _, err := csvio.ReadVlanCSV(strings.NewReader(input)) require.Error(t, err) - assert.Contains(t, err.Error(), "VLAN ID") } -func TestReadCSVInvalidNetwork(t *testing.T) { +func TestWriteVlanCSVHeaders(t *testing.T) { t.Parallel() - input := "VLAN,IP Range,Beschreibung,WAN\n100,invalid,test,1\n" - _, err := csvio.ReadVlanCSV(strings.NewReader(input)) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid network") -} - -func TestReadCSVEmptyDescription(t *testing.T) { - t.Parallel() + var buf bytes.Buffer + require.NoError(t, csvio.WriteVlanCSV(&buf, &model.CommonDevice{})) - input := "VLAN,IP Range,Beschreibung,WAN\n100,10.1.1.0/24,,1\n" - _, err := csvio.ReadVlanCSV(strings.NewReader(input)) - require.Error(t, err) - assert.Contains(t, err.Error(), "description") + content := buf.String() + // BOM is 3 bytes. + require.GreaterOrEqual(t, len(content), 3) + assert.Equal(t, byte(0xEF), content[0]) + assert.Equal(t, byte(0xBB), content[1]) + assert.Equal(t, byte(0xBF), content[2]) + assert.True(t, strings.HasPrefix(content[3:], "VLAN,IP Range,Beschreibung,WAN\n")) } -func TestReadCSVInvalidWAN(t *testing.T) { +func TestWriteVlanCSVEmptyDevice(t *testing.T) { t.Parallel() - input := "VLAN,IP Range,Beschreibung,WAN\n100,10.1.1.0/24,test,5\n" - _, err := csvio.ReadVlanCSV(strings.NewReader(input)) - require.Error(t, err) - assert.Contains(t, err.Error(), "WAN assignment") -} - -func TestReadCSVWrongHeaders(t *testing.T) { - t.Parallel() + var buf bytes.Buffer + require.NoError(t, csvio.WriteVlanCSV(&buf, &model.CommonDevice{})) - input := "ID,Network,Name,WAN\n100,10.1.1.0/24,test,1\n" - _, err := csvio.ReadVlanCSV(strings.NewReader(input)) - require.Error(t, err) - assert.Contains(t, err.Error(), "header") + content := strings.TrimPrefix(buf.String(), "\ufeff") + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + assert.Len(t, lines, 1, "empty device: header only") } -func TestReadCSVEmpty(t *testing.T) { +func TestWriteVlanCSVIPRangeDerivation(t *testing.T) { t.Parallel() - input := "VLAN,IP Range,Beschreibung,WAN\n" - vlans, err := csvio.ReadVlanCSV(strings.NewReader(input)) - require.NoError(t, err) - assert.Empty(t, vlans) -} - -func TestWriteCSVEmptySlice(t *testing.T) { - t.Parallel() + dev := &model.CommonDevice{ + VLANs: []model.VLAN{ + {VLANIf: "vlan0.42", Tag: "42", Description: "IT"}, + }, + Interfaces: []model.Interface{ + {Name: "opt1", PhysicalIf: "vlan0.42", IPAddress: "10.42.0.1", Subnet: "24"}, + }, + } var buf bytes.Buffer - err := csvio.WriteVlanCSV(&buf, []generator.VlanConfig{}) - require.NoError(t, err) + require.NoError(t, csvio.WriteVlanCSV(&buf, dev)) - // Should still have BOM + header. - result, err := csvio.ReadVlanCSV(bytes.NewReader(buf.Bytes())) - require.NoError(t, err) - assert.Empty(t, result) + content := strings.TrimPrefix(buf.String(), "\ufeff") + assert.Contains(t, content, "42,10.42.0.1/24,IT,1") } diff --git a/internal/opnsensegen/commondevice_test.go b/internal/opnsensegen/commondevice_test.go index 586fd91..af247a1 100644 --- a/internal/opnsensegen/commondevice_test.go +++ b/internal/opnsensegen/commondevice_test.go @@ -1,8 +1,8 @@ // Tests in this file verify that opnConfigGenerator can act as an external -// consumer of opnDossier's public pkg/ API surface. Specifically, they exercise -// the file->CommonDevice pipeline described in NATS-146: +// consumer of opnDossier's public pkg/ API surface. They exercise the full +// reverse-serializer pipeline: // -// generate -> marshal XML -> parse XML -> ConvertDocument -> CommonDevice +// faker.NewCommonDevice -> serializer.Serialize -> MarshalConfig -> ParseConfig -> ConvertDocument // // These tests intentionally use github.com/EvilBit-Labs/opnDossier/pkg/model // and github.com/EvilBit-Labs/opnDossier/pkg/parser/opnsense so that a build @@ -11,83 +11,49 @@ package opnsensegen_test import ( "bytes" - "math/rand/v2" - "net/netip" "testing" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" + "github.com/EvilBit-Labs/opnConfigGenerator/internal/faker" "github.com/EvilBit-Labs/opnConfigGenerator/internal/opnsensegen" + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" "github.com/EvilBit-Labs/opnDossier/pkg/model" opnsenseparser "github.com/EvilBit-Labs/opnDossier/pkg/parser/opnsense" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestCommonDeviceRoundTrip exercises the full consumer pipeline against a -// realistic generated config: generate -> marshal XML -> parse XML -> convert. -// This is the primary acceptance test for NATS-146 acceptance criteria #1, #2, -// and #8 on the opnConfigGenerator side. -func TestCommonDeviceRoundTrip(t *testing.T) { +// TestCommonDeviceRoundTripViaSerializer exercises the full reverse pipeline +// against a faker-generated device and asserts round-trip parity on the +// fields Phase 1 covers. Zero ConversionWarnings is the primary gate. +func TestCommonDeviceRoundTripViaSerializer(t *testing.T) { t.Parallel() - const ( - testHostname = "nats146-host" - testDomain = "test.local" + original := faker.NewCommonDevice( + faker.WithSeed(146), + faker.WithVLANCount(2), ) - cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") + doc, err := serializer.Serialize(original) require.NoError(t, err) - cfg.System.Hostname = testHostname - cfg.System.Domain = testDomain - - vlans := []generator.VlanConfig{ - { - VlanID: 42, - IPNetwork: netip.MustParsePrefix("10.42.7.0/24"), - Description: "IT VLAN 42", - WanAssignment: 1, - Department: generator.DeptIT, - }, - { - VlanID: 100, - IPNetwork: netip.MustParsePrefix("10.100.0.0/24"), - Description: "Sales VLAN 100", - WanAssignment: 2, - Department: generator.DeptSales, - }, - } - opnsensegen.InjectVlans(cfg, vlans, 6) - - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng := rand.New(rand.NewPCG(42, 0)) - dhcpConfigs := []generator.DhcpServerConfig{ - generator.DeriveDHCPConfig(vlans[0], rng), - } - opnsensegen.InjectDHCP(cfg, dhcpConfigs, 6) - - // Round-trip through XML bytes to prove the consumer pipeline works against - // on-disk representation, not just in-memory struct passing. + var buf bytes.Buffer - require.NoError(t, opnsensegen.MarshalConfig(cfg, &buf)) + require.NoError(t, opnsensegen.MarshalConfig(doc, &buf)) parsed, err := opnsensegen.ParseConfig(buf.Bytes()) require.NoError(t, err) device, warnings, err := opnsenseparser.ConvertDocument(parsed) - require.NoError(t, err, "ConvertDocument must accept a generator-produced document") - require.NotNil(t, device, "ConvertDocument must return a non-nil CommonDevice") - - // Zero warnings expected for output we ship. If warnings appear, surface - // them in the failure so future regressions are diagnosable. - assert.Empty(t, warnings, - "generator output produced %d ConversionWarning(s): %+v", len(warnings), warnings) - - assert.Equal(t, model.DeviceTypeOPNsense, device.DeviceType, - "DeviceType must be OPNsense for opnsense converter output") - assert.Equal(t, testHostname, device.System.Hostname) - assert.Equal(t, testDomain, device.System.Domain) - assert.Len(t, device.VLANs, 2, "both injected VLANs must be present in CommonDevice") - assert.NotEmpty(t, device.Interfaces, "injected VLAN interfaces must surface in CommonDevice") + require.NoError(t, err, "ConvertDocument must accept a serializer-produced document") + require.NotNil(t, device) + + assert.Emptyf(t, warnings, + "serializer output produced %d ConversionWarning(s): %+v", len(warnings), warnings) + + assert.Equal(t, model.DeviceTypeOPNsense, device.DeviceType) + assert.Equal(t, original.System.Hostname, device.System.Hostname) + assert.Equal(t, original.System.Domain, device.System.Domain) + assert.Len(t, device.VLANs, 2) + assert.NotEmpty(t, device.Interfaces) } // TestCommonDeviceMinimalConfig verifies ConvertDocument accepts the sparse @@ -103,7 +69,7 @@ func TestCommonDeviceMinimalConfig(t *testing.T) { device, warnings, err := opnsenseparser.ConvertDocument(cfg) require.NoError(t, err) require.NotNil(t, device) - assert.Empty(t, warnings, + assert.Emptyf(t, warnings, "minimal config produced %d ConversionWarning(s): %+v", len(warnings), warnings) assert.Equal(t, model.DeviceTypeOPNsense, device.DeviceType) @@ -111,8 +77,8 @@ func TestCommonDeviceMinimalConfig(t *testing.T) { } // TestCommonDeviceNilDocument pins the consumer-visible error contract for -// ConvertDocument. Guards against a silent change where nil input starts -// returning a different error or panicking. +// ConvertDocument against nil input. Guards against a silent change where +// nil input starts returning a different error or panicking. func TestCommonDeviceNilDocument(t *testing.T) { t.Parallel() diff --git a/internal/opnsensegen/template.go b/internal/opnsensegen/template.go index a2e29e7..9679dea 100644 --- a/internal/opnsensegen/template.go +++ b/internal/opnsensegen/template.go @@ -1,24 +1,32 @@ -// Package opnsensegen handles loading, injecting, and marshaling OPNsense XML configurations. +// Package opnsensegen is the transport layer for OPNsense XML documents. +// It exposes only load / parse / marshal; all generation and serialization +// logic lives in internal/faker and internal/serializer/opnsense respectively. // // It uses the opnDossier schema types (github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense) // as the canonical OPNsense data model, ensuring consistency across the opnDossier ecosystem. package opnsensegen import ( + "bytes" "encoding/xml" + "errors" "fmt" "io" "os" - "strconv" - "strings" + "sort" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" ) -// defaultParentInterface is the default physical NIC for VLAN tagging. -const defaultParentInterface = "igb0" +// mapBackedSections names the OPNsense XML elements whose children are +// serialized from a Go map and therefore emit in non-deterministic order. +// MarshalConfig post-processes these sections to sort children by tag name, +// which is the cheapest way to guarantee byte-stable output without forking +// opnDossier's MarshalXML implementations. +var mapBackedSections = map[string]bool{ + "interfaces": true, + "dhcpd": true, +} // LoadBaseConfig reads and parses a base OPNsense config.xml file. func LoadBaseConfig(path string) (*opnsense.OpnSenseDocument, error) { @@ -26,7 +34,6 @@ func LoadBaseConfig(path string) (*opnsense.OpnSenseDocument, error) { if err != nil { return nil, fmt.Errorf("read base config %q: %w", path, err) } - return ParseConfig(data) } @@ -36,102 +43,35 @@ func ParseConfig(data []byte) (*opnsense.OpnSenseDocument, error) { if err := xml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config XML: %w", err) } - return &cfg, nil } -// InjectVlans adds generated VLAN data and corresponding interface entries into the config. -func InjectVlans(cfg *opnsense.OpnSenseDocument, vlans []generator.VlanConfig, optCounter int) { - if cfg.Interfaces.Items == nil { - cfg.Interfaces.Items = make(map[string]opnsense.Interface) - } - - for i, v := range vlans { - ifName := fmt.Sprintf("opt%d", optCounter+i) - vlanIfName := fmt.Sprintf("vlan0.%d", v.VlanID) - - cfg.VLANs.VLAN = append(cfg.VLANs.VLAN, opnsense.VLAN{ - If: defaultParentInterface, - Tag: strconv.FormatUint(uint64(v.VlanID), 10), - Descr: v.Description, - Vlanif: vlanIfName, - }) - - gateway := netutil.GatewayIP(v.IPNetwork) - cfg.Interfaces.Items[ifName] = opnsense.Interface{ - Enable: "1", - Descr: v.Description, - If: vlanIfName, - IPAddr: gateway.String(), - Subnet: strconv.Itoa(v.IPNetwork.Bits()), - } - } -} - -// InjectDHCP adds generated DHCP configurations into the config. -func InjectDHCP( - cfg *opnsense.OpnSenseDocument, - dhcpConfigs []generator.DhcpServerConfig, - optCounter int, -) { - if cfg.Dhcpd.Items == nil { - cfg.Dhcpd.Items = make(map[string]opnsense.DhcpdInterface) - } - - for i, dhcp := range dhcpConfigs { - ifName := fmt.Sprintf("opt%d", optCounter+i) - - dhcpIface := opnsense.NewDhcpdInterface() - dhcpIface.Enable = "1" - dhcpIface.Range = opnsense.Range{ - From: dhcp.RangeStart.String(), - To: dhcp.RangeEnd.String(), - } - dhcpIface.Gateway = dhcp.Gateway.String() - dhcpIface.Dnsserver = strings.Join(dhcp.DNSServers, ",") - - cfg.Dhcpd.Items[ifName] = dhcpIface - } -} - -// InjectFirewallRules adds generated firewall rules into the config. -func InjectFirewallRules(cfg *opnsense.OpnSenseDocument, rules []generator.FirewallRule) { - for _, r := range rules { - src := buildSource(r.Source) - dst := buildDestination(r.Destination, r.Ports) - - var log opnsense.BoolFlag - if r.Log { - log = true - } - - cfg.Filter.Rule = append(cfg.Filter.Rule, opnsense.Rule{ - Type: r.Action, - Descr: r.Description, - Interface: opnsense.InterfaceList{r.Interface}, - IPProtocol: "inet", - Protocol: r.Protocol, - Source: src, - Destination: dst, - Log: log, - Direction: r.Direction, - Tracker: strconv.FormatUint(r.Tracker, 10), - }) - } -} - -// MarshalConfig writes the config to XML with proper formatting. +// MarshalConfig writes the config to XML with a standard OPNsense header, +// two-space indentation, and a trailing newline. Children of map-backed +// sections (interfaces, dhcpd) are sorted alphabetically so output is +// byte-stable under a fixed seed. func MarshalConfig(cfg *opnsense.OpnSenseDocument, w io.Writer) error { if _, err := io.WriteString(w, xml.Header); err != nil { return fmt.Errorf("write XML header: %w", err) } - enc := xml.NewEncoder(w) + var buf bytes.Buffer + enc := xml.NewEncoder(&buf) enc.Indent("", " ") - if err := enc.Encode(cfg); err != nil { return fmt.Errorf("encode config XML: %w", err) } + if err := enc.Flush(); err != nil { + return fmt.Errorf("flush XML encoder: %w", err) + } + + stable, err := sortMapBackedSections(buf.Bytes()) + if err != nil { + return fmt.Errorf("stabilize XML: %w", err) + } + if _, err := w.Write(stable); err != nil { + return fmt.Errorf("write XML body: %w", err) + } if _, err := io.WriteString(w, "\n"); err != nil { return fmt.Errorf("write trailing newline: %w", err) @@ -140,30 +80,104 @@ func MarshalConfig(cfg *opnsense.OpnSenseDocument, w io.Writer) error { return nil } -// buildSource creates an opnsense.Source from a generator source string. -func buildSource(source string) opnsense.Source { - if source == opnsense.NetworkAny { - empty := "" - return opnsense.Source{Any: &empty} - } +// sortMapBackedSections walks the token stream and, whenever it encounters +// a section in mapBackedSections, buffers its direct children, sorts them by +// tag name, and re-emits them in sorted order. Non-section tokens pass +// through untouched. +func sortMapBackedSections(raw []byte) ([]byte, error) { + dec := xml.NewDecoder(bytes.NewReader(raw)) + var out bytes.Buffer + enc := xml.NewEncoder(&out) + enc.Indent("", " ") - return opnsense.Source{Network: source} -} + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } -// buildDestination creates an opnsense.Destination from generator destination and port strings. -func buildDestination(destination, ports string) opnsense.Destination { - dst := opnsense.Destination{} + start, isStart := tok.(xml.StartElement) + if !isStart || !mapBackedSections[start.Name.Local] { + if err := enc.EncodeToken(tok); err != nil { + return nil, err + } + continue + } + + if err := emitSortedChildren(dec, enc, start); err != nil { + return nil, err + } + } - if destination == opnsense.NetworkAny { - empty := "" - dst.Any = &empty - } else { - dst.Network = destination + if err := enc.Flush(); err != nil { + return nil, err } + return out.Bytes(), nil +} - if ports != opnsense.NetworkAny { - dst.Port = ports +// emitSortedChildren buffers the direct children of a map-backed section, +// sorts them by start-element name, and emits the section with sorted +// children. It consumes tokens through the section's closing end element. +func emitSortedChildren(dec *xml.Decoder, enc *xml.Encoder, start xml.StartElement) error { + type child struct { + name string + tokens []xml.Token } + var ( + children []child + current child + depth int + ) + + for { + t, err := dec.Token() + if err != nil { + return err + } - return dst + switch tt := t.(type) { + case xml.StartElement: + if depth == 0 { + current = child{name: tt.Name.Local} + } + current.tokens = append(current.tokens, xml.CopyToken(t)) + depth++ + + case xml.EndElement: + if depth == 0 { + // End of the map-backed section itself. + sort.SliceStable(children, func(i, j int) bool { + return children[i].name < children[j].name + }) + if err := enc.EncodeToken(start); err != nil { + return err + } + for _, c := range children { + for _, ct := range c.tokens { + if err := enc.EncodeToken(ct); err != nil { + return err + } + } + } + return enc.EncodeToken(tt) + } + depth-- + current.tokens = append(current.tokens, xml.CopyToken(t)) + if depth == 0 { + children = append(children, current) + current = child{} + } + + default: + if depth > 0 { + current.tokens = append(current.tokens, xml.CopyToken(t)) + } + // CharData/Comment/Directive/ProcInst at depth 0 (between + // children) is indentation whitespace — drop it; the encoder + // re-indents on emit. + } + } } diff --git a/internal/opnsensegen/template_test.go b/internal/opnsensegen/template_test.go index a4aa21d..3eca7e6 100644 --- a/internal/opnsensegen/template_test.go +++ b/internal/opnsensegen/template_test.go @@ -2,12 +2,8 @@ package opnsensegen_test import ( "bytes" - "math/rand/v2" - "net/netip" - "strings" "testing" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" "github.com/EvilBit-Labs/opnConfigGenerator/internal/opnsensegen" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,6 +20,14 @@ func TestLoadBaseConfig(t *testing.T) { assert.Equal(t, "localdomain", cfg.System.Domain) } +func TestLoadBaseConfigMissingFile(t *testing.T) { + t.Parallel() + + _, err := opnsensegen.LoadBaseConfig("does-not-exist.xml") + require.Error(t, err) + assert.Contains(t, err.Error(), "read base config") +} + func TestParseConfig(t *testing.T) { t.Parallel() @@ -43,95 +47,15 @@ func TestParseConfig(t *testing.T) { cfg, err := opnsensegen.ParseConfig(xmlData) require.NoError(t, err) assert.Equal(t, "test", cfg.System.Hostname) + assert.Equal(t, "test.local", cfg.System.Domain) } -func TestInjectVlans(t *testing.T) { - t.Parallel() - - cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") - require.NoError(t, err) - - vlans := []generator.VlanConfig{ - {VlanID: 42, IPNetwork: netip.MustParsePrefix("10.42.7.0/24"), Description: "IT VLAN 42", WanAssignment: 1}, - { - VlanID: 100, - IPNetwork: netip.MustParsePrefix("10.100.0.0/24"), - Description: "Sales VLAN 100", - WanAssignment: 2, - }, - } - - opnsensegen.InjectVlans(cfg, vlans, 6) - - assert.Len(t, cfg.VLANs.VLAN, 2) - assert.Equal(t, "42", cfg.VLANs.VLAN[0].Tag) - assert.Equal(t, "IT VLAN 42", cfg.VLANs.VLAN[0].Descr) - - // Verify interfaces were added to the map. - assert.Len(t, cfg.Interfaces.Items, 2) - opt6, ok := cfg.Interfaces.Items["opt6"] - require.True(t, ok) - assert.Equal(t, "10.42.7.1", opt6.IPAddr) - assert.Equal(t, "IT VLAN 42", opt6.Descr) -} - -func TestInjectFirewallRules(t *testing.T) { - t.Parallel() - - cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") - require.NoError(t, err) - - rules := []generator.FirewallRule{ - { - RuleID: "r1", Action: "pass", Protocol: "tcp", Direction: "in", - Source: "10.1.1.0/24", Destination: "any", Ports: "80,443", - Description: "Allow HTTP", Interface: "opt6", Tracker: 12345, - }, - { - RuleID: "r2", Action: "block", Protocol: "any", Direction: "in", - Source: "any", Destination: "any", Ports: "any", - Description: "Block all", Interface: "opt6", Tracker: 67890, Log: true, - }, - } - - opnsensegen.InjectFirewallRules(cfg, rules) - assert.Len(t, cfg.Filter.Rule, 2) - assert.Equal(t, "pass", cfg.Filter.Rule[0].Type) - assert.Equal(t, "Allow HTTP", cfg.Filter.Rule[0].Descr) - - // Verify "any" source path produces Source.Any field. - assert.NotNil(t, cfg.Filter.Rule[1].Source.Any, "source 'any' should set Any field") - assert.Empty(t, cfg.Filter.Rule[1].Source.Network, "source 'any' should not set Network") -} - -func TestInjectDHCP(t *testing.T) { +func TestParseConfigInvalidXML(t *testing.T) { t.Parallel() - cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") - require.NoError(t, err) - - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng := rand.New(rand.NewPCG(42, 0)) - vlans := []generator.VlanConfig{ - { - VlanID: 42, - IPNetwork: netip.MustParsePrefix("10.42.7.0/24"), - Description: "IT", - WanAssignment: 1, - Department: generator.DeptIT, - }, - } - dhcpConfigs := []generator.DhcpServerConfig{ - generator.DeriveDHCPConfig(vlans[0], rng), - } - - opnsensegen.InjectDHCP(cfg, dhcpConfigs, 6) - - // Verify DHCP was added to the map. - assert.Len(t, cfg.Dhcpd.Items, 1) - dhcpIface, ok := cfg.Dhcpd.Items["opt6"] - require.True(t, ok) - assert.Equal(t, "1", dhcpIface.Enable) + _, err := opnsensegen.ParseConfig([]byte("not xml")) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse config XML") } func TestMarshalRoundTrip(t *testing.T) { @@ -141,8 +65,7 @@ func TestMarshalRoundTrip(t *testing.T) { require.NoError(t, err) var buf bytes.Buffer - err = opnsensegen.MarshalConfig(cfg, &buf) - require.NoError(t, err) + require.NoError(t, opnsensegen.MarshalConfig(cfg, &buf)) output := buf.String() assert.Contains(t, output, "opnsense") assert.Contains(t, output, "") } - -func TestMarshalWithInjectedData(t *testing.T) { - t.Parallel() - - cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") - require.NoError(t, err) - - vlans := []generator.VlanConfig{ - {VlanID: 42, IPNetwork: netip.MustParsePrefix("10.42.7.0/24"), Description: "IT VLAN 42", WanAssignment: 1}, - } - - opnsensegen.InjectVlans(cfg, vlans, 6) - - var buf bytes.Buffer - err = opnsensegen.MarshalConfig(cfg, &buf) - require.NoError(t, err) - - output := buf.String() - assert.Contains(t, output, "42") - assert.Contains(t, output, "IT VLAN 42") - assert.Contains(t, output, "10.42.7.1") -} - -func TestMarshalXMLEscaping(t *testing.T) { - t.Parallel() - - cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") - require.NoError(t, err) - - vlans := []generator.VlanConfig{ - { - VlanID: 42, - IPNetwork: netip.MustParsePrefix("10.42.7.0/24"), - Description: "Test & \"Chars\"", - WanAssignment: 1, - }, - } - - opnsensegen.InjectVlans(cfg, vlans, 6) - - var buf bytes.Buffer - err = opnsensegen.MarshalConfig(cfg, &buf) - require.NoError(t, err) - - output := buf.String() - // XML encoder should escape special characters. - assert.Contains(t, output, "&") - assert.Contains(t, output, "<Special>") - assert.True(t, strings.Contains(output, ""Chars"") || strings.Contains(output, ""Chars""), - "quotes should be escaped") -} From dd0f27c47185bfe8ce73d652f46ca962de5876b3 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 01:46:13 -0400 Subject: [PATCH 05/11] feat(cmd/generate): zero-arg config.xml emission Default path: opnConfigGenerator generate with no arguments emits a valid OPNsense config.xml on stdout. Drops MarkFlagRequired on --format; xml is the default. New flag surface: --format xml (default) or csv --vlan-count/-n 0..4092 VLANs (default 10) --base-config optional overlay source --firewall-rules opt-in default allow-rules per interface --seed deterministic PCG seed (0 = random) --hostname override faker-generated hostname --domain override faker-generated domain --force, --output preserved from prior CLI Removed flags that served the deleted injection pipeline: --count (renamed to --vlan-count), --csv-file, --firewall-nr, --opt-counter, --include-firewall-rules, --firewall-rule-complexity, --vlan-range, --vpn-count, --nat-mappings, --wan-assignments. runGenerate composes faker.NewCommonDevice -> serializer.Serialize (or Overlay when --base-config is set) -> MarshalConfig. Covers R1 (zero-arg valid config.xml), R3 (CSV opt-in), R4 (--base-config overlay), R5 (deterministic seed). Signed-off-by: UncleSp1d3r --- cmd/cmd_test.go | 229 +++++++++++++++++++------------------- cmd/generate.go | 285 ++++++++++++++---------------------------------- 2 files changed, 190 insertions(+), 324 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 5aa0a89..996f79b 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -16,9 +16,24 @@ import ( // newTestRootCmd creates a fresh command tree to avoid state leakage between tests. // Each test gets its own root command with all subcommands attached. // -// NOTE: Because cobra flag bindings point to package-level variables (quiet, noColor, -// output, format, etc.), tests that call this function must NOT run in parallel. +// NOTE: Because cobra flag bindings point to package-level variables, tests +// that call this function must NOT run in parallel. func newTestRootCmd() *cobra.Command { + // Reset package-level flag vars to their defaults before rebuilding the + // command tree. This matters because Cobra wires flags to these variables + // directly; state leaks across tests otherwise. + outputFormat = formatXML + vlanCount = defaultVlanCount + baseConfigPath = "" + includeFirewall = false + seed = 0 + force = false + hostnameOverride = "" + domainOverride = "" + output = "" + quiet = false + noColor = false + root := &cobra.Command{ Use: "opnConfigGenerator", Short: "Generate realistic OPNsense configuration files with fake data", @@ -37,23 +52,14 @@ func newTestRootCmd() *cobra.Command { Short: "Generate OPNsense configuration data", RunE: runGenerate, } - genCmd.Flags().StringVar(&format, "format", "", "output format (csv|xml)") - if err := genCmd.MarkFlagRequired("format"); err != nil { - panic(err) - } - genCmd.Flags().IntVarP(&count, "count", "c", 10, "number of VLANs to generate (1-10000)") - genCmd.Flags().StringVar(&baseConfig, "base-config", "", "base OPNsense XML template") - genCmd.Flags().StringVar(&csvFile, "csv-file", "", "read VLANs from existing CSV file") - genCmd.Flags().IntVar(&firewallNr, "firewall-nr", 1, "firewall instance number") - genCmd.Flags().IntVar(&optCounter, "opt-counter", 6, "starting interface counter") - genCmd.Flags().BoolVar(&force, "force", false, "overwrite existing output files") + genCmd.Flags().StringVar(&outputFormat, "format", formatXML, "output format (xml|csv)") + genCmd.Flags().IntVarP(&vlanCount, "vlan-count", "n", defaultVlanCount, "number of VLANs to generate (0-4092)") + genCmd.Flags().StringVar(&baseConfigPath, "base-config", "", "optional base OPNsense config.xml") + genCmd.Flags().BoolVar(&includeFirewall, "firewall-rules", false, "include default firewall rules") genCmd.Flags().Int64Var(&seed, "seed", 0, "RNG seed for reproducibility") - genCmd.Flags().BoolVar(&includeFirewallRules, "include-firewall-rules", false, "generate firewall rules") - genCmd.Flags().StringVar(&firewallRuleComplexity, "firewall-rule-complexity", "basic", "complexity") - genCmd.Flags().StringVar(&vlanRange, "vlan-range", "", "VLAN range spec") - genCmd.Flags().IntVar(&vpnCount, "vpn-count", 0, "number of VPN configurations") - genCmd.Flags().IntVar(&natMappings, "nat-mappings", 0, "number of NAT rules") - genCmd.Flags().StringVar(&wanAssignments, "wan-assignments", "single", "WAN strategy") + genCmd.Flags().BoolVar(&force, "force", false, "overwrite existing output file") + genCmd.Flags().StringVar(&hostnameOverride, "hostname", "", "override the generated hostname") + genCmd.Flags().StringVar(&domainOverride, "domain", "", "override the generated domain") valCmd := &cobra.Command{ Use: "validate", @@ -105,8 +111,8 @@ func executeCommand(root *cobra.Command, args ...string) (string, error) { return stdoutBuf.String(), err } -// baseConfigPath returns the absolute path to the base-config.xml test fixture. -func baseConfigPath(t *testing.T) string { +// baseConfigFixture returns the absolute path to the base-config.xml fixture. +func baseConfigFixture(t *testing.T) string { t.Helper() abs, err := filepath.Abs(filepath.Join("..", "testdata", "base-config.xml")) require.NoError(t, err) @@ -134,11 +140,19 @@ func TestRootVersion(t *testing.T) { // --- Generate Command Tests --- -func TestGenerateMissingFormat(t *testing.T) { +func TestGenerateZeroArgsProducesXML(t *testing.T) { + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "default.xml") + cmd := newTestRootCmd() - _, err := executeCommand(cmd, "generate") - require.Error(t, err) - assert.Contains(t, err.Error(), "format") + _, err := executeCommand(cmd, "generate", "--seed", "1", "--output", outPath) + require.NoError(t, err) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "") } func TestGenerateInvalidFormat(t *testing.T) { @@ -153,35 +167,30 @@ func TestGenerateCSVToFile(t *testing.T) { outPath := filepath.Join(tmpDir, "vlans.csv") cmd := newTestRootCmd() - _, err := executeCommand(cmd, "generate", "--format", "csv", "--count", "5", "--seed", "42", "--output", outPath) + _, err := executeCommand(cmd, "generate", + "--format", "csv", + "--vlan-count", "5", + "--seed", "42", + "--output", outPath, + ) require.NoError(t, err) data, err := os.ReadFile(outPath) require.NoError(t, err) content := string(data) - // CSV uses German headers with UTF-8 BOM prefix. lines := strings.Split(strings.TrimSpace(content), "\n") - assert.Len(t, lines, 6, "expected header + 5 data rows") + assert.Len(t, lines, 6, "header + 5 data rows") assert.Contains(t, lines[0], "VLAN") } -func TestGenerateXMLRequiresBaseConfig(t *testing.T) { - cmd := newTestRootCmd() - _, err := executeCommand(cmd, "generate", "--format", "xml") - require.Error(t, err) - assert.Contains(t, err.Error(), "--base-config is required") -} - -func TestGenerateXMLWithBaseConfig(t *testing.T) { +func TestGenerateXMLWithoutBaseConfig(t *testing.T) { tmpDir := t.TempDir() - outPath := filepath.Join(tmpDir, "output.xml") - baseCfgPath := baseConfigPath(t) + outPath := filepath.Join(tmpDir, "scratch.xml") cmd := newTestRootCmd() _, err := executeCommand(cmd, - "generate", "--format", "xml", - "--count", "3", - "--base-config", baseCfgPath, + "generate", + "--vlan-count", "3", "--seed", "42", "--output", outPath, ) @@ -195,52 +204,54 @@ func TestGenerateXMLWithBaseConfig(t *testing.T) { assert.Contains(t, content, "") } -func TestGenerateCSVThreeRows(t *testing.T) { +func TestGenerateXMLWithBaseConfig(t *testing.T) { tmpDir := t.TempDir() - outPath := filepath.Join(tmpDir, "vlans.csv") + outPath := filepath.Join(tmpDir, "overlay.xml") + baseCfgPath := baseConfigFixture(t) cmd := newTestRootCmd() - _, err := executeCommand(cmd, "generate", "--format", "csv", "--count", "3", "--seed", "42", "--output", outPath) + _, err := executeCommand(cmd, + "generate", + "--vlan-count", "3", + "--base-config", baseCfgPath, + "--seed", "42", + "--output", outPath, + ) require.NoError(t, err) data, err := os.ReadFile(outPath) require.NoError(t, err) - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - assert.Len(t, lines, 4, "expected header + 3 data rows") + content := string(data) + assert.Contains(t, content, "") + assert.Contains(t, content, "") +} + +func TestGenerateBaseConfigMissing(t *testing.T) { + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", "--base-config", "/does/not/exist.xml") + require.Error(t, err) + assert.Contains(t, err.Error(), "load base config") } func TestGenerateDeterministicSeed(t *testing.T) { tmpDir := t.TempDir() - outPath1 := filepath.Join(tmpDir, "run1.csv") - outPath2 := filepath.Join(tmpDir, "run2.csv") + outPath1 := filepath.Join(tmpDir, "run1.xml") + outPath2 := filepath.Join(tmpDir, "run2.xml") cmd1 := newTestRootCmd() - _, err := executeCommand( - cmd1, - "generate", - "--format", - "csv", - "--count", - "5", - "--seed", - "42", - "--output", - outPath1, + _, err := executeCommand(cmd1, "generate", + "--vlan-count", "5", + "--seed", "42", + "--output", outPath1, ) require.NoError(t, err) cmd2 := newTestRootCmd() - _, err = executeCommand( - cmd2, - "generate", - "--format", - "csv", - "--count", - "5", - "--seed", - "42", - "--output", - outPath2, + _, err = executeCommand(cmd2, "generate", + "--vlan-count", "5", + "--seed", "42", + "--output", outPath2, ) require.NoError(t, err) @@ -248,102 +259,82 @@ func TestGenerateDeterministicSeed(t *testing.T) { require.NoError(t, err) data2, err := os.ReadFile(outPath2) require.NoError(t, err) - assert.Equal(t, string(data1), string(data2), "same seed should produce identical output") + assert.Equal(t, string(data1), string(data2), "same seed must produce byte-identical output") } func TestGenerateFileExistsWithoutForce(t *testing.T) { tmpDir := t.TempDir() - outPath := filepath.Join(tmpDir, "existing.csv") - + outPath := filepath.Join(tmpDir, "existing.xml") require.NoError(t, os.WriteFile(outPath, []byte("existing"), 0o600)) cmd := newTestRootCmd() - _, err := executeCommand(cmd, "generate", "--format", "csv", "--count", "3", "--seed", "42", "--output", outPath) + _, err := executeCommand(cmd, "generate", "--seed", "42", "--output", outPath) require.Error(t, err) assert.Contains(t, err.Error(), "already exists") } func TestGenerateFileExistsWithForce(t *testing.T) { tmpDir := t.TempDir() - outPath := filepath.Join(tmpDir, "overwrite.csv") - + outPath := filepath.Join(tmpDir, "overwrite.xml") require.NoError(t, os.WriteFile(outPath, []byte("old"), 0o600)) cmd := newTestRootCmd() - _, err := executeCommand( - cmd, - "generate", - "--format", - "csv", - "--count", - "3", - "--seed", - "42", - "--output", - outPath, + _, err := executeCommand(cmd, "generate", + "--seed", "42", + "--output", outPath, "--force", ) require.NoError(t, err) data, err := os.ReadFile(outPath) require.NoError(t, err) - assert.Contains(t, string(data), "VLAN", "file should be overwritten with CSV data") + assert.Contains(t, string(data), "", "file must be overwritten with generated XML") } func TestGenerateXMLWithFirewallRules(t *testing.T) { tmpDir := t.TempDir() outPath := filepath.Join(tmpDir, "fw.xml") - baseCfgPath := baseConfigPath(t) cmd := newTestRootCmd() - _, err := executeCommand(cmd, - "generate", "--format", "xml", - "--count", "3", - "--base-config", baseCfgPath, + _, err := executeCommand(cmd, "generate", + "--vlan-count", "3", "--seed", "42", - "--include-firewall-rules", + "--firewall-rules", "--output", outPath, ) require.NoError(t, err) data, err := os.ReadFile(outPath) require.NoError(t, err) - assert.Contains(t, string(data), "") + content := string(data) + assert.Contains(t, content, "") } -func TestGenerateXMLRejectsNatMappings(t *testing.T) { - baseCfgPath := baseConfigPath(t) - +func TestGenerateInvalidVlanCount(t *testing.T) { cmd := newTestRootCmd() - _, err := executeCommand(cmd, - "generate", "--format", "xml", - "--count", "3", - "--base-config", baseCfgPath, - "--nat-mappings", "5", - ) + _, err := executeCommand(cmd, "generate", "--vlan-count", "-1") require.Error(t, err) - assert.Contains(t, err.Error(), "not yet supported") + assert.Contains(t, err.Error(), "--vlan-count") } -func TestGenerateXMLRejectsVpnCount(t *testing.T) { - baseCfgPath := baseConfigPath(t) +func TestGenerateHostnameAndDomainOverride(t *testing.T) { + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "named.xml") cmd := newTestRootCmd() - _, err := executeCommand(cmd, - "generate", "--format", "xml", - "--count", "3", - "--base-config", baseCfgPath, - "--vpn-count", "2", + _, err := executeCommand(cmd, "generate", + "--seed", "42", + "--hostname", "mygateway", + "--domain", "example.test", + "--output", outPath, ) - require.Error(t, err) - assert.Contains(t, err.Error(), "not yet supported") -} + require.NoError(t, err) -func TestGenerateInvalidCount(t *testing.T) { - cmd := newTestRootCmd() - _, err := executeCommand(cmd, "generate", "--format", "csv", "--count", "0") - require.Error(t, err) - assert.Contains(t, err.Error(), "--count must be between") + data, err := os.ReadFile(outPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "mygateway") + assert.Contains(t, content, "example.test") } // --- Validate Command Tests --- diff --git a/cmd/generate.go b/cmd/generate.go index 1ba2bfb..002bc62 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -1,188 +1,96 @@ package cmd import ( - "errors" "fmt" - "math/rand/v2" "github.com/EvilBit-Labs/opnConfigGenerator/internal/csvio" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" + "github.com/EvilBit-Labs/opnConfigGenerator/internal/faker" "github.com/EvilBit-Labs/opnConfigGenerator/internal/opnsensegen" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/validate" + serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" "github.com/charmbracelet/log" "github.com/spf13/cobra" ) const ( - // formatXML is the XML output format identifier. formatXML = "xml" - // formatCSV is the CSV output format identifier. formatCSV = "csv" + + // defaultVlanCount is the number of VLANs generated when no count is + // supplied. 10 matches the tool's prior default. + defaultVlanCount = 10 + // maxVlanCount mirrors the 802.1Q tag space (less reserved 0, 1, 4095). + maxVlanCount = 4092 ) var ( - format string - count int - baseConfig string - csvFile string - firewallNr int - optCounter int - force bool - seed int64 - includeFirewallRules bool - firewallRuleComplexity string - vlanRange string - vpnCount int - natMappings int - wanAssignments string + outputFormat string + vlanCount int + baseConfigPath string + includeFirewall bool + seed int64 + force bool + hostnameOverride string + domainOverride string ) var generateCmd = &cobra.Command{ Use: "generate", Short: "Generate OPNsense configuration data", - Long: `Generate realistic OPNsense configuration data in various formats. + Long: `Generate realistic OPNsense configuration data. + +With no arguments, emits a valid OPNsense config.xml on stdout. Examples: - # Generate 25 VLANs in XML format - opnConfigGenerator generate --format xml --count 25 --base-config config.xml + # Zero-input: valid config.xml with 10 VLANs on stdout + opnConfigGenerator generate + + # Reproducible output + opnConfigGenerator generate --seed 42 - # Generate with firewall rules - opnConfigGenerator generate --format xml --count 10 --include-firewall-rules + # With default firewall rules and 20 VLANs + opnConfigGenerator generate --vlan-count 20 --firewall-rules - # Generate CSV data - opnConfigGenerator generate --format csv --count 50 --output network-data.csv + # Overlay generated content onto an existing config + opnConfigGenerator generate --base-config existing.xml - # Generate with VPN and NAT (CSV only — XML serialization pending) - opnConfigGenerator generate --format csv --count 15 --vpn-count 3 --nat-mappings 10`, + # CSV inspection dump + opnConfigGenerator generate --format csv`, RunE: runGenerate, } func init() { - generateCmd.Flags().StringVar(&format, "format", "", "output format (csv|xml)") - if err := generateCmd.MarkFlagRequired("format"); err != nil { - panic(fmt.Sprintf("failed to mark format flag required: %v", err)) - } - - //nolint:mnd // CLI flag default value - generateCmd.Flags().IntVarP(&count, "count", "c", 10, "number of VLANs to generate (1-4085)") + generateCmd.Flags().StringVar(&outputFormat, "format", formatXML, "output format (xml|csv)") + generateCmd.Flags().IntVarP(&vlanCount, "vlan-count", "n", defaultVlanCount, "number of VLANs to generate (0-4092)") generateCmd.Flags(). - StringVar(&baseConfig, "base-config", "", "base OPNsense XML template (required for xml format)") - generateCmd.Flags().StringVar(&csvFile, "csv-file", "", "read VLANs from existing CSV file") - - generateCmd.Flags().IntVar(&firewallNr, "firewall-nr", 1, "firewall instance number (1-999)") - //nolint:mnd // CLI flag default value - generateCmd.Flags().IntVar(&optCounter, "opt-counter", 6, "starting interface counter") - - generateCmd.Flags().BoolVar(&force, "force", false, "overwrite existing output files") - generateCmd.Flags().Int64Var(&seed, "seed", 0, "RNG seed for reproducibility (0 = random)") - - generateCmd.Flags().BoolVar(&includeFirewallRules, "include-firewall-rules", false, "generate firewall rules") + StringVar(&baseConfigPath, "base-config", "", "optional base OPNsense config.xml to overlay generated content onto") generateCmd.Flags(). - StringVar(&firewallRuleComplexity, "firewall-rule-complexity", "basic", "complexity (basic|intermediate|advanced)") - - generateCmd.Flags().StringVar(&vlanRange, "vlan-range", "", "VLAN range spec (e.g., '100-150,200-250')") - generateCmd.Flags().IntVar(&vpnCount, "vpn-count", 0, "number of VPN configurations") - generateCmd.Flags().IntVar(&natMappings, "nat-mappings", 0, "number of NAT rules") - generateCmd.Flags().StringVar(&wanAssignments, "wan-assignments", "single", "WAN strategy (single|multi|balanced)") + BoolVar(&includeFirewall, "firewall-rules", false, "include default allow-all-to-any rules per interface") + generateCmd.Flags().Int64Var(&seed, "seed", 0, "RNG seed for reproducibility (0 = random)") + generateCmd.Flags().BoolVar(&force, "force", false, "overwrite existing output file") + generateCmd.Flags().StringVar(&hostnameOverride, "hostname", "", "override the generated hostname") + generateCmd.Flags().StringVar(&domainOverride, "domain", "", "override the generated domain") } -// CLI validation constants. -const ( - maxFirewallNr = 999 - minOptCounter = 0 -) - -func runGenerate(_ *cobra.Command, _ []string) error { - normalizedFormat := normalizeStringFlag(format) - - // Validate format. - switch normalizedFormat { - case formatCSV, formatXML: - // Valid. +func runGenerate(_ *cobra.Command, _ []string) (err error) { + format := normalizeStringFlag(outputFormat) + switch format { + case formatXML, formatCSV: default: - return fmt.Errorf("invalid format %q: must be csv or xml", format) - } - - // XML format requires base config. - if normalizedFormat == formatXML && baseConfig == "" { - return errors.New("--base-config is required for xml format") - } - - // Validate count range. - if count <= 0 || count > generator.MaxUniqueVlans { - return fmt.Errorf("--count must be between 1 and %d, got %d", generator.MaxUniqueVlans, count) - } - - // Validate firewallNr range. - if firewallNr < 1 || firewallNr > maxFirewallNr { - return fmt.Errorf("--firewall-nr must be between 1 and %d, got %d", maxFirewallNr, firewallNr) - } - - // Validate optCounter range. - if optCounter < minOptCounter { - return fmt.Errorf("--opt-counter must be non-negative, got %d", optCounter) - } - - // NAT and VPN injection into XML is not yet implemented. - if normalizedFormat == formatXML && (natMappings > 0 || vpnCount > 0) { - return errors.New("--nat-mappings and --vpn-count are not yet supported for XML output") - } - - // Parse WAN assignment strategy. - wanStrategy, err := generator.ParseWanAssignment(normalizeStringFlag(wanAssignments)) - if err != nil { - return err - } - - // Parse firewall complexity. - complexity, err := generator.ParseFirewallComplexity(normalizeStringFlag(firewallRuleComplexity)) - if err != nil { - return err - } - - // Set up seed. - var seedPtr *int64 - if seed != 0 { - seedPtr = &seed - } - - log.Info("generating configuration", "format", normalizedFormat, "count", count) - - // Generate VLANs. - vlanGen := generator.NewVlanGenerator(seedPtr, wanStrategy) - vlans, err := vlanGen.GenerateBatch(count) - if err != nil { - return fmt.Errorf("generate VLANs: %w", err) + return fmt.Errorf("invalid format %q: must be xml or csv", outputFormat) } - // Validate VLANs. - result := validate.Vlans(vlans) - if !result.IsValid() { - return result.Error() + if vlanCount < 0 || vlanCount > maxVlanCount { + return fmt.Errorf("--vlan-count must be between 0 and %d, got %d", maxVlanCount, vlanCount) } - log.Info("generated VLANs", "count", len(vlans)) - - // Generate firewall rules if requested. - var fwRules []generator.FirewallRule - if includeFirewallRules { - fwGen := generator.NewFirewallGenerator(seedPtr) - fwRules = fwGen.GenerateRulesForBatch(vlans, complexity) - log.Info("generated firewall rules", "count", len(fwRules)) - } - - // Output based on format. - switch normalizedFormat { - case formatCSV: - return outputCSV(vlans) - case formatXML: - return outputXML(vlans, fwRules, seedPtr) - default: - return fmt.Errorf("unsupported format: %s", normalizedFormat) - } -} + device := faker.NewCommonDevice( + faker.WithSeed(seed), + faker.WithVLANCount(vlanCount), + faker.WithFirewallRules(includeFirewall), + faker.WithHostname(hostnameOverride), + faker.WithDomain(domainOverride), + ) -func outputCSV(vlans []generator.VlanConfig) (err error) { w, needClose, err := getOutputWriter() if err != nil { return err @@ -195,66 +103,33 @@ func outputCSV(vlans []generator.VlanConfig) (err error) { }() } - if err := csvio.WriteVlanCSV(w, vlans); err != nil { - return fmt.Errorf("write CSV: %w", err) - } - - log.Info("wrote CSV output", "vlans", len(vlans)) - return nil -} - -func outputXML( - vlans []generator.VlanConfig, - fwRules []generator.FirewallRule, - seedPtr *int64, -) (err error) { - // Load base config. - cfg, err := opnsensegen.LoadBaseConfig(baseConfig) - if err != nil { - return fmt.Errorf("load base config: %w", err) - } - - // Inject generated data. - opnsensegen.InjectVlans(cfg, vlans, optCounter) - - // Generate and inject DHCP configs using same seed logic as other generators. - var rng *rand.Rand - if seedPtr != nil { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(uint64(*seedPtr), 0)) - } else { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) - } - dhcpConfigs := make([]generator.DhcpServerConfig, len(vlans)) - for i, v := range vlans { - dhcpConfigs[i] = generator.DeriveDHCPConfig(v, rng) - } - opnsensegen.InjectDHCP(cfg, dhcpConfigs, optCounter) - - // Inject firewall rules. - if len(fwRules) > 0 { - opnsensegen.InjectFirewallRules(cfg, fwRules) - } - - // Get output writer. - w, needClose, err := getOutputWriter() - if err != nil { - return err - } - if needClose { - defer func() { - if cerr := w.Close(); cerr != nil && err == nil { - err = fmt.Errorf("close output file: %w", cerr) + switch format { + case formatCSV: + if cerr := csvio.WriteVlanCSV(w, device); cerr != nil { + return fmt.Errorf("write CSV: %w", cerr) + } + log.Info("wrote CSV output", "vlans", len(device.VLANs)) + return nil + case formatXML: + doc, sErr := serializer.Serialize(device) + if sErr != nil { + return fmt.Errorf("serialize: %w", sErr) + } + if baseConfigPath != "" { + base, lErr := opnsensegen.LoadBaseConfig(baseConfigPath) + if lErr != nil { + return fmt.Errorf("load base config: %w", lErr) } - }() - } - - // Write output. - if err := opnsensegen.MarshalConfig(cfg, w); err != nil { - return fmt.Errorf("write XML: %w", err) - } - - log.Info("wrote XML output", "vlans", len(vlans), "rules", len(fwRules)) - return nil + doc, sErr = serializer.Overlay(base, device) + if sErr != nil { + return fmt.Errorf("overlay: %w", sErr) + } + } + if mErr := opnsensegen.MarshalConfig(doc, w); mErr != nil { + return fmt.Errorf("write XML: %w", mErr) + } + log.Info("wrote XML output", "vlans", len(device.VLANs), "interfaces", len(device.Interfaces)) + return nil + } + return fmt.Errorf("unsupported format: %s", format) } From de4ffba9af36d8590b78819a661b51f8c5af4f1b Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 01:46:25 -0400 Subject: [PATCH 06/11] refactor: delete generator and validate packages internal/generator (VlanConfig, DhcpServerConfig, FirewallRule, FirewallGenerator, DhcpGenerator, NatGenerator, VpnGenerator, departments) existed only to feed the deleted Inject* pipeline. internal/validate only validated generator.VlanConfig. Both are dead code under the CommonDevice pipeline. No consumers outside these directories remained after the CLI migration in the previous commits. Covers R7 (no compatibility shims for the old pipeline). Signed-off-by: UncleSp1d3r --- internal/generator/departments.go | 119 ----------- internal/generator/departments_test.go | 268 ------------------------- internal/generator/dhcp.go | 103 ---------- internal/generator/dhcp_test.go | 119 ----------- internal/generator/firewall.go | 155 -------------- internal/generator/firewall_test.go | 174 ---------------- internal/generator/helpers_test.go | 5 - internal/generator/nat.go | 170 ---------------- internal/generator/nat_test.go | 125 ------------ internal/generator/types.go | 202 ------------------- internal/generator/vlan.go | 173 ---------------- internal/generator/vlan_test.go | 245 ---------------------- internal/generator/vpn.go | 223 -------------------- internal/generator/vpn_test.go | 147 -------------- internal/validate/validate.go | 144 ------------- internal/validate/validate_test.go | 204 ------------------- 16 files changed, 2576 deletions(-) delete mode 100644 internal/generator/departments.go delete mode 100644 internal/generator/departments_test.go delete mode 100644 internal/generator/dhcp.go delete mode 100644 internal/generator/dhcp_test.go delete mode 100644 internal/generator/firewall.go delete mode 100644 internal/generator/firewall_test.go delete mode 100644 internal/generator/helpers_test.go delete mode 100644 internal/generator/nat.go delete mode 100644 internal/generator/nat_test.go delete mode 100644 internal/generator/types.go delete mode 100644 internal/generator/vlan.go delete mode 100644 internal/generator/vlan_test.go delete mode 100644 internal/generator/vpn.go delete mode 100644 internal/generator/vpn_test.go delete mode 100644 internal/validate/validate.go delete mode 100644 internal/validate/validate_test.go diff --git a/internal/generator/departments.go b/internal/generator/departments.go deleted file mode 100644 index da43a50..0000000 --- a/internal/generator/departments.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package generator produces realistic fake OPNsense configuration data. -package generator - -import "math/rand/v2" - -// Department represents a network department for VLAN naming. -type Department string - -// Department constants for all supported network departments. -const ( - DeptSales Department = "Sales" - DeptIT Department = "IT" - DeptHR Department = "HR" - DeptFinance Department = "Finance" - DeptMarketing Department = "Marketing" - DeptOperations Department = "Operations" - DeptEngineering Department = "Engineering" - DeptSupport Department = "Support" - DeptLegal Department = "Legal" - DeptProcurement Department = "Procurement" - DeptSecurity Department = "Security" - DeptDevelopment Department = "Development" - DeptQA Department = "QA" - DeptResearch Department = "Research" - DeptTraining Department = "Training" - DeptManagement Department = "Management" - DeptAccounting Department = "Accounting" - DeptCustomerService Department = "Customer Service" - DeptLogistics Department = "Logistics" - DeptProduction Department = "Production" -) - -// AllDepartments is the complete list of departments. -var AllDepartments = []Department{ - DeptSales, DeptIT, DeptHR, DeptFinance, DeptMarketing, - DeptOperations, DeptEngineering, DeptSupport, DeptLegal, DeptProcurement, - DeptSecurity, DeptDevelopment, DeptQA, DeptResearch, DeptTraining, - DeptManagement, DeptAccounting, DeptCustomerService, DeptLogistics, DeptProduction, -} - -// DHCP lease times in seconds by department category. -const ( - LeaseTimeCorporate = 86400 // 24h - IT, Finance, Legal, Accounting, Management - LeaseTimeProduction = 43200 // 12h - Engineering, Development, QA, Research - LeaseTimeDynamic = 28800 // 8h - Sales, Marketing, Customer Service - LeaseTimeHighMobility = 14400 // 4h - HR, Logistics, Training, Support, Operations, Procurement, Production - LeaseTimeSecurity = 21600 // 6h - Security -) - -// LeaseTime returns the DHCP lease time for a department. -func (d Department) LeaseTime() int { - switch d { - case DeptIT, DeptFinance, DeptLegal, DeptAccounting, DeptManagement: - return LeaseTimeCorporate - case DeptEngineering, DeptDevelopment, DeptQA, DeptResearch: - return LeaseTimeProduction - case DeptSales, DeptMarketing, DeptCustomerService: - return LeaseTimeDynamic - case DeptSecurity: - return LeaseTimeSecurity - default: - return LeaseTimeHighMobility - } -} - -// Static DHCP reservation counts per department type. -const ( - reservationsHigh = 3 // IT: printer, NAS, server - reservationsMedium = 2 // Engineering/Security: build-server or camera - reservationsLow = 1 // Management/Finance: single device -) - -// StaticReservationCount returns how many static DHCP reservations a department gets. -func (d Department) StaticReservationCount() int { - switch d { - case DeptIT: - return reservationsHigh - case DeptEngineering, DeptSecurity: - return reservationsMedium - case DeptDevelopment, DeptQA: - return reservationsMedium - case DeptManagement, DeptFinance: - return reservationsLow - default: - return 0 - } -} - -// StaticReservationDevices returns device name templates for a department. -func (d Department) StaticReservationDevices() []string { - switch d { - case DeptIT: - return []string{"printer", "nas", "server"} - case DeptEngineering: - return []string{"build-server", "ci-runner"} - case DeptSecurity: - return []string{"camera", "access-controller"} - case DeptDevelopment: - return []string{"dev-server", "staging"} - case DeptQA: - return []string{"test-server", "qa-runner"} - case DeptManagement: - return []string{"exec-printer"} - case DeptFinance: - return []string{"finance-printer"} - default: - return nil - } -} - -// RandomDepartment returns a random department using the provided RNG. -func RandomDepartment(rng *rand.Rand) Department { - return AllDepartments[rng.IntN(len(AllDepartments))] -} - -// DepartmentCount returns the total number of departments. -func DepartmentCount() int { - return len(AllDepartments) -} diff --git a/internal/generator/departments_test.go b/internal/generator/departments_test.go deleted file mode 100644 index 3e02202..0000000 --- a/internal/generator/departments_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package generator_test - -import ( - "math/rand/v2" - "slices" - "testing" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" -) - -func TestAllDepartments(t *testing.T) { - expected := 20 - if got := len(generator.AllDepartments); got != expected { - t.Errorf("AllDepartments length = %d, want %d", got, expected) - } - - // Verify all expected departments are present - expectedDepts := []generator.Department{ - generator.DeptSales, generator.DeptIT, generator.DeptHR, generator.DeptFinance, - generator.DeptMarketing, generator.DeptOperations, generator.DeptEngineering, - generator.DeptSupport, generator.DeptLegal, generator.DeptProcurement, - generator.DeptSecurity, generator.DeptDevelopment, generator.DeptQA, - generator.DeptResearch, generator.DeptTraining, generator.DeptManagement, - generator.DeptAccounting, generator.DeptCustomerService, generator.DeptLogistics, - generator.DeptProduction, - } - - for _, dept := range expectedDepts { - if !slices.Contains(generator.AllDepartments, dept) { - t.Errorf("Department %s not found in AllDepartments", dept) - } - } -} - -func TestDepartmentCount(t *testing.T) { - expected := 20 - if got := generator.DepartmentCount(); got != expected { - t.Errorf("DepartmentCount() = %d, want %d", got, expected) - } -} - -func TestDepartmentLeaseTime(t *testing.T) { - tests := []struct { - name string - dept generator.Department - expected int - category string - }{ - // Corporate departments (24h) - {"IT corporate", generator.DeptIT, generator.LeaseTimeCorporate, "corporate"}, - {"Finance corporate", generator.DeptFinance, generator.LeaseTimeCorporate, "corporate"}, - {"Legal corporate", generator.DeptLegal, generator.LeaseTimeCorporate, "corporate"}, - {"Accounting corporate", generator.DeptAccounting, generator.LeaseTimeCorporate, "corporate"}, - {"Management corporate", generator.DeptManagement, generator.LeaseTimeCorporate, "corporate"}, - - // Production departments (12h) - {"Engineering production", generator.DeptEngineering, generator.LeaseTimeProduction, "production"}, - {"Development production", generator.DeptDevelopment, generator.LeaseTimeProduction, "production"}, - {"QA production", generator.DeptQA, generator.LeaseTimeProduction, "production"}, - {"Research production", generator.DeptResearch, generator.LeaseTimeProduction, "production"}, - - // Dynamic departments (8h) - {"Sales dynamic", generator.DeptSales, generator.LeaseTimeDynamic, "dynamic"}, - {"Marketing dynamic", generator.DeptMarketing, generator.LeaseTimeDynamic, "dynamic"}, - {"Customer Service dynamic", generator.DeptCustomerService, generator.LeaseTimeDynamic, "dynamic"}, - - // Security department (6h) - {"Security special", generator.DeptSecurity, generator.LeaseTimeSecurity, "security"}, - - // High mobility departments (4h) - {"HR mobility", generator.DeptHR, generator.LeaseTimeHighMobility, "mobility"}, - {"Logistics mobility", generator.DeptLogistics, generator.LeaseTimeHighMobility, "mobility"}, - {"Training mobility", generator.DeptTraining, generator.LeaseTimeHighMobility, "mobility"}, - {"Support mobility", generator.DeptSupport, generator.LeaseTimeHighMobility, "mobility"}, - {"Operations mobility", generator.DeptOperations, generator.LeaseTimeHighMobility, "mobility"}, - {"Procurement mobility", generator.DeptProcurement, generator.LeaseTimeHighMobility, "mobility"}, - {"Production mobility", generator.DeptProduction, generator.LeaseTimeHighMobility, "mobility"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.dept.LeaseTime(); got != tt.expected { - t.Errorf("%s.LeaseTime() = %d, want %d (%s category)", tt.dept, got, tt.expected, tt.category) - } - }) - } -} - -func TestDepartmentStaticReservationCount(t *testing.T) { - tests := []struct { - name string - dept generator.Department - expected int - }{ - {"IT has 3", generator.DeptIT, 3}, - {"Engineering has 2", generator.DeptEngineering, 2}, - {"Security has 2", generator.DeptSecurity, 2}, - {"Development has 2", generator.DeptDevelopment, 2}, - {"QA has 2", generator.DeptQA, 2}, - {"Management has 1", generator.DeptManagement, 1}, - {"Finance has 1", generator.DeptFinance, 1}, - {"Sales has 0", generator.DeptSales, 0}, - {"HR has 0", generator.DeptHR, 0}, - {"Marketing has 0", generator.DeptMarketing, 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.dept.StaticReservationCount(); got != tt.expected { - t.Errorf("%s.StaticReservationCount() = %d, want %d", tt.dept, got, tt.expected) - } - }) - } -} - -func TestDepartmentStaticReservationDevices(t *testing.T) { - tests := []struct { - name string - dept generator.Department - expected []string - }{ - {"IT devices", generator.DeptIT, []string{"printer", "nas", "server"}}, - {"Engineering devices", generator.DeptEngineering, []string{"build-server", "ci-runner"}}, - {"Security devices", generator.DeptSecurity, []string{"camera", "access-controller"}}, - {"Development devices", generator.DeptDevelopment, []string{"dev-server", "staging"}}, - {"QA devices", generator.DeptQA, []string{"test-server", "qa-runner"}}, - {"Management devices", generator.DeptManagement, []string{"exec-printer"}}, - {"Finance devices", generator.DeptFinance, []string{"finance-printer"}}, - {"Sales no devices", generator.DeptSales, nil}, - {"HR no devices", generator.DeptHR, nil}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.dept.StaticReservationDevices() - if !slicesEqual(got, tt.expected) { - t.Errorf("%s.StaticReservationDevices() = %v, want %v", tt.dept, got, tt.expected) - } - }) - } -} - -func TestRandomDepartment(t *testing.T) { - // Test with seeded RNG for deterministic output - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng := rand.New(rand.NewPCG(42, 1337)) - - // Generate multiple departments and verify they're all valid - seenDepts := make(map[generator.Department]bool) - for range 100 { - dept := generator.RandomDepartment(rng) - if !slices.Contains(generator.AllDepartments, dept) { - t.Errorf("RandomDepartment() returned invalid department: %s", dept) - } - seenDepts[dept] = true - } - - // With 100 iterations, we should see multiple different departments - if len(seenDepts) < 5 { - t.Errorf("RandomDepartment() should produce variety, got only %d unique departments", len(seenDepts)) - } -} - -func TestRandomDepartmentDeterministic(t *testing.T) { - // Test that the same seed produces the same sequence - seed1 := uint64(12345) - seed2 := uint64(67890) - - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng1a := rand.New(rand.NewPCG(seed1, seed2)) - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng1b := rand.New(rand.NewPCG(seed1, seed2)) - - dept1a := generator.RandomDepartment(rng1a) - dept1b := generator.RandomDepartment(rng1b) - - if dept1a != dept1b { - t.Errorf("Same seed should produce same department: got %s and %s", dept1a, dept1b) - } - - // Test sequence of departments - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng2a := rand.New(rand.NewPCG(seed1, seed2)) - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng2b := rand.New(rand.NewPCG(seed1, seed2)) - - for i := range 10 { - deptA := generator.RandomDepartment(rng2a) - deptB := generator.RandomDepartment(rng2b) - if deptA != deptB { - t.Errorf("Same seed should produce same sequence at position %d: got %s and %s", i, deptA, deptB) - } - } -} - -func TestLeaseTimeConstants(t *testing.T) { - tests := []struct { - name string - constant int - expected int - }{ - {"Corporate 24h", generator.LeaseTimeCorporate, 86400}, - {"Production 12h", generator.LeaseTimeProduction, 43200}, - {"Dynamic 8h", generator.LeaseTimeDynamic, 28800}, - {"Security 6h", generator.LeaseTimeSecurity, 21600}, - {"High Mobility 4h", generator.LeaseTimeHighMobility, 14400}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.constant != tt.expected { - t.Errorf("%s = %d, want %d", tt.name, tt.constant, tt.expected) - } - }) - } -} - -func TestAllDepartmentsHaveValidLeaseTime(t *testing.T) { - validTimes := map[int]bool{ - generator.LeaseTimeCorporate: true, - generator.LeaseTimeProduction: true, - generator.LeaseTimeDynamic: true, - generator.LeaseTimeSecurity: true, - generator.LeaseTimeHighMobility: true, - } - - for _, dept := range generator.AllDepartments { - leaseTime := dept.LeaseTime() - if !validTimes[leaseTime] { - t.Errorf("Department %s has invalid lease time %d", dept, leaseTime) - } - } -} - -func TestAllDepartmentsHaveValidReservationCounts(t *testing.T) { - for _, dept := range generator.AllDepartments { - count := dept.StaticReservationCount() - devices := dept.StaticReservationDevices() - - // If count > 0, should have devices - if count > 0 && len(devices) != count { - t.Errorf("Department %s has count %d but %d devices", dept, count, len(devices)) - } - - // If count == 0, should have no devices - if count == 0 && devices != nil { - t.Errorf("Department %s has count 0 but devices %v", dept, devices) - } - - // Count should not exceed 3 - if count > 3 { - t.Errorf("Department %s has excessive static reservation count: %d", dept, count) - } - } -} - -// Helper function to compare slices. -func slicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/internal/generator/dhcp.go b/internal/generator/dhcp.go deleted file mode 100644 index e824689..0000000 --- a/internal/generator/dhcp.go +++ /dev/null @@ -1,103 +0,0 @@ -package generator - -import ( - "fmt" - "math/rand/v2" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" -) - -// defaultNTPServers returns the NTP servers assigned to DHCP configurations. -func defaultNTPServers() []string { - return []string{ - "0.pool.ntp.org", - "1.pool.ntp.org", - "2.pool.ntp.org", - } -} - -// DeriveDHCPConfig computes a DHCP server configuration from a VLAN config. -func DeriveDHCPConfig(vlan VlanConfig, rng *rand.Rand) DhcpServerConfig { - gateway := netutil.GatewayIP(vlan.IPNetwork) - rangeStart := netutil.DHCPRangeStart(vlan.IPNetwork) - rangeEnd := netutil.DHCPRangeEnd(vlan.IPNetwork) - leaseTime := vlan.Department.LeaseTime() - - dnsServers := []string{ - gateway.String(), - "8.8.8.8", - "1.1.1.1", - } - - domainName := domainSafe(string(vlan.Department)) + ".local" - reservations := generateStaticReservations(vlan, rng) - - //nolint:mnd // Max lease time is conventionally double the base lease time - maxLease := leaseTime * 2 - - return DhcpServerConfig{ - Enabled: true, - RangeStart: rangeStart, - RangeEnd: rangeEnd, - LeaseTime: leaseTime, - MaxLeaseTime: maxLease, - DNSServers: dnsServers, - DomainName: domainName, - Gateway: gateway, - NTPServers: defaultNTPServers(), - StaticReservations: reservations, - } -} - -// staticReservationHostOffset is the starting host IP offset for static DHCP reservations. -const staticReservationHostOffset = 10 - -func generateStaticReservations(vlan VlanConfig, rng *rand.Rand) []StaticReservation { - devices := vlan.Department.StaticReservationDevices() - if len(devices) == 0 { - return nil - } - - reservations := make([]StaticReservation, 0, len(devices)) - for i, device := range devices { - hostIP := netutil.HostIP(vlan.IPNetwork, uint8(staticReservationHostOffset+i)) - mac := generateMAC(rng) - reservations = append(reservations, StaticReservation{ - MAC: mac, - IP: hostIP, - Hostname: fmt.Sprintf("%s-%s", domainSafe(string(vlan.Department)), device), - }) - } - - return reservations -} - -// macRandomByteRange is the number of possible values for a random MAC byte (0-255). -const macRandomByteRange = 256 - -func generateMAC(rng *rand.Rand) string { - return fmt.Sprintf("AA:BB:CC:%02X:%02X:%02X", - //nolint:gosec // IntN(256) fits uint8 (0-255), no overflow possible - uint8(rng.IntN(macRandomByteRange)), - //nolint:gosec // IntN(256) fits uint8 (0-255), no overflow possible - uint8(rng.IntN(macRandomByteRange)), - //nolint:gosec // IntN(256) fits uint8 (0-255), no overflow possible - uint8(rng.IntN(macRandomByteRange)), - ) -} - -func domainSafe(s string) string { - result := make([]byte, 0, len(s)) - for i := range len(s) { - c := s[i] - switch { - case c >= 'A' && c <= 'Z': - result = append(result, c+'a'-'A') - case c >= 'a' && c <= 'z', c >= '0' && c <= '9': - result = append(result, c) - case c == ' ': - result = append(result, '-') - } - } - return string(result) -} diff --git a/internal/generator/dhcp_test.go b/internal/generator/dhcp_test.go deleted file mode 100644 index fd356f4..0000000 --- a/internal/generator/dhcp_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package generator_test - -import ( - "math/rand/v2" - "net/netip" - "testing" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/stretchr/testify/assert" -) - -func TestDeriveDHCPConfig(t *testing.T) { - t.Parallel() - - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng := rand.New(rand.NewPCG(42, 0)) - network := netip.MustParsePrefix("10.42.7.0/24") - - vlan := generator.VlanConfig{ - VlanID: 42, - IPNetwork: network, - Description: "IT VLAN 42", - WanAssignment: 1, - Department: generator.DeptIT, - } - - cfg := generator.DeriveDHCPConfig(vlan, rng) - - assert.True(t, cfg.Enabled) - assert.Equal(t, "10.42.7.1", cfg.Gateway.String()) - assert.Equal(t, "10.42.7.100", cfg.RangeStart.String()) - assert.Equal(t, "10.42.7.200", cfg.RangeEnd.String()) - assert.Equal(t, generator.LeaseTimeCorporate, cfg.LeaseTime) - assert.Equal(t, "it.local", cfg.DomainName) - assert.Len(t, cfg.DNSServers, 3) - assert.Len(t, cfg.NTPServers, 3) -} - -func TestDHCPLeaseTimesByDepartment(t *testing.T) { - t.Parallel() - - tests := []struct { - dept generator.Department - expected int - }{ - {generator.DeptIT, generator.LeaseTimeCorporate}, - {generator.DeptFinance, generator.LeaseTimeCorporate}, - {generator.DeptLegal, generator.LeaseTimeCorporate}, - {generator.DeptEngineering, generator.LeaseTimeProduction}, - {generator.DeptDevelopment, generator.LeaseTimeProduction}, - {generator.DeptSales, generator.LeaseTimeDynamic}, - {generator.DeptSecurity, generator.LeaseTimeSecurity}, - {generator.DeptHR, generator.LeaseTimeHighMobility}, - } - - for _, tt := range tests { - t.Run(string(tt.dept), func(t *testing.T) { - t.Parallel() - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng := rand.New(rand.NewPCG(42, 0)) - network := netip.MustParsePrefix("10.1.1.0/24") - vlan := generator.VlanConfig{ - VlanID: 100, IPNetwork: network, Description: "test", - WanAssignment: 1, Department: tt.dept, - } - cfg := generator.DeriveDHCPConfig(vlan, rng) - assert.Equal(t, tt.expected, cfg.LeaseTime, "department %s", tt.dept) - }) - } -} - -func TestDHCPStaticReservations(t *testing.T) { - t.Parallel() - - tests := []struct { - dept generator.Department - expectedCount int - }{ - {generator.DeptIT, 3}, - {generator.DeptEngineering, 2}, - {generator.DeptSecurity, 2}, - {generator.DeptSales, 0}, - {generator.DeptHR, 0}, - } - - for _, tt := range tests { - t.Run(string(tt.dept), func(t *testing.T) { - t.Parallel() - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng := rand.New(rand.NewPCG(42, 0)) - network := netip.MustParsePrefix("10.1.1.0/24") - vlan := generator.VlanConfig{ - VlanID: 100, IPNetwork: network, Description: "test", - WanAssignment: 1, Department: tt.dept, - } - cfg := generator.DeriveDHCPConfig(vlan, rng) - assert.Len(t, cfg.StaticReservations, tt.expectedCount) - }) - } -} - -func TestDHCPStaticReservationMACs(t *testing.T) { - t.Parallel() - - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng := rand.New(rand.NewPCG(42, 0)) - network := netip.MustParsePrefix("10.1.1.0/24") - vlan := generator.VlanConfig{ - VlanID: 100, IPNetwork: network, Description: "test", - WanAssignment: 1, Department: generator.DeptIT, - } - cfg := generator.DeriveDHCPConfig(vlan, rng) - - for _, res := range cfg.StaticReservations { - assert.Regexp(t, `^AA:BB:CC:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$`, res.MAC) - assert.NotEmpty(t, res.Hostname) - assert.True(t, network.Contains(res.IP)) - } -} diff --git a/internal/generator/firewall.go b/internal/generator/firewall.go deleted file mode 100644 index 4aac8dd..0000000 --- a/internal/generator/firewall.go +++ /dev/null @@ -1,155 +0,0 @@ -package generator - -import ( - "fmt" - "math/rand/v2" -) - -// FirewallGenerator produces firewall rules for VLANs at configurable complexity. -type FirewallGenerator struct { - rng *rand.Rand - ruleCounter uint64 - usedTracker map[uint64]bool -} - -// NewFirewallGenerator creates a new firewall rule generator. -func NewFirewallGenerator(seed *int64) *FirewallGenerator { - var rng *rand.Rand - if seed != nil { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(uint64(*seed), 0)) - } else { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) - } - - return &FirewallGenerator{ - rng: rng, - usedTracker: make(map[uint64]bool), - } -} - -// GenerateRules produces firewall rules for a VLAN at the given complexity. -func (g *FirewallGenerator) GenerateRules(vlan VlanConfig, complexity FirewallComplexity) []FirewallRule { - interfaceName := fmt.Sprintf("opt%d", vlan.VlanID) - vlanNet := vlan.IPNetwork.String() - - var rules []FirewallRule - - rules = append(rules, g.basicRules(vlan.VlanID, interfaceName, vlanNet)...) - - if complexity >= FirewallIntermediate { - rules = append(rules, g.intermediateRules(vlan.VlanID, interfaceName, vlanNet)...) - } - - if complexity >= FirewallAdvanced { - rules = append(rules, g.advancedRules(vlan, interfaceName)...) - } - - return rules -} - -// GenerateRulesForBatch produces rules for multiple VLANs. -func (g *FirewallGenerator) GenerateRulesForBatch(vlans []VlanConfig, complexity FirewallComplexity) []FirewallRule { - totalRules := len(vlans) * complexity.RulesPerVlan() - rules := make([]FirewallRule, 0, totalRules) - - for _, vlan := range vlans { - rules = append(rules, g.GenerateRules(vlan, complexity)...) - } - - return rules -} - -func (g *FirewallGenerator) basicRules(vlanID uint16, iface, vlanNet string) []FirewallRule { - return []FirewallRule{ - g.newRule(vlanID, iface, vlanNet, vlanNet, "any", "any", "pass", "Allow internal VLAN traffic", false), - g.newRule(vlanID, iface, vlanNet, "any", "udp", "53", "pass", "Allow DNS queries", false), - g.newRule(vlanID, iface, vlanNet, "any", "tcp", "80,443", "pass", "Allow HTTP/HTTPS", false), - } -} - -func (g *FirewallGenerator) intermediateRules(vlanID uint16, iface, vlanNet string) []FirewallRule { - return []FirewallRule{ - g.newRule(vlanID, iface, vlanNet, "any", "udp", "123", "pass", "Allow NTP", false), - g.newRule(vlanID, iface, vlanNet, "any", "icmp", "any", "pass", "Allow ICMP diagnostics", false), - g.newRule(vlanID, iface, "any", "any", "tcp", "23,445,3389", "block", "Block common attack ports", false), - g.newRule(vlanID, iface, "any", "any", "any", "any", "block", "Log denied traffic", true), - } -} - -func (g *FirewallGenerator) advancedRules(vlan VlanConfig, iface string) []FirewallRule { - vlanNet := vlan.IPNetwork.String() - rules := make([]FirewallRule, 0, advancedRuleCount-intermediateRuleCount) - - switch vlan.Department { - case DeptIT, DeptEngineering, DeptDevelopment: - rules = append(rules, - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "22", "pass", "Allow SSH access", false), - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "3306,5432", "pass", "Allow database access", false), - ) - case DeptSecurity: - rules = append(rules, - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "514,6514", "pass", "Allow syslog", false), - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "9090,9100", "pass", "Allow monitoring", false), - ) - default: - rules = append(rules, - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "993,587", "pass", "Allow email", false), - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "5060,5061", "pass", "Allow SIP/VoIP", false), - ) - } - - rules = append(rules, - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "8080,8443", "pass", "Allow web services", false), - g.newRule(vlan.VlanID, iface, "any", vlanNet, "tcp", "135,139", "block", "Block SMB/NetBIOS inbound", false), - g.newRule(vlan.VlanID, iface, vlanNet, "any", "udp", "1194", "pass", "Allow VPN tunnels", false), - g.newRule(vlan.VlanID, iface, "any", "any", "tcp", "25", "block", "Block outbound SMTP", false), - g.newRule(vlan.VlanID, iface, vlanNet, "any", "tcp", "1024:65535", "pass", "Allow high ports", false), - g.newRule(vlan.VlanID, iface, "any", "any", "any", "any", "block", "Default deny all", true), - ) - - return rules -} - -func (g *FirewallGenerator) newRule( - vlanID uint16, - iface, src, dst, proto, ports, action, desc string, - log bool, -) FirewallRule { - g.ruleCounter++ - tracker := g.nextTracker() - - return FirewallRule{ - RuleID: fmt.Sprintf("rule-%d-%d", vlanID, g.ruleCounter), - Source: src, - Destination: dst, - Protocol: proto, - Ports: ports, - Action: action, - Direction: "in", - Description: desc, - Log: log, - VlanID: vlanID, - //nolint:gosec // Capped at max uint16 via min() - Priority: uint16(min(g.ruleCounter, uint64(^uint16(0)))), - Interface: iface, - Tracker: tracker, - } -} - -// maxTrackerRetries is the maximum attempts to find a unique tracker value. -const maxTrackerRetries = 1000 - -func (g *FirewallGenerator) nextTracker() uint64 { - for range maxTrackerRetries { - tracker := g.rng.Uint64() - if !g.usedTracker[tracker] { - g.usedTracker[tracker] = true - return tracker - } - } - - // Extremely unlikely with uint64 space, but fail deterministically rather than loop forever. - panic("failed to generate unique tracker after maximum retries — possible RNG issue") -} diff --git a/internal/generator/firewall_test.go b/internal/generator/firewall_test.go deleted file mode 100644 index 742d83b..0000000 --- a/internal/generator/firewall_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package generator_test - -import ( - "net/netip" - "testing" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func makeVlan(id uint16, dept generator.Department) generator.VlanConfig { - return generator.VlanConfig{ - VlanID: id, - IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), - Description: "test", - WanAssignment: 1, - Department: dept, - } -} - -func TestFirewallRuleCountPerComplexity(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - complexity generator.FirewallComplexity - expected int - }{ - {"basic", generator.FirewallBasic, 3}, - {"intermediate", generator.FirewallIntermediate, 7}, - {"advanced", generator.FirewallAdvanced, 15}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlan := makeVlan(100, generator.DeptSales) - rules := gen.GenerateRules(vlan, tt.complexity) - assert.Len(t, rules, tt.expected) - }) - } -} - -func TestFirewallRuleValidActions(t *testing.T) { - t.Parallel() - - validActions := map[string]bool{"pass": true, "block": true} - - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlan := makeVlan(100, generator.DeptIT) - rules := gen.GenerateRules(vlan, generator.FirewallAdvanced) - - for _, rule := range rules { - assert.True(t, validActions[rule.Action], "rule %s has invalid action: %s", rule.RuleID, rule.Action) - } -} - -func TestFirewallRuleValidProtocols(t *testing.T) { - t.Parallel() - - validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true, "any": true} - - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlan := makeVlan(100, generator.DeptIT) - rules := gen.GenerateRules(vlan, generator.FirewallAdvanced) - - for _, rule := range rules { - assert.True(t, validProtocols[rule.Protocol], "rule %s has invalid protocol: %s", rule.RuleID, rule.Protocol) - } -} - -func TestFirewallUniqueTrackers(t *testing.T) { - t.Parallel() - - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - makeVlan(100, generator.DeptIT), - makeVlan(200, generator.DeptSales), - makeVlan(300, generator.DeptEngineering), - } - - rules := gen.GenerateRulesForBatch(vlans, generator.FirewallAdvanced) - - seen := make(map[uint64]bool) - for _, rule := range rules { - assert.False(t, seen[rule.Tracker], "duplicate tracker: %d", rule.Tracker) - seen[rule.Tracker] = true - } -} - -func TestFirewallUniqueRuleIDs(t *testing.T) { - t.Parallel() - - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - makeVlan(100, generator.DeptIT), - makeVlan(200, generator.DeptSales), - } - - rules := gen.GenerateRulesForBatch(vlans, generator.FirewallIntermediate) - - seen := make(map[string]bool) - for _, rule := range rules { - assert.False(t, seen[rule.RuleID], "duplicate rule ID: %s", rule.RuleID) - seen[rule.RuleID] = true - } -} - -func TestFirewallRulesReferenceCorrectInterface(t *testing.T) { - t.Parallel() - - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlan := makeVlan(42, generator.DeptIT) - rules := gen.GenerateRules(vlan, generator.FirewallBasic) - - for _, rule := range rules { - assert.Equal(t, "opt42", rule.Interface) - } -} - -func TestFirewallBatchTotalRules(t *testing.T) { - t.Parallel() - - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlans := make([]generator.VlanConfig, 10) - for i := range vlans { - vlans[i] = makeVlan(uint16(100+i), generator.DeptSales) - } - - rules := gen.GenerateRulesForBatch(vlans, generator.FirewallBasic) - assert.Len(t, rules, 30) -} - -func TestFirewallComplexityParsing(t *testing.T) { - t.Parallel() - - tests := []struct { - input string - expected generator.FirewallComplexity - wantErr bool - }{ - {"basic", generator.FirewallBasic, false}, - {"intermediate", generator.FirewallIntermediate, false}, - {"advanced", generator.FirewallAdvanced, false}, - {"invalid", 0, true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - t.Parallel() - got, err := generator.ParseFirewallComplexity(tt.input) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.expected, got) - }) - } -} - -func TestFirewallAllRulesDirection(t *testing.T) { - t.Parallel() - - gen := generator.NewFirewallGenerator(int64Ptr(42)) - vlan := makeVlan(100, generator.DeptIT) - rules := gen.GenerateRules(vlan, generator.FirewallAdvanced) - - for _, rule := range rules { - assert.Equal(t, "in", rule.Direction) - } -} diff --git a/internal/generator/helpers_test.go b/internal/generator/helpers_test.go deleted file mode 100644 index 4ec92c7..0000000 --- a/internal/generator/helpers_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package generator_test - -//nolint:gocheckcompilerdirectives // go:fix is a valid Go 1.24+ pragma for inlinable wrappers -//go:fix inline -func int64Ptr(v int64) *int64 { return new(v) } diff --git a/internal/generator/nat.go b/internal/generator/nat.go deleted file mode 100644 index 8c80e3a..0000000 --- a/internal/generator/nat.go +++ /dev/null @@ -1,170 +0,0 @@ -package generator - -import ( - "fmt" - "math/rand/v2" - "net/netip" - "strconv" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" - "github.com/google/uuid" -) - -// Common port forward targets. -var portForwardPorts = []struct { - external string - internal string - proto string - desc string -}{ - {"80", "80", "tcp", "HTTP port forward"}, - {"443", "443", "tcp", "HTTPS port forward"}, - {"8080", "8080", "tcp", "Alt HTTP port forward"}, - {"22", "22", "tcp", "SSH port forward"}, - {"3389", "3389", "tcp", "RDP port forward"}, -} - -// NatGenerator produces NAT rules for VLANs. -type NatGenerator struct { - rng *rand.Rand -} - -// NewNatGenerator creates a new NAT rule generator. -func NewNatGenerator(seed *int64) *NatGenerator { - var rng *rand.Rand - if seed != nil { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(uint64(*seed), 0)) - } else { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) - } - - return &NatGenerator{rng: rng} -} - -// GenerateMappings produces NAT rules distributed across the given VLANs. -func (g *NatGenerator) GenerateMappings(vlans []VlanConfig, count int) []NatMapping { - if count <= 0 || len(vlans) == 0 { - return nil - } - - mappings := make([]NatMapping, 0, count) - for i := range count { - vlan := vlans[i%len(vlans)] - ruleType := NatRuleType(g.rng.IntN(natRuleTypes)) - mapping := g.generateMapping(vlan, ruleType) - mappings = append(mappings, mapping) - } - - return mappings -} - -// NAT generation constants for host address range and port mapping. -const ( - natHostRange = 200 // Number of host addresses in the NAT pool - natHostOffset = 10 // Starting host offset for NAT internal addresses - natPortBase = 8000 - natPortRange = 1000 - natRuleTypes = 5 -) - -func (g *NatGenerator) generateMapping(vlan VlanConfig, ruleType NatRuleType) NatMapping { - gateway := netutil.GatewayIP(vlan.IPNetwork) - //nolint:gosec // IntN(200)+10 max is 209, fits uint8 (max 255) - internalHost := netutil.HostIP(vlan.IPNetwork, uint8(g.rng.IntN(natHostRange)+natHostOffset)) - - switch ruleType { - case NatPortForward: - return g.portForward(vlan, internalHost) - case NatSourceNat: - return g.sourceNat(vlan, gateway) - case NatDestinationNat: - return g.destinationNat(vlan, internalHost) - case NatOneToOne: - return g.oneToOneNat(vlan, internalHost) - default: - return g.outboundNat(vlan, gateway) - } -} - -func (g *NatGenerator) portForward(vlan VlanConfig, target netip.Addr) NatMapping { - pf := portForwardPorts[g.rng.IntN(len(portForwardPorts))] - return NatMapping{ - ID: uuid.NewString(), - RuleType: NatPortForward, - Interface: "wan", - Protocol: pf.proto, - SourceAddr: "any", - SourcePort: "any", - DestAddr: "wan_ip", - DestPort: pf.external, - TargetAddr: target.String(), - TargetPort: pf.internal, - Description: fmt.Sprintf("%s to %s (VLAN %d)", pf.desc, target, vlan.VlanID), - VlanID: vlan.VlanID, - } -} - -func (g *NatGenerator) sourceNat(vlan VlanConfig, gateway netip.Addr) NatMapping { - return NatMapping{ - ID: uuid.NewString(), - RuleType: NatSourceNat, - Interface: "wan", - Protocol: "any", - SourceAddr: vlan.IPNetwork.String(), - SourcePort: "any", - DestAddr: "any", - DestPort: "any", - TargetAddr: gateway.String(), - Description: fmt.Sprintf("Source NAT for %s (VLAN %d)", vlan.IPNetwork, vlan.VlanID), - VlanID: vlan.VlanID, - } -} - -func (g *NatGenerator) destinationNat(vlan VlanConfig, target netip.Addr) NatMapping { - return NatMapping{ - ID: uuid.NewString(), - RuleType: NatDestinationNat, - Interface: "wan", - Protocol: "tcp", - SourceAddr: "any", - SourcePort: "any", - DestAddr: "wan_ip", - DestPort: strconv.Itoa(natPortBase + g.rng.IntN(natPortRange)), - TargetAddr: target.String(), - TargetPort: "443", - Description: fmt.Sprintf("Destination NAT to %s (VLAN %d)", target, vlan.VlanID), - VlanID: vlan.VlanID, - } -} - -func (g *NatGenerator) oneToOneNat(vlan VlanConfig, internalIP netip.Addr) NatMapping { - return NatMapping{ - ID: uuid.NewString(), - RuleType: NatOneToOne, - Interface: "wan", - Protocol: "any", - SourceAddr: internalIP.String(), - DestAddr: "any", - TargetAddr: "wan_ip", - Description: fmt.Sprintf("1:1 NAT for %s (VLAN %d)", internalIP, vlan.VlanID), - VlanID: vlan.VlanID, - } -} - -func (g *NatGenerator) outboundNat(vlan VlanConfig, gateway netip.Addr) NatMapping { - return NatMapping{ - ID: uuid.NewString(), - RuleType: NatOutbound, - Interface: "wan", - Protocol: "any", - SourceAddr: vlan.IPNetwork.String(), - SourcePort: "any", - DestAddr: "any", - DestPort: "any", - TargetAddr: gateway.String(), - Description: fmt.Sprintf("Outbound NAT for %s (VLAN %d)", vlan.IPNetwork, vlan.VlanID), - VlanID: vlan.VlanID, - } -} diff --git a/internal/generator/nat_test.go b/internal/generator/nat_test.go deleted file mode 100644 index 388fd76..0000000 --- a/internal/generator/nat_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package generator_test - -import ( - "net/netip" - "testing" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNatGenerateMappings(t *testing.T) { - t.Parallel() - - gen := generator.NewNatGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Department: generator.DeptIT}, - {VlanID: 200, IPNetwork: netip.MustParsePrefix("10.1.2.0/24"), Department: generator.DeptSales}, - } - - mappings := gen.GenerateMappings(vlans, 10) - assert.Len(t, mappings, 10) -} - -func TestNatMappingsDistributeAcrossVlans(t *testing.T) { - t.Parallel() - - gen := generator.NewNatGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Department: generator.DeptIT}, - {VlanID: 200, IPNetwork: netip.MustParsePrefix("10.1.2.0/24"), Department: generator.DeptSales}, - } - - mappings := gen.GenerateMappings(vlans, 10) - vlanCounts := make(map[uint16]int) - for _, m := range mappings { - vlanCounts[m.VlanID]++ - } - - assert.Equal(t, 5, vlanCounts[100]) - assert.Equal(t, 5, vlanCounts[200]) -} - -func TestNatMappingsUniqueIDs(t *testing.T) { - t.Parallel() - - gen := generator.NewNatGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Department: generator.DeptIT}, - } - - mappings := gen.GenerateMappings(vlans, 20) - seen := make(map[string]bool) - for _, m := range mappings { - assert.False(t, seen[m.ID], "duplicate NAT ID: %s", m.ID) - seen[m.ID] = true - } -} - -func TestNatMappingsHaveDescriptions(t *testing.T) { - t.Parallel() - - gen := generator.NewNatGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Department: generator.DeptIT}, - } - - mappings := gen.GenerateMappings(vlans, 5) - for _, m := range mappings { - assert.NotEmpty(t, m.Description, "mapping %s should have a description", m.ID) - assert.Contains(t, m.Description, "VLAN 100") - } -} - -func TestNatZeroCountReturnsNil(t *testing.T) { - t.Parallel() - - gen := generator.NewNatGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Department: generator.DeptIT}, - } - - assert.Nil(t, gen.GenerateMappings(vlans, 0)) - assert.Nil(t, gen.GenerateMappings(vlans, -1)) - assert.Nil(t, gen.GenerateMappings(nil, 5)) -} - -func TestNatRuleTypes(t *testing.T) { - t.Parallel() - - gen := generator.NewNatGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Department: generator.DeptIT}, - } - - // Generate enough to get a mix of types. - mappings := gen.GenerateMappings(vlans, 100) - require.NotEmpty(t, mappings) - - typeCounts := make(map[generator.NatRuleType]int) - for _, m := range mappings { - typeCounts[m.RuleType]++ - } - - // With 100 mappings and 5 types, we should see at least a few of each. - assert.Greater(t, len(typeCounts), 1, "should generate multiple NAT rule types") -} - -func TestNatPortForwardHasValidPorts(t *testing.T) { - t.Parallel() - - gen := generator.NewNatGenerator(int64Ptr(42)) - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Department: generator.DeptIT}, - } - - mappings := gen.GenerateMappings(vlans, 50) - for _, m := range mappings { - if m.RuleType == generator.NatPortForward { - assert.NotEmpty(t, m.DestPort) - assert.NotEmpty(t, m.TargetPort) - assert.NotEmpty(t, m.TargetAddr) - } - } -} diff --git a/internal/generator/types.go b/internal/generator/types.go deleted file mode 100644 index 3da3542..0000000 --- a/internal/generator/types.go +++ /dev/null @@ -1,202 +0,0 @@ -package generator - -import ( - "net/netip" - "time" -) - -// VlanConfig holds the generation parameters for a single VLAN. -type VlanConfig struct { - VlanID uint16 - IPNetwork netip.Prefix - Description string - WanAssignment uint8 - Department Department -} - -// DhcpServerConfig holds derived DHCP server settings for a VLAN. -type DhcpServerConfig struct { - Enabled bool - RangeStart netip.Addr - RangeEnd netip.Addr - LeaseTime int - MaxLeaseTime int - DNSServers []string - DomainName string - Gateway netip.Addr - NTPServers []string - StaticReservations []StaticReservation -} - -// StaticReservation maps a MAC address to an IP for DHCP. -type StaticReservation struct { - MAC string - IP netip.Addr - Hostname string -} - -// FirewallRule holds a single generated firewall rule. -type FirewallRule struct { - RuleID string - Source string - Destination string - Protocol string - Ports string - Action string - Direction string - Description string - Log bool - VlanID uint16 - Priority uint16 - Interface string - Tracker uint64 -} - -// FirewallComplexity determines how many rules are generated per VLAN. -type FirewallComplexity int - -const ( - // FirewallBasic generates 3 essential rules per VLAN. - FirewallBasic FirewallComplexity = iota - // FirewallIntermediate generates 7 rules per VLAN (basic + network services). - FirewallIntermediate - // FirewallAdvanced generates 15 rules per VLAN (intermediate + department-specific). - FirewallAdvanced -) - -// Rule count constants for each firewall complexity level. -const ( - basicRuleCount = 3 - intermediateRuleCount = 7 - advancedRuleCount = 15 -) - -// RulesPerVlan returns the number of rules generated for this complexity level. -func (c FirewallComplexity) RulesPerVlan() int { - switch c { - case FirewallBasic: - return basicRuleCount - case FirewallIntermediate: - return intermediateRuleCount - case FirewallAdvanced: - return advancedRuleCount - default: - return basicRuleCount - } -} - -// Firewall complexity level string constants. -const ( - complexityBasic = "basic" - complexityIntermediate = "intermediate" - complexityAdvanced = "advanced" -) - -// String returns the string representation of the complexity level. -func (c FirewallComplexity) String() string { - switch c { - case FirewallBasic: - return complexityBasic - case FirewallIntermediate: - return complexityIntermediate - case FirewallAdvanced: - return complexityAdvanced - default: - return complexityBasic - } -} - -// ParseFirewallComplexity parses a string into a FirewallComplexity. -func ParseFirewallComplexity(s string) (FirewallComplexity, error) { - switch s { - case complexityBasic: - return FirewallBasic, nil - case complexityIntermediate: - return FirewallIntermediate, nil - case complexityAdvanced: - return FirewallAdvanced, nil - default: - return FirewallBasic, &InvalidComplexityError{Value: s} - } -} - -// InvalidComplexityError is returned when a complexity string is not recognized. -type InvalidComplexityError struct { - Value string -} - -func (e *InvalidComplexityError) Error() string { - return "invalid complexity level '" + e.Value + "': must be basic, intermediate, or advanced" -} - -// NatRuleType identifies the kind of NAT rule to generate. -type NatRuleType int - -// NatRuleType constants for all supported NAT rule types. -const ( - NatPortForward NatRuleType = iota - NatSourceNat - NatDestinationNat - NatOneToOne - NatOutbound -) - -// WanAssignment controls how VLANs are distributed across WAN interfaces. -type WanAssignment int - -const ( - // WanSingle assigns all VLANs to WAN 1. - WanSingle WanAssignment = iota - // WanMulti distributes VLANs round-robin across WANs 1-3. - WanMulti - // WanBalanced distributes VLANs randomly across WANs 1-3. - WanBalanced -) - -// ParseWanAssignment parses a string into a WanAssignment strategy. -func ParseWanAssignment(s string) (WanAssignment, error) { - switch s { - case "single": - return WanSingle, nil - case "multi": - return WanMulti, nil - case "balanced": - return WanBalanced, nil - default: - return WanSingle, &InvalidWanAssignmentError{Value: s} - } -} - -// InvalidWanAssignmentError is returned when a WAN assignment string is not recognized. -type InvalidWanAssignmentError struct { - Value string -} - -func (e *InvalidWanAssignmentError) Error() string { - return "invalid WAN assignment '" + e.Value + "': must be single, multi, or balanced" -} - -// PerformanceMetrics tracks generation timing and resource usage. -type PerformanceMetrics struct { - StartTime time.Time - Duration time.Duration - ConfigCount int - MemoryUsedKB int64 -} - -// NatMapping holds a generated NAT rule. -type NatMapping struct { - ID string - RuleType NatRuleType - Interface string - Protocol string - SourceAddr string - SourcePort string - DestAddr string - DestPort string - TargetAddr string - TargetPort string - Description string - Log bool - VlanID uint16 -} diff --git a/internal/generator/vlan.go b/internal/generator/vlan.go deleted file mode 100644 index bc60fd9..0000000 --- a/internal/generator/vlan.go +++ /dev/null @@ -1,173 +0,0 @@ -package generator - -import ( - "fmt" - "math/rand/v2" - "net/netip" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" -) - -const ( - // MinVlanID is the minimum valid VLAN ID (IEEE 802.1Q). - MinVlanID = 10 - // MaxVlanID is the maximum valid VLAN ID. - MaxVlanID = 4094 - // MaxUniqueVlans is the total number of unique VLAN IDs available. - MaxUniqueVlans = MaxVlanID - MinVlanID + 1 - // maxNetworkRetries is the maximum attempts to find a unique network. - maxNetworkRetries = 100 - // wanInterfaceCount is the number of WAN interfaces for multi/balanced distribution. - wanInterfaceCount = 3 -) - -// VlanGenerator produces unique VLAN configurations with seeded randomness. -type VlanGenerator struct { - rng *rand.Rand - usedVlanIDs map[uint16]bool - usedNets map[netip.Prefix]bool - wanStrategy WanAssignment - wanCounter int -} - -// NewVlanGenerator creates a new VLAN generator. If seed is nil, a random seed is used. -func NewVlanGenerator(seed *int64, wanStrategy WanAssignment) *VlanGenerator { - var rng *rand.Rand - if seed != nil { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(uint64(*seed), 0)) - } else { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) - } - - return &VlanGenerator{ - rng: rng, - usedVlanIDs: make(map[uint16]bool), - usedNets: make(map[netip.Prefix]bool), - wanStrategy: wanStrategy, - } -} - -// GenerateSingle produces a single unique VLAN configuration. -func (g *VlanGenerator) GenerateSingle() (VlanConfig, error) { - vlanID, err := g.nextUniqueVlanID() - if err != nil { - return VlanConfig{}, err - } - - network, err := g.nextUniqueNetwork() - if err != nil { - return VlanConfig{}, err - } - - dept := RandomDepartment(g.rng) - wan := g.nextWanAssignment() - - return VlanConfig{ - VlanID: vlanID, - IPNetwork: network, - Description: fmt.Sprintf("%s VLAN %d", dept, vlanID), - WanAssignment: wan, - Department: dept, - }, nil -} - -// GenerateBatch produces a batch of unique VLAN configurations. -func (g *VlanGenerator) GenerateBatch(count int) ([]VlanConfig, error) { - if count <= 0 { - return nil, fmt.Errorf("count must be positive, got %d", count) - } - - if count > MaxUniqueVlans { - return nil, fmt.Errorf( - "requested %d VLANs exceeds maximum of %d unique VLAN IDs", - count, MaxUniqueVlans, - ) - } - - configs := make([]VlanConfig, 0, count) - for range count { - cfg, err := g.GenerateSingle() - if err != nil { - return nil, fmt.Errorf("generate VLAN %d of %d: %w", len(configs)+1, count, err) - } - configs = append(configs, cfg) - } - - return configs, nil -} - -// maxVlanIDRandomRetries is the maximum random probing attempts before falling back to sequential scan. -const maxVlanIDRandomRetries = 1000 - -// nextUniqueVlanID generates a unique VLAN ID not already in use. -// Uses random probing first for speed, then falls back to sequential scan to guarantee -// finding a free ID as long as the pool is not exhausted. -func (g *VlanGenerator) nextUniqueVlanID() (uint16, error) { - if len(g.usedVlanIDs) >= MaxUniqueVlans { - return 0, fmt.Errorf("VLAN ID pool exhausted: all %d IDs in use", MaxUniqueVlans) - } - - // Fast path: random probing (efficient when pool utilization is low). - for range maxVlanIDRandomRetries { - //nolint:gosec // IntN(4085) yields 0-4084, adding MinVlanID stays within uint16 - id := uint16(g.rng.IntN(MaxVlanID-MinVlanID+1)) + MinVlanID - if !g.usedVlanIDs[id] { - g.usedVlanIDs[id] = true - return id, nil - } - } - - // Slow path: sequential scan (guarantees finding a free ID). - for id := uint16(MinVlanID); id <= MaxVlanID; id++ { - if !g.usedVlanIDs[id] { - g.usedVlanIDs[id] = true - return id, nil - } - } - - return 0, fmt.Errorf("VLAN ID pool exhausted: all %d IDs in use", MaxUniqueVlans) -} - -// nextUniqueNetwork generates a unique RFC 1918 /24 network. -func (g *VlanGenerator) nextUniqueNetwork() (netip.Prefix, error) { - for range maxNetworkRetries { - network := netutil.GenerateRandomNetwork(g.rng) - canonical := network.Masked() - if !g.usedNets[canonical] { - g.usedNets[canonical] = true - return canonical, nil - } - } - - return netip.Prefix{}, fmt.Errorf( - "failed to generate unique network after %d attempts (%d networks in use)", - maxNetworkRetries, len(g.usedNets), - ) -} - -// nextWanAssignment returns the next WAN assignment based on the strategy. -func (g *VlanGenerator) nextWanAssignment() uint8 { - switch g.wanStrategy { - case WanMulti: - g.wanCounter++ - //nolint:gosec // Modulo wanInterfaceCount yields 0-2, +1 yields 1-3, fits uint8 - return uint8((g.wanCounter-1)%wanInterfaceCount) + 1 - case WanBalanced: - //nolint:gosec // IntN(wanInterfaceCount) yields 0-2, +1 yields 1-3, fits uint8 - return uint8(g.rng.IntN(wanInterfaceCount)) + 1 - default: - return 1 - } -} - -// UsedVlanIDCount returns the number of VLAN IDs already allocated. -func (g *VlanGenerator) UsedVlanIDCount() int { - return len(g.usedVlanIDs) -} - -// UsedNetworkCount returns the number of networks already allocated. -func (g *VlanGenerator) UsedNetworkCount() int { - return len(g.usedNets) -} diff --git a/internal/generator/vlan_test.go b/internal/generator/vlan_test.go deleted file mode 100644 index eaa38e6..0000000 --- a/internal/generator/vlan_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package generator_test - -import ( - "testing" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGenerateSingle(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - cfg, err := gen.GenerateSingle() - require.NoError(t, err) - - assert.GreaterOrEqual(t, cfg.VlanID, uint16(generator.MinVlanID)) - assert.LessOrEqual(t, cfg.VlanID, uint16(generator.MaxVlanID)) - assert.True(t, netutil.IsRFC1918(cfg.IPNetwork), "network should be RFC 1918") - assert.Equal(t, 24, cfg.IPNetwork.Bits(), "network should be /24") - assert.NotEmpty(t, cfg.Description) - assert.Equal(t, uint8(1), cfg.WanAssignment) -} - -func TestGenerateBatch(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - count int - wantErr bool - }{ - {"single", 1, false}, - {"ten", 10, false}, - {"hundred", 100, false}, - {"zero count", 0, true}, - {"negative count", -1, true}, - {"exceeds max", generator.MaxUniqueVlans + 1, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - configs, err := gen.GenerateBatch(tt.count) - - if tt.wantErr { - assert.Error(t, err) - return - } - - require.NoError(t, err) - assert.Len(t, configs, tt.count) - }) - } -} - -func TestVlanIDUniqueness(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - configs, err := gen.GenerateBatch(500) - require.NoError(t, err) - - seen := make(map[uint16]bool) - for _, cfg := range configs { - assert.False(t, seen[cfg.VlanID], "duplicate VLAN ID: %d", cfg.VlanID) - seen[cfg.VlanID] = true - } -} - -func TestNetworkUniqueness(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - configs, err := gen.GenerateBatch(500) - require.NoError(t, err) - - seen := make(map[string]bool) - for _, cfg := range configs { - key := cfg.IPNetwork.String() - assert.False(t, seen[key], "duplicate network: %s", key) - seen[key] = true - } -} - -func TestAllNetworksRFC1918(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - configs, err := gen.GenerateBatch(200) - require.NoError(t, err) - - for _, cfg := range configs { - assert.True(t, netutil.IsRFC1918(cfg.IPNetwork), - "network %s should be RFC 1918", cfg.IPNetwork) - } -} - -func TestAllVlanIDsInRange(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - configs, err := gen.GenerateBatch(200) - require.NoError(t, err) - - for _, cfg := range configs { - assert.GreaterOrEqual(t, cfg.VlanID, uint16(generator.MinVlanID)) - assert.LessOrEqual(t, cfg.VlanID, uint16(generator.MaxVlanID)) - } -} - -func TestDeterministicOutput(t *testing.T) { - t.Parallel() - - gen1 := generator.NewVlanGenerator(int64Ptr(12345), generator.WanSingle) - gen2 := generator.NewVlanGenerator(int64Ptr(12345), generator.WanSingle) - - configs1, err := gen1.GenerateBatch(10) - require.NoError(t, err) - - configs2, err := gen2.GenerateBatch(10) - require.NoError(t, err) - - for i := range configs1 { - assert.Equal(t, configs1[i].VlanID, configs2[i].VlanID, "VLAN ID mismatch at index %d", i) - assert.Equal(t, configs1[i].IPNetwork, configs2[i].IPNetwork, "network mismatch at index %d", i) - assert.Equal(t, configs1[i].Description, configs2[i].Description, "description mismatch at index %d", i) - assert.Equal(t, configs1[i].WanAssignment, configs2[i].WanAssignment, "WAN mismatch at index %d", i) - } -} - -func TestWanAssignmentSingle(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - configs, err := gen.GenerateBatch(10) - require.NoError(t, err) - - for _, cfg := range configs { - assert.Equal(t, uint8(1), cfg.WanAssignment) - } -} - -func TestWanAssignmentMulti(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanMulti) - configs, err := gen.GenerateBatch(9) - require.NoError(t, err) - - for i, cfg := range configs { - expected := uint8(i%3) + 1 - assert.Equal(t, expected, cfg.WanAssignment, "WAN assignment at index %d", i) - } -} - -func TestWanAssignmentBalanced(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanBalanced) - configs, err := gen.GenerateBatch(100) - require.NoError(t, err) - - counts := make(map[uint8]int) - for _, cfg := range configs { - assert.GreaterOrEqual(t, cfg.WanAssignment, uint8(1)) - assert.LessOrEqual(t, cfg.WanAssignment, uint8(3)) - counts[cfg.WanAssignment]++ - } - - for wan := uint8(1); wan <= 3; wan++ { - assert.Positive(t, counts[wan], "WAN %d should have at least one assignment", wan) - } -} - -func TestMaxVlanGeneration(t *testing.T) { - if testing.Short() { - t.Skip("skipping max VLAN generation test in short mode") - } - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - configs, err := gen.GenerateBatch(generator.MaxUniqueVlans) - require.NoError(t, err) - assert.Len(t, configs, generator.MaxUniqueVlans) -} - -func TestDescriptionFormat(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - cfg, err := gen.GenerateSingle() - require.NoError(t, err) - - assert.Contains(t, cfg.Description, "VLAN") - assert.NotEmpty(t, cfg.Department) -} - -func TestUsedCountTracking(t *testing.T) { - t.Parallel() - - gen := generator.NewVlanGenerator(int64Ptr(42), generator.WanSingle) - assert.Equal(t, 0, gen.UsedVlanIDCount()) - assert.Equal(t, 0, gen.UsedNetworkCount()) - - _, err := gen.GenerateBatch(5) - require.NoError(t, err) - - assert.Equal(t, 5, gen.UsedVlanIDCount()) - assert.Equal(t, 5, gen.UsedNetworkCount()) -} - -func TestParseWanAssignment(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - expected generator.WanAssignment - wantErr bool - }{ - {"single", "single", generator.WanSingle, false}, - {"multi", "multi", generator.WanMulti, false}, - {"balanced", "balanced", generator.WanBalanced, false}, - {"invalid", "roundrobin", generator.WanSingle, true}, - {"empty", "", generator.WanSingle, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, err := generator.ParseWanAssignment(tt.input) - if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), "must be single, multi, or balanced") - return - } - require.NoError(t, err) - assert.Equal(t, tt.expected, got) - }) - } -} diff --git a/internal/generator/vpn.go b/internal/generator/vpn.go deleted file mode 100644 index b35a524..0000000 --- a/internal/generator/vpn.go +++ /dev/null @@ -1,223 +0,0 @@ -package generator - -import ( - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - mathrand "math/rand/v2" - "net/netip" - - "github.com/charmbracelet/log" - "github.com/google/uuid" -) - -// VpnType represents the type of VPN configuration. -type VpnType int - -// VPN type constants for all supported VPN protocols. -const ( - VpnOpenVPN VpnType = iota - VpnWireGuard - VpnIPSec -) - -// VpnConfig holds a generated VPN configuration. -type VpnConfig struct { - ID string - Type VpnType - Name string - TunnelNetwork netip.Prefix - Port int - Protocol string - Description string - - // OpenVPN-specific fields. - Cipher string - TLSAuth bool - - // WireGuard-specific fields. - PublicKey string - ListenKey string // redacted in exports - - // IPSec-specific fields. - IKEVersion int - DHGroup int - HashAlgo string -} - -// VpnGenerator produces VPN configurations with non-overlapping tunnel subnets. -type VpnGenerator struct { - rng *mathrand.Rand - usedSubnets map[netip.Prefix]bool - tunnelBase uint8 // Starting third octet for tunnel networks. -} - -// NewVpnGenerator creates a new VPN configuration generator. -func NewVpnGenerator(seed *int64) *VpnGenerator { - var rng *mathrand.Rand - if seed != nil { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = mathrand.New(mathrand.NewPCG(uint64(*seed), 0)) - } else { - //nolint:gosec // Deterministic fake data generation, not security-sensitive - rng = mathrand.New(mathrand.NewPCG(mathrand.Uint64(), mathrand.Uint64())) - } - - return &VpnGenerator{ - rng: rng, - usedSubnets: make(map[netip.Prefix]bool), - tunnelBase: 1, - } -} - -// GenerateConfigs produces VPN configurations. -func (g *VpnGenerator) GenerateConfigs(count int) ([]VpnConfig, error) { - if count <= 0 { - return nil, nil - } - - configs := make([]VpnConfig, 0, count) - for range count { - vpnType := VpnType(g.rng.IntN(vpnTypeCount)) - cfg, err := g.generateConfig(vpnType) - if err != nil { - return nil, err - } - configs = append(configs, cfg) - } - - return configs, nil -} - -// GenerateConfigsOfType produces VPN configurations of a specific type. -func (g *VpnGenerator) GenerateConfigsOfType(vpnType VpnType, count int) ([]VpnConfig, error) { - if count <= 0 { - return nil, nil - } - - configs := make([]VpnConfig, 0, count) - for range count { - cfg, err := g.generateConfig(vpnType) - if err != nil { - return nil, err - } - configs = append(configs, cfg) - } - - return configs, nil -} - -func (g *VpnGenerator) generateConfig(vpnType VpnType) (VpnConfig, error) { - tunnel, err := g.nextTunnelSubnet() - if err != nil { - return VpnConfig{}, err - } - - switch vpnType { - case VpnOpenVPN: - return g.openVPNConfig(tunnel), nil - case VpnWireGuard: - return g.wireGuardConfig(tunnel), nil - case VpnIPSec: - return g.ipsecConfig(tunnel), nil - default: - return g.openVPNConfig(tunnel), nil - } -} - -// VPN generation constants for port ranges and protocol parameters. -const ( - ovpnBasePort = 1194 - ovpnPortRange = 100 - wgBasePort = 51820 - wgPortRange = 100 - vpnTypeCount = 3 - ipsecPort = 500 - ipsecIKEVersion = 2 - ipsecDHGroup = 14 - maxTunnelOctet = 254 - tunnelPrefix = 24 - fakeKeySize = 32 -) - -func (g *VpnGenerator) openVPNConfig(tunnel netip.Prefix) VpnConfig { - port := ovpnBasePort + g.rng.IntN(ovpnPortRange) - return VpnConfig{ - ID: uuid.NewString(), - Type: VpnOpenVPN, - Name: fmt.Sprintf("ovpn-server-%d", port), - TunnelNetwork: tunnel, - Port: port, - Protocol: "udp", - Description: fmt.Sprintf("OpenVPN server on port %d", port), - Cipher: "AES-256-GCM", - TLSAuth: true, - } -} - -func (g *VpnGenerator) wireGuardConfig(tunnel netip.Prefix) VpnConfig { - port := wgBasePort + g.rng.IntN(wgPortRange) - pubKey := generateFakeKey() - return VpnConfig{ - ID: uuid.NewString(), - Type: VpnWireGuard, - Name: fmt.Sprintf("wg-%d", port), - TunnelNetwork: tunnel, - Port: port, - Protocol: "udp", - Description: fmt.Sprintf("WireGuard tunnel on port %d", port), - PublicKey: pubKey, - ListenKey: generateFakeKey(), - } -} - -func (g *VpnGenerator) ipsecConfig(tunnel netip.Prefix) VpnConfig { - return VpnConfig{ - ID: uuid.NewString(), - Type: VpnIPSec, - Name: fmt.Sprintf("ipsec-%s", tunnel.Addr()), - TunnelNetwork: tunnel, - Port: ipsecPort, - Protocol: "esp", - Description: fmt.Sprintf("IPSec tunnel to %s", tunnel), - IKEVersion: ipsecIKEVersion, - DHGroup: ipsecDHGroup, - HashAlgo: "sha256", - } -} - -// nextTunnelSubnet generates a unique tunnel /24 in the 10.200.x.0 range. -func (g *VpnGenerator) nextTunnelSubnet() (netip.Prefix, error) { - if g.tunnelBase > maxTunnelOctet { - return netip.Prefix{}, errors.New("tunnel subnet pool exhausted") - } - - addr := netip.AddrFrom4([4]byte{10, 200, g.tunnelBase, 0}) - prefix := netip.PrefixFrom(addr, tunnelPrefix) - g.tunnelBase++ - g.usedSubnets[prefix] = true - - return prefix, nil -} - -// UsedSubnets returns all tunnel subnets allocated so far. -func (g *VpnGenerator) UsedSubnets() []netip.Prefix { - subnets := make([]netip.Prefix, 0, len(g.usedSubnets)) - for s := range g.usedSubnets { - subnets = append(subnets, s) - } - return subnets -} - -// generateFakeKey produces a base64-encoded 32-byte fake key. -func generateFakeKey() string { - key := make([]byte, fakeKeySize) - if _, err := rand.Read(key); err != nil { - log.Warn("crypto/rand unavailable, using deterministic placeholder key", "error", err) - for i := range key { - key[i] = byte(i) - } - } - return base64.StdEncoding.EncodeToString(key) -} diff --git a/internal/generator/vpn_test.go b/internal/generator/vpn_test.go deleted file mode 100644 index 8b6f3a2..0000000 --- a/internal/generator/vpn_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package generator_test - -import ( - "testing" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestVpnGenerateConfigs(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigs(5) - require.NoError(t, err) - assert.Len(t, configs, 5) -} - -func TestVpnZeroCountReturnsNil(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigs(0) - require.NoError(t, err) - assert.Nil(t, configs) -} - -func TestVpnTunnelSubnetsUnique(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigs(10) - require.NoError(t, err) - - seen := make(map[string]bool) - for _, cfg := range configs { - key := cfg.TunnelNetwork.String() - assert.False(t, seen[key], "duplicate tunnel subnet: %s", key) - seen[key] = true - } -} - -func TestVpnTunnelSubnetsAreRFC1918(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigs(10) - require.NoError(t, err) - - for _, cfg := range configs { - assert.True(t, netutil.IsRFC1918(cfg.TunnelNetwork), - "tunnel %s should be RFC 1918", cfg.TunnelNetwork) - } -} - -func TestVpnOpenVPNConfig(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigsOfType(generator.VpnOpenVPN, 3) - require.NoError(t, err) - - for _, cfg := range configs { - assert.Equal(t, generator.VpnOpenVPN, cfg.Type) - assert.Equal(t, "udp", cfg.Protocol) - assert.Equal(t, "AES-256-GCM", cfg.Cipher) - assert.True(t, cfg.TLSAuth) - assert.GreaterOrEqual(t, cfg.Port, 1194) - assert.Less(t, cfg.Port, 1294) - } -} - -func TestVpnWireGuardConfig(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigsOfType(generator.VpnWireGuard, 3) - require.NoError(t, err) - - for _, cfg := range configs { - assert.Equal(t, generator.VpnWireGuard, cfg.Type) - assert.NotEmpty(t, cfg.PublicKey) - assert.NotEmpty(t, cfg.ListenKey) - assert.GreaterOrEqual(t, cfg.Port, 51820) - assert.Less(t, cfg.Port, 51920) - } -} - -func TestVpnIPSecConfig(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigsOfType(generator.VpnIPSec, 3) - require.NoError(t, err) - - for _, cfg := range configs { - assert.Equal(t, generator.VpnIPSec, cfg.Type) - assert.Equal(t, 2, cfg.IKEVersion) - assert.GreaterOrEqual(t, cfg.DHGroup, 14) - assert.Equal(t, "sha256", cfg.HashAlgo) - assert.Equal(t, 500, cfg.Port) - } -} - -func TestVpnConfigsHaveDescriptions(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigs(10) - require.NoError(t, err) - - for _, cfg := range configs { - assert.NotEmpty(t, cfg.Description) - assert.NotEmpty(t, cfg.Name) - assert.NotEmpty(t, cfg.ID) - } -} - -func TestVpnConfigsHaveUniqueIDs(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - configs, err := gen.GenerateConfigs(20) - require.NoError(t, err) - - seen := make(map[string]bool) - for _, cfg := range configs { - assert.False(t, seen[cfg.ID], "duplicate VPN ID: %s", cfg.ID) - seen[cfg.ID] = true - } -} - -func TestVpnSubnetExhaustion(t *testing.T) { - t.Parallel() - - gen := generator.NewVpnGenerator(int64Ptr(42)) - // 254 tunnel subnets available (10.200.1-254.0/24). - _, err := gen.GenerateConfigs(254) - require.NoError(t, err) - - // 255th should fail. - _, err = gen.GenerateConfigs(1) - require.Error(t, err) - assert.Contains(t, err.Error(), "exhausted") -} diff --git a/internal/validate/validate.go b/internal/validate/validate.go deleted file mode 100644 index 530d3b4..0000000 --- a/internal/validate/validate.go +++ /dev/null @@ -1,144 +0,0 @@ -// Package validate provides cross-object consistency checks for generated configurations. -package validate - -import ( - "fmt" - "net/netip" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" -) - -// ValidationResult collects all validation errors for a configuration set. -type ValidationResult struct { - Errors []string -} - -// IsValid returns true if no validation errors were found. -func (r ValidationResult) IsValid() bool { - return len(r.Errors) == 0 -} - -// Error returns all validation errors as a single error, or nil if valid. -func (r ValidationResult) Error() error { - if r.IsValid() { - return nil - } - return fmt.Errorf("validation failed with %d errors: %v", len(r.Errors), r.Errors) -} - -// Vlans checks a batch of VLAN configurations for consistency. -func Vlans(vlans []generator.VlanConfig) ValidationResult { - var result ValidationResult - - seenIDs := make(map[uint16]bool) - seenNets := make(map[string]bool) - - for i, v := range vlans { - // VLAN ID range check. - if v.VlanID < generator.MinVlanID || v.VlanID > generator.MaxVlanID { - result.Errors = append( - result.Errors, - fmt.Sprintf( - "VLAN[%d]: ID %d outside range %d-%d", - i, - v.VlanID, - generator.MinVlanID, - generator.MaxVlanID, - ), - ) - } - - // VLAN ID uniqueness. - if seenIDs[v.VlanID] { - result.Errors = append(result.Errors, - fmt.Sprintf("VLAN[%d]: duplicate VLAN ID %d", i, v.VlanID)) - } - seenIDs[v.VlanID] = true - - // Network uniqueness. - netKey := v.IPNetwork.Masked().String() - if seenNets[netKey] { - result.Errors = append(result.Errors, - fmt.Sprintf("VLAN[%d]: duplicate network %s", i, netKey)) - } - seenNets[netKey] = true - - // RFC 1918 compliance. - if !netutil.IsRFC1918(v.IPNetwork) { - result.Errors = append(result.Errors, - fmt.Sprintf("VLAN[%d]: network %s is not RFC 1918", i, v.IPNetwork)) - } - - // WAN assignment range. - if v.WanAssignment < 1 || v.WanAssignment > 3 { - result.Errors = append(result.Errors, - fmt.Sprintf("VLAN[%d]: WAN assignment %d outside range 1-3", i, v.WanAssignment)) - } - - // Description not empty. - if v.Description == "" { - result.Errors = append(result.Errors, - fmt.Sprintf("VLAN[%d]: description is empty", i)) - } - } - - return result -} - -// FirewallRules checks rules for valid actions, protocols, and interface references. -func FirewallRules(rules []generator.FirewallRule, validInterfaces map[string]bool) ValidationResult { - var result ValidationResult - - validActions := map[string]bool{"pass": true, "block": true, "reject": true} - validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true, "any": true} - validDirections := map[string]bool{"in": true, "out": true} - - seenTrackers := make(map[uint64]bool) - - for i, r := range rules { - if !validActions[r.Action] { - result.Errors = append(result.Errors, - fmt.Sprintf("rule[%d] %s: invalid action %q", i, r.RuleID, r.Action)) - } - - if !validProtocols[r.Protocol] { - result.Errors = append(result.Errors, - fmt.Sprintf("rule[%d] %s: invalid protocol %q", i, r.RuleID, r.Protocol)) - } - - if !validDirections[r.Direction] { - result.Errors = append(result.Errors, - fmt.Sprintf("rule[%d] %s: invalid direction %q", i, r.RuleID, r.Direction)) - } - - if validInterfaces != nil && !validInterfaces[r.Interface] { - result.Errors = append(result.Errors, - fmt.Sprintf("rule[%d] %s: references unknown interface %q", i, r.RuleID, r.Interface)) - } - - if seenTrackers[r.Tracker] { - result.Errors = append(result.Errors, - fmt.Sprintf("rule[%d] %s: duplicate tracker %d", i, r.RuleID, r.Tracker)) - } - seenTrackers[r.Tracker] = true - } - - return result -} - -// NoSubnetOverlap checks that VPN tunnel subnets don't overlap with VLAN subnets. -func NoSubnetOverlap(vlanNets, vpnNets []netip.Prefix) ValidationResult { - var result ValidationResult - - for _, vpn := range vpnNets { - for _, vlan := range vlanNets { - if vpn.Overlaps(vlan) { - result.Errors = append(result.Errors, - fmt.Sprintf("VPN tunnel %s overlaps with VLAN network %s", vpn, vlan)) - } - } - } - - return result -} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go deleted file mode 100644 index ebfe76d..0000000 --- a/internal/validate/validate_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package validate_test - -import ( - "net/netip" - "testing" - - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/validate" - "github.com/stretchr/testify/assert" -) - -func TestValidateVlansValid(t *testing.T) { - t.Parallel() - - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Description: "IT VLAN", WanAssignment: 1}, - {VlanID: 200, IPNetwork: netip.MustParsePrefix("10.1.2.0/24"), Description: "Sales VLAN", WanAssignment: 2}, - } - - result := validate.Vlans(vlans) - assert.True(t, result.IsValid(), "valid VLANs should pass: %v", result.Errors) -} - -func TestValidateVlansDuplicateIDs(t *testing.T) { - t.Parallel() - - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Description: "test", WanAssignment: 1}, - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.2.0/24"), Description: "test", WanAssignment: 1}, - } - - result := validate.Vlans(vlans) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "duplicate VLAN ID") -} - -func TestValidateVlansDuplicateNetworks(t *testing.T) { - t.Parallel() - - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Description: "test", WanAssignment: 1}, - {VlanID: 200, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Description: "test", WanAssignment: 1}, - } - - result := validate.Vlans(vlans) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "duplicate network") -} - -func TestValidateVlansIDOutOfRange(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - vlanID uint16 - wantErr bool - }{ - {"below minimum", 5, true}, - {"at minimum", 10, false}, - {"at maximum", 4094, false}, - {"above maximum", 4095, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - vlans := []generator.VlanConfig{ - { - VlanID: tt.vlanID, - IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), - Description: "test", - WanAssignment: 1, - }, - } - result := validate.Vlans(vlans) - if tt.wantErr { - assert.False(t, result.IsValid()) - } else { - assert.True(t, result.IsValid(), "errors: %v", result.Errors) - } - }) - } -} - -func TestValidateVlansNonRFC1918(t *testing.T) { - t.Parallel() - - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("8.8.8.0/24"), Description: "test", WanAssignment: 1}, - } - - result := validate.Vlans(vlans) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "not RFC 1918") -} - -func TestValidateVlansInvalidWan(t *testing.T) { - t.Parallel() - - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Description: "test", WanAssignment: 0}, - } - - result := validate.Vlans(vlans) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "WAN assignment") -} - -func TestValidateVlansEmptyDescription(t *testing.T) { - t.Parallel() - - vlans := []generator.VlanConfig{ - {VlanID: 100, IPNetwork: netip.MustParsePrefix("10.1.1.0/24"), Description: "", WanAssignment: 1}, - } - - result := validate.Vlans(vlans) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "description is empty") -} - -func TestValidateFirewallRulesValid(t *testing.T) { - t.Parallel() - - rules := []generator.FirewallRule{ - {RuleID: "r1", Action: "pass", Protocol: "tcp", Direction: "in", Interface: "opt1", Tracker: 1}, - {RuleID: "r2", Action: "block", Protocol: "udp", Direction: "in", Interface: "opt1", Tracker: 2}, - } - - interfaces := map[string]bool{"opt1": true} - result := validate.FirewallRules(rules, interfaces) - assert.True(t, result.IsValid(), "errors: %v", result.Errors) -} - -func TestValidateFirewallRulesInvalidAction(t *testing.T) { - t.Parallel() - - rules := []generator.FirewallRule{ - {RuleID: "r1", Action: "deny", Protocol: "tcp", Direction: "in", Interface: "opt1", Tracker: 1}, - } - - result := validate.FirewallRules(rules, nil) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "invalid action") -} - -func TestValidateFirewallRulesDuplicateTracker(t *testing.T) { - t.Parallel() - - rules := []generator.FirewallRule{ - {RuleID: "r1", Action: "pass", Protocol: "tcp", Direction: "in", Tracker: 42}, - {RuleID: "r2", Action: "pass", Protocol: "tcp", Direction: "in", Tracker: 42}, - } - - result := validate.FirewallRules(rules, nil) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "duplicate tracker") -} - -func TestValidateFirewallRulesUnknownInterface(t *testing.T) { - t.Parallel() - - rules := []generator.FirewallRule{ - {RuleID: "r1", Action: "pass", Protocol: "tcp", Direction: "in", Interface: "opt99", Tracker: 1}, - } - - interfaces := map[string]bool{"opt1": true, "opt2": true} - result := validate.FirewallRules(rules, interfaces) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "unknown interface") -} - -func TestValidateNoSubnetOverlap(t *testing.T) { - t.Parallel() - - vlanNets := []netip.Prefix{ - netip.MustParsePrefix("10.1.1.0/24"), - netip.MustParsePrefix("10.1.2.0/24"), - } - - // Non-overlapping VPN subnets. - vpnNets := []netip.Prefix{ - netip.MustParsePrefix("10.200.1.0/24"), - netip.MustParsePrefix("10.200.2.0/24"), - } - - result := validate.NoSubnetOverlap(vlanNets, vpnNets) - assert.True(t, result.IsValid(), "non-overlapping should be valid: %v", result.Errors) -} - -func TestValidateSubnetOverlapDetected(t *testing.T) { - t.Parallel() - - vlanNets := []netip.Prefix{ - netip.MustParsePrefix("10.1.1.0/24"), - } - - vpnNets := []netip.Prefix{ - netip.MustParsePrefix("10.1.1.0/24"), // Same as VLAN! - } - - result := validate.NoSubnetOverlap(vlanNets, vpnNets) - assert.False(t, result.IsValid()) - assert.Contains(t, result.Errors[0], "overlaps") -} From b100924fad1daec1f8a56d300856b1edcf4906bd Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 01:46:33 -0400 Subject: [PATCH 07/11] docs: reframe README as CommonDevice reverse serializer Leads with the project's actual purpose: the missing inverse of opnDossier's parser. Zero-argument 'generate' is the headline example. New Pipeline section shows the mermaid flow. Command reference matches the new flag surface. "What Phase 1 Covers" table enumerates delivered subsystems alongside the deferred list so followup plans have a clear anchor. Signed-off-by: UncleSp1d3r --- README.md | 223 ++++++++++++++++++------------------------------------ 1 file changed, 73 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 8b5e562..a59bf45 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# opnConfigGenerator -- Realistic Network Device Config Generator +# opnConfigGenerator -- CommonDevice → config.xml reverse serializer [![CI][ci-badge]][ci] [![Go Version][go-badge]][go] [![License][license-badge]][license] [![Go Report Card][goreportcard-badge]][goreportcard] -Generate realistic, valid OPNsense `config.xml` files populated with fake data -- for testing, training, development, and demos. No real network data exposed. +Generate realistic, valid OPNsense `config.xml` files from a synthetic [opnDossier](https://github.com/EvilBit-Labs/opnDossier) `CommonDevice`. opnDossier parses `config.xml → CommonDevice`; opnConfigGenerator is the missing inverse: `faker → CommonDevice → OpnSenseDocument → config.xml`. No inputs required — the tool owns every field. -Part of the [opnDossier](https://github.com/EvilBit-Labs/opnDossier) ecosystem. Built for offline operation: single binary, no network calls, no telemetry. +Built for offline operation: single binary, no network calls, no telemetry. ## Quick Start @@ -12,137 +12,74 @@ Part of the [opnDossier](https://github.com/EvilBit-Labs/opnDossier) ecosystem. # Install go install github.com/EvilBit-Labs/opnConfigGenerator@latest -# Generate 25 VLANs with firewall rules as OPNsense XML -opnconfiggenerator generate \ - --format xml \ - --count 25 \ - --base-config config.xml \ - --include-firewall-rules \ - --seed 42 +# Zero arguments -- a valid config.xml on stdout +opnconfiggenerator generate -# Export as CSV for spreadsheet analysis -opnconfiggenerator generate --format csv --count 50 --output vlans.csv -``` - -Same `--seed` always produces identical output -- across runs and platforms. - -## What It Generates - -| Component | Description | Options | -| ------------------ | ----------------------------------------------------- | --------------------------------------------------- | -| **VLANs** | Unique IDs, RFC 1918 networks, department assignments | 1--4085 per run | -| **DHCP** | Ranges, gateways, DNS, NTP, static reservations | Per-VLAN automatic | -| **Firewall rules** | Allow/block rules with proper dependencies | basic (3), intermediate (7), advanced (15) per VLAN | -| **Interfaces** | Named assignments with IP and subnet | Tracks opt counters | -| **VPN** | OpenVPN, WireGuard, IPsec tunnel configs | Mix of types | -| **NAT** | Port forwards, source/dest NAT, outbound rules | 5 rule types | +# Reproducible output (same seed => byte-identical bytes) +opnconfiggenerator generate --seed 42 > config.xml -All generated data passes structural validation: unique VLAN IDs, no overlapping subnets, valid RFC 1918 addresses, consistent cross-references. +# 20 VLANs with default firewall rules +opnconfiggenerator generate --vlan-count 20 --firewall-rules --seed 42 -## Installation - -### Pre-built binaries +# Overlay generated content onto an existing config, preserving everything +# outside the serializer's Phase 1 scope (NAT, VPN, certificates, ...) +opnconfiggenerator generate --base-config existing.xml --seed 42 -Download from [GitHub Releases](https://github.com/EvilBit-Labs/opnConfigGenerator/releases) for Linux, macOS (universal), and Windows. - -### From source - -Requires Go 1.26+: - -```bash -go install github.com/EvilBit-Labs/opnConfigGenerator@latest +# CSV inspection dump of the generated VLANs +opnconfiggenerator generate --format csv --vlan-count 10 --seed 42 ``` -### Verify +Same `--seed` always produces byte-identical output across runs and platforms. -```bash -opnconfiggenerator --version -``` - -## Usage - -### Generate OPNsense XML - -Start with a base `config.xml` (a minimal OPNsense config or an export from a real device): - -```bash -opnconfiggenerator generate \ - --format xml \ - --count 10 \ - --base-config config.xml \ - --output generated.xml -``` - -The tool injects generated VLANs, interfaces, DHCP pools, and optionally firewall rules into the base config while preserving its existing structure. - -### Generate with firewall rules +## Pipeline -Three complexity levels control how many rules are created per VLAN: - -```bash -# Basic: 3 rules per VLAN (allow internal, DNS, HTTP/S) -opnconfiggenerator generate --format xml --count 10 \ - --base-config config.xml --include-firewall-rules - -# Advanced: 15 rules per VLAN (department-specific + security rules) -opnconfiggenerator generate --format xml --count 10 \ - --base-config config.xml \ - --include-firewall-rules --firewall-rule-complexity advanced -``` - -### Reproducible output - -Use `--seed` for deterministic generation -- the same seed always produces identical configs: - -```bash -# These two runs produce byte-identical output -opnconfiggenerator generate --format csv --count 20 --seed 42 --output run1.csv -opnconfiggenerator generate --format csv --count 20 --seed 42 --output run2.csv -diff run1.csv run2.csv # no differences -``` - -### Export as CSV - -CSV output uses German headers (`VLAN`, `IP Range`, `Beschreibung`, `WAN`) for backward compatibility with the original toolchain, and includes a UTF-8 BOM for Excel compatibility on Windows: - -```bash -opnconfiggenerator generate --format csv --count 50 --output network-data.csv +```mermaid +flowchart LR + CLI[cmd/generate] --> F[faker.NewCommonDevice] + F --> CD[*model.CommonDevice] + CD --> S[serializer/opnsense.Serialize] + CD -- "--format csv" --> CSV[csvio.WriteVlanCSV] + S --> D[*opnsense.OpnSenseDocument] + BASE[--base-config file] --> LOAD[opnsensegen.LoadBaseConfig] + LOAD --> OV[serializer/opnsense.Overlay] + CD --> OV + OV --> D + D --> M[opnsensegen.MarshalConfig] + M --> XML[(config.xml)] + CSV --> OUT[(csv)] ``` -### WAN distribution +`*model.CommonDevice` is the single intermediate representation. opnDossier defines it; this project populates and serializes it. A future `internal/serializer/pfsense/` sibling will plug in alongside `internal/serializer/opnsense/` when pfSense support lands; the CLI routes by `CommonDevice.DeviceType`. -Control how VLANs are distributed across WAN interfaces: +## What the Phase 1 Serializer Covers -```bash -# All VLANs on WAN 1 (default) -opnconfiggenerator generate --format xml --count 10 --base-config config.xml +| Subsystem | Coverage | +| -------------- | ---------------------------------------------------------------- | +| System | Hostname, domain, timezone, DNS/NTP servers, WebGUI/SSH defaults | +| Interfaces | WAN (DHCP), LAN (static RFC 1918 /24), per-VLAN opt interfaces | +| VLANs | Unique 802.1Q tags [2..4094] on shared physical parent | +| DHCP | ISC DHCP scope per statically-addressed interface (WAN excluded) | +| Firewall rules | One default pass rule per non-WAN interface (opt-in) | -# Round-robin across WANs 1-3 -opnconfiggenerator generate --format xml --count 10 --base-config config.xml \ - --wan-assignments multi - -# Random distribution across WANs 1-3 -opnconfiggenerator generate --format xml --count 10 --base-config config.xml \ - --wan-assignments balanced -``` +Deferred to follow-up plans (one per subsystem): NAT, VPN (OpenVPN/WireGuard/IPsec), Users/Groups, Certificates/CAs, IDS, HighAvailability, VirtualIPs, Bridges, GIF/GRE/LAGG, PPP, CaptivePortal, Kea DHCP, Monit, Netflow, TrafficShaper, Syslog forwarding, pfSense target. ## Command Reference ### `generate` -| Flag | Default | Description | -| ---------------------------- | ------------ | ----------------------------------------------------------------- | -| `--format` | *(required)* | Output format: `csv` or `xml` | -| `--count` | `10` | Number of VLANs to generate (1--4085) | -| `--base-config` | | Base OPNsense XML template (required for `xml`) | -| `--seed` | `0` (random) | RNG seed for reproducible output | -| `--include-firewall-rules` | `false` | Generate firewall rules per VLAN | -| `--firewall-rule-complexity` | `basic` | `basic` (3), `intermediate` (7), `advanced` (15) rules per VLAN | -| `--wan-assignments` | `single` | WAN strategy: `single`, `multi`, `balanced` | -| `--output` | stdout | Output file path | -| `--force` | `false` | Overwrite existing output files | -| `--quiet` | `false` | Suppress non-error output | -| `--no-color` | `false` | Disable colored output (also respects `NO_COLOR` and `TERM=dumb`) | +| Flag | Default | Description | +| ------------------- | ------------ | ----------------------------------------------------------------- | +| `--format` | `xml` | Output format: `xml` (valid config.xml) or `csv` (VLAN dump) | +| `--vlan-count`/`-n` | `10` | Number of VLANs to generate (0--4092) | +| `--base-config` | | Optional base `config.xml`; serializer overlays onto it | +| `--firewall-rules` | `false` | Include default allow-all-to-any rules per interface | +| `--seed` | `0` (random) | RNG seed for reproducible output | +| `--hostname` | | Override the generated hostname | +| `--domain` | | Override the generated domain | +| `--output`/`-o` | stdout | Output file path | +| `--force` | `false` | Overwrite existing output files | +| `--quiet` | `false` | Suppress non-error output | +| `--no-color` | `false` | Disable colored output (also respects `NO_COLOR` and `TERM=dumb`) | ### `validate` @@ -160,45 +97,31 @@ opnconfiggenerator completion fish > ~/.config/fish/completions/opnconfiggenerat ## Use Cases -- **Testing opnDossier** -- Generate diverse configs to test parsing, validation, and audit features -- **Training environments** -- Create realistic lab configs for network engineering courses +- **Testing opnDossier** -- Round-trip synthetic configs through the parser to catch schema or conversion regressions +- **Training environments** -- Realistic lab configs for network engineering courses without real network exposure - **CI/CD test fixtures** -- Deterministic `--seed` output for integration test suites - **Demo data** -- Populate OPNsense instances for product demos without exposing real networks -- **Security research** -- Generate configs with known firewall rule patterns for analysis +- **Security research** -- Generate configs with controlled firewall rule shapes for analysis + +## Installation -## How It Works +### Pre-built binaries -```mermaid -graph LR - subgraph Generators["Generators (device-agnostic)"] - VLAN[VLAN] - FW[Firewall] - DHCP[DHCP] - NAT[NAT] - VPN[VPN] - end - - subgraph Serializers["Serializers (device-specific)"] - OPN[opnsensegen] - PF[pfsensegen
planned] - end - - VLAN --> OPN - FW --> OPN - DHCP --> OPN - NAT --> OPN - VPN --> OPN - - OPN -->|opnDossier
schema types| XML[config.xml] - PF -->|opnDossier
schema types| PFXML[config.xml] - - style PF stroke-dasharray: 5 5 - style PFXML stroke-dasharray: 5 5 +Download from [GitHub Releases](https://github.com/EvilBit-Labs/opnConfigGenerator/releases) for Linux, macOS (universal), and Windows. + +### From source + +Requires Go 1.26+: + +```bash +go install github.com/EvilBit-Labs/opnConfigGenerator@latest ``` -Generators produce device-agnostic data. Device-specific serializers translate that into the target schema using [opnDossier's canonical types](https://github.com/EvilBit-Labs/opnDossier). Generated configs are structurally identical to real device exports. +Verify: -Currently supports OPNsense. pfSense support is planned as opnDossier expands its device parser coverage. +```bash +opnconfiggenerator --version +``` ## Development @@ -211,11 +134,11 @@ just ci-check # Full CI validation (required before committing) just build # Build binary ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for coding standards, architecture details, and PR process. +See [CONTRIBUTING.md](CONTRIBUTING.md) for coding standards and PR process. ## Related Projects -- **[opnDossier](https://github.com/EvilBit-Labs/opnDossier)** -- Process OPNsense/pfSense configs into documentation, audits, and structured data +- **[opnDossier](https://github.com/EvilBit-Labs/opnDossier)** -- Process OPNsense/pfSense configs into documentation, audits, and structured data. opnDossier provides `*model.CommonDevice` and `*opnsense.OpnSenseDocument` as public API; this project is its reverse serializer. ## License From 54787a60b276fbb0a7dd0533e73dc606fc18abe6 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 02:08:17 -0400 Subject: [PATCH 08/11] fix: round-trip fidelity, stability hardening, and docs from ce:review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autofixes driven by ce:review (mode:autofix) findings: Correctness (HIGH — silent round-trip field loss): * SerializeInterfaces now propagates iface.Type and iface.Virtual. Without these, every faker-generated opt VLAN interface lost Virtual=true and every interface lost its Type on the round-trip. Strengthened TestRoundTrip with per-field parity assertions that would have caught this; replaced count-only checks on Interfaces/VLANs/DHCP with lookup+equality. * endpointToSource/endpointToDestination no longer collapse empty Address onto . Empty means "unspecified" and now emits nothing; only Address == "any" emits the presence flag. Updated the relevant firewall test. Reliability: * MarshalConfig buffers the entire document before the first Write so a mid-stream encode or stabilization error no longer leaves a truncated file on disk. * pickUniqueTag and pickUniqueNet get a max-attempts cap with an explicit panic message on exhaustion. Today's pool sizes make this unreachable, but a future shrink in the address or tag space no longer hangs the CLI. * fakeDHCPScopes now logs a warning before silently skipping an interface whose IPAddress/Subnet cannot be parsed. Maintainability: * Dropped the unused *gofakeit.Faker parameter from fakeFirewallRules (YAGNI — the ruleset is deterministic today). * Lowercased NetworkResult → networkResult since nothing outside the faker package consumes it. Byte stability: * Added TestRoundTripByteStable — 10 re-marshals on the same input must produce identical bytes. Direct guard for the sortMapBackedSections workaround instead of relying on TestGenerateDeterministicSeed's two-run proxy. Docs: * GOTCHAS §7.1 captures the map-backed XML ordering workaround so future contributors adding NAT/VPN/etc. know where to extend mapBackedSections. * GOTCHAS §7.2 captures the round-trip-test-must-assert-per-field lesson. * CONTRIBUTING.md Project Structure and Key Design Decisions sections rewritten to describe the faker → CommonDevice → serializer/opnsense pipeline; removed references to the deleted internal/generator and internal/validate packages. * Added "Adding a New CommonDevice Subsystem" playbook so follow-up plans (NAT, VPN, Users, ...) have a concrete onboarding path. * Rewrote cmd/root.go Long description: removed VPN/NAT feature claims that were never implemented here and now clearly list Phase 1 scope. Signed-off-by: UncleSp1d3r --- CONTRIBUTING.md | 78 ++++++++------ GOTCHAS.md | 14 +++ cmd/root.go | 31 +++--- internal/faker/device.go | 2 +- internal/faker/dhcp.go | 3 + internal/faker/firewall.go | 6 +- internal/faker/firewall_test.go | 9 +- internal/faker/network.go | 22 ++-- internal/opnsensegen/template.go | 28 ++--- internal/serializer/opnsense/firewall.go | 15 ++- internal/serializer/opnsense/firewall_test.go | 24 ++++- internal/serializer/opnsense/interfaces.go | 12 +++ .../serializer/opnsense/serializer_test.go | 101 +++++++++++++++++- 13 files changed, 256 insertions(+), 89 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8947136..83b8ad9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,49 +81,63 @@ opnConfigGenerator uses a layered architecture separating data generation from d ```text opnConfigGenerator/ -├── cmd/ # CLI commands (Cobra) -│ ├── root.go # Root command, global flags -│ ├── generate.go # generate command (csv + xml) -│ ├── validate.go # validate command (stub) -│ └── completion.go # Shell completion +├── cmd/ # CLI commands (Cobra) +│ ├── root.go # Root command, global flags +│ ├── generate.go # generate command (xml + csv) +│ ├── validate.go # validate command (stub) +│ └── completion.go # Shell completion ├── internal/ -│ ├── errors/ # Typed errors (ConfigError, VlanError) -│ ├── netutil/ # RFC 1918 address generation/validation -│ ├── generator/ # Device-agnostic data generators -│ │ ├── vlan.go # VLAN generation with uniqueness tracking -│ │ ├── dhcp.go # DHCP config derivation -│ │ ├── firewall.go # Firewall rule generation (3 complexity levels) -│ │ ├── nat.go # NAT mapping generation -│ │ ├── vpn.go # VPN config generation (OpenVPN/WireGuard/IPsec) -│ │ ├── departments.go # 20 departments with lease time mappings -│ │ └── types.go # Shared generator types -│ ├── opnsensegen/ # OPNsense-specific serializer (uses opnDossier schema) -│ ├── csvio/ # CSV read/write with German headers -│ └── validate/ # Cross-object consistency checks -├── testdata/ # Test fixtures -├── main.go # Entry point +│ ├── errors/ # Typed errors +│ ├── netutil/ # RFC 1918 address generation/validation +│ ├── faker/ # *model.CommonDevice populator +│ │ ├── device.go # NewCommonDevice entry point +│ │ ├── options.go # Functional options +│ │ ├── rand.go # Seeded *rand.Rand + *gofakeit.Faker +│ │ ├── system.go # model.System populator +│ │ ├── network.go # WAN/LAN/VLAN interfaces + VLAN list +│ │ ├── dhcp.go # []model.DHCPScope populator +│ │ └── firewall.go # []model.FirewallRule populator +│ ├── serializer/ +│ │ └── opnsense/ # CommonDevice → OpnSenseDocument +│ │ ├── serializer.go # Serialize entry point + ErrNilDevice +│ │ ├── overlay.go # Overlay onto a base config +│ │ ├── system.go # SerializeSystem +│ │ ├── interfaces.go # SerializeInterfaces +│ │ ├── vlans.go # SerializeVLANs +│ │ ├── dhcp.go # SerializeDHCP +│ │ └── firewall.go # SerializeFilter +│ ├── opnsensegen/ # Transport only: load/parse/marshal XML +│ └── csvio/ # CSV output derived from CommonDevice +├── testdata/ # Test fixtures (base-config.xml) +├── main.go # Entry point ├── go.mod / go.sum -├── .golangci.yml # Linter configuration (50+ linters) -└── justfile # Task runner recipes +├── .golangci.yml # Linter configuration +└── justfile # Task runner recipes ``` ### Key Design Decisions -**Generators are device-agnostic.** The `internal/generator/` package produces abstract configuration data (`VlanConfig`, `FirewallRule`, `NatMapping`, etc.) with no knowledge of OPNsense XML structure. This data flows into device-specific serializers. +**`*model.CommonDevice` is the single intermediate representation.** opnDossier defines the model; this project populates it (via `internal/faker/`) and serializes it (via `internal/serializer/opnsense/`). There is no parallel type or wrapper. -**Serializers are device-specific.** `internal/opnsensegen/` maps generator output to opnDossier's `OpnSenseDocument` schema type and marshals to XML. Future device types (pfSense) would get their own serializer package (e.g., `internal/pfsensegen/`). +**Package layout reserves a pfSense sibling.** `internal/serializer/opnsense/` is organized so a future `internal/serializer/pfsense/` can plug in alongside without restructuring shared code. The CLI routes by `device.DeviceType`. -**Schema types are imported, not duplicated.** The `opnsensegen` package imports types from `github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense` directly. This guarantees the mock configs match the production schema. +**Transport is separate from serialization.** `internal/opnsensegen/` only loads, parses, and marshals XML. It does not generate or serialize. `MarshalConfig` post-processes to sort map-backed sections alphabetically (see GOTCHAS §7.1) so output is byte-stable under a fixed seed. -### Adding a New Device Type +**Schema types are imported, not duplicated.** The serializer imports types from `github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense` directly. Generated configs are structurally identical to real device exports. -When opnDossier adds a new device parser (e.g., pfSense): +### Adding a New CommonDevice Subsystem (NAT, VPN, Users, …) -1. Create `internal/pfsensegen/` with serialization logic -2. Import the appropriate schema types from opnDossier -3. Wire up a new `--device-type pfsense` flag in `cmd/generate.go` -4. Add device-specific tests with testdata fixtures -5. The existing generators (`generator/vlan.go`, etc.) should work unchanged +1. **Faker** — add `internal/faker/.go` that returns the corresponding `model.*` type, and wire it into `internal/faker/device.go`'s `NewCommonDevice`. +2. **Serializer** — add `internal/serializer/opnsense/.go` exposing `Serialize(in) opnsense.`, and wire it into both `Serialize` (`serializer.go`) and `Overlay` (`overlay.go`) so overlay replaces the subsystem wholesale. +3. **Round-trip test** — extend `TestRoundTrip` in `serializer_test.go` with per-field parity assertions on the new subsystem. A new subsystem without round-trip assertions is not in-scope for CI. +4. **GOTCHAS §7.1** — if the schema type is `map[string]T`, add the parent element name to `mapBackedSections` in `internal/opnsensegen/template.go`. + +### Adding a New Device Type (pfSense, …) + +1. Create `internal/serializer/pfsense/` mirroring `internal/serializer/opnsense/`. +2. Import the appropriate schema types (e.g., `github.com/EvilBit-Labs/opnDossier/pkg/schema/pfsense`). +3. Route from `cmd/generate.go` based on `device.DeviceType` (or an explicit flag). +4. Round-trip tests must go through opnDossier's corresponding parser. ## Code Style diff --git a/GOTCHAS.md b/GOTCHAS.md index 4fd8808..28407de 100644 --- a/GOTCHAS.md +++ b/GOTCHAS.md @@ -18,6 +18,20 @@ It is also worthwhile to check the [opnDossier GOTCHAS](https://raw.githubuserco ## 7. CommonDevice to Device Serializer +### 7.1 Map-backed XML sections emit in randomized order + +`opnsense.Interfaces` and `opnsense.Dhcpd` in `github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense` are defined as `map[string]T` with a custom `MarshalXML` that iterates the map directly. Go map iteration is randomized per encode, so a naive `xml.Marshal(doc)` emits `` and `` children in a different order on every call — even with a fixed RNG seed. + +- **Symptom:** `generate --seed 42` produces byte-different output across runs; `TestGenerateDeterministicSeed` flakes; downstream diff tooling registers spurious changes. +- **Fix:** `internal/opnsensegen/template.go` `MarshalConfig` post-processes the marshaled XML via `sortMapBackedSections`, which walks the token stream and re-emits the children of any element in `mapBackedSections` in alphabetical order by tag name. +- **When adding a new subsystem:** if the opnDossier schema type is `map[string]T`, add the parent element name to `mapBackedSections` in `internal/opnsensegen/template.go`. Slice-backed sections (VLANs, Filter.Rule, CAs, Certs) are emitted in struct-field order and do not need this treatment. + +### 7.2 Serializer must propagate every round-trip field or fail CI silently + +opnDossier's `opnsenseparser.ConvertDocument` does **not** warn on structurally-valid but semantically-empty output. If `SerializeInterfaces` drops `Interface.Type` or `Interface.Virtual`, the parser reads them back as the zero value and produces zero `ConversionWarning`s. Round-trip assertions based on counts alone will pass silently while every generated VLAN interface loses its `Virtual: true` flag. + +- **Fix:** `TestRoundTrip` in `internal/serializer/opnsense/serializer_test.go` asserts per-field parity on `Interface.Type`, `Virtual`, `Description`, `IPAddress`, `Subnet`; on `VLAN.Tag`, `VLANIf`, `PhysicalIf`, `Description`; on `DHCPScope.Range`, `Gateway`, `DNSServer`. Any new subsystem must extend this test or CI will not gate its round-trip fidelity. + ## 8. Git Tagging ### 8.1 Tag the Squash-Merge Commit on Main diff --git a/cmd/root.go b/cmd/root.go index 5588b77..b851c98 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,20 +19,23 @@ var ( var rootCmd = &cobra.Command{ Use: "opnConfigGenerator", Short: "Generate realistic OPNsense configuration files with fake data", - Long: `opnConfigGenerator is a command-line tool for generating realistic OPNsense config.xml files -populated with fake but valid network configuration data. It's designed for testing, development, -and demonstration purposes where you need realistic OPNsense configurations without exposing -sensitive network information. - -Features: - • Generate realistic VLAN configurations - • Create valid interface assignments - • Generate DHCP pools and static mappings - • Create firewall rules with proper dependencies - • Generate VPN configurations (OpenVPN, WireGuard, IPSec) - • Create NAT rules and port forwards - • Support for various output formats (XML, CSV) - • Configurable generation parameters`, + Long: `opnConfigGenerator is the reverse serializer for opnDossier's *model.CommonDevice. +It produces a valid OPNsense config.xml from a synthetic CommonDevice populated by a faker. + +Phase 1 coverage: + • System: hostname, domain, timezone, DNS/NTP servers + • Interfaces: WAN (DHCP), LAN (static RFC 1918), per-VLAN opt interfaces + • VLANs with unique 802.1Q tags on a shared physical parent + • DHCP scopes per statically-addressed interface (ISC DHCP) + • Default allow firewall rules per non-WAN interface (opt-in) + +Deferred to follow-up plans: NAT, VPN (OpenVPN/WireGuard/IPsec), Users/Groups, +Certificates, IDS, HighAvailability, VirtualIPs, Bridges, GIF/GRE/LAGG, PPP, +CaptivePortal, Kea DHCP, Monit, Netflow, TrafficShaper, pfSense target. + +Zero arguments emits a valid config.xml on stdout. With --base-config, the +serializer overlays generated content onto an existing document, preserving +fields Phase 1 does not own.`, PersistentPreRun: func(_ *cobra.Command, _ []string) { // Set up logging based on flags and environment setupLogging() diff --git a/internal/faker/device.go b/internal/faker/device.go index 7cb1e7b..6f369bc 100644 --- a/internal/faker/device.go +++ b/internal/faker/device.go @@ -28,7 +28,7 @@ func NewCommonDevice(opts ...Option) *model.CommonDevice { var fwRules []model.FirewallRule if cfg.firewallRules { - fwRules = fakeFirewallRules(f, net.Interfaces) + fwRules = fakeFirewallRules(net.Interfaces) } return &model.CommonDevice{ diff --git a/internal/faker/dhcp.go b/internal/faker/dhcp.go index b7532f3..04f7ec4 100644 --- a/internal/faker/dhcp.go +++ b/internal/faker/dhcp.go @@ -6,6 +6,7 @@ import ( "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/charmbracelet/log" ) // fakeDHCPScopes emits one DHCP scope per statically-addressed interface. @@ -19,6 +20,8 @@ func fakeDHCPScopes(interfaces []model.Interface) []model.DHCPScope { } prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet)) if err != nil { + log.Warn("skipping DHCP scope for interface with unparseable prefix", + "interface", iface.Name, "ip", iface.IPAddress, "subnet", iface.Subnet, "err", err) continue } scopes = append(scopes, model.DHCPScope{ diff --git a/internal/faker/firewall.go b/internal/faker/firewall.go index 71ec1b8..2c6608c 100644 --- a/internal/faker/firewall.go +++ b/internal/faker/firewall.go @@ -2,17 +2,13 @@ package faker import ( "github.com/EvilBit-Labs/opnDossier/pkg/model" - "github.com/brianvoe/gofakeit/v7" ) // fakeFirewallRules emits a minimal default ruleset: one pass rule per // non-WAN interface, sourcing from that interface's network to "any". This // matches OPNsense's out-of-the-box LAN default and is the smallest ruleset // that produces a semantically useful config.xml. -// -// The *gofakeit.Faker parameter is reserved for future diversification; the -// current ruleset is deterministic. -func fakeFirewallRules(_ *gofakeit.Faker, interfaces []model.Interface) []model.FirewallRule { +func fakeFirewallRules(interfaces []model.Interface) []model.FirewallRule { rules := make([]model.FirewallRule, 0, len(interfaces)) for _, iface := range interfaces { if iface.Name == "wan" { diff --git a/internal/faker/firewall_test.go b/internal/faker/firewall_test.go index 940c315..99e2932 100644 --- a/internal/faker/firewall_test.go +++ b/internal/faker/firewall_test.go @@ -15,9 +15,8 @@ func TestFakeFirewallRulesDefaultAllowLAN(t *testing.T) { {Name: "wan", Type: "dhcp"}, {Name: "lan", Type: "static"}, } - _, f := newRand(1) - rules := fakeFirewallRules(f, interfaces) + rules := fakeFirewallRules(interfaces) require.Len(t, rules, 1, "WAN excluded, LAN emits one rule") r := rules[0] @@ -32,14 +31,12 @@ func TestFakeFirewallRulesDefaultAllowLAN(t *testing.T) { func TestFakeFirewallRulesNoInterfacesNoRules(t *testing.T) { t.Parallel() - _, f := newRand(1) - assert.Empty(t, fakeFirewallRules(f, nil)) + assert.Empty(t, fakeFirewallRules(nil)) } func TestFakeFirewallRulesOnlyWANNoRules(t *testing.T) { t.Parallel() - _, f := newRand(1) - rules := fakeFirewallRules(f, []model.Interface{{Name: "wan"}}) + rules := fakeFirewallRules([]model.Interface{{Name: "wan"}}) assert.Empty(t, rules) } diff --git a/internal/faker/network.go b/internal/faker/network.go index 13eebcc..73e8b59 100644 --- a/internal/faker/network.go +++ b/internal/faker/network.go @@ -23,17 +23,17 @@ const ( baseInterfaceCount = 2 ) -// NetworkResult groups everything the network faker produces so callers +// networkResult groups everything the network faker produces so callers // stitch it into a CommonDevice in one step. -type NetworkResult struct { +type networkResult struct { Interfaces []model.Interface VLANs []model.VLAN } // fakeNetwork produces the standard WAN + LAN pair plus vlanCount opt // interfaces, each backed by a unique VLAN tag and RFC 1918 /24 network. -func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) NetworkResult { - result := NetworkResult{ +func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) networkResult { + result := networkResult{ Interfaces: make([]model.Interface, 0, baseInterfaceCount+vlanCount), VLANs: make([]model.VLAN, 0, vlanCount), } @@ -91,8 +91,14 @@ func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) NetworkResult return result } +// maxPickAttempts bounds the coupon-collector loops below so a shrinking +// pool or an off-by-one in the callers cannot hang the CLI indefinitely. +// Tags have a 4093-slot pool, the RFC 1918 /24 space has ~68K slots — either +// attempts <= 10 * pool-size is far more than a deterministic run needs. +const maxPickAttempts = 100_000 + func pickUniqueTag(rng *rand.Rand, used map[uint16]bool) uint16 { - for { + for range maxPickAttempts { //nolint:gosec // Fake data; IntN bounded to uint16 range below. tag := uint16(vlanTagMin + rng.IntN(vlanTagMax-vlanTagMin+1)) if !used[tag] { @@ -100,14 +106,18 @@ func pickUniqueTag(rng *rand.Rand, used map[uint16]bool) uint16 { return tag } } + panic(fmt.Sprintf("faker: exhausted %d attempts picking a unique VLAN tag (used=%d of %d)", + maxPickAttempts, len(used), vlanTagMax-vlanTagMin+1)) } func pickUniqueNet(rng *rand.Rand, used map[string]bool) netip.Prefix { - for { + for range maxPickAttempts { n := netutil.GenerateRandomNetwork(rng) if !used[n.String()] { used[n.String()] = true return n } } + panic(fmt.Sprintf("faker: exhausted %d attempts picking a unique RFC 1918 /24 network (used=%d)", + maxPickAttempts, len(used))) } diff --git a/internal/opnsensegen/template.go b/internal/opnsensegen/template.go index 9679dea..74079f7 100644 --- a/internal/opnsensegen/template.go +++ b/internal/opnsensegen/template.go @@ -50,13 +50,13 @@ func ParseConfig(data []byte) (*opnsense.OpnSenseDocument, error) { // two-space indentation, and a trailing newline. Children of map-backed // sections (interfaces, dhcpd) are sorted alphabetically so output is // byte-stable under a fixed seed. +// +// The entire document is assembled in memory before the first Write to w so +// that any encoding or stabilization error leaves the destination untouched +// rather than emitting a truncated file. func MarshalConfig(cfg *opnsense.OpnSenseDocument, w io.Writer) error { - if _, err := io.WriteString(w, xml.Header); err != nil { - return fmt.Errorf("write XML header: %w", err) - } - - var buf bytes.Buffer - enc := xml.NewEncoder(&buf) + var body bytes.Buffer + enc := xml.NewEncoder(&body) enc.Indent("", " ") if err := enc.Encode(cfg); err != nil { return fmt.Errorf("encode config XML: %w", err) @@ -65,18 +65,20 @@ func MarshalConfig(cfg *opnsense.OpnSenseDocument, w io.Writer) error { return fmt.Errorf("flush XML encoder: %w", err) } - stable, err := sortMapBackedSections(buf.Bytes()) + stable, err := sortMapBackedSections(body.Bytes()) if err != nil { return fmt.Errorf("stabilize XML: %w", err) } - if _, err := w.Write(stable); err != nil { - return fmt.Errorf("write XML body: %w", err) - } - if _, err := io.WriteString(w, "\n"); err != nil { - return fmt.Errorf("write trailing newline: %w", err) - } + var out bytes.Buffer + out.Grow(len(xml.Header) + len(stable) + 1) + out.WriteString(xml.Header) + out.Write(stable) + out.WriteByte('\n') + if _, err := w.Write(out.Bytes()); err != nil { + return fmt.Errorf("write XML: %w", err) + } return nil } diff --git a/internal/serializer/opnsense/firewall.go b/internal/serializer/opnsense/firewall.go index 1bf39e8..f6b74bd 100644 --- a/internal/serializer/opnsense/firewall.go +++ b/internal/serializer/opnsense/firewall.go @@ -27,13 +27,16 @@ func SerializeFilter(rules []model.FirewallRule) opnsense.Filter { return out } -// endpointToSource maps model.RuleEndpoint onto opnsense.Source. Address -// "any" becomes the presence-flag form (); a non-empty Network-style -// label or CIDR lands in Source.Network. +// endpointToSource maps model.RuleEndpoint onto opnsense.Source. Only +// Address == "any" becomes the presence-flag form (). Empty Address +// leaves Source with all match fields unset — OPNsense treats that as +// "no match specified", which is distinct from "explicitly any". func endpointToSource(ep model.RuleEndpoint) opnsense.Source { s := opnsense.Source{Port: ep.Port} switch ep.Address { - case "", opnsense.NetworkAny: + case "": + // Leave Any/Network/Address unset. + case opnsense.NetworkAny: empty := "" s.Any = &empty default: @@ -49,7 +52,9 @@ func endpointToSource(ep model.RuleEndpoint) opnsense.Source { func endpointToDestination(ep model.RuleEndpoint) opnsense.Destination { d := opnsense.Destination{Port: ep.Port} switch ep.Address { - case "", opnsense.NetworkAny: + case "": + // Leave Any/Network/Address unset. + case opnsense.NetworkAny: empty := "" d.Any = &empty default: diff --git a/internal/serializer/opnsense/firewall_test.go b/internal/serializer/opnsense/firewall_test.go index 1cfb022..e02f10c 100644 --- a/internal/serializer/opnsense/firewall_test.go +++ b/internal/serializer/opnsense/firewall_test.go @@ -40,7 +40,7 @@ func TestSerializeFilterEmpty(t *testing.T) { assert.Empty(t, serializer.SerializeFilter(nil).Rule) } -func TestSerializeFilterEmptyEndpointDefaultsToAny(t *testing.T) { +func TestSerializeFilterEmptyEndpointStaysEmpty(t *testing.T) { t.Parallel() in := []model.FirewallRule{{ @@ -52,6 +52,26 @@ func TestSerializeFilterEmptyEndpointDefaultsToAny(t *testing.T) { out := serializer.SerializeFilter(in) require.Len(t, out.Rule, 1) - require.NotNil(t, out.Rule[0].Source.Any, "empty source address → ") + // Empty source (Address=="") is distinct from "any"; Any pointer stays nil. + assert.Nil(t, out.Rule[0].Source.Any) + assert.Empty(t, out.Rule[0].Source.Network) + assert.Empty(t, out.Rule[0].Source.Address) assert.Equal(t, "10.0.0.0/24", out.Rule[0].Destination.Network) } + +func TestSerializeFilterExplicitAnyEmitsAnyElement(t *testing.T) { + t.Parallel() + + in := []model.FirewallRule{{ + Interfaces: []string{"lan"}, + Type: model.RuleTypePass, + Source: model.RuleEndpoint{Address: "lan"}, + Destination: model.RuleEndpoint{Address: "any"}, + }} + + out := serializer.SerializeFilter(in) + require.Len(t, out.Rule, 1) + assert.Equal(t, "lan", out.Rule[0].Source.Network) + assert.Nil(t, out.Rule[0].Source.Any, "explicit source network must not emit ") + require.NotNil(t, out.Rule[0].Destination.Any, "explicit 'any' destination must emit ") +} diff --git a/internal/serializer/opnsense/interfaces.go b/internal/serializer/opnsense/interfaces.go index 6ed5dfc..b1411b2 100644 --- a/internal/serializer/opnsense/interfaces.go +++ b/internal/serializer/opnsense/interfaces.go @@ -5,8 +5,16 @@ import ( "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" ) +// virtualIfaceMarker is the opnsense.Interface.Virtual sentinel meaning +// "virtual interface" (int-valued in the opnDossier schema; 1 == virtual). +const virtualIfaceMarker = 1 + // SerializeInterfaces maps a CommonDevice interface slice onto the OPNsense // map-based Interfaces container, keyed by interface Name (wan, lan, opt*). +// +// Type and Virtual are propagated verbatim because opnDossier's ConvertDocument +// reads them back on the reverse trip (iface.Virtual != 0, iface.Type +// verbatim). Dropping either silently breaks round-trip parity. func SerializeInterfaces(in []model.Interface) opnsense.Interfaces { items := make(map[string]opnsense.Interface, len(in)) for _, iface := range in { @@ -14,10 +22,14 @@ func SerializeInterfaces(in []model.Interface) opnsense.Interfaces { If: iface.PhysicalIf, Descr: iface.Description, MTU: iface.MTU, + Type: iface.Type, } if iface.Enabled { out.Enable = "1" } + if iface.Virtual { + out.Virtual = virtualIfaceMarker + } switch iface.Type { case "dhcp": out.IPAddr = "dhcp" diff --git a/internal/serializer/opnsense/serializer_test.go b/internal/serializer/opnsense/serializer_test.go index e5c5c96..546b735 100644 --- a/internal/serializer/opnsense/serializer_test.go +++ b/internal/serializer/opnsense/serializer_test.go @@ -15,8 +15,9 @@ import ( // TestRoundTrip is the primary acceptance gate for R1. It exercises the // full pipeline: faker → Serialize → MarshalConfig → ParseConfig → -// ConvertDocument. The CommonDevice that comes out must match the one that -// went in on the fields Phase 1 covers. +// ConvertDocument, then asserts per-field parity on every field Phase 1 +// claims to cover. Count-only assertions were insufficient — they missed +// silent field drops (e.g., Interface.Type, Interface.Virtual). func TestRoundTrip(t *testing.T) { t.Parallel() @@ -41,17 +42,107 @@ func TestRoundTrip(t *testing.T) { require.NotNil(t, roundTripped) assert.Equal(t, model.DeviceTypeOPNsense, roundTripped.DeviceType) + + // System parity. assert.Equal(t, original.System.Hostname, roundTripped.System.Hostname) assert.Equal(t, original.System.Domain, roundTripped.System.Domain) - assert.Len(t, roundTripped.VLANs, len(original.VLANs)) - assert.Len(t, roundTripped.Interfaces, len(original.Interfaces)) - assert.Len(t, roundTripped.DHCP, len(original.DHCP)) + + // Interface per-field parity, keyed by Name. + require.Len(t, roundTripped.Interfaces, len(original.Interfaces)) + origIfaces := interfacesByName(original.Interfaces) + rtIfaces := interfacesByName(roundTripped.Interfaces) + for name, want := range origIfaces { + got, ok := rtIfaces[name] + require.Truef(t, ok, "interface %q missing on round-trip", name) + assert.Equalf(t, want.Type, got.Type, "interface %q Type", name) + assert.Equalf(t, want.Virtual, got.Virtual, "interface %q Virtual", name) + assert.Equalf(t, want.Description, got.Description, "interface %q Description", name) + if want.Type == "static" { + assert.Equalf(t, want.IPAddress, got.IPAddress, "interface %q IPAddress", name) + assert.Equalf(t, want.Subnet, got.Subnet, "interface %q Subnet", name) + } + } + + // VLAN per-field parity, keyed by Tag because order is not guaranteed. + require.Len(t, roundTripped.VLANs, len(original.VLANs)) + origVLANs := vlansByTag(original.VLANs) + rtVLANs := vlansByTag(roundTripped.VLANs) + for tag, want := range origVLANs { + got, ok := rtVLANs[tag] + require.Truef(t, ok, "VLAN %q missing on round-trip", tag) + assert.Equalf(t, want.VLANIf, got.VLANIf, "VLAN %q VLANIf", tag) + assert.Equalf(t, want.PhysicalIf, got.PhysicalIf, "VLAN %q PhysicalIf", tag) + assert.Equalf(t, want.Description, got.Description, "VLAN %q Description", tag) + } + + // DHCP scope parity, keyed by interface name. + require.Len(t, roundTripped.DHCP, len(original.DHCP)) + origDHCP := dhcpByInterface(original.DHCP) + rtDHCP := dhcpByInterface(roundTripped.DHCP) + for iface, want := range origDHCP { + got, ok := rtDHCP[iface] + require.Truef(t, ok, "DHCP scope for %q missing on round-trip", iface) + assert.Equalf(t, want.Range.From, got.Range.From, "DHCP %q Range.From", iface) + assert.Equalf(t, want.Range.To, got.Range.To, "DHCP %q Range.To", iface) + assert.Equalf(t, want.Gateway, got.Gateway, "DHCP %q Gateway", iface) + assert.Equalf(t, want.DNSServer, got.DNSServer, "DHCP %q DNSServer", iface) + } + assert.Len(t, roundTripped.FirewallRules, len(original.FirewallRules)) } +// TestRoundTripByteStable asserts MarshalConfig output is byte-identical +// across repeated calls on the same input. A single repeat is not enough +// to catch a regression in sortMapBackedSections because Go's map iteration +// is randomized per-encode; 10 iterations provide high confidence. +func TestRoundTripByteStable(t *testing.T) { + t.Parallel() + + device := faker.NewCommonDevice( + faker.WithSeed(99), + faker.WithVLANCount(4), + faker.WithFirewallRules(true), + ) + doc, err := serializer.Serialize(device) + require.NoError(t, err) + + var first bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(doc, &first)) + + for i := range 10 { + var next bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(doc, &next)) + require.Equalf(t, first.Bytes(), next.Bytes(), "marshal #%d diverged", i+1) + } +} + func TestSerializeNilDevice(t *testing.T) { t.Parallel() _, err := serializer.Serialize(nil) require.ErrorIs(t, err, serializer.ErrNilDevice) } + +func interfacesByName(xs []model.Interface) map[string]model.Interface { + m := make(map[string]model.Interface, len(xs)) + for _, x := range xs { + m[x.Name] = x + } + return m +} + +func vlansByTag(xs []model.VLAN) map[string]model.VLAN { + m := make(map[string]model.VLAN, len(xs)) + for _, x := range xs { + m[x.Tag] = x + } + return m +} + +func dhcpByInterface(xs []model.DHCPScope) map[string]model.DHCPScope { + m := make(map[string]model.DHCPScope, len(xs)) + for _, x := range xs { + m[x.Interface] = x + } + return m +} From 3756d969ce7e3220caf686a5a9777f01b9ea8f14 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 03:14:41 -0400 Subject: [PATCH 09/11] fix: surface faker exhaustion as error; harden XML stabilizer and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #9 secondary review findings beyond the initial ce:review pass. Reliability: * pickUniqueTag and pickUniqueNet now return (value, error) instead of panicking on exhaustion. fakeNetwork, fakeDHCPScopes, and NewCommonDevice propagate the error. cmd/generate wraps it as "generate device: %w" so a pathological seed + vlan-count surfaces as a clean CLI error instead of a Go stack trace. * fakeDHCPScopes upgrades the ParsePrefix failure from log.Warn (silently suppressed by --quiet) to a returned error. An unparseable IP/subnet in the faker's input is a bug, not a recoverable condition. * emitSortedChildren now warns when it drops non-whitespace tokens (comments, CDATA, processing instructions, directives) between children of a map-backed section. Operators authoring an annotated base config see the drop rather than discovering it on a diff. Tests: * TestRoundTrip now asserts per-field parity on FirewallRules (Type/Description/IPProtocol/Source.Address/Destination.Address) to match the parity checks on Interface/VLAN/DHCP. * TestOverlayPreservesBaseConfigUnrelatedFields now populates Sysctl and CAs in the base and asserts they survive the overlay. The old empty-base fixture couldn't detect a field-preservation regression. * TestMarshalConfigIsAtomicOnSuccess verifies exactly one Write call on success; TestMarshalConfigDoesNotWriteOnWriterFailure pins zero partial output on failure. Both lock in the "atomic or nothing" contract that MarshalConfig's docstring promises. * TestMarshalConfigSortsMapBackedSections feeds interfaces named zeta/alpha/mu and asserts alphabetical order in the output — a direct unit test for the sort post-processor instead of proxying through the faker. * TestMarshalConfigHandlesEmptyMapBackedSections covers empty interfaces and dhcpd sections. * TestMarshalConfigByteStableMapIteration runs 20 re-marshals (up from 10 in the previous serializer test) as a stronger defeat-randomization guard. * TestPickUniqueTagReturnsErrorOnExhaustion pre-populates the used set with every valid tag and asserts the error path. * TestFakeDHCPScopesErrorOnUnparseablePrefix pins the new error-returning contract. * TestNewCommonDeviceFuzzSeeds loops 200 distinct seeds with 8 VLANs to catch regressions under adversarial seed streams. * cmd/cmd_test.go adds boundary tests: --vlan-count 0, 1, 4093 (reject), malformed --base-config XML, and a pin on Overlay's wholesale-replace semantic for . Docs / comments: * cmd/validate.go Long no longer advertises features the subcommand doesn't have. It now states "not yet implemented — reserved for a future phase". * internal/faker/rand.go drops the duplicated seed-zero prose and points to WithSeed as the single source of truth. * internal/faker/firewall.go clarifies that a bare interface name in RuleEndpoint.Address relies on OPNsense's network-alias resolution. Signed-off-by: UncleSp1d3r --- cmd/cmd_test.go | 92 ++++++++++++ cmd/generate.go | 5 +- cmd/validate.go | 25 +--- internal/csvio/csvio_test.go | 3 +- internal/faker/device.go | 21 ++- internal/faker/device_test.go | 36 ++++- internal/faker/dhcp.go | 15 +- internal/faker/dhcp_test.go | 17 ++- internal/faker/firewall.go | 8 +- internal/faker/network.go | 43 ++++-- internal/faker/network_test.go | 31 +++- internal/faker/rand.go | 3 +- internal/opnsensegen/commondevice_test.go | 3 +- internal/opnsensegen/template.go | 26 +++- internal/opnsensegen/template_test.go | 133 ++++++++++++++++++ internal/serializer/opnsense/overlay_test.go | 20 ++- .../serializer/opnsense/serializer_test.go | 32 ++++- 17 files changed, 437 insertions(+), 76 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 996f79b..ce1422a 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -317,6 +317,98 @@ func TestGenerateInvalidVlanCount(t *testing.T) { assert.Contains(t, err.Error(), "--vlan-count") } +func TestGenerateVlanCountZero(t *testing.T) { + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "zero.xml") + + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", + "--vlan-count", "0", + "--seed", "1", + "--output", outPath, + ) + require.NoError(t, err) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "") + // No children in the section. + assert.NotContains(t, content, "") +} + +func TestGenerateVlanCountOne(t *testing.T) { + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "one.xml") + + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", + "--vlan-count", "1", + "--seed", "1", + "--output", outPath, + ) + require.NoError(t, err) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(data), "")) +} + +func TestGenerateVlanCountExceedsMax(t *testing.T) { + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", "--vlan-count", "4093") + require.Error(t, err) + assert.Contains(t, err.Error(), "--vlan-count") + assert.Contains(t, err.Error(), "4092") +} + +func TestGenerateBaseConfigMalformed(t *testing.T) { + tmpDir := t.TempDir() + badPath := filepath.Join(tmpDir, "bad.xml") + require.NoError(t, os.WriteFile(badPath, []byte("not section with an empty one from + // the device. Pin this behavior so a future shift to merge-semantics + // is a deliberate, test-visible change. + tmpDir := t.TempDir() + basePath := filepath.Join(tmpDir, "base.xml") + base := ` + + 1.0 + basebase.test + + + block + from base — must be dropped on wholesale overlay + + +` + require.NoError(t, os.WriteFile(basePath, []byte(base), 0o600)) + + outPath := filepath.Join(tmpDir, "overlay.xml") + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", + "--base-config", basePath, + "--seed", "1", + "--output", outPath, + ) + require.NoError(t, err) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + content := string(data) + assert.NotContains(t, content, "must be dropped on wholesale overlay", + "base Filter.Rule must be replaced wholesale when overlaying without --firewall-rules") +} + func TestGenerateHostnameAndDomainOverride(t *testing.T) { tmpDir := t.TempDir() outPath := filepath.Join(tmpDir, "named.xml") diff --git a/cmd/generate.go b/cmd/generate.go index 002bc62..58bbeec 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -83,13 +83,16 @@ func runGenerate(_ *cobra.Command, _ []string) (err error) { return fmt.Errorf("--vlan-count must be between 0 and %d, got %d", maxVlanCount, vlanCount) } - device := faker.NewCommonDevice( + device, err := faker.NewCommonDevice( faker.WithSeed(seed), faker.WithVLANCount(vlanCount), faker.WithFirewallRules(includeFirewall), faker.WithHostname(hostnameOverride), faker.WithDomain(domainOverride), ) + if err != nil { + return fmt.Errorf("generate device: %w", err) + } w, needClose, err := getOutputWriter() if err != nil { diff --git a/cmd/validate.go b/cmd/validate.go index 45eb71a..2cfa51d 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -17,28 +17,11 @@ var ( var validateCmd = &cobra.Command{ Use: "validate", Short: "Validate OPNsense configuration files", - Long: `Validate OPNsense configuration files for correctness and compliance. + Long: `Validate OPNsense configuration files. -This command validates OPNsense configuration files against the OPNsense schema -and checks for common configuration errors, conflicts, and best practices. - -The validator can detect: - • XML schema violations - • Network configuration conflicts (IP overlaps, VLAN conflicts) - • Invalid interface assignments - • Malformed firewall rules - • Missing required dependencies - • Security misconfigurations - -Examples: - # Validate an OPNsense config.xml file - opnConfigGenerator validate --input config.xml - - # Validate with format auto-detection - opnConfigGenerator validate --input network-config.xml --format xml - - # Limit error reporting - opnConfigGenerator validate --input config.xml --max-errors 5`, +Not yet implemented — this subcommand is reserved for a future phase and +currently returns an error. Flags are defined only so they are stable when +implementation lands.`, RunE: func(_ *cobra.Command, _ []string) error { return errors.New("validate command not yet implemented") }, diff --git a/internal/csvio/csvio_test.go b/internal/csvio/csvio_test.go index 8c46bc9..b000c10 100644 --- a/internal/csvio/csvio_test.go +++ b/internal/csvio/csvio_test.go @@ -15,7 +15,8 @@ import ( func TestWriteVlanCSVFromCommonDevice(t *testing.T) { t.Parallel() - dev := faker.NewCommonDevice(faker.WithSeed(7), faker.WithVLANCount(2)) + dev, err := faker.NewCommonDevice(faker.WithSeed(7), faker.WithVLANCount(2)) + require.NoError(t, err) var buf bytes.Buffer require.NoError(t, csvio.WriteVlanCSV(&buf, dev)) diff --git a/internal/faker/device.go b/internal/faker/device.go index 6f369bc..907577d 100644 --- a/internal/faker/device.go +++ b/internal/faker/device.go @@ -1,13 +1,19 @@ package faker import ( + "fmt" + "github.com/EvilBit-Labs/opnDossier/pkg/model" ) // NewCommonDevice returns a fully-populated *model.CommonDevice ready for // serialization. All randomness is seeded from the Option set; with a fixed // WithSeed value the function is deterministic. -func NewCommonDevice(opts ...Option) *model.CommonDevice { +// +// Returns an error when the VLAN tag or RFC 1918 /24 uniqueness pool is +// exhausted (e.g., extreme VLAN counts with a small pool). Callers should +// propagate the error to the user rather than retrying blindly. +func NewCommonDevice(opts ...Option) (*model.CommonDevice, error) { cfg := &config{} for _, opt := range opts { opt(cfg) @@ -23,8 +29,15 @@ func NewCommonDevice(opts ...Option) *model.CommonDevice { sys.Domain = cfg.domain } - net := fakeNetwork(rng, f, cfg.vlanCount) - dhcp := fakeDHCPScopes(net.Interfaces) + net, err := fakeNetwork(rng, f, cfg.vlanCount) + if err != nil { + return nil, fmt.Errorf("generate network topology: %w", err) + } + + dhcp, err := fakeDHCPScopes(net.Interfaces) + if err != nil { + return nil, fmt.Errorf("generate DHCP scopes: %w", err) + } var fwRules []model.FirewallRule if cfg.firewallRules { @@ -38,5 +51,5 @@ func NewCommonDevice(opts ...Option) *model.CommonDevice { VLANs: net.VLANs, DHCP: dhcp, FirewallRules: fwRules, - } + }, nil } diff --git a/internal/faker/device_test.go b/internal/faker/device_test.go index f893fc0..0f98683 100644 --- a/internal/faker/device_test.go +++ b/internal/faker/device_test.go @@ -11,7 +11,8 @@ import ( func TestNewCommonDeviceDefaults(t *testing.T) { t.Parallel() - dev := NewCommonDevice() + dev, err := NewCommonDevice() + require.NoError(t, err) require.NotNil(t, dev) assert.Equal(t, model.DeviceTypeOPNsense, dev.DeviceType) assert.NotEmpty(t, dev.System.Hostname) @@ -26,15 +27,18 @@ func TestNewCommonDeviceDefaults(t *testing.T) { func TestNewCommonDeviceDeterministic(t *testing.T) { t.Parallel() - a := NewCommonDevice(WithSeed(99), WithVLANCount(3)) - b := NewCommonDevice(WithSeed(99), WithVLANCount(3)) + a, err := NewCommonDevice(WithSeed(99), WithVLANCount(3)) + require.NoError(t, err) + b, err := NewCommonDevice(WithSeed(99), WithVLANCount(3)) + require.NoError(t, err) assert.Equal(t, a, b, "same seed + options must produce equal devices") } func TestNewCommonDeviceVLANCount(t *testing.T) { t.Parallel() - dev := NewCommonDevice(WithSeed(1), WithVLANCount(4)) + dev, err := NewCommonDevice(WithSeed(1), WithVLANCount(4)) + require.NoError(t, err) assert.Len(t, dev.VLANs, 4) assert.Len(t, dev.Interfaces, 2+4, "WAN + LAN + 4 opt interfaces") assert.Len(t, dev.DHCP, 5, "LAN + 4 opts each carry a DHCP scope") @@ -43,17 +47,35 @@ func TestNewCommonDeviceVLANCount(t *testing.T) { func TestNewCommonDeviceFirewallRulesOptIn(t *testing.T) { t.Parallel() - without := NewCommonDevice(WithSeed(1)) + without, err := NewCommonDevice(WithSeed(1)) + require.NoError(t, err) assert.Empty(t, without.FirewallRules) - with := NewCommonDevice(WithSeed(1), WithFirewallRules(true)) + with, err := NewCommonDevice(WithSeed(1), WithFirewallRules(true)) + require.NoError(t, err) assert.NotEmpty(t, with.FirewallRules) } func TestNewCommonDeviceHostnameAndDomainOverride(t *testing.T) { t.Parallel() - dev := NewCommonDevice(WithSeed(1), WithHostname("gateway"), WithDomain("example.test")) + dev, err := NewCommonDevice(WithSeed(1), WithHostname("gateway"), WithDomain("example.test")) + require.NoError(t, err) assert.Equal(t, "gateway", dev.System.Hostname) assert.Equal(t, "example.test", dev.System.Domain) } + +// TestNewCommonDeviceFuzzSeeds exercises the faker across many distinct seeds +// to catch any regression that would silently produce empty output or trigger +// the pickUnique* exhaustion paths under adversarial seed streams. +func TestNewCommonDeviceFuzzSeeds(t *testing.T) { + t.Parallel() + + for seed := int64(1); seed <= 200; seed++ { + dev, err := NewCommonDevice(WithSeed(seed), WithVLANCount(8)) + require.NoErrorf(t, err, "seed %d produced error", seed) + require.NotNilf(t, dev, "seed %d produced nil device", seed) + assert.Lenf(t, dev.VLANs, 8, "seed %d wrong VLAN count", seed) + assert.Lenf(t, dev.Interfaces, 10, "seed %d wrong interface count (WAN+LAN+8 opts)", seed) + } +} diff --git a/internal/faker/dhcp.go b/internal/faker/dhcp.go index 04f7ec4..513619f 100644 --- a/internal/faker/dhcp.go +++ b/internal/faker/dhcp.go @@ -6,13 +6,15 @@ import ( "github.com/EvilBit-Labs/opnConfigGenerator/internal/netutil" "github.com/EvilBit-Labs/opnDossier/pkg/model" - "github.com/charmbracelet/log" ) // fakeDHCPScopes emits one DHCP scope per statically-addressed interface. // WAN (type="dhcp") is excluded because DHCP servers run on downstream -// interfaces, not on the uplink. -func fakeDHCPScopes(interfaces []model.Interface) []model.DHCPScope { +// interfaces, not on the uplink. An unparseable IP/subnet pair on any +// interface is a programmer error (the faker or an external CommonDevice +// producer malformed its inputs) and is surfaced as a returned error +// rather than a silent skip. +func fakeDHCPScopes(interfaces []model.Interface) ([]model.DHCPScope, error) { scopes := make([]model.DHCPScope, 0, len(interfaces)) for _, iface := range interfaces { if iface.Type != "static" || iface.IPAddress == "" || iface.Subnet == "" { @@ -20,9 +22,8 @@ func fakeDHCPScopes(interfaces []model.Interface) []model.DHCPScope { } prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet)) if err != nil { - log.Warn("skipping DHCP scope for interface with unparseable prefix", - "interface", iface.Name, "ip", iface.IPAddress, "subnet", iface.Subnet, "err", err) - continue + return nil, fmt.Errorf("interface %q has unparseable prefix %s/%s: %w", + iface.Name, iface.IPAddress, iface.Subnet, err) } scopes = append(scopes, model.DHCPScope{ Interface: iface.Name, @@ -35,5 +36,5 @@ func fakeDHCPScopes(interfaces []model.Interface) []model.DHCPScope { DNSServer: iface.IPAddress, }) } - return scopes + return scopes, nil } diff --git a/internal/faker/dhcp_test.go b/internal/faker/dhcp_test.go index 1ecc0b0..c3f9720 100644 --- a/internal/faker/dhcp_test.go +++ b/internal/faker/dhcp_test.go @@ -18,7 +18,8 @@ func TestFakeDHCPScopesOnePerStaticInterface(t *testing.T) { {Name: "opt1", Type: "static", IPAddress: "10.0.0.1", Subnet: "24", Virtual: true}, } - scopes := fakeDHCPScopes(interfaces) + scopes, err := fakeDHCPScopes(interfaces) + require.NoError(t, err) require.Len(t, scopes, 2, "WAN excluded; LAN + opt1 each produce one scope") names := []string{scopes[0].Interface, scopes[1].Interface} @@ -40,10 +41,22 @@ func TestFakeDHCPScopesOnePerStaticInterface(t *testing.T) { func TestFakeDHCPScopesSkipsWhenFieldsMissing(t *testing.T) { t.Parallel() - scopes := fakeDHCPScopes([]model.Interface{ + scopes, err := fakeDHCPScopes([]model.Interface{ {Name: "lan", Type: "static", IPAddress: "", Subnet: "24"}, {Name: "opt1", Type: "static", IPAddress: "10.0.0.1", Subnet: ""}, {Name: "opt2", Type: "none"}, }) + require.NoError(t, err) assert.Empty(t, scopes, "interfaces with missing fields produce no scope") } + +func TestFakeDHCPScopesErrorOnUnparseablePrefix(t *testing.T) { + t.Parallel() + + _, err := fakeDHCPScopes([]model.Interface{ + {Name: "bad", Type: "static", IPAddress: "not-an-ip", Subnet: "24"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unparseable prefix") + assert.Contains(t, err.Error(), "bad") +} diff --git a/internal/faker/firewall.go b/internal/faker/firewall.go index 2c6608c..04ecb0e 100644 --- a/internal/faker/firewall.go +++ b/internal/faker/firewall.go @@ -5,9 +5,11 @@ import ( ) // fakeFirewallRules emits a minimal default ruleset: one pass rule per -// non-WAN interface, sourcing from that interface's network to "any". This -// matches OPNsense's out-of-the-box LAN default and is the smallest ruleset -// that produces a semantically useful config.xml. +// non-WAN interface, sourcing from that interface's network (OPNsense +// resolves a bare interface name like "lan" into the alias for the network +// behind that interface) to "any". This matches OPNsense's out-of-the-box +// LAN default and is the smallest ruleset that produces a semantically +// useful config.xml. func fakeFirewallRules(interfaces []model.Interface) []model.FirewallRule { rules := make([]model.FirewallRule, 0, len(interfaces)) for _, iface := range interfaces { diff --git a/internal/faker/network.go b/internal/faker/network.go index 73e8b59..8426a33 100644 --- a/internal/faker/network.go +++ b/internal/faker/network.go @@ -32,7 +32,11 @@ type networkResult struct { // fakeNetwork produces the standard WAN + LAN pair plus vlanCount opt // interfaces, each backed by a unique VLAN tag and RFC 1918 /24 network. -func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) networkResult { +// +// Returns an error if the uniqueness pool for tags or /24 networks is +// exhausted before every requested VLAN is populated. Callers should +// propagate this to the user as a CLI error rather than panicking. +func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) (networkResult, error) { result := networkResult{ Interfaces: make([]model.Interface, 0, baseInterfaceCount+vlanCount), VLANs: make([]model.VLAN, 0, vlanCount), @@ -62,8 +66,14 @@ func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) networkResult usedNets := map[string]bool{lanNet.String(): true} for i := range vlanCount { - tag := pickUniqueTag(rng, usedTags) - net := pickUniqueNet(rng, usedNets) + tag, err := pickUniqueTag(rng, usedTags) + if err != nil { + return networkResult{}, err + } + net, err := pickUniqueNet(rng, usedNets) + if err != nil { + return networkResult{}, err + } gw := netutil.GatewayIP(net) vlanIf := fmt.Sprintf("vlan0.%d", tag) @@ -88,36 +98,43 @@ func fakeNetwork(rng *rand.Rand, f *gofakeit.Faker, vlanCount int) networkResult }) } - return result + return result, nil } // maxPickAttempts bounds the coupon-collector loops below so a shrinking // pool or an off-by-one in the callers cannot hang the CLI indefinitely. // Tags have a 4093-slot pool, the RFC 1918 /24 space has ~68K slots — either -// attempts <= 10 * pool-size is far more than a deterministic run needs. +// way, 100_000 attempts is far more than a deterministic run needs. const maxPickAttempts = 100_000 -func pickUniqueTag(rng *rand.Rand, used map[uint16]bool) uint16 { +func pickUniqueTag(rng *rand.Rand, used map[uint16]bool) (uint16, error) { for range maxPickAttempts { //nolint:gosec // Fake data; IntN bounded to uint16 range below. tag := uint16(vlanTagMin + rng.IntN(vlanTagMax-vlanTagMin+1)) if !used[tag] { used[tag] = true - return tag + return tag, nil } } - panic(fmt.Sprintf("faker: exhausted %d attempts picking a unique VLAN tag (used=%d of %d)", - maxPickAttempts, len(used), vlanTagMax-vlanTagMin+1)) + return 0, fmt.Errorf( + "faker: exhausted %d attempts picking a unique VLAN tag (used=%d of %d); try a smaller vlan count", + maxPickAttempts, + len(used), + vlanTagMax-vlanTagMin+1, + ) } -func pickUniqueNet(rng *rand.Rand, used map[string]bool) netip.Prefix { +func pickUniqueNet(rng *rand.Rand, used map[string]bool) (netip.Prefix, error) { for range maxPickAttempts { n := netutil.GenerateRandomNetwork(rng) if !used[n.String()] { used[n.String()] = true - return n + return n, nil } } - panic(fmt.Sprintf("faker: exhausted %d attempts picking a unique RFC 1918 /24 network (used=%d)", - maxPickAttempts, len(used))) + return netip.Prefix{}, fmt.Errorf( + "faker: exhausted %d attempts picking a unique RFC 1918 /24 network (used=%d); try a smaller vlan count", + maxPickAttempts, + len(used), + ) } diff --git a/internal/faker/network_test.go b/internal/faker/network_test.go index 0a8f28d..c20f776 100644 --- a/internal/faker/network_test.go +++ b/internal/faker/network_test.go @@ -13,7 +13,8 @@ func TestFakeNetworkWANAndLANOnly(t *testing.T) { t.Parallel() rng, f := newRand(100) - result := fakeNetwork(rng, f, 0) + result, err := fakeNetwork(rng, f, 0) + require.NoError(t, err) require.Len(t, result.Interfaces, 2, "WAN + LAN only when vlanCount == 0") names := []string{result.Interfaces[0].Name, result.Interfaces[1].Name} @@ -26,7 +27,8 @@ func TestFakeNetworkProducesRequestedVLANs(t *testing.T) { t.Parallel() rng, f := newRand(101) - result := fakeNetwork(rng, f, 5) + result, err := fakeNetwork(rng, f, 5) + require.NoError(t, err) assert.Len(t, result.VLANs, 5) assert.Len(t, result.Interfaces, 2+5, "WAN + LAN + one opt per VLAN") @@ -46,7 +48,8 @@ func TestFakeNetworkLANIsRFC1918(t *testing.T) { t.Parallel() rng, f := newRand(102) - result := fakeNetwork(rng, f, 0) + result, err := fakeNetwork(rng, f, 0) + require.NoError(t, err) for i := range result.Interfaces { if result.Interfaces[i].Name != "lan" { @@ -66,7 +69,23 @@ func TestFakeNetworkDeterministic(t *testing.T) { rngA, fa := newRand(42) rngB, fb := newRand(42) - a := fakeNetwork(rngA, fa, 3) - b := fakeNetwork(rngB, fb, 3) - assert.Equal(t, a, b, "same seed must produce identical NetworkResult") + a, errA := fakeNetwork(rngA, fa, 3) + require.NoError(t, errA) + b, errB := fakeNetwork(rngB, fb, 3) + require.NoError(t, errB) + assert.Equal(t, a, b, "same seed must produce identical networkResult") +} + +func TestPickUniqueTagReturnsErrorOnExhaustion(t *testing.T) { + t.Parallel() + + // Pre-populate the used set with every valid tag. + used := make(map[uint16]bool, vlanTagMax-vlanTagMin+1) + for tag := uint16(vlanTagMin); tag <= vlanTagMax; tag++ { + used[tag] = true + } + rng, _ := newRand(1) + _, err := pickUniqueTag(rng, used) + require.Error(t, err) + assert.Contains(t, err.Error(), "exhausted") } diff --git a/internal/faker/rand.go b/internal/faker/rand.go index c434fd2..ed09207 100644 --- a/internal/faker/rand.go +++ b/internal/faker/rand.go @@ -10,8 +10,7 @@ import ( ) // newRand builds a *rand.Rand and a *gofakeit.Faker sharing the same stream. -// A seed of 0 is the sentinel for "non-deterministic": a fresh random stream -// per call. Any non-zero seed produces a deterministic stream. +// See WithSeed for the seed == 0 sentinel semantics. func newRand(seed int64) (*rand.Rand, *gofakeit.Faker) { var rng *rand.Rand if seed == 0 { diff --git a/internal/opnsensegen/commondevice_test.go b/internal/opnsensegen/commondevice_test.go index af247a1..aca2d86 100644 --- a/internal/opnsensegen/commondevice_test.go +++ b/internal/opnsensegen/commondevice_test.go @@ -28,10 +28,11 @@ import ( func TestCommonDeviceRoundTripViaSerializer(t *testing.T) { t.Parallel() - original := faker.NewCommonDevice( + original, err := faker.NewCommonDevice( faker.WithSeed(146), faker.WithVLANCount(2), ) + require.NoError(t, err) doc, err := serializer.Serialize(original) require.NoError(t, err) diff --git a/internal/opnsensegen/template.go b/internal/opnsensegen/template.go index 74079f7..ca63d45 100644 --- a/internal/opnsensegen/template.go +++ b/internal/opnsensegen/template.go @@ -16,6 +16,7 @@ import ( "sort" "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" + "github.com/charmbracelet/log" ) // mapBackedSections names the OPNsense XML elements whose children are @@ -176,10 +177,29 @@ func emitSortedChildren(dec *xml.Decoder, enc *xml.Encoder, start xml.StartEleme default: if depth > 0 { current.tokens = append(current.tokens, xml.CopyToken(t)) + continue + } + // At depth 0 (between children) we silently drop indentation + // whitespace because the encoder re-indents on emit. A + // non-whitespace token here (user-authored comment, CDATA, + // processing instruction) is preserved-but-warned so operators + // can see annotations were dropped from their base config. + switch tok := t.(type) { + case xml.CharData: + if len(bytes.TrimSpace(tok)) > 0 { + log.Warn("dropping non-whitespace chardata in map-backed section", + "section", start.Name.Local) + } + case xml.Comment: + log.Warn("dropping XML comment in map-backed section (sort post-processor does not preserve comments)", + "section", start.Name.Local) + case xml.ProcInst: + log.Warn("dropping XML processing instruction in map-backed section", + "section", start.Name.Local, "target", tok.Target) + case xml.Directive: + log.Warn("dropping XML directive in map-backed section", + "section", start.Name.Local) } - // CharData/Comment/Directive/ProcInst at depth 0 (between - // children) is indentation whitespace — drop it; the encoder - // re-indents on emit. } } } diff --git a/internal/opnsensegen/template_test.go b/internal/opnsensegen/template_test.go index 3eca7e6..23b4c8a 100644 --- a/internal/opnsensegen/template_test.go +++ b/internal/opnsensegen/template_test.go @@ -2,9 +2,12 @@ package opnsensegen_test import ( "bytes" + "errors" + "strings" "testing" "github.com/EvilBit-Labs/opnConfigGenerator/internal/opnsensegen" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -73,3 +76,133 @@ func TestMarshalRoundTrip(t *testing.T) { assert.Contains(t, output, "opnsense") assert.Contains(t, output, "") } + +// countingWriter records the number of Write calls so we can verify the +// atomic-write contract: MarshalConfig buffers the full document in memory +// and performs exactly one Write when encode/stabilize succeed. +type countingWriter struct { + writes int + err error + buf bytes.Buffer +} + +func (w *countingWriter) Write(p []byte) (int, error) { + w.writes++ + if w.err != nil { + return 0, w.err + } + return w.buf.Write(p) +} + +func TestMarshalConfigIsAtomicOnSuccess(t *testing.T) { + t.Parallel() + + cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") + require.NoError(t, err) + + w := &countingWriter{} + require.NoError(t, opnsensegen.MarshalConfig(cfg, w)) + + assert.Equal(t, 1, w.writes, "MarshalConfig must perform exactly one Write on success") +} + +func TestMarshalConfigDoesNotWriteOnWriterFailure(t *testing.T) { + t.Parallel() + + cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") + require.NoError(t, err) + + sentinel := errors.New("disk full") + w := &countingWriter{err: sentinel} + err = opnsensegen.MarshalConfig(cfg, w) + + require.ErrorIs(t, err, sentinel) + // The writer saw exactly one attempt — no header-first / body-second + // partial write pattern. + assert.Equal(t, 1, w.writes) + assert.Zero(t, w.buf.Len(), "failing writer must not accumulate partial output") +} + +// TestMarshalConfigSortsMapBackedSections exercises the token-stream +// stabilizer by constructing a document with children that would iterate +// in non-alphabetical order under Go's randomized map iteration, then +// asserts the output is ordered. +func TestMarshalConfigSortsMapBackedSections(t *testing.T) { + t.Parallel() + + cfg := &opnsense.OpnSenseDocument{ + Version: "1.0", + System: opnsense.System{Hostname: "test", Domain: "test.local"}, + Interfaces: opnsense.Interfaces{ + Items: map[string]opnsense.Interface{ + "zeta": {If: "igb0", Enable: "1"}, + "alpha": {If: "igb1", Enable: "1"}, + "mu": {If: "igb2", Enable: "1"}, + }, + }, + } + + var buf bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(cfg, &buf)) + + out := buf.String() + iAlpha := strings.Index(out, "") + iMu := strings.Index(out, "") + iZeta := strings.Index(out, "") + require.NotEqual(t, -1, iAlpha, "alpha must appear") + require.NotEqual(t, -1, iMu, "mu must appear") + require.NotEqual(t, -1, iZeta, "zeta must appear") + assert.Less(t, iAlpha, iMu, "alpha must appear before mu") + assert.Less(t, iMu, iZeta, "mu must appear before zeta") +} + +func TestMarshalConfigHandlesEmptyMapBackedSections(t *testing.T) { + t.Parallel() + + cfg := &opnsense.OpnSenseDocument{ + Version: "1.0", + System: opnsense.System{Hostname: "test", Domain: "test.local"}, + Interfaces: opnsense.Interfaces{Items: map[string]opnsense.Interface{}}, + Dhcpd: opnsense.Dhcpd{Items: map[string]opnsense.DhcpdInterface{}}, + } + + var buf bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(cfg, &buf)) + + out := buf.String() + // Empty map-backed sections must round-trip without error and must + // still be well-formed XML. + assert.Contains(t, out, "") + assert.Contains(t, out, "") +} + +// TestMarshalConfigByteStableMapIteration runs MarshalConfig 20 times on the +// same input. Go's map iteration is randomized per encode, so without the +// sort post-processor, iterations diverge. 20 is high enough to defeat +// randomization luck. +func TestMarshalConfigByteStableMapIteration(t *testing.T) { + t.Parallel() + + cfg := &opnsense.OpnSenseDocument{ + Version: "1.0", + System: opnsense.System{Hostname: "test", Domain: "test.local"}, + Interfaces: opnsense.Interfaces{ + Items: map[string]opnsense.Interface{ + "wan": {If: "igb0", Enable: "1", IPAddr: "dhcp"}, + "lan": {If: "igb1", Enable: "1", IPAddr: "192.168.1.1", Subnet: "24"}, + "opt1": {If: "igb2", Enable: "1", IPAddr: "10.0.0.1", Subnet: "24"}, + "opt2": {If: "igb3", Enable: "1", IPAddr: "10.0.1.1", Subnet: "24"}, + "opt3": {If: "igb4", Enable: "1", IPAddr: "10.0.2.1", Subnet: "24"}, + }, + }, + } + + var first bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(cfg, &first)) + + for i := range 20 { + var next bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(cfg, &next)) + require.Equalf(t, first.Bytes(), next.Bytes(), "iteration %d diverged", i+1) + } +} diff --git a/internal/serializer/opnsense/overlay_test.go b/internal/serializer/opnsense/overlay_test.go index 71bdc97..c988baa 100644 --- a/internal/serializer/opnsense/overlay_test.go +++ b/internal/serializer/opnsense/overlay_test.go @@ -17,14 +17,28 @@ func TestOverlayPreservesBaseConfigUnrelatedFields(t *testing.T) { Version: "1.2.3", Theme: "opnsense", System: opnschema.System{Hostname: "base-host", Domain: "base.test"}, + Sysctl: []opnschema.SysctlItem{ + {Tunable: "net.inet.ip.forwarding", Value: "1", Descr: "route"}, + }, + CAs: []opnschema.CertificateAuthority{ + {Refid: "ca-1", Descr: "internal root"}, + }, } - device := faker.NewCommonDevice(faker.WithSeed(1), faker.WithVLANCount(2)) + device, err := faker.NewCommonDevice(faker.WithSeed(1), faker.WithVLANCount(2)) + require.NoError(t, err) merged, err := serializer.Overlay(base, device) require.NoError(t, err) + // Fields outside Phase 1 scope must survive wholesale. assert.Equal(t, "opnsense", merged.Theme, "Theme must survive overlay") assert.Equal(t, "1.2.3", merged.Version, "Version must survive overlay") + require.Len(t, merged.Sysctl, 1, "Sysctl must survive overlay") + assert.Equal(t, "net.inet.ip.forwarding", merged.Sysctl[0].Tunable) + require.Len(t, merged.CAs, 1, "CAs must survive overlay") + assert.Equal(t, "ca-1", merged.CAs[0].Refid) + + // Phase 1 subsystems come from the device. assert.Equal(t, device.System.Hostname, merged.System.Hostname, "System replaced from device") assert.Len(t, merged.VLANs.VLAN, 2) } @@ -32,7 +46,9 @@ func TestOverlayPreservesBaseConfigUnrelatedFields(t *testing.T) { func TestOverlayNilBase(t *testing.T) { t.Parallel() - _, err := serializer.Overlay(nil, faker.NewCommonDevice(faker.WithSeed(1))) + dev, err := faker.NewCommonDevice(faker.WithSeed(1)) + require.NoError(t, err) + _, err = serializer.Overlay(nil, dev) require.ErrorIs(t, err, serializer.ErrNilBase) } diff --git a/internal/serializer/opnsense/serializer_test.go b/internal/serializer/opnsense/serializer_test.go index 546b735..6be6f94 100644 --- a/internal/serializer/opnsense/serializer_test.go +++ b/internal/serializer/opnsense/serializer_test.go @@ -21,11 +21,12 @@ import ( func TestRoundTrip(t *testing.T) { t.Parallel() - original := faker.NewCommonDevice( + original, err := faker.NewCommonDevice( faker.WithSeed(2026), faker.WithVLANCount(3), faker.WithFirewallRules(true), ) + require.NoError(t, err) doc, err := serializer.Serialize(original) require.NoError(t, err) @@ -88,7 +89,20 @@ func TestRoundTrip(t *testing.T) { assert.Equalf(t, want.DNSServer, got.DNSServer, "DHCP %q DNSServer", iface) } - assert.Len(t, roundTripped.FirewallRules, len(original.FirewallRules)) + // Firewall rule per-field parity. Faker emits rules keyed to an + // interface name, so we compare by Interfaces[0] + Type to find pairs. + require.Len(t, roundTripped.FirewallRules, len(original.FirewallRules)) + origRules := firewallRulesByInterface(original.FirewallRules) + rtRules := firewallRulesByInterface(roundTripped.FirewallRules) + for iface, want := range origRules { + got, ok := rtRules[iface] + require.Truef(t, ok, "firewall rule for interface %q missing on round-trip", iface) + assert.Equalf(t, want.Type, got.Type, "rule %q Type", iface) + assert.Equalf(t, want.Description, got.Description, "rule %q Description", iface) + assert.Equalf(t, want.IPProtocol, got.IPProtocol, "rule %q IPProtocol", iface) + assert.Equalf(t, want.Source.Address, got.Source.Address, "rule %q Source.Address", iface) + assert.Equalf(t, want.Destination.Address, got.Destination.Address, "rule %q Destination.Address", iface) + } } // TestRoundTripByteStable asserts MarshalConfig output is byte-identical @@ -98,11 +112,12 @@ func TestRoundTrip(t *testing.T) { func TestRoundTripByteStable(t *testing.T) { t.Parallel() - device := faker.NewCommonDevice( + device, err := faker.NewCommonDevice( faker.WithSeed(99), faker.WithVLANCount(4), faker.WithFirewallRules(true), ) + require.NoError(t, err) doc, err := serializer.Serialize(device) require.NoError(t, err) @@ -146,3 +161,14 @@ func dhcpByInterface(xs []model.DHCPScope) map[string]model.DHCPScope { } return m } + +func firewallRulesByInterface(xs []model.FirewallRule) map[string]model.FirewallRule { + m := make(map[string]model.FirewallRule, len(xs)) + for _, x := range xs { + if len(x.Interfaces) == 0 { + continue + } + m[x.Interfaces[0]] = x + } + return m +} From 6b660fba2af86285c06142867567c5f6b08bc958 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 03:36:28 -0400 Subject: [PATCH 10/11] Address PR review feedback (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteen unresolved review threads from coderabbitai and the Copilot reviewer — fixes below cover the full set. CLI validation: * maxVlanCount was off-by-one vs the faker's tag pool. The 802.1Q range [2..4094] holds 4093 unique tags; bump maxVlanCount to 4093 and update the --vlan-count flag help + README + cmd_test boundary case. The constant now aliases faker.MaxVLANCount so the CLI and library share one source of truth. * Reject --base-config when --format is not xml. The overlay path only runs for XML; silently ignoring the flag for csv is a usability trap. Tests pin the rejection. Faker library hardening: * NewCommonDevice now validates vlan count (>=0, <=MaxVLANCount) before calling fakeNetwork, with explicit error messages. Defense in depth — CLI already bounds at the flag boundary, but the library is directly reachable by future consumers. Overlay error wrapping: * Wrap the Serialize error with "overlay: serialize device: %w" so callers see operation context in logs. CSV robustness: * Skip interfaces with empty PhysicalIf when indexing (they can never match a VLAN.VLANIf anyway). Extracted indexByPhysical and ipRangeFor helpers to keep the package average complexity under the linter bound. Docs drift (dispatcher described as current, not planned): * README and CONTRIBUTING reworded to say the CLI hardwires the OPNsense serializer today; CommonDevice.DeviceType-based routing lands when the pfSense sibling ships. Test hardening — mostly key-presence and per-field parity gaps: * TestNewRandZeroSeedIsRandom samples 8 draws instead of 1 (flake probability drops from ~2^-64 to ~2^-512). * TestFakeSystemDomainIsLowercaseFQDN runs across 5 seeds, matching TestFakeSystemHostnameIsDNSSafe's coverage. * TestFakeDHCPScopesOnePerStaticInterface pins Gateway and DNSServer (previously only range endpoints were checked). * NewCommonDevice gains boundary tests: VLANCount=0, negative (reject), > MaxVLANCount (reject). * Serializer dhcp/interfaces tests require.True on map key presence before asserting fields — a regression that drops the entry entirely would now fail loud. * TestSerializeVLANs spot-checks the second entry (previously only first-entry fields were validated). * TestCommonDeviceRoundTripViaSerializer now enables WithFirewallRules, asserts the rule count survives round-trip, and spot-checks Timezone + Language + per-VLAN tag identity. * New TestOverlayReplacesDhcpdAndFilter: seeds base Dhcpd + Filter with distinct sentinel values, enables faker firewall rules, asserts base values are dropped wholesale. * TestRoundTrip firewall parity now includes Direction, Protocol, Log, Disabled, Tracker, Source/Destination.Port, Source/Destination.Negated. Signed-off-by: UncleSp1d3r --- CONTRIBUTING.md | 2 +- README.md | 4 +- cmd/cmd_test.go | 20 ++++++- cmd/generate.go | 14 +++-- internal/csvio/csvio.go | 40 +++++++++----- internal/faker/device.go | 18 +++++- internal/faker/device_test.go | 27 +++++++++ internal/faker/dhcp_test.go | 13 +++++ internal/faker/rand_test.go | 14 ++++- internal/faker/system_test.go | 14 +++-- internal/opnsensegen/commondevice_test.go | 12 ++++ internal/serializer/opnsense/dhcp_test.go | 4 +- .../serializer/opnsense/interfaces_test.go | 10 +++- internal/serializer/opnsense/overlay.go | 3 +- internal/serializer/opnsense/overlay_test.go | 55 +++++++++++++++++++ .../serializer/opnsense/serializer_test.go | 11 +++- internal/serializer/opnsense/vlans_test.go | 5 ++ 17 files changed, 228 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83b8ad9..5e2fbd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ opnConfigGenerator/ **`*model.CommonDevice` is the single intermediate representation.** opnDossier defines the model; this project populates it (via `internal/faker/`) and serializes it (via `internal/serializer/opnsense/`). There is no parallel type or wrapper. -**Package layout reserves a pfSense sibling.** `internal/serializer/opnsense/` is organized so a future `internal/serializer/pfsense/` can plug in alongside without restructuring shared code. The CLI routes by `device.DeviceType`. +**Package layout reserves a pfSense sibling (planned).** `internal/serializer/opnsense/` is organized so a future `internal/serializer/pfsense/` can plug in alongside without restructuring shared code. When that sibling lands, the CLI will route by `device.DeviceType`; today it hardwires the OPNsense serializer. **Transport is separate from serialization.** `internal/opnsensegen/` only loads, parses, and marshals XML. It does not generate or serialize. `MarshalConfig` post-processes to sort map-backed sections alphabetically (see GOTCHAS §7.1) so output is byte-stable under a fixed seed. diff --git a/README.md b/README.md index a59bf45..e14d951 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ flowchart LR CSV --> OUT[(csv)] ``` -`*model.CommonDevice` is the single intermediate representation. opnDossier defines it; this project populates and serializes it. A future `internal/serializer/pfsense/` sibling will plug in alongside `internal/serializer/opnsense/` when pfSense support lands; the CLI routes by `CommonDevice.DeviceType`. +`*model.CommonDevice` is the single intermediate representation. opnDossier defines it; this project populates and serializes it. A future `internal/serializer/pfsense/` sibling will plug in alongside `internal/serializer/opnsense/` when pfSense support lands. Until then the CLI hardwires the OPNsense serializer; `CommonDevice.DeviceType`-based routing is planned, not implemented. ## What the Phase 1 Serializer Covers @@ -70,7 +70,7 @@ Deferred to follow-up plans (one per subsystem): NAT, VPN (OpenVPN/WireGuard/IPs | Flag | Default | Description | | ------------------- | ------------ | ----------------------------------------------------------------- | | `--format` | `xml` | Output format: `xml` (valid config.xml) or `csv` (VLAN dump) | -| `--vlan-count`/`-n` | `10` | Number of VLANs to generate (0--4092) | +| `--vlan-count`/`-n` | `10` | Number of VLANs to generate (0--4093) | | `--base-config` | | Optional base `config.xml`; serializer overlays onto it | | `--firewall-rules` | `false` | Include default allow-all-to-any rules per interface | | `--seed` | `0` (random) | RNG seed for reproducible output | diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index ce1422a..80ed36c 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -53,7 +53,7 @@ func newTestRootCmd() *cobra.Command { RunE: runGenerate, } genCmd.Flags().StringVar(&outputFormat, "format", formatXML, "output format (xml|csv)") - genCmd.Flags().IntVarP(&vlanCount, "vlan-count", "n", defaultVlanCount, "number of VLANs to generate (0-4092)") + genCmd.Flags().IntVarP(&vlanCount, "vlan-count", "n", defaultVlanCount, "number of VLANs to generate (0-4093)") genCmd.Flags().StringVar(&baseConfigPath, "base-config", "", "optional base OPNsense config.xml") genCmd.Flags().BoolVar(&includeFirewall, "firewall-rules", false, "include default firewall rules") genCmd.Flags().Int64Var(&seed, "seed", 0, "RNG seed for reproducibility") @@ -356,10 +356,24 @@ func TestGenerateVlanCountOne(t *testing.T) { func TestGenerateVlanCountExceedsMax(t *testing.T) { cmd := newTestRootCmd() - _, err := executeCommand(cmd, "generate", "--vlan-count", "4093") + _, err := executeCommand(cmd, "generate", "--vlan-count", "4094") require.Error(t, err) assert.Contains(t, err.Error(), "--vlan-count") - assert.Contains(t, err.Error(), "4092") + assert.Contains(t, err.Error(), "4093") +} + +func TestGenerateBaseConfigRejectedWithCSV(t *testing.T) { + tmpDir := t.TempDir() + basePath := filepath.Join(tmpDir, "base.xml") + require.NoError(t, os.WriteFile(basePath, []byte(""), 0o600)) + + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", + "--format", "csv", + "--base-config", basePath, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "--base-config is only supported with --format xml") } func TestGenerateBaseConfigMalformed(t *testing.T) { diff --git a/cmd/generate.go b/cmd/generate.go index 58bbeec..a13606c 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -16,12 +16,14 @@ const ( formatCSV = "csv" // defaultVlanCount is the number of VLANs generated when no count is - // supplied. 10 matches the tool's prior default. + // supplied. defaultVlanCount = 10 - // maxVlanCount mirrors the 802.1Q tag space (less reserved 0, 1, 4095). - maxVlanCount = 4092 ) +// maxVlanCount mirrors faker.MaxVLANCount so the CLI and library bound +// validation use the same number. +var maxVlanCount = faker.MaxVLANCount + var ( outputFormat string vlanCount int @@ -60,7 +62,7 @@ Examples: func init() { generateCmd.Flags().StringVar(&outputFormat, "format", formatXML, "output format (xml|csv)") - generateCmd.Flags().IntVarP(&vlanCount, "vlan-count", "n", defaultVlanCount, "number of VLANs to generate (0-4092)") + generateCmd.Flags().IntVarP(&vlanCount, "vlan-count", "n", defaultVlanCount, "number of VLANs to generate (0-4093)") generateCmd.Flags(). StringVar(&baseConfigPath, "base-config", "", "optional base OPNsense config.xml to overlay generated content onto") generateCmd.Flags(). @@ -83,6 +85,10 @@ func runGenerate(_ *cobra.Command, _ []string) (err error) { return fmt.Errorf("--vlan-count must be between 0 and %d, got %d", maxVlanCount, vlanCount) } + if format != formatXML && baseConfigPath != "" { + return fmt.Errorf("--base-config is only supported with --format %s", formatXML) + } + device, err := faker.NewCommonDevice( faker.WithSeed(seed), faker.WithVLANCount(vlanCount), diff --git a/internal/csvio/csvio.go b/internal/csvio/csvio.go index 2aa1e7a..e2b28c5 100644 --- a/internal/csvio/csvio.go +++ b/internal/csvio/csvio.go @@ -36,24 +36,13 @@ func WriteVlanCSV(w io.Writer, device *model.CommonDevice) error { } cw := csv.NewWriter(w) - if err := cw.Write(vlanHeaders); err != nil { return fmt.Errorf("write header: %w", err) } - // Index interfaces by PhysicalIf so each VLAN row can pull its IP range - // from the matching opt interface. - byPhysical := make(map[string]model.Interface, len(device.Interfaces)) - for _, iface := range device.Interfaces { - byPhysical[iface.PhysicalIf] = iface - } - + byPhysical := indexByPhysical(device.Interfaces) for i, v := range device.VLANs { - ipRange := "" - if iface, ok := byPhysical[v.VLANIf]; ok && iface.IPAddress != "" && iface.Subnet != "" { - ipRange = fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet) - } - record := []string{v.Tag, ipRange, v.Description, defaultWanAssignment} + record := []string{v.Tag, ipRangeFor(v, byPhysical), v.Description, defaultWanAssignment} if err := cw.Write(record); err != nil { return fmt.Errorf("write row %d: %w", i, err) } @@ -62,3 +51,28 @@ func WriteVlanCSV(w io.Writer, device *model.CommonDevice) error { cw.Flush() return cw.Error() } + +// indexByPhysical returns a map from PhysicalIf to Interface for every +// interface that carries a non-empty PhysicalIf. Interfaces without one are +// skipped because they cannot be matched to a VLAN.VLANIf anyway. +func indexByPhysical(interfaces []model.Interface) map[string]model.Interface { + byPhysical := make(map[string]model.Interface, len(interfaces)) + for _, iface := range interfaces { + if iface.PhysicalIf == "" { + continue + } + byPhysical[iface.PhysicalIf] = iface + } + return byPhysical +} + +// ipRangeFor derives the "/" CSV cell for a VLAN by looking up +// the backing interface in the PhysicalIf index. Returns "" when no match +// exists or the backing interface lacks an IP/Subnet. +func ipRangeFor(v model.VLAN, byPhysical map[string]model.Interface) string { + iface, ok := byPhysical[v.VLANIf] + if !ok || iface.IPAddress == "" || iface.Subnet == "" { + return "" + } + return fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet) +} diff --git a/internal/faker/device.go b/internal/faker/device.go index 907577d..41bac9e 100644 --- a/internal/faker/device.go +++ b/internal/faker/device.go @@ -6,19 +6,31 @@ import ( "github.com/EvilBit-Labs/opnDossier/pkg/model" ) +// MaxVLANCount is the upper bound on WithVLANCount enforced by +// NewCommonDevice. Exported so callers (CLI, tests) can reference the same +// value instead of duplicating the literal. +const MaxVLANCount = 4093 + // NewCommonDevice returns a fully-populated *model.CommonDevice ready for // serialization. All randomness is seeded from the Option set; with a fixed // WithSeed value the function is deterministic. // -// Returns an error when the VLAN tag or RFC 1918 /24 uniqueness pool is -// exhausted (e.g., extreme VLAN counts with a small pool). Callers should -// propagate the error to the user rather than retrying blindly. +// Returns an error when the requested VLAN count is out of range or when +// the VLAN tag / RFC 1918 /24 uniqueness pool is exhausted. Callers should +// propagate the error rather than retrying blindly. func NewCommonDevice(opts ...Option) (*model.CommonDevice, error) { cfg := &config{} for _, opt := range opts { opt(cfg) } + if cfg.vlanCount < 0 { + return nil, fmt.Errorf("VLAN count must be >= 0, got %d", cfg.vlanCount) + } + if cfg.vlanCount > MaxVLANCount { + return nil, fmt.Errorf("VLAN count must be <= %d, got %d", MaxVLANCount, cfg.vlanCount) + } + rng, f := newRand(cfg.seed) sys := fakeSystem(f) diff --git a/internal/faker/device_test.go b/internal/faker/device_test.go index 0f98683..474d5f6 100644 --- a/internal/faker/device_test.go +++ b/internal/faker/device_test.go @@ -1,6 +1,7 @@ package faker import ( + "fmt" "testing" "github.com/EvilBit-Labs/opnDossier/pkg/model" @@ -44,6 +45,32 @@ func TestNewCommonDeviceVLANCount(t *testing.T) { assert.Len(t, dev.DHCP, 5, "LAN + 4 opts each carry a DHCP scope") } +func TestNewCommonDeviceVLANCountZero(t *testing.T) { + t.Parallel() + + dev, err := NewCommonDevice(WithSeed(1), WithVLANCount(0)) + require.NoError(t, err) + assert.Empty(t, dev.VLANs) + assert.Len(t, dev.Interfaces, 2, "WAN + LAN only") + assert.Len(t, dev.DHCP, 1, "LAN only") +} + +func TestNewCommonDeviceVLANCountNegative(t *testing.T) { + t.Parallel() + + _, err := NewCommonDevice(WithVLANCount(-1)) + require.Error(t, err) + assert.Contains(t, err.Error(), ">= 0") +} + +func TestNewCommonDeviceVLANCountExceedsMax(t *testing.T) { + t.Parallel() + + _, err := NewCommonDevice(WithVLANCount(MaxVLANCount + 1)) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("<= %d", MaxVLANCount)) +} + func TestNewCommonDeviceFirewallRulesOptIn(t *testing.T) { t.Parallel() diff --git a/internal/faker/dhcp_test.go b/internal/faker/dhcp_test.go index c3f9720..9913f47 100644 --- a/internal/faker/dhcp_test.go +++ b/internal/faker/dhcp_test.go @@ -26,7 +26,9 @@ func TestFakeDHCPScopesOnePerStaticInterface(t *testing.T) { assert.Contains(t, names, "lan") assert.Contains(t, names, "opt1") + byName := make(map[string]model.DHCPScope, len(scopes)) for _, s := range scopes { + byName[s.Interface] = s assert.True(t, s.Enabled) assert.NotEmpty(t, s.Range.From) assert.NotEmpty(t, s.Range.To) @@ -36,6 +38,17 @@ func TestFakeDHCPScopesOnePerStaticInterface(t *testing.T) { require.NoError(t, err) assert.True(t, from.Less(to), "DHCP range.from must be < range.to") } + + // Gateway and DNSServer are populated from the interface IP; a regression + // that empties or swaps them would otherwise be invisible. + lan, ok := byName["lan"] + require.True(t, ok) + assert.Equal(t, "192.168.1.1", lan.Gateway) + assert.Equal(t, "192.168.1.1", lan.DNSServer) + opt1, ok := byName["opt1"] + require.True(t, ok) + assert.Equal(t, "10.0.0.1", opt1.Gateway) + assert.Equal(t, "10.0.0.1", opt1.DNSServer) } func TestFakeDHCPScopesSkipsWhenFieldsMissing(t *testing.T) { diff --git a/internal/faker/rand_test.go b/internal/faker/rand_test.go index 94f3288..e30a86a 100644 --- a/internal/faker/rand_test.go +++ b/internal/faker/rand_test.go @@ -27,7 +27,19 @@ func TestNewRandZeroSeedIsRandom(t *testing.T) { a, _ := newRand(0) b, _ := newRand(0) - assert.NotEqual(t, a.Uint64(), b.Uint64(), "zero-seed streams must diverge") + + // Sampling a single Uint64 pair has a ~1 in 2^64 collision chance — + // astronomical but not zero. Checking 8 consecutive draws and asserting + // at least one differs brings flake probability to 2^-512. + const samples = 8 + diverged := false + for range samples { + if a.Uint64() != b.Uint64() { + diverged = true + break + } + } + assert.True(t, diverged, "zero-seed streams must diverge within %d draws", samples) } func TestNewRandGofakeitHonorsSeed(t *testing.T) { diff --git a/internal/faker/system_test.go b/internal/faker/system_test.go index 360e340..c365635 100644 --- a/internal/faker/system_test.go +++ b/internal/faker/system_test.go @@ -49,11 +49,15 @@ func TestFakeSystemHostnameIsDNSSafe(t *testing.T) { func TestFakeSystemDomainIsLowercaseFQDN(t *testing.T) { t.Parallel() - _, f := newRand(17) - sys := fakeSystem(f) + for _, seed := range []int64{1, 2, 3, 42, 100} { + _, f := newRand(seed) + sys := fakeSystem(f) - for _, r := range sys.Domain { - assert.NotContainsf(t, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", string(r), "domain %q must be lowercase", sys.Domain) + for _, r := range sys.Domain { + assert.NotContainsf(t, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", string(r), + "seed %d: domain %q must be lowercase", seed, sys.Domain) + } + assert.Containsf(t, sys.Domain, ".", + "seed %d: domain %q must contain at least one dot", seed, sys.Domain) } - assert.Contains(t, sys.Domain, ".", "domain must contain at least one dot") } diff --git a/internal/opnsensegen/commondevice_test.go b/internal/opnsensegen/commondevice_test.go index aca2d86..4b7bfbc 100644 --- a/internal/opnsensegen/commondevice_test.go +++ b/internal/opnsensegen/commondevice_test.go @@ -31,8 +31,11 @@ func TestCommonDeviceRoundTripViaSerializer(t *testing.T) { original, err := faker.NewCommonDevice( faker.WithSeed(146), faker.WithVLANCount(2), + // Phase 1 includes filter serialization — exercise it end-to-end. + faker.WithFirewallRules(true), ) require.NoError(t, err) + require.NotEmpty(t, original.FirewallRules, "test precondition: faker must emit firewall rules") doc, err := serializer.Serialize(original) require.NoError(t, err) @@ -53,8 +56,17 @@ func TestCommonDeviceRoundTripViaSerializer(t *testing.T) { assert.Equal(t, model.DeviceTypeOPNsense, device.DeviceType) assert.Equal(t, original.System.Hostname, device.System.Hostname) assert.Equal(t, original.System.Domain, device.System.Domain) + assert.Equal(t, original.System.Timezone, device.System.Timezone) + assert.Equal(t, original.System.Language, device.System.Language) assert.Len(t, device.VLANs, 2) + // Spot-check VLAN tag identity (not just count). + origTags := map[string]bool{original.VLANs[0].Tag: true, original.VLANs[1].Tag: true} + for _, v := range device.VLANs { + assert.Truef(t, origTags[v.Tag], "round-trip produced unexpected VLAN tag %q", v.Tag) + } assert.NotEmpty(t, device.Interfaces) + assert.Len(t, device.FirewallRules, len(original.FirewallRules), + "at least one firewall rule must survive the round-trip") } // TestCommonDeviceMinimalConfig verifies ConvertDocument accepts the sparse diff --git a/internal/serializer/opnsense/dhcp_test.go b/internal/serializer/opnsense/dhcp_test.go index 4f1354d..2b5397b 100644 --- a/internal/serializer/opnsense/dhcp_test.go +++ b/internal/serializer/opnsense/dhcp_test.go @@ -36,5 +36,7 @@ func TestSerializeDHCPDisabled(t *testing.T) { t.Parallel() out := serializer.SerializeDHCP([]model.DHCPScope{{Interface: "lan", Enabled: false}}) - assert.Empty(t, out.Items["lan"].Enable) + lan, ok := out.Items["lan"] + require.True(t, ok, "serializer must emit the lan scope even when Enabled=false") + assert.Empty(t, lan.Enable) } diff --git a/internal/serializer/opnsense/interfaces_test.go b/internal/serializer/opnsense/interfaces_test.go index 763405c..e390898 100644 --- a/internal/serializer/opnsense/interfaces_test.go +++ b/internal/serializer/opnsense/interfaces_test.go @@ -52,7 +52,9 @@ func TestSerializeInterfacesDisabled(t *testing.T) { t.Parallel() out := serializer.SerializeInterfaces([]model.Interface{{Name: "lan", Enabled: false}}) - assert.Empty(t, out.Items["lan"].Enable) + lan, ok := out.Items["lan"] + require.True(t, ok, "serializer must emit the lan interface even when Enabled=false") + assert.Empty(t, lan.Enable) } func TestSerializeInterfacesTypeNoneLeavesAddressingEmpty(t *testing.T) { @@ -61,6 +63,8 @@ func TestSerializeInterfacesTypeNoneLeavesAddressingEmpty(t *testing.T) { out := serializer.SerializeInterfaces([]model.Interface{{ Name: "opt1", Enabled: true, Type: "", IPAddress: "1.2.3.4", Subnet: "24", }}) - assert.Empty(t, out.Items["opt1"].IPAddr) - assert.Empty(t, out.Items["opt1"].Subnet) + opt1, ok := out.Items["opt1"] + require.True(t, ok, "serializer must emit the opt1 interface") + assert.Empty(t, opt1.IPAddr) + assert.Empty(t, opt1.Subnet) } diff --git a/internal/serializer/opnsense/overlay.go b/internal/serializer/opnsense/overlay.go index 3c5fbb2..473db4e 100644 --- a/internal/serializer/opnsense/overlay.go +++ b/internal/serializer/opnsense/overlay.go @@ -2,6 +2,7 @@ package opnsense import ( "errors" + "fmt" "github.com/EvilBit-Labs/opnDossier/pkg/model" "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" @@ -22,7 +23,7 @@ func Overlay(base *opnsense.OpnSenseDocument, device *model.CommonDevice) (*opns } serialized, err := Serialize(device) if err != nil { - return nil, err + return nil, fmt.Errorf("overlay: serialize device: %w", err) } out := *base out.System = serialized.System diff --git a/internal/serializer/opnsense/overlay_test.go b/internal/serializer/opnsense/overlay_test.go index c988baa..38e8669 100644 --- a/internal/serializer/opnsense/overlay_test.go +++ b/internal/serializer/opnsense/overlay_test.go @@ -43,6 +43,61 @@ func TestOverlayPreservesBaseConfigUnrelatedFields(t *testing.T) { assert.Len(t, merged.VLANs.VLAN, 2) } +// TestOverlayReplacesDhcpdAndFilter seeds both Dhcpd and Filter in the base +// fixture and enables faker firewall rules, then asserts that overlay drops +// the base's Dhcpd/Filter content and installs the device's. Pins the +// wholesale-replace contract for both sections so a future silent shift to +// merge semantics fails this test. +func TestOverlayReplacesDhcpdAndFilter(t *testing.T) { + t.Parallel() + + base := &opnschema.OpnSenseDocument{ + Version: "1.0", + Dhcpd: opnschema.Dhcpd{ + Items: map[string]opnschema.DhcpdInterface{ + "lan": { + Enable: "1", + Gateway: "10.99.99.1", + Range: opnschema.Range{ + From: "10.99.99.10", + To: "10.99.99.20", + }, + }, + }, + }, + Filter: opnschema.Filter{ + Rule: []opnschema.Rule{ + {Type: "block", Descr: "from base — must be dropped"}, + }, + }, + } + device, err := faker.NewCommonDevice( + faker.WithSeed(1), + faker.WithVLANCount(2), + faker.WithFirewallRules(true), + ) + require.NoError(t, err) + require.NotEmpty(t, device.FirewallRules) + require.NotEmpty(t, device.DHCP) + + merged, err := serializer.Overlay(base, device) + require.NoError(t, err) + + // Dhcpd — base's lan gateway 10.99.99.1 must NOT survive; the device's + // LAN gateway replaces it. + lan, ok := merged.Dhcpd.Items["lan"] + require.True(t, ok, "merged document must have a lan dhcpd entry from the device") + assert.NotEqual(t, "10.99.99.1", lan.Gateway, + "base Dhcpd must be replaced wholesale; base gateway must not survive") + + // Filter — the base's block rule must not survive. + for _, r := range merged.Filter.Rule { + assert.NotEqual(t, "from base — must be dropped", r.Descr, + "base Filter.Rule entries must be replaced wholesale") + } + assert.NotEmpty(t, merged.Filter.Rule, "device firewall rules must land in merged Filter") +} + func TestOverlayNilBase(t *testing.T) { t.Parallel() diff --git a/internal/serializer/opnsense/serializer_test.go b/internal/serializer/opnsense/serializer_test.go index 6be6f94..684b8c5 100644 --- a/internal/serializer/opnsense/serializer_test.go +++ b/internal/serializer/opnsense/serializer_test.go @@ -90,7 +90,7 @@ func TestRoundTrip(t *testing.T) { } // Firewall rule per-field parity. Faker emits rules keyed to an - // interface name, so we compare by Interfaces[0] + Type to find pairs. + // interface name, so we compare by Interfaces[0] to find pairs. require.Len(t, roundTripped.FirewallRules, len(original.FirewallRules)) origRules := firewallRulesByInterface(original.FirewallRules) rtRules := firewallRulesByInterface(roundTripped.FirewallRules) @@ -100,8 +100,17 @@ func TestRoundTrip(t *testing.T) { assert.Equalf(t, want.Type, got.Type, "rule %q Type", iface) assert.Equalf(t, want.Description, got.Description, "rule %q Description", iface) assert.Equalf(t, want.IPProtocol, got.IPProtocol, "rule %q IPProtocol", iface) + assert.Equalf(t, want.Direction, got.Direction, "rule %q Direction", iface) + assert.Equalf(t, want.Protocol, got.Protocol, "rule %q Protocol", iface) + assert.Equalf(t, want.Log, got.Log, "rule %q Log", iface) + assert.Equalf(t, want.Disabled, got.Disabled, "rule %q Disabled", iface) + assert.Equalf(t, want.Tracker, got.Tracker, "rule %q Tracker", iface) assert.Equalf(t, want.Source.Address, got.Source.Address, "rule %q Source.Address", iface) + assert.Equalf(t, want.Source.Port, got.Source.Port, "rule %q Source.Port", iface) + assert.Equalf(t, want.Source.Negated, got.Source.Negated, "rule %q Source.Negated", iface) assert.Equalf(t, want.Destination.Address, got.Destination.Address, "rule %q Destination.Address", iface) + assert.Equalf(t, want.Destination.Port, got.Destination.Port, "rule %q Destination.Port", iface) + assert.Equalf(t, want.Destination.Negated, got.Destination.Negated, "rule %q Destination.Negated", iface) } } diff --git a/internal/serializer/opnsense/vlans_test.go b/internal/serializer/opnsense/vlans_test.go index b2d51dc..dce2826 100644 --- a/internal/serializer/opnsense/vlans_test.go +++ b/internal/serializer/opnsense/vlans_test.go @@ -23,6 +23,11 @@ func TestSerializeVLANs(t *testing.T) { assert.Equal(t, "igb0", out.VLAN[0].If) assert.Equal(t, "vlan0.42", out.VLAN[0].Vlanif) assert.Equal(t, "IT", out.VLAN[0].Descr) + // Spot-check the second entry so a regression that only populates the + // first slice element doesn't pass. + assert.Equal(t, "100", out.VLAN[1].Tag) + assert.Equal(t, "vlan0.100", out.VLAN[1].Vlanif) + assert.Equal(t, "Sales", out.VLAN[1].Descr) } func TestSerializeVLANsEmpty(t *testing.T) { From 2f615dec3820bfb67d8555c8f5be1c5cc3c5df34 Mon Sep 17 00:00:00 2001 From: UncleSp1d3r Date: Sun, 19 Apr 2026 11:26:38 -0400 Subject: [PATCH 11/11] Address PR review feedback (#9) Round 2 of automated reviewer feedback. CLI cleanup: * Factor out buildOpnSenseDocument so the XML path branches before serializing. With --base-config, only Overlay runs (which serializes internally); without it, only Serialize runs. The old path ran Serialize unconditionally and then discarded the result on the overlay branch. * Reset validate subcommand globals (inputFile, inputFormat, maxErrors) in newTestRootCmd alongside the other CLI globals so validate tests do not leak state across invocations. Faker architecture: * NewCommonDevice no longer hard-codes model.DeviceTypeOPNsense on the produced device. Added WithDeviceType option; the CLI sets OPNsense explicitly. Keeps the faker target-neutral so future pfSense tests (and any other consumer) can choose their own type. Round-trip tests updated to pass WithDeviceType(OPNsense) where they assert on the type. Docstring accuracy: * template.go emitSortedChildren: the code logs-and-drops non-whitespace tokens between children. The comment claimed "preserved-but-warned" which was a lie. Rewrote to explain why the sort post-processor cannot place inter-child tokens back in a stable position after reordering. Signed-off-by: UncleSp1d3r --- cmd/cmd_test.go | 4 ++ cmd/generate.go | 42 +++++++++++++------ internal/faker/device.go | 2 +- internal/faker/device_test.go | 12 +++++- internal/faker/options.go | 12 ++++++ internal/opnsensegen/commondevice_test.go | 1 + internal/opnsensegen/template.go | 6 ++- .../serializer/opnsense/serializer_test.go | 1 + 8 files changed, 64 insertions(+), 16 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 80ed36c..3989435 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -33,6 +33,10 @@ func newTestRootCmd() *cobra.Command { output = "" quiet = false noColor = false + // validate subcommand globals (defined in cmd/validate.go). + inputFile = "" + inputFormat = "" + maxErrors = 10 root := &cobra.Command{ Use: "opnConfigGenerator", diff --git a/cmd/generate.go b/cmd/generate.go index a13606c..6d56973 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -7,6 +7,8 @@ import ( "github.com/EvilBit-Labs/opnConfigGenerator/internal/faker" "github.com/EvilBit-Labs/opnConfigGenerator/internal/opnsensegen" serializer "github.com/EvilBit-Labs/opnConfigGenerator/internal/serializer/opnsense" + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/opnsense" "github.com/charmbracelet/log" "github.com/spf13/cobra" ) @@ -73,6 +75,29 @@ func init() { generateCmd.Flags().StringVar(&domainOverride, "domain", "", "override the generated domain") } +// buildOpnSenseDocument routes the serialize step. When --base-config is set +// we go through Overlay (which serializes the device against the loaded base); +// otherwise we call Serialize directly. Keeping the branch here means the XML +// path never serializes the device twice. +func buildOpnSenseDocument(device *model.CommonDevice) (*opnsense.OpnSenseDocument, error) { + if baseConfigPath == "" { + doc, err := serializer.Serialize(device) + if err != nil { + return nil, fmt.Errorf("serialize: %w", err) + } + return doc, nil + } + base, err := opnsensegen.LoadBaseConfig(baseConfigPath) + if err != nil { + return nil, fmt.Errorf("load base config: %w", err) + } + doc, err := serializer.Overlay(base, device) + if err != nil { + return nil, fmt.Errorf("overlay: %w", err) + } + return doc, nil +} + func runGenerate(_ *cobra.Command, _ []string) (err error) { format := normalizeStringFlag(outputFormat) switch format { @@ -95,6 +120,9 @@ func runGenerate(_ *cobra.Command, _ []string) (err error) { faker.WithFirewallRules(includeFirewall), faker.WithHostname(hostnameOverride), faker.WithDomain(domainOverride), + // Today the CLI always targets OPNsense; when the pfSense + // serializer lands, route this by a user-facing flag. + faker.WithDeviceType(model.DeviceTypeOPNsense), ) if err != nil { return fmt.Errorf("generate device: %w", err) @@ -120,19 +148,9 @@ func runGenerate(_ *cobra.Command, _ []string) (err error) { log.Info("wrote CSV output", "vlans", len(device.VLANs)) return nil case formatXML: - doc, sErr := serializer.Serialize(device) + doc, sErr := buildOpnSenseDocument(device) if sErr != nil { - return fmt.Errorf("serialize: %w", sErr) - } - if baseConfigPath != "" { - base, lErr := opnsensegen.LoadBaseConfig(baseConfigPath) - if lErr != nil { - return fmt.Errorf("load base config: %w", lErr) - } - doc, sErr = serializer.Overlay(base, device) - if sErr != nil { - return fmt.Errorf("overlay: %w", sErr) - } + return sErr } if mErr := opnsensegen.MarshalConfig(doc, w); mErr != nil { return fmt.Errorf("write XML: %w", mErr) diff --git a/internal/faker/device.go b/internal/faker/device.go index 41bac9e..b14116d 100644 --- a/internal/faker/device.go +++ b/internal/faker/device.go @@ -57,7 +57,7 @@ func NewCommonDevice(opts ...Option) (*model.CommonDevice, error) { } return &model.CommonDevice{ - DeviceType: model.DeviceTypeOPNsense, + DeviceType: cfg.deviceType, System: sys, Interfaces: net.Interfaces, VLANs: net.VLANs, diff --git a/internal/faker/device_test.go b/internal/faker/device_test.go index 474d5f6..5166d8c 100644 --- a/internal/faker/device_test.go +++ b/internal/faker/device_test.go @@ -15,7 +15,9 @@ func TestNewCommonDeviceDefaults(t *testing.T) { dev, err := NewCommonDevice() require.NoError(t, err) require.NotNil(t, dev) - assert.Equal(t, model.DeviceTypeOPNsense, dev.DeviceType) + // Faker is target-neutral; DeviceType defaults to the zero value + // (unset). Callers that need a concrete target pass WithDeviceType. + assert.Empty(t, string(dev.DeviceType)) assert.NotEmpty(t, dev.System.Hostname) assert.NotEmpty(t, dev.System.Domain) assert.Len(t, dev.Interfaces, 2, "default shape: WAN + LAN, no VLANs") @@ -25,6 +27,14 @@ func TestNewCommonDeviceDefaults(t *testing.T) { assert.Len(t, dev.DHCP, 1) } +func TestNewCommonDeviceDeviceTypeOption(t *testing.T) { + t.Parallel() + + dev, err := NewCommonDevice(WithDeviceType(model.DeviceTypeOPNsense)) + require.NoError(t, err) + assert.Equal(t, model.DeviceTypeOPNsense, dev.DeviceType) +} + func TestNewCommonDeviceDeterministic(t *testing.T) { t.Parallel() diff --git a/internal/faker/options.go b/internal/faker/options.go index ad07f80..7d05a03 100644 --- a/internal/faker/options.go +++ b/internal/faker/options.go @@ -1,5 +1,7 @@ package faker +import "github.com/EvilBit-Labs/opnDossier/pkg/model" + // Option configures the faker pipeline. type Option func(*config) @@ -9,6 +11,7 @@ type config struct { firewallRules bool hostname string domain string + deviceType model.DeviceType } // WithSeed sets a deterministic RNG seed. A seed of 0 is the sentinel for @@ -36,3 +39,12 @@ func WithHostname(h string) Option { func WithDomain(d string) Option { return func(c *config) { c.domain = d } } + +// WithDeviceType sets the target device type on the produced CommonDevice. +// The faker itself is target-neutral (pure data generation); callers that +// intend to serialize for a specific target set the type here. The default +// is the zero value (unset); callers can pass model.DeviceTypeOPNsense, +// model.DeviceTypePfSense, etc. +func WithDeviceType(dt model.DeviceType) Option { + return func(c *config) { c.deviceType = dt } +} diff --git a/internal/opnsensegen/commondevice_test.go b/internal/opnsensegen/commondevice_test.go index 4b7bfbc..384ecaa 100644 --- a/internal/opnsensegen/commondevice_test.go +++ b/internal/opnsensegen/commondevice_test.go @@ -33,6 +33,7 @@ func TestCommonDeviceRoundTripViaSerializer(t *testing.T) { faker.WithVLANCount(2), // Phase 1 includes filter serialization — exercise it end-to-end. faker.WithFirewallRules(true), + faker.WithDeviceType(model.DeviceTypeOPNsense), ) require.NoError(t, err) require.NotEmpty(t, original.FirewallRules, "test precondition: faker must emit firewall rules") diff --git a/internal/opnsensegen/template.go b/internal/opnsensegen/template.go index ca63d45..c6af28b 100644 --- a/internal/opnsensegen/template.go +++ b/internal/opnsensegen/template.go @@ -182,8 +182,10 @@ func emitSortedChildren(dec *xml.Decoder, enc *xml.Encoder, start xml.StartEleme // At depth 0 (between children) we silently drop indentation // whitespace because the encoder re-indents on emit. A // non-whitespace token here (user-authored comment, CDATA, - // processing instruction) is preserved-but-warned so operators - // can see annotations were dropped from their base config. + // processing instruction) is dropped with a warning so + // operators can see annotations were lost from their base + // config — the sort post-processor cannot place inter-child + // tokens back in a stable position after reordering. switch tok := t.(type) { case xml.CharData: if len(bytes.TrimSpace(tok)) > 0 { diff --git a/internal/serializer/opnsense/serializer_test.go b/internal/serializer/opnsense/serializer_test.go index 684b8c5..18a614e 100644 --- a/internal/serializer/opnsense/serializer_test.go +++ b/internal/serializer/opnsense/serializer_test.go @@ -25,6 +25,7 @@ func TestRoundTrip(t *testing.T) { faker.WithSeed(2026), faker.WithVLANCount(3), faker.WithFirewallRules(true), + faker.WithDeviceType(model.DeviceTypeOPNsense), ) require.NoError(t, err)