feat: Scope the wide area network rule to a parent network alias #4

Merged
gsadmin merged 1 commits from development into main 2026-08-18 03:04:25 +00:00
3 changed files with 23 additions and 8 deletions
@@ -412,6 +412,7 @@ options are omitted entirely when `-SkipPXEConfiguration` is supplied.
| --- | --- | --- |
| `RFC1918` | `network` | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` |
| `CGNAT` | `network` | `100.64.0.0/10` |
| `PARENT_NETWORK_001` | `network` | The resolved parent network, e.g. `10.130.240.0/20` |
| `DNS_SERVERS_001` | `host` | `-DNSServerAddressList`, defaulting to the firewall address on every planned network |
| `DNS_PORTS_TCP_001` | `port` | `-DNSTCPPortList`, default `53`, `853` |
| `DNS_PORTS_UDP_001` | `port` | `-DNSUDPPortList`, default `53`, `853`, `5353` |
@@ -435,7 +436,7 @@ Everything that would otherwise be duplicated per interface is emitted that way,
| 10 | floating | `pass` | `any` | management net | `any` | - |
| 20 | floating | `pass` | `TCP` | `BASTION_SERVERS_001` | `any` | `BASTION_PORTS_001` |
| 30 | floating | `pass` | `any` | `RFC1918` | `PXE_SERVERS_001` | - |
| 40 | `wan`, `disablereplyto=1` | `pass` | `any` | `RFC1918` | `RFC1918` | - |
| 40 | `wan`, `disablereplyto=1` | `pass` | `any` | `RFC1918` | `PARENT_NETWORK_001` | - |
| 100 | floating | `pass` | `TCP` | `RFC1918` | `DNS_SERVERS_001` | `DNS_PORTS_TCP_001` |
| 101 | floating | `pass` | `UDP` | `RFC1918` | `DNS_SERVERS_001` | `DNS_PORTS_UDP_001` |
| 200 | floating | `block` | `TCP` | `any` | `any` | `DNS_PORTS_TCP_001` |
@@ -475,12 +476,14 @@ home. All three are configured, so the route is the only operator step.
| Piece | Value | Note |
| --- | --- | --- |
| `blockpriv` on WAN | `0` (disabled) | **This is what allows traffic to ingress from the host.** The block discards any packet whose *source* falls in the private ranges, and it is applied ahead of the rule set. The host is on the upstream network and so carries a private address, so with the block enabled every packet it sent to this interface would be discarded on arrival and no rule could permit it, because the packet would never reach the rules. Independently correct anyway, since WAN faces a private lab network and the block would otherwise discard the upstream gateway too. `blockbogons` stays `1` - the bogon list excludes the private ranges |
| Rule 40, `RFC1918` → `RFC1918` | `enabled=1` | Clearing the block only gets the packet to the rule set; this rule is what admits it. Bound to `wan`, not floating - floating would apply it to every interface and widen it far past the one path intended |
| Rule 40, `RFC1918` → `PARENT_NETWORK_001` | `enabled=1` | Clearing the block only gets the packet to the rule set; this rule is what admits it. Bound to `wan`, not floating - floating would apply it to every interface and widen it far past the one path intended |
| `disablereplyto` on rule 40 | `1` | Suppresses the `reply-to` that OPNsense would otherwise add automatically (below) |
| `route add <supernet> mask <mask> <WAN address> metric 9999` | deployment summary | One route covers the whole `/20`, so no future network needs adding. The high metric makes it the lowest-priority claim on that prefix; being more specific than the default route is what gets it chosen, so the metric only arbitrates against another route to the same `/20` |
Because the rule is enabled in every deployment rather than only the private-switch one, any private
source on the upstream network can reach the lab over WAN. That is the intent for a lab behind an
source on the upstream network can reach the lab over WAN. The destination is `PARENT_NETWORK_001`
rather than `RFC1918`, which scopes it to the addresses this appliance is responsible for instead of
everything it might route towards, and keeps it that way as routes are added. That is the intent for a lab behind an
operator's own router, and it is what keeps the appliance reachable for management, but it is a
deliberate widening and is documented as the first thing to disable if the upstream network is not
trusted.
@@ -920,7 +923,7 @@ The following were executed against the current implementation rather than assum
| VLAN ceiling guard | A range ending past `4094` throws and names the range; `-StartingVlanId 4080` with 15 networks is accepted and ends exactly at `4094` |
| `New-OPNSenseConfigurationDocument` + `Save-OPNSenseConfigurationDocument` | Valid document, UTF-8 without BOM, matches the OPNsense element shapes |
| Generated rule set on a 16 network plan | 28 rules and 8 aliases, in the sequence order defined in §5.3 |
| Sequence 40 in the generated document | Bound to `wan`, `RFC1918``RFC1918`, direction `in`, `enabled=1`, `disablereplyto=1`. Every other rule `enabled=1` and `disablereplyto=0`, so no rule regressed. `blockpriv=0` confirmed on the same interface |
| Sequence 40 in the generated document | Bound to `wan`, `RFC1918``PARENT_NETWORK_001`, direction `in`, `enabled=1`, `disablereplyto=1`. Every other rule `enabled=1` and `disablereplyto=0`, so no rule regressed. `blockpriv=0` confirmed on the same interface |
| `reply-to` suppression requirement | Confirmed against `FilterRule::convertReplyTo()` in `opnsense/core`: the guard is `empty($rule['disablereplyto'])`, so `0` would not suppress it and `1` is required. Rule 40 meets every other condition (names an interface, direction not `any`, no gateway), and floating rules are exempt because the interface is empty |
| Blast radius of the `reply-to` change | 22 interface-bound rules have a direction other than `any`, but 0 of 17 interfaces define a non-empty `gateway` element, and only `wan` acquires one dynamically through `ipaddr=dhcp`. Rule 40 is the only affected rule and the only one carrying `disablereplyto=1` |
| WAN reachability test, replayed against the live host | Satisfied by the external WAN switch that the management OS shares; correctly declined for both internal switches |
+6 -3
View File
@@ -165,6 +165,7 @@ access to `Servers_Zone_003`. Change the layout with `-NetworkRoleList`; any rol
| --- | --- | --- |
| `RFC1918` | network | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` |
| `CGNAT` | network | `100.64.0.0/10` |
| `PARENT_NETWORK_001` | network | The parent network the lab was subnetted out of, e.g. `10.130.240.0/20` |
| `DNS_SERVERS_001` | host | The address of the firewall on every planned network |
| `DNS_PORTS_TCP_001` | port | `53`, `853` |
| `DNS_PORTS_UDP_001` | port | `53`, `853`, `5353` |
@@ -185,7 +186,7 @@ rule applied to all interfaces rather than sixteen copies.
| 10 | floating | pass | The management network reaches every destination |
| 20 | floating | pass | `BASTION_SERVERS_001` reaches everything over `BASTION_PORTS_001` |
| 30 | floating | pass | `RFC1918` reaches `PXE_SERVERS_001` |
| 40 | WAN | pass | `RFC1918` reaches `RFC1918`, so a private source upstream can reach the lab (§ [Reaching the lab from the host](#reaching-the-lab-from-the-host)) |
| 40 | WAN | pass | `RFC1918` reaches `PARENT_NETWORK_001`, so a private source upstream can reach the lab (§ [Reaching the lab from the host](#reaching-the-lab-from-the-host)) |
| 100-101 | floating | pass | `RFC1918` may resolve against `DNS_SERVERS_001`, one rule per transport |
| 200-201 | floating | block | Name resolution towards any other resolver is dropped |
| 300+ | per interface | pass | Each client zone reaches the server zone carrying the same index |
@@ -234,13 +235,15 @@ things on the appliance decide whether it survives arrival, and all three are co
| What | Why it is needed |
| --- | --- |
| **`blockpriv` is disabled on WAN** - *this allows traffic to ingress from the host* | The private-network block discards any packet whose **source** is in the private ranges, and it runs *ahead of* the rule set. Your host is on the upstream network, so it has a private address itself - with the block on, every packet it sent here would be discarded on arrival and no rule could save it, because the packet would never reach the rules. Disabling the block is what lets those packets in to be evaluated |
| **Rule 40 is enabled** | Getting past the block only gets the packet to the rule set. Rule 40 is what actually admits it. Without it the packet arrives and is dropped |
| **Rule 40 is enabled** | Getting past the block only gets the packet to the rule set. Rule 40 (`RFC1918``PARENT_NETWORK_001`) is what actually admits it. Without it the packet arrives and is dropped |
| **`reply-to` is disabled on rule 40** | OPNsense adds `reply-to` automatically to any rule naming an interface and a direction, which forces replies to the *interface's gateway* instead of following the routing table. Since your host is on the same segment as WAN, that would send every reply out to the upstream router just for it to turn around and deliver it back to the segment it came from. Disabled, replies go straight back |
`blockbogons` stays on - the bogon list does not include the private ranges, so it costs nothing here.
Worth knowing: rule 40 is enabled in **every** deployment, not just the private-switch one, so any
private source on the upstream network can reach the lab over WAN. For a lab sitting behind your own
private source on the upstream network can reach the lab over WAN. Its destination is
`PARENT_NETWORK_001` rather than all of `RFC1918`, so it is scoped to this lab's own supernet and
does not additionally permit whatever else the appliance happens to route towards. For a lab behind your own
router that is the intent - it is what makes the appliance reachable for management. If you ever put
this somewhere the upstream network is not trusted, that rule is the first thing to disable.
@@ -926,6 +926,7 @@ Function New-OPNSenseConfigurationDocument
$AliasDefinitionList = New-Object -TypeName 'System.Collections.Generic.List[System.Management.Automation.PSObject]'
$AliasDefinitionList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property @{Name = 'RFC1918'; Type = 'network'; Content = @('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'); Description = 'Every private address range that is defined by RFC 1918.'}))
$AliasDefinitionList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property @{Name = 'CGNAT'; Type = 'network'; Content = @('100.64.0.0/10'); Description = 'The carrier grade network address translation range that is defined by RFC 6598.'}))
$AliasDefinitionList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property @{Name = 'PARENT_NETWORK_001'; Type = 'network'; Content = @($NetworkPlan.ParentNetwork); Description = 'The parent network that every network behind this appliance was subnetted out of.'}))
$AliasDefinitionList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property @{Name = 'DNS_SERVERS_001'; Type = 'host'; Content = $DNSServerAddressList; Description = 'The name servers that internal networks are permitted to resolve against.'}))
$AliasDefinitionList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property @{Name = 'DNS_PORTS_TCP_001'; Type = 'port'; Content = $DNSTCPPortList; Description = 'Every port that carries name resolution traffic over TCP.'}))
$AliasDefinitionList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property @{Name = 'DNS_PORTS_UDP_001'; Type = 'port'; Content = $DNSUDPPortList; Description = 'Every port that carries name resolution traffic over UDP.'}))
@@ -1198,7 +1199,15 @@ Function New-OPNSenseConfigurationDocument
# Note that the automatic behaviour is not suppressed by writing a zero into the element, because the value is tested in PHP
# with empty(), and a string holding zero satisfies that test. The element has to carry a one.
$Null = $AddRuleDefinition.InvokeReturnAsIs(40, 'pass', 'wan', 'any', 'RFC1918', 'RFC1918', $Null, 'Allow a private source on the upstream network to reach the networks behind this appliance.', 'in', $Null, '1', '1')
# The destination is the parent network rather than every private range. The path this opens is inbound from the upstream
# network, so it is scoped to the addresses this appliance is actually responsible for. Naming every private range as the
# destination would additionally permit anything this appliance happens to route towards, which is wider than the intent
# and widens further the moment another route is added.
#
# Both ends are aliases, so an operator reading the rule set sees what the rule means rather than a pair of literal ranges,
# and the parent network alias tracks whatever parent network the plan resolved.
$Null = $AddRuleDefinition.InvokeReturnAsIs(40, 'pass', 'wan', 'any', 'RFC1918', 'PARENT_NETWORK_001', $Null, 'Allow a private source on the upstream network to reach the networks behind this appliance.', 'in', $Null, '1', '1')
#endregion
#region Name resolution