Proxy 03 - firewalld rich rules
Proxy Solution · Previous: Interfaces and firewalld zones · Next: Squid with SSL interception
After the previous part every zone on the proxy drops what it is not told to accept, so a host that answered nobody had to be told, rule by rule, who may reach which port on its internal interface. This part is those rules: the proxy port for the clients, ICMP, SSH for the two Ansible hosts and for the management ranges, SNMP for the Sensu hosts. The lab had two of them, datacenter 1 got nineteen and datacenter 2 twenty-nine, and the three sets are not the same. The whole command sets are Config documents: firewalld in the lab, firewalld on dc1-a-vcprx001 and firewalld on dc2-a-vcprx001; the final listing of datacenter 2 is rich rules on dc2-a-vcprx001.
Why rich rules, and why with a destination
The notes do not say why rich rules were chosen over --add-service or --add-port, so this is my reading. A service or a port added to a zone accepts that port from everything that lands in the zone, which on internal means every host behind ens3. A rich rule carries a source and a destination of its own, so it can say "port 22 from these two hosts" and "port 3128 from the three private ranges and the organisation's /32", and nothing else. Writing the proxy's own ens3 address as destination, 10.11.16.113/32 or 2001:db8:a1:b96::f:1/128, pins each rule to that address: a packet for the external address, should one ever arrive on ens3, matches no rule and falls to the zone target DROP, where --set-log-denied=all logs it. On a zone with target DROP the rules, plus the ssh service that stayed in the zone on every host, are therefore the complete list of what the host accepts, which is the point of the design. In the lab the first version used source address=0.0.0.0/0, any IPv4 source; the datacenters replaced that with the three RFC 1918 ranges.
Every rule was added with --permanent and became active only at the firewall-cmd --reload at the end, after all the groups; there was no reload in between on either datacenter host.
The five groups
| Group | Source | Destination port | Datacenter 1 | Datacenter 2 |
|---|---|---|---|---|
| Proxy | 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8; 2001:db8::/32 | TCP 3128 | 4 rules | 4 rules |
| ICMP | any | protocol icmp | 1 rule, no family | 1 rule family=ipv4, plus a direct rule for ICMPv6 |
| Ansible | the two Ansible hosts, IPv4 and IPv6 | TCP 22 | 4 rules, datacenter 1 hosts only | 8 rules, the hosts of both datacenters |
| OS management | the same three ranges and the /32 as the proxy group | TCP 22 | 4 rules | 4 rules |
| Sensu | the three Sensu hosts, IPv4 and IPv6 | UDP 161 | 6 rules, datacenter 1 hosts only | 12 rules, the hosts of both datacenters |
The same pattern repeats for each: one rule per source network or host and per address family, with the host's own IPv4 or IPv6 address as destination. The first proxy rule of datacenter 1, as root on dc1-a-vcprx001, and the ICMPv6 rule of datacenter 2, as root on dc2-a-vcprx001, show the two shapes.
$ firewall-cmd --permanent --zone=internal --add-rich-rule='rule family=ipv4 source address=192.168.0.0/16 destination address=10.11.16.113/32 port port=3128 protocol=tcp accept' $ firewall-cmd --permanent --zone=internal --direct --add-rule ipv6 filter INPUT_direct -p icmp6 -j ACCEPT
The management group opens SSH from the same ranges that may use the proxy, which is every host in the three private ranges and the /32; the Ansible group, which came before it, opened SSH from two hosts. Once the management rules are in, the Ansible rules add nothing, because 10.11.17.241 is inside 10.0.0.0/8 and 2001:db8:a1:b89::f:1 inside 2001:db8::/32. The notes do not comment on this; I read the Ansible group as the first, narrow intent and the management group as the one that was required.
Who may reach what in datacenter 2
flowchart LR rfc["RFC 1918 ranges 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8"] v6["2001:db8::/32"] ans1["Ansible datacenter 1, 10.11.17.241, .242, 2001:db8:a1:b89::f:1, ::f:2"] ans2["Ansible datacenter 2, 10.12.17.241, .242, 2001:db8:a2:b89::f:1, ::f:2"] sen1["Sensu datacenter 1, 10.11.16.129 to .131, 2001:db8:a1:bb1::f:1 to ::f:3"] sen2["Sensu datacenter 2, 10.12.16.129 to .131, 2001:db8:a2:bb1::f:1 to ::f:3"] any["anyone on ens3"] subgraph dc2["dc2-a-vcprx001, zone internal on ens3, target DROP"] p3128["TCP 3128 Squid, 10.12.16.113 and 2001:db8:a2:b96::f:1"] p22["TCP 22 SSH"] p161["UDP 161 SNMP"] icmp["ICMP"] icmp6["ICMPv6, direct rule"] drop["everything else, dropped and logged"] end rfc -- "3 rich rules" --> p3128 v6 -- "1 rich rule, listed with the datacenter 1 address" --> p3128 rfc -- "3 rich rules" --> p22 v6 -- "1 rich rule" --> p22 ans1 -- "4 rich rules" --> p22 ans2 -- "4 rich rules" --> p22 sen1 -- "6 rich rules" --> p161 sen2 -- "6 rich rules" --> p161 any -- "1 rich rule" --> icmp any -- "ip6tables INPUT_direct" --> icmp6 any -.-> drop
ICMP, three ways
The lab and datacenter 1 allow ICMP with one rule, rule protocol value=icmp accept, without an address family. Datacenter 2 wrote the same rule with family=ipv4 and then, under the title "allow ICMP (IPv6)", added not a rich rule but a direct rule: --direct --add-rule ipv6 filter INPUT_direct -p icmp6 -j ACCEPT. As I understand firewalld, a rich rule without a family is added for both families, so the datacenter 1 rule already covered ICMPv6 and the direct rule was not needed; and a direct rule is an ip6tables line placed in firewalld's INPUT_direct chain, which belongs to no zone, so the --zone=internal on that command line has no effect and ICMPv6 is accepted on ens4 as well. The notes do not say why I believed the family was necessary, and they do not test either host with ping6. The direct rule also does not show up in --list-rich-rules, which is why the datacenter 2 listing has twenty-nine lines and not thirty.
Two datacenters, two rule sets
The two production hosts were meant to be the same design with other addresses, and the proxy and management groups are. The rest is not.
| Point | Datacenter 1 | Datacenter 2 |
|---|---|---|
| Ansible SSH | its own two hosts | the two hosts of each datacenter, eight rules |
| Sensu SNMP | its own three hosts | the three hosts of each datacenter, twelve rules |
| ICMP | one rule, no family | family=ipv4 plus the direct rule for ICMPv6 |
ssh service on external | kept, as in the lab | removed |
dhcpv6-client on public | removed | kept |
public zone | ssh only | ssh, dhcpv6-client and ports: 3128/tcp left in place |
| Found on the machine | eth0/eth1 bindings, gone after --change-interface | eth0/eth1 bindings next to ens3/ens4, removed with --remove-interface; sources: 192.168.0.0/16, squid and ports 443, 3128, 80 and 22 on internal, cleaned up by hand except the source |
| Final listing | --list-rich-rules run, no output in the notes | 29 rules listed |
Why datacenter 2 trusts the Ansible and Sensu hosts of datacenter 1 while datacenter 1 does not trust those of datacenter 2 the notes do not say; it may be that datacenter 2 was built second and the cross-datacenter rules were added to it only, or that datacenter 1 was never brought up to date. The two hosts also leave SSH on external in different states: in datacenter 1 the external zone still has the ssh service, and as I understand zone targets a service is accepted before the target DROP applies, so port 22 of that host is open on the internet side unless something in front of it says otherwise; the notes do not mention it. public has no interface on either host, so what it keeps is harmless, but it is untidy.
What the notes contradict
| Where | The notes say | What is wrong with it |
|---|---|---|
| Datacenter 2, proxy IPv6 rule | entered with destination 2001:db8:a2:b96::f:1, listed after the reload with 2001:db8:a1:b96::f:1; the step title also names a1 | the listed address is the datacenter 1 proxy. Either the listing was edited by hand or the rule never matched anything on this host; nothing in the notes decides it |
| Datacenter 2, both Ansible IPv6 titles | "on the IPv6 address 2001:db8:a1:b96::f:1" | the rules under them have 2001:db8:a2:b96::f:1, this host's address, and the listing agrees with the rules |
| Datacenter 2, ICMP rule | entered as rule family=ipv4 protocol value=icmp accept, listed as rule protocol value="icmp" accept | the family is gone in the listing; whether firewalld dropped it or the listing was edited I cannot tell |
| Both datacenters, "OS Management" IPv6 title | "allow the proxy port (tcp/3128)" | the rule is port 22 |
| Datacenter 2, Sensu IPv4 title | 10.11.16.131/32 among the datacenter 2 hosts | the rule has 10.12.16.131 |
| Datacenter 1, Sensu IPv6 title | "on the IPv4 address 10.11.16.113" | the rules have the IPv6 destination |
Datacenter 2, --list-all after --permanent --set-target=DROP | target: default | the lab and datacenter 1 show target: DROP at the same point, with no reload in between either. A --list-all without --permanent shows the runtime configuration, so default is what I would expect and the other two notes look edited; that is an inference |
Both datacenters, ifconfig | the same MAC addresses 96:ea:30:12:34:56 and fc:c4:a1:12:34:57 and the same link-local addresses on both hosts | two machines do not share them; I do not derive anything from the values |
The titles are copy-and-paste damage and the commands under them match this host's addresses; for the IPv6 proxy rule whether the command or the listing shows what ran is the open question of the first row. That row is the one that matters, because a proxy that IPv6 clients cannot reach would have been noticed, or not, depending on whether any client used IPv6; the notes hold no test from a client at all.
What is missing
The notes end at the listing. There is no firewall-cmd --direct --get-all-rules, no iptables -L or ip6tables -L to show what the kernel got, no test from a client against port 3128 or 22, no view of the denied-traffic log that --set-log-denied=all turned on, and no firewalld package version. Who the clients in the three private ranges are, which SSH users came from the management ranges, and what the Sensu hosts asked over SNMP are not in them either. The datacenter 1 listing, which would show whether that host's rules survived the reload as entered, is empty in the notes.
Today
Checked against firewalld 2.5.2 of September 2026 (RHEL 7.5 had 0.4.4.4, RHEL 9 ships 1.3.4, RHEL 10 2.3.1 and later 2.4.3; nftables has been the backend since 0.6.0), the rich rules themselves still work: the grammar is unchanged apart from a new priority= attribute, so all nineteen and twenty-nine rules would be accepted as typed. The ICMPv6 direct rule of datacenter 2 would not be written this way: the direct interface is deprecated since 1.0.0 and "superseded by policies", Red Hat calls direct rules "not future-proof", and the same thing is a rich rule, rule family="ipv6" protocol value="ipv6-icmp" accept, or --add-protocol=ipv6-icmp on the zone, an option 0.4.4.4 did not have. The default zone target changed in 1.0.0 to be "identical to reject with one caveat: default allows ICMP packets", so on a zone left at default the ICMP rules would be unnecessary today; on the DROP zones of this design they are still what lets a ping through. Source-based zone bindings are still matched before interface-based ones, so the 192.168.0.0/16 source that stayed on internal in datacenter 2 would still take precedence over the ens3 binding, and firewalld 2.0 added an explicit priority per zone for exactly this kind of overlap. One line from the old man page bears on the target: default contradiction above: in 0.4.4.4 --set-target was documented as "Set the target of a permanent zone", a permanent-only option, which is what a runtime --list-all printing default would mean.
What I would do differently
The listing of datacenter 2 has a datacenter 1 address in it: the rule sets were typed twice, once per host, and the second copy carried the first one's addresses in its titles and, in one rule, maybe in the firewall; and the datacenter 1 listing is empty, so the final state there was listed but its output was not kept. I would list and keep the final state on every host, and I would compare the two sets side by side before calling them the same. Both points come from the notes themselves; what tooling I would use for it today is not something the notes can answer.