I Finally Dropped My Custom 1996 iptables Firewall Script—And Why It Was Time

Thirty years. That is how long my home network routing infrastructure relied on a monolithic shell script I wrote back in 1996. I crafted it back in the golden era of early Linux packet filtering, packed it with raw configuration lines, and tweaked it over decades as a hobby project to survive moving from ipfwadm to ipchains, and eventually to iptables.

It was a personal point of pride. Whenever I set up a new tool in my home lab, I didn’t click buttons; I opened a massive text file, added a manual tracking line, flushed the kernel tables, and reloaded the whole script.

But this week, on my Fedora router, the limitations finally caught up. It was time to pull the plug, learn the modern way of doing things, and migrate my entire home routing setup to firewalld and native nftables.

Here is the story of how I transitioned my home lab, the weird architectural traps I fell into, and how I built a modern, locked-down zone security posture.


The Catalyst: Tracking Down the Ghosts in the Logs

The transition started where all good home lab projects begin: digging through log streams. By default, firewalld drops packets silently. If you want to see what is actually happening behind the scenes, you have to tell it to start logging denied traffic.

I turned on global drop logging using this quick command:

bash

sudo firewall-cmd --set-log-denied=all

Use code with caution.

(Pro-tip: If your logs get too flooded later on after everything is working, you can easily turn this back off by running sudo firewall-cmd --set-log-denied=off).

With logging activated, my journalctl stream instantly exposed the ghosts haunting my network. My internal server was actively throwing network blocks:

text

filter_IN_internal_REJECT: IN=eth1 OUT= MAC=... SRC=10.0.10.55 DST=10.0.10.200 PROTO=TCP DPT=443 SYN

Use code with caution.

Because of these newly introduced blocks from the transition to firewalld, the computers inside my house suddenly couldn’t load local secure web pages, my email client setups were dropping synchronization steps, and my smart devices were choking on network discovery packets. While my legacy 1996 script had seamlessly allowed this internal traffic for years, firewalld‘s strict, out-of-the-box zone behaviors completely locked down the single machine from serving both functions until I configured it correctly.


Step 1: Restoring the Internet Gateway (Masquerading)

The first order of business was transforming my Fedora server into an efficient edge gateway router for the house. In the old days, this meant writing explicit POSTROUTING -o eth0 -j MASQUERADE loops.

With firewalld, we instead split the hardware interfaces into discrete logical security boundaries: external (the WAN modem link on eth0) and internal (the private LAN switch on eth1). To feed the household devices out to the web, we built a dedicated outbound routing policy:

bash

# Set up a clean outbound forwarding pipeline with NAT masquerading
sudo firewall-cmd --permanent --new-policy=internalToExternal
sudo firewall-cmd --permanent --policy=internalToExternal --add-ingress-zone=internal
sudo firewall-cmd --permanent --policy=internalToExternal --add-egress-zone=public
sudo firewall-cmd --permanent --policy=internalToExternal --set-target=ACCEPT
sudo firewall-cmd --permanent --policy=internalToExternal --add-masquerade
sudo firewall-cmd --reload

Use code with caution.

Suddenly, outbound data—including seamless features like Wi-Fi Calling on our phones—slid out to the internet natively without requiring a single manually defined high-number port mapping.


Step 2: Falling Into the “Policy Object” Trap

My first instinct as a hobbyist was to consolidate everything into elegant inter-zone Policy Objects. I attempted to route all inbound public web, mail, and application traffic through a custom externalToInternal forward policy.

The firewall immediately threw a massive architectural roadblock:
Error: INVALID_ZONE: 'forward-port' cannot be used because egress zone 'internal' has assigned interfaces

The lesson learned:firewalld Policy Objects completely forbid port-forwarding rules if the destination zone is attached to a real, physical network card. Policies are designed for virtual spaces (like isolated Docker networks or hypervisor VM bridges).

To achieve a pristine setup, the correct move was to put forwarding rules back onto the zone itself, but keep the raw underlying system ports completely closed off to the public router space.


Step 3: Embracing “Option B” (Strict Local Lockdown)

When configuring internal communication, I faced a fork in the road. Option A was a loose, blanket ACCEPT target for the whole internal network. Option B was a hardened, granular lockdown where only verified application services could move data.

I chose Option B.

Because firewalld drops negative priority policies when traffic is destined for the host itself, trying to manage local server loops through a policy wrapper caused endless rejections. The correct architectural solution was to map my application signatures directly onto our physical internal zone gate.

We stripped out manual numeric ports and used pre-configured service wrappers to expose exactly what we needed, keeping everything else locked tight against unauthorized lateral movement:

bash

# Lock down the internal gate to ONLY trusted local infrastructure services
sudo firewall-cmd --permanent --zone=internal --add-service=http
sudo firewall-cmd --permanent --zone=internal --add-service=https
sudo firewall-cmd --permanent --zone=internal --add-service=ssh
sudo firewall-cmd --permanent --zone=internal --add-service=dns
sudo firewall-cmd --permanent --zone=internal --add-service=smtp
sudo firewall-cmd --permanent --zone=internal --add-service=smtps
sudo firewall-cmd --permanent --zone=internal --add-service=imaps
sudo firewall-cmd --permanent --zone=internal --add-service=plex
sudo firewall-cmd --permanent --zone=internal --add-service=samba
sudo firewall-cmd --permanent --zone=internal --add-port=8123/tcp  # Home Assistant
sudo firewall-cmd --reload

Use code with caution.


The Final Verdict: A Pristine Architecture

After sweeping out legacy clutter (like default desktop samba-client wrappers and unneeded dhcpv6-client daemons on the inner switch interface), the firewall configuration achieved absolute stability.

Running a diagnostic audit prints out an incredibly clean, legible system baseline:

text

external (active)
  interfaces: eth0
  services: http https imaps smtp smtps ssh

internal (active)
  interfaces: eth1
  services: dns http https imaps plex samba smtp smtps ssh
  ports: 8123/tcp

Use code with caution.

Everything is fully persistent and gracefully survives cold reboots. Local devices can map storage drives over Samba, stream Plex, resolve local DNS queries, and view internal pages. Meanwhile, the public web interface remains blind to everything except our secure edge proxies—and public pings remain active to satisfy network path diagnostics and MTU discovery boundaries as recommended by universal best practices.

It took me thirty years to finally retire that old text script. But seeing a silent journalctl stream and a flawless, granular zone layout?

It was an awesome weekend project upgrade.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.