diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8947136..5e2fbd8 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 (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. -**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/README.md b/README.md index 8b5e562..e14d951 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. Until then the CLI hardwires the OPNsense serializer; `CommonDevice.DeviceType`-based routing is planned, not implemented. -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--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 | +| `--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 diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 5aa0a89..3989435 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -16,9 +16,28 @@ 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 + // validate subcommand globals (defined in cmd/validate.go). + inputFile = "" + inputFormat = "" + maxErrors = 10 + root := &cobra.Command{ Use: "opnConfigGenerator", Short: "Generate realistic OPNsense configuration files with fake data", @@ -37,23 +56,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-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") - 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 +115,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 +144,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 +171,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 +208,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 +263,188 @@ 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) + content := string(data) + assert.Contains(t, content, "") +} + +func TestGenerateInvalidVlanCount(t *testing.T) { + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", "--vlan-count", "-1") + require.Error(t, err) + 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) - assert.Contains(t, string(data), "") + content := string(data) + assert.Contains(t, content, "") + // No children in the section. + assert.NotContains(t, content, "") } -func TestGenerateXMLRejectsNatMappings(t *testing.T) { - baseCfgPath := baseConfigPath(t) +func TestGenerateVlanCountOne(t *testing.T) { + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "one.xml") cmd := newTestRootCmd() - _, err := executeCommand(cmd, - "generate", "--format", "xml", - "--count", "3", - "--base-config", baseCfgPath, - "--nat-mappings", "5", + _, 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", "4094") require.Error(t, err) - assert.Contains(t, err.Error(), "not yet supported") + assert.Contains(t, err.Error(), "--vlan-count") + assert.Contains(t, err.Error(), "4093") } -func TestGenerateXMLRejectsVpnCount(t *testing.T) { - baseCfgPath := baseConfigPath(t) +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", "xml", - "--count", "3", - "--base-config", baseCfgPath, - "--vpn-count", "2", + _, err := executeCommand(cmd, "generate", + "--format", "csv", + "--base-config", basePath, ) require.Error(t, err) - assert.Contains(t, err.Error(), "not yet supported") + assert.Contains(t, err.Error(), "--base-config is only supported with --format xml") } -func TestGenerateInvalidCount(t *testing.T) { +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") + + cmd := newTestRootCmd() + _, err := executeCommand(cmd, "generate", + "--seed", "42", + "--hostname", "mygateway", + "--domain", "example.test", + "--output", outPath, + ) + require.NoError(t, err) + + 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..6d56973 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -1,243 +1,133 @@ 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/EvilBit-Labs/opnDossier/pkg/model" + "github.com/EvilBit-Labs/opnDossier/pkg/schema/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. + defaultVlanCount = 10 ) +// maxVlanCount mirrors faker.MaxVLANCount so the CLI and library bound +// validation use the same number. +var maxVlanCount = faker.MaxVLANCount + 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 - # Generate with firewall rules - opnConfigGenerator generate --format xml --count 10 --include-firewall-rules + # Reproducible output + opnConfigGenerator generate --seed 42 - # Generate CSV data - opnConfigGenerator generate --format csv --count 50 --output network-data.csv + # With default firewall rules and 20 VLANs + opnConfigGenerator generate --vlan-count 20 --firewall-rules - # Generate with VPN and NAT (CSV only — XML serialization pending) - opnConfigGenerator generate --format csv --count 15 --vpn-count 3 --nat-mappings 10`, + # Overlay generated content onto an existing config + opnConfigGenerator generate --base-config existing.xml + + # 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-4093)") 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. - 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)) +// 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 err + return nil, fmt.Errorf("load base config: %w", err) } - - // Parse firewall complexity. - complexity, err := generator.ParseFirewallComplexity(normalizeStringFlag(firewallRuleComplexity)) + doc, err := serializer.Overlay(base, device) 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) - } - - // Validate VLANs. - result := validate.Vlans(vlans) - if !result.IsValid() { - return result.Error() - } - - 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)) + return nil, fmt.Errorf("overlay: %w", err) } + return doc, nil +} - // Output based on format. - switch normalizedFormat { - case formatCSV: - return outputCSV(vlans) - case formatXML: - return outputXML(vlans, fwRules, seedPtr) +func runGenerate(_ *cobra.Command, _ []string) (err error) { + format := normalizeStringFlag(outputFormat) + switch format { + case formatXML, formatCSV: default: - return fmt.Errorf("unsupported format: %s", normalizedFormat) + return fmt.Errorf("invalid format %q: must be xml or csv", outputFormat) } -} -func outputCSV(vlans []generator.VlanConfig) (err error) { - 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) - } - }() + if vlanCount < 0 || vlanCount > maxVlanCount { + return fmt.Errorf("--vlan-count must be between 0 and %d, got %d", maxVlanCount, vlanCount) } - if err := csvio.WriteVlanCSV(w, vlans); err != nil { - return fmt.Errorf("write CSV: %w", err) + if format != formatXML && baseConfigPath != "" { + return fmt.Errorf("--base-config is only supported with --format %s", formatXML) } - 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) + device, err := faker.NewCommonDevice( + faker.WithSeed(seed), + faker.WithVLANCount(vlanCount), + 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("load base config: %w", err) + return fmt.Errorf("generate device: %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 @@ -250,11 +140,23 @@ func outputXML( }() } - // 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 + 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 := buildOpnSenseDocument(device) + if sErr != nil { + return 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) } 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/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/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= diff --git a/internal/csvio/csvio.go b/internal/csvio/csvio.go index a98c805..e2b28c5 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,41 @@ 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), - } + byPhysical := indexByPhysical(device.Interfaces) + for i, v := range device.VLANs { + 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) } @@ -49,108 +52,27 @@ func WriteVlanCSV(w io.Writer, vlans []generator.VlanConfig) error { 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) +// 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 nil + return byPhysical } -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) +// 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 "" } - - 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 + return fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet) } diff --git a/internal/csvio/csvio_test.go b/internal/csvio/csvio_test.go index 239d5b0..b000c10 100644 --- a/internal/csvio/csvio_test.go +++ b/internal/csvio/csvio_test.go @@ -2,148 +2,81 @@ 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, - }, - } - - var buf bytes.Buffer - err := csvio.WriteVlanCSV(&buf, vlans) - require.NoError(t, err) - - result, err := csvio.ReadVlanCSV(&buf) + dev, err := faker.NewCommonDevice(faker.WithSeed(7), faker.WithVLANCount(2)) 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) - } -} - -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:] - } + require.NoError(t, csvio.WriteVlanCSV(&buf, dev)) - 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/faker/device.go b/internal/faker/device.go new file mode 100644 index 0000000..b14116d --- /dev/null +++ b/internal/faker/device.go @@ -0,0 +1,67 @@ +package faker + +import ( + "fmt" + + "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 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) + if cfg.hostname != "" { + sys.Hostname = cfg.hostname + } + if cfg.domain != "" { + sys.Domain = cfg.domain + } + + 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 { + fwRules = fakeFirewallRules(net.Interfaces) + } + + return &model.CommonDevice{ + DeviceType: cfg.deviceType, + System: sys, + Interfaces: net.Interfaces, + VLANs: net.VLANs, + DHCP: dhcp, + FirewallRules: fwRules, + }, nil +} diff --git a/internal/faker/device_test.go b/internal/faker/device_test.go new file mode 100644 index 0000000..5166d8c --- /dev/null +++ b/internal/faker/device_test.go @@ -0,0 +1,118 @@ +package faker + +import ( + "fmt" + "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, err := NewCommonDevice() + require.NoError(t, err) + require.NotNil(t, dev) + // 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") + 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 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() + + 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, 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") +} + +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() + + without, err := NewCommonDevice(WithSeed(1)) + require.NoError(t, err) + assert.Empty(t, without.FirewallRules) + + with, err := NewCommonDevice(WithSeed(1), WithFirewallRules(true)) + require.NoError(t, err) + assert.NotEmpty(t, with.FirewallRules) +} + +func TestNewCommonDeviceHostnameAndDomainOverride(t *testing.T) { + t.Parallel() + + 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 new file mode 100644 index 0000000..513619f --- /dev/null +++ b/internal/faker/dhcp.go @@ -0,0 +1,40 @@ +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. 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 == "" { + continue + } + prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%s", iface.IPAddress, iface.Subnet)) + if err != nil { + 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, + Enabled: true, + Range: model.DHCPRange{ + From: netutil.DHCPRangeStart(prefix).String(), + To: netutil.DHCPRangeEnd(prefix).String(), + }, + Gateway: iface.IPAddress, + DNSServer: iface.IPAddress, + }) + } + return scopes, nil +} diff --git a/internal/faker/dhcp_test.go b/internal/faker/dhcp_test.go new file mode 100644 index 0000000..9913f47 --- /dev/null +++ b/internal/faker/dhcp_test.go @@ -0,0 +1,75 @@ +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, 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} + 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) + 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") + } + + // 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) { + t.Parallel() + + 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 new file mode 100644 index 0000000..04ecb0e --- /dev/null +++ b/internal/faker/firewall.go @@ -0,0 +1,30 @@ +package faker + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" +) + +// fakeFirewallRules emits a minimal default ruleset: one pass rule per +// 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 { + 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..99e2932 --- /dev/null +++ b/internal/faker/firewall_test.go @@ -0,0 +1,42 @@ +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"}, + } + + rules := fakeFirewallRules(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() + + assert.Empty(t, fakeFirewallRules(nil)) +} + +func TestFakeFirewallRulesOnlyWANNoRules(t *testing.T) { + t.Parallel() + + rules := fakeFirewallRules([]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..8426a33 --- /dev/null +++ b/internal/faker/network.go @@ -0,0 +1,140 @@ +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. +// +// 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), + } + + // 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, 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) + 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, 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 +// 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, 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, nil + } + } + 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, error) { + for range maxPickAttempts { + n := netutil.GenerateRandomNetwork(rng) + if !used[n.String()] { + used[n.String()] = true + return n, nil + } + } + 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 new file mode 100644 index 0000000..c20f776 --- /dev/null +++ b/internal/faker/network_test.go @@ -0,0 +1,91 @@ +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, 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} + 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, 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") + + 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, err := fakeNetwork(rng, f, 0) + require.NoError(t, err) + + 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, 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/options.go b/internal/faker/options.go new file mode 100644 index 0000000..7d05a03 --- /dev/null +++ b/internal/faker/options.go @@ -0,0 +1,50 @@ +package faker + +import "github.com/EvilBit-Labs/opnDossier/pkg/model" + +// Option configures the faker pipeline. +type Option func(*config) + +type config struct { + seed int64 + vlanCount int + firewallRules bool + hostname string + domain string + deviceType model.DeviceType +} + +// 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 } +} + +// 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/faker/rand.go b/internal/faker/rand.go new file mode 100644 index 0000000..ed09207 --- /dev/null +++ b/internal/faker/rand.go @@ -0,0 +1,24 @@ +// 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. +// See WithSeed for the seed == 0 sentinel semantics. +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..e30a86a --- /dev/null +++ b/internal/faker/rand_test.go @@ -0,0 +1,51 @@ +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) + + // 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) { + 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..c365635 --- /dev/null +++ b/internal/faker/system_test.go @@ -0,0 +1,63 @@ +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() + + 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), + "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) + } +} 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/opnsensegen/commondevice_test.go b/internal/opnsensegen/commondevice_test.go index 586fd91..384ecaa 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,63 @@ 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, err := faker.NewCommonDevice( + faker.WithSeed(146), + faker.WithVLANCount(2), + // Phase 1 includes filter serialization — exercise it end-to-end. + faker.WithFirewallRules(true), + faker.WithDeviceType(model.DeviceTypeOPNsense), ) - - cfg, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") 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) + require.NotEmpty(t, original.FirewallRules, "test precondition: faker must emit firewall rules") - //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) + doc, err := serializer.Serialize(original) + require.NoError(t, err) - // 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.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 @@ -103,7 +83,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 +91,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..c6af28b 100644 --- a/internal/opnsensegen/template.go +++ b/internal/opnsensegen/template.go @@ -1,24 +1,33 @@ -// 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" + "github.com/charmbracelet/log" ) -// 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 +35,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,134 +44,164 @@ 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) +// 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. +// +// 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 { + 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) } - - 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()), - } + if err := enc.Flush(); err != nil { + return fmt.Errorf("flush XML encoder: %w", err) } -} -// 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) + stable, err := sortMapBackedSections(body.Bytes()) + if err != nil { + return fmt.Errorf("stabilize XML: %w", err) } - for i, dhcp := range dhcpConfigs { - ifName := fmt.Sprintf("opt%d", optCounter+i) + var out bytes.Buffer + out.Grow(len(xml.Header) + len(stable) + 1) + out.WriteString(xml.Header) + out.Write(stable) + out.WriteByte('\n') - 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 + if _, err := w.Write(out.Bytes()); err != nil { + return fmt.Errorf("write XML: %w", err) } + return nil } -// 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) +// 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("", " ") - var log opnsense.BoolFlag - if r.Log { - log = true + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err } - 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. -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) - enc.Indent("", " ") - - if err := enc.Encode(cfg); err != nil { - return fmt.Errorf("encode config XML: %w", err) - } + start, isStart := tok.(xml.StartElement) + if !isStart || !mapBackedSections[start.Name.Local] { + if err := enc.EncodeToken(tok); err != nil { + return nil, err + } + continue + } - if _, err := io.WriteString(w, "\n"); err != nil { - return fmt.Errorf("write trailing newline: %w", err) + if err := emitSortedChildren(dec, enc, start); err != nil { + return nil, err + } } - 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} + if err := enc.Flush(); err != nil { + return nil, err } - - return opnsense.Source{Network: source} + return out.Bytes(), nil } -// buildDestination creates an opnsense.Destination from generator destination and port strings. -func buildDestination(destination, ports string) opnsense.Destination { - dst := opnsense.Destination{} - - if destination == opnsense.NetworkAny { - empty := "" - dst.Any = &empty - } else { - dst.Network = destination +// 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 + } - if ports != opnsense.NetworkAny { - dst.Port = ports + 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)) + 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 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 { + 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) + } + } } - - return dst } diff --git a/internal/opnsensegen/template_test.go b/internal/opnsensegen/template_test.go index a4aa21d..23b4c8a 100644 --- a/internal/opnsensegen/template_test.go +++ b/internal/opnsensegen/template_test.go @@ -2,13 +2,12 @@ package opnsensegen_test import ( "bytes" - "math/rand/v2" - "net/netip" + "errors" "strings" "testing" - "github.com/EvilBit-Labs/opnConfigGenerator/internal/generator" "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" ) @@ -24,6 +23,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,161 +50,159 @@ 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 TestParseConfigInvalidXML(t *testing.T) { + t.Parallel() + + _, err := opnsensegen.ParseConfig([]byte("not xml")) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse config XML") } -func TestInjectVlans(t *testing.T) { +func TestMarshalRoundTrip(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, - }, - } + var buf bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(cfg, &buf)) - opnsensegen.InjectVlans(cfg, vlans, 6) + output := buf.String() + assert.Contains(t, output, "") + assert.Contains(t, output, "opnsense") + assert.Contains(t, output, "") +} - 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) +// 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 +} - // 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 (w *countingWriter) Write(p []byte) (int, error) { + w.writes++ + if w.err != nil { + return 0, w.err + } + return w.buf.Write(p) } -func TestInjectFirewallRules(t *testing.T) { +func TestMarshalConfigIsAtomicOnSuccess(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) + w := &countingWriter{} + require.NoError(t, opnsensegen.MarshalConfig(cfg, w)) - // 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") + assert.Equal(t, 1, w.writes, "MarshalConfig must perform exactly one Write on success") } -func TestInjectDHCP(t *testing.T) { +func TestMarshalConfigDoesNotWriteOnWriterFailure(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) + sentinel := errors.New("disk full") + w := &countingWriter{err: sentinel} + err = opnsensegen.MarshalConfig(cfg, w) - // 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) + 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") } -func TestMarshalRoundTrip(t *testing.T) { +// 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, err := opnsensegen.LoadBaseConfig("../../testdata/base-config.xml") - require.NoError(t, err) + 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 - err = opnsensegen.MarshalConfig(cfg, &buf) - require.NoError(t, err) - - output := buf.String() - assert.Contains(t, output, "") - assert.Contains(t, output, "opnsense") - assert.Contains(t, output, "") + 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 TestMarshalWithInjectedData(t *testing.T) { +func TestMarshalConfigHandlesEmptyMapBackedSections(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}, + 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{}}, } - opnsensegen.InjectVlans(cfg, vlans, 6) - 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, "42") - assert.Contains(t, output, "IT VLAN 42") - assert.Contains(t, output, "10.42.7.1") + 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, "") } -func TestMarshalXMLEscaping(t *testing.T) { +// 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, 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, + 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"}, + }, }, } - opnsensegen.InjectVlans(cfg, vlans, 6) - - var buf bytes.Buffer - err = opnsensegen.MarshalConfig(cfg, &buf) - require.NoError(t, err) + var first bytes.Buffer + require.NoError(t, opnsensegen.MarshalConfig(cfg, &first)) - 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") + 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/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..2b5397b --- /dev/null +++ b/internal/serializer/opnsense/dhcp_test.go @@ -0,0 +1,42 @@ +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}}) + 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/firewall.go b/internal/serializer/opnsense/firewall.go new file mode 100644 index 0000000..f6b74bd --- /dev/null +++ b/internal/serializer/opnsense/firewall.go @@ -0,0 +1,67 @@ +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. 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 "": + // Leave Any/Network/Address unset. + 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 "": + // Leave Any/Network/Address unset. + 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..e02f10c --- /dev/null +++ b/internal/serializer/opnsense/firewall_test.go @@ -0,0 +1,77 @@ +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 TestSerializeFilterEmptyEndpointStaysEmpty(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) + // 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 new file mode 100644 index 0000000..b1411b2 --- /dev/null +++ b/internal/serializer/opnsense/interfaces.go @@ -0,0 +1,44 @@ +package opnsense + +import ( + "github.com/EvilBit-Labs/opnDossier/pkg/model" + "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 { + out := opnsense.Interface{ + 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" + 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..e390898 --- /dev/null +++ b/internal/serializer/opnsense/interfaces_test.go @@ -0,0 +1,70 @@ +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}}) + 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) { + t.Parallel() + + out := serializer.SerializeInterfaces([]model.Interface{{ + Name: "opt1", Enabled: true, Type: "", IPAddress: "1.2.3.4", Subnet: "24", + }}) + 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 new file mode 100644 index 0000000..473db4e --- /dev/null +++ b/internal/serializer/opnsense/overlay.go @@ -0,0 +1,35 @@ +package opnsense + +import ( + "errors" + "fmt" + + "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, fmt.Errorf("overlay: serialize device: %w", 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..38e8669 --- /dev/null +++ b/internal/serializer/opnsense/overlay_test.go @@ -0,0 +1,116 @@ +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"}, + Sysctl: []opnschema.SysctlItem{ + {Tunable: "net.inet.ip.forwarding", Value: "1", Descr: "route"}, + }, + CAs: []opnschema.CertificateAuthority{ + {Refid: "ca-1", Descr: "internal root"}, + }, + } + 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) +} + +// 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() + + dev, err := faker.NewCommonDevice(faker.WithSeed(1)) + require.NoError(t, err) + _, err = serializer.Overlay(nil, dev) + 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..18a614e --- /dev/null +++ b/internal/serializer/opnsense/serializer_test.go @@ -0,0 +1,184 @@ +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, 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() + + original, err := faker.NewCommonDevice( + faker.WithSeed(2026), + faker.WithVLANCount(3), + faker.WithFirewallRules(true), + faker.WithDeviceType(model.DeviceTypeOPNsense), + ) + require.NoError(t, err) + + 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) + + // System parity. + assert.Equal(t, original.System.Hostname, roundTripped.System.Hostname) + assert.Equal(t, original.System.Domain, roundTripped.System.Domain) + + // 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) + } + + // Firewall rule per-field parity. Faker emits rules keyed to an + // 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) + 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.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) + } +} + +// 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, 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) + + 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 +} + +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 +} 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..dce2826 --- /dev/null +++ b/internal/serializer/opnsense/vlans_test.go @@ -0,0 +1,38 @@ +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) + // 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) { + t.Parallel() + + out := serializer.SerializeVLANs(nil) + assert.Empty(t, out.VLAN) +} 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") -}