Signal9Signal9 — home
online
all lessons

firewalld: Managing the Linux Firewall

Beginner40 min6 steps
What you'll learn
  • Explain firewalld's zone model — rules attach to trust levels, not just ports
  • Find the default and active zone, and read a zone's full ruleset before changing it
  • Tell runtime from permanent config — and never lose a rule to a reboot, or wonder why one isn't active
  • Allow services and ports the persistent way, and verify both halves
  • Scope access to specific sources with rich rules and zone bindings
  1. Why firewalld, and the zone idea

    Understand that rules attach to zones, and zones model trust

    A host firewall is the machine's own bouncer: for every incoming connection, it decides accept or refuse before any application even hears about it. You hardened SSH's lock; the firewall decides who gets to touch the lock at all. firewalld is the manager on RHEL-family systems, and it has two headline properties: it applies changes dynamically (no restart, no dropped connections — critical on a production box), and it organizes everything around zones.

    Here's the idea that makes firewalld click. A laptop on coffee-shop Wi-Fi and the same laptop on your home LAN shouldn't apply the same rules — same machine, different trust. So firewalld doesn't attach rules to the machine; it attaches them to zones, where a zone is a named trust level. Picture a receptionist with a different rulebook per entrance: connections arriving through the coffee-shop entrance get the strict public book; the management-LAN entrance gets a relaxed one. Every network interface (and any source IP you explicitly assign) is bound to exactly one zone, and that zone's rulebook is what applies.

    The zones you'll actually use:

    • `public` — the default: untrusted; only explicitly-allowed traffic gets in. Where most server work happens.
    • `trusted` — accept everything. For interfaces where the network itself is the security boundary (a private cluster interconnect). Use sparingly.
    • `drop` — silently discard all incoming, not even a refusal message. The right home for known-hostile sources.
    • `block` — refuse with an ICMP "no." Politer than drop; the sender knows immediately rather than timing out.

    On a single-interface server you'll live in public — but the model still matters, because every rule you write lands in a zone, and rules in the wrong zone silently do nothing.

    Check yourself

    You added a rule to the 'work' zone but traffic is still blocked. ss shows the service listening. What's the first thing to check?

  2. Find your zone, read the rulebook

    See the default and active zones and the current ruleset before changing anything

    Same discipline as every other subsystem: read before you write. Three questions, in order — is the firewall running, what's the default zone, and (the one that actually matters) which zone is active on my interfaces right now?

    Example — not runnable on your lab box

    sudo systemctl enable --now firewalld
    firewall-cmd --state; firewall-cmd --get-default-zone; firewall-cmd --get-active-zones
    running
    public
    public
      interfaces: eth0
    --state
    is firewalld running?
    --get-default-zone
    the fallback zone for unassigned interfaces
    --get-active-zones
    zones with interfaces/sources bound now — the rulebooks in force
    enable --now, from your systemctl toolkit — running now, back at every boot. Then the three reads: --state (running), --get-default-zone (the zone interfaces fall into when not explicitly assigned — public, almost always), and --get-active-zones (which zones have interfaces or sources bound RIGHT NOW, and which interfaces). The active zone is the rulebook actually in force — the default zone only tells you where new interfaces will land.

    Example — not runnable on your lab box

    firewall-cmd --list-all
    public (active)
      target: default
      icmp-block-inversion: no
      interfaces: eth0
      sources:
      services: ssh dhcpv6-client
      ports:
      protocols:
      forward-ports:
      rich rules:
    The full rulebook for the active zone — your "what is allowed right now" report. Read the meaningful lines: interfaces (eth0 is governed by this book), services (ssh and dhcpv6-client — nothing else gets in), and the empty ports/rich-rules lines (no ad-hoc openings yet). Note ssh in the defaults: that's the deliberate courtesy that stops enabling firewalld from cutting off the SSH session that enabled it.

    Check yourself

    What's the difference between the default zone and the active zone — and which one decides whether your packet gets in?

  3. Runtime vs permanent — the rule that bites everyone

    Make changes that both take effect now AND survive a reboot

    You've met this split three times now — ip vs nmcli, start vs enable — and firewalld has its own flavor, sharp enough to bite in both directions. It keeps two copies of its configuration:

    • Runtime — what the kernel is enforcing this second. A whiteboard: changes hit instantly, and it's wiped clean on --reload or reboot (rewritten from the binder).
    • Permanent — the policy binder on disk, read at boot and on reload. Writing to it changes nothing about what's enforced right now.

    A plain firewall-cmd --add-service=X writes only the whiteboard. Adding --permanent writes only the binder. Neither alone gives you what you almost always want — active now and after reboot — which is why the canonical idiom is two commands:

    Example — not runnable on your lab box

    sudo firewall-cmd --permanent --add-service=http
    sudo firewall-cmd --reload
    success
    success
    --permanent
    write the rule to disk — NOT active until a reload
    --reload
    rebuild the runtime from the permanent config — applies binder, wipes un-persisted scribbles
    The RHCSA idiom, and the production idiom: write the binder (--permanent), then re-read the binder onto the whiteboard (--reload). After these two lines http is allowed now AND at every future boot. One nuance of --reload worth knowing: it wipes any runtime-only rules that never made it to the binder — which is tidy when intentional and a surprise when someone's un-persisted emergency rule vanishes.

    Check yourself

    A colleague ran firewall-cmd --permanent --add-port=9090/tcp an hour ago and reports the port still refuses connections. Config file shows the rule. What happened?

  4. Allow services and ports

    Open access by named service or raw port, and verify

    What goes in the rulebook? Two kinds of allowance. A service is a named bundle — http means 80/tcp, https means 443/tcp, defined in shipped XML — so rules read as intent (--list-services says this box serves web, not port 80 is open, good luck). A raw port is for anything without a predefined name: your app on 8080, a custom agent. The rule of thumb: service when one exists, port when it doesn't.

    Example — not runnable on your lab box

    firewall-cmd --get-services | tr ' ' '\n' | grep -cE '.'
    firewall-cmd --list-services
    150
    ssh dhcpv6-client http
    Two different questions: --get-services lists every service firewalld KNOWS (150 definitions — https, mysql, nfs, samba are all in there), while --list-services shows what's ALLOWED in the current zone (our http from the previous step is now live). Mixing these two up makes --get-services output look alarmingly like an open-everything firewall — it's the menu, not the order.

    Example — not runnable on your lab box

    sudo firewall-cmd --permanent --add-port=8080/tcp
    sudo firewall-cmd --reload
    firewall-cmd --list-ports
    success
    success
    8080/tcp
    --add-port=8080/tcp
    open a port/protocol; ranges like 5000-5100/tcp work
    --remove-service / --remove-port
    the inverse — same --permanent + --reload discipline
    --zone=NAME
    aim the command at a specific zone instead of the default
    The raw-port version of the same idiom, for a dev server with no service definition — permanent, reload, then VERIFY with --list-ports. That verify beat matters: it reads the runtime, so it confirms the reload actually happened, closing out both halves of the trap in one check. Ranges work too (--add-port=5000-5100/tcp), removal is the mirror image (--remove-port, same permanent+reload discipline), and any command can target a non-active zone with --zone=work.

    Check yourself

    Why prefer --add-service=https over --add-port=443/tcp when both open the same port?

  5. Scope access with rich rules and sources

    Allow something for one host or network, not the whole world

    --add-service=mysql opens the database to everyone the interface can hear — usually far more trust than intended. The real requirement is almost always scoped: "only the app server may reach 3306." firewalld gives you two scoping tools at two granularities: a rich rule (a precise sentence: this source, this port, this action) and a source-to-zone binding (coarse: this whole network gets that zone's entire rulebook).

    Example — not runnable on your lab box

    sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.50" port port="3306" protocol="tcp" accept'
    sudo firewall-cmd --reload
    success
    success
    Read the rich rule left to right like the sentence it is: for IPv4, from source 192.168.1.50, to TCP port 3306 — accept. That one host reaches MySQL; everyone else still hits the zone's default refusal, because 3306 appears in no general allowance. Rich rules also speak reject (refuse loudly), log prefix="db-probe" (record attempts), and limit value="3/m" (rate-limit) — the vocabulary for rules with judgement.

    Example — not runnable on your lab box

    sudo firewall-cmd --permanent --zone=drop --add-source=203.0.113.0/24
    sudo firewall-cmd --reload
    firewall-cmd --get-active-zones
    success
    success
    drop
      sources: 203.0.113.0/24
    public
      interfaces: eth0
    The coarse tool: bind a source to a zone, and that zone's whole rulebook applies to it. Everything from 203.0.113.0/24 is now silently discarded — the drop zone just became active (see the output) with that network as its member, and source bindings are checked BEFORE interface zones, so this wins over anything public allows. This is the clean, persistent way to ban a hostile network — the firewalld move that pairs with fail2ban's automated per-IP version.

    Check yourself

    Write the scoping decision: the whole 10.8.0.0/24 VPN subnet should be fully trusted, but exactly one external host (198.51.100.7) needs to reach TCP 8443. Which tool for which?

  6. Now do it — open a service the graded way

    Run the open-verify loop a "configure the firewall" lab grades, both halves checked

    The full lifecycle, hands on: baseline, change, then — the part that separates done from provably done — verify both stores. Graded labs check the permanent config (will it survive reboot?) AND the runtime (is it active?); so should you, every time.

    Example — not runnable on your lab box

    sudo systemctl enable --now firewalld
    firewall-cmd --get-active-zones; firewall-cmd --list-all
    Running + persistent (one systemctl idiom), then the two reads: which zone is live on which interface, and its full current rulebook. This baseline tells you what 'unchanged' looks like — you can't verify a change without it.

    Example — not runnable on your lab box

    sudo firewall-cmd --permanent --add-service=https
    sudo firewall-cmd --reload
    firewall-cmd --list-services
    The canonical idiom, all three beats: binder, reload, verify-runtime. https should now appear in the services line. Say the two-store model out loud as you type — 'writing the binder… copying to the whiteboard… reading the whiteboard' — until the --permanent-then---reload pair is one gesture in your fingers.

    Example — not runnable on your lab box

    firewall-cmd --query-service=https; firewall-cmd --permanent --list-services
    The both-stores verification: --query-service asks the RUNTIME (expect 'yes' — it's enforced now), and --permanent --list-services reads the BINDER (https present — it survives reboot). Two stores, two checks. This pair is exactly how you'd prove the task in an exam, and exactly how you'd debug either half of the trap in production.

    Example — not runnable on your lab box

    sudo firewall-cmd --add-port=9999/tcp; firewall-cmd --list-ports; sudo firewall-cmd --reload; firewall-cmd --list-ports
    Spring the trap on purpose: add 9999 runtime-only (no --permanent), see it in --list-ports… then watch --reload WIPE it — the whiteboard rewritten from a binder that never knew about 9999. That vanishing act, witnessed once deliberately, is the reboot-loses-my-rule bug you'll now never ship.
Recap
  • firewalld attaches rules to ZONES — named trust levels bound to interfaces/sources (public = default-deny-except-allowed; trusted/drop/block for the edges). A correct rule in the wrong zone silently does nothing: always check --get-active-zones first.
  • Two config stores: runtime (the whiteboard — enforced now, wiped on reload/reboot) and permanent (the binder — disk, read at reload/boot). The trap bites BOTH ways: runtime-only vanishes at reboot; permanent-only isn't active now.
  • The canonical idiom is three beats: --permanent (write binder) → --reload (rebuild whiteboard) → verify (--list-services/--list-ports reads the runtime).
  • Allow by named service where one exists (self-documenting intent), raw port where it doesn't; --remove-* mirrors with the same discipline; --zone=NAME aims at non-default zones.
  • Scope by granularity: rich rules for precise sentences (one source, one port, accept/reject/log/limit); source-to-zone bindings for whole networks (drop zone = the clean persistent ban). Panic mode exists — console-only.
  • Prove work in both stores: --query-service (runtime) + --permanent --list-services (binder) — that pair is exam verification and production debugging alike.
Task → command
TaskCommand
Zone in force / rulebook--get-active-zones · --list-all
Allow service (persist)--permanent --add-service=http + --reload
Allow port (persist)--permanent --add-port=8080/tcp + --reload
Verify runtime / binder--query-service=NAME · --permanent --list-services
One host → one port--add-rich-rule='rule family=ipv4 source address=IP port port=N protocol=tcp accept'
Ban a network--permanent --zone=drop --add-source=CIDR + --reload
Break glass--panic-on / --panic-off (console only)