Skip to content

Firewall

vxnsin edited this page Sep 7, 2026 · 8 revisions

Firewall

warden holds the machine's packet filter in whatever that machine actually uses, and every change it makes can be undone by doing nothing.

warden firewall allow ssh --from 10.0.0.0/8
warden firewall allow 8080
warden firewall apply

Rules are written down first and applied when you say so. warden firewall export prints what the machine would be told, and changes nothing. --for nftables writes for a firewall this machine does not have, which is how a ruleset gets read on a laptop before it reaches the server it is meant for.

Which firewall

kind Used on How a whole ruleset is applied
nftables Linux, by default nft -f — one transaction
iptables Linux, asked for by name iptables-restore — one transaction
pf macOS, FreeBSD, OpenBSD, NetBSD pfctl -f — one ruleset at a time
windows Windows netsh, rule by rule, with a snapshot around it

warden picks by what the machine is. WARDEN_FIREWALL_BACKEND or --for names one instead — an older Linux box still running iptables-legacy wants iptables.

Three of the four swap a whole ruleset in one step. Windows Defender Firewall has no such thing, so that backend takes its own snapshot before it starts and puts it back if any rule fails: a half-applied policy is never left standing.

Windows also filters statefully whichever way it is told, so it needs no established-traffic rule — allowing outbound is what lets the answers back. The other three are told explicitly, and warden never writes a ruleset without it:

ct state established,related accept
iif lo accept

Without those two lines a default-drop policy also drops the ssh session that applied it.

Nothing that cannot be undone

$ warden firewall apply
12 rules applied
rolling back in 60s unless you run `warden firewall confirm`

The order matters and is not negotiable:

  1. Snapshot first. Taken after the change, it would describe the change rather than what to go back to.
  2. Arm the rollback second. Armed after applying, there would be a window where a lost session means a lost machine.
  3. Apply third. An apply that fails disarms, because nothing was applied and nothing should be waiting to be undone.

The watchdog runs detached — its own session on POSIX, DETACHED_PROCESS on Windows — so it outlives the ssh session that armed it. That is the whole point: a rule that locks you out costs a minute, not a drive.

Command
warden firewall confirm Keep it, and call off the rollback
warden firewall restore Put it back now, without waiting
warden firewall status Whether a rollback is waiting, and for how long

--rollback 0 turns the window off, for a machine you are sitting at. WARDEN_FIREWALL_ROLLBACK sets the default, which is 60 seconds.

Taking over from ufw or firewalld

Two firewalls managing one machine is one too many, so taking over means reading the other one first and turning it off last.

$ warden firewall adopt
ufw is holding 5 rules
NAME              DIR  ACTION  WHAT           FROM        ORIGIN   UNTIL
allow-22          in   allow   tcp/22         any         adopted  -
allow-8000-8100…  in   allow   tcp/8000-8100  10.0.0.0/8  adopted  -
deny-3389         in   deny    tcp/3389       any         adopted  -
could not read: [ 6] Anywhere on eth0  ALLOW FWD  10.5.0.0/16
those would be lost. Write them by hand first, or say no.
Take over from ufw? [y/N]:
  • ufw is read from ufw status numbered, which is the output it documents. A bare 3389 means both protocols to ufw, so it becomes two rules rather than half an answer. ufw lists v4 and v6 apart; warden's table is inet, which is both, so the twin is not a second rule.
  • firewalld is read from firewall-cmd --list-all: services resolved against warden's own catalogue, ports and ranges as written, and the zone's source becoming the rule's source.

Anything that cannot be translated is named before anything is applied. A rule quietly dropped in this step is a door quietly left open, or quietly shut.

The other firewall stays enabled while the rollback window is open. Its rules are not loaded — warden's ruleset replaced them in one step — but the service is untouched, so rolling back returns the machine exactly as it was. warden firewall confirm is what finally disables it, and says so:

kept
  ufw --force disable
ufw is off

A rate on a rule

warden firewall allow ssh --from 10.0.0.0/8 --limit 6/minute
warden firewall allow 8080 --limit 10/second

A count and a span: second, minute, hour or day. Anything else is refused at the model rather than written into a ruleset, because a field somebody can put anything into ends up in a ruleset.

Backend What it becomes
nftables limit rate 6/minute before the verdict, so what exceeds it falls through to the chain policy
iptables -m limit --limit 6/minute
pf keep state (max-src-conn-rate 6/60) — counted per source address, which is the closest thing pf has
Windows refused by name

The Windows firewall has no rate limit. Applying the rule without it would mean the same rule lets a great deal more through on that machine than on any other, and nothing would say so — so the rule is refused with its name in the message instead.

Adopting a ufw LIMIT rule

ufw's LIMIT is an allow with a brake: six attempts in thirty seconds and the seventh is dropped. warden used to recognise the word, fail to translate it, and name the rule in the report as untranslated — which is honest, and still means somebody who was rate-limiting ssh before adopting warden was not afterwards.

It is now carried over as 12/minute, the same rate written the way warden and all three backends can say it, and the original ufw line is kept in the rule's comment:

$ warden firewall list
NAME       DIR  ACTION  WHAT          FROM  RATE       ORIGIN    UNTIL
allow-22   in   allow   tcp/22 (ssh)  any   12/minute  adopted   -

warden counts rates; ufw counts hits in a window. The rate is the nearest thing, not the same thing, which is why the line it came from stays in the comment.

Named services

warden firewall allow ssh
warden firewall allow postgres --from 10.0.0.0/8
warden firewall deny smb

ssh, sftp, ftp, http, https, dns, dhcp, ntp, smtp, imap, rdp, vnc, smb, postgres, mysql, redis, mongodb, wireguard, mdns. A name nobody knows suggests the nearest one rather than just refusing.

A bare port number works too, and --proto udp when it is not tcp.

A rule that closes itself

warden firewall allow 8443 --from 10.0.0.0/8 --for 2h
warden firewall deny 25 --for 30m

30s, 15m, 2h, 1d. When the time is up the rule is swept out of the book the same way a rule that borrowed a service's lease goes when the lease lapses — same sweep, same event, same line in the history.

Before this, only two things could close themselves: a rule bound to a registration, and the dev-mode window. Opening one port for one afternoon meant opening it and remembering to close it, which is the failure every other part of this design refuses to allow.

warden firewall list shows what is left in the UNTIL column, and warden doctor says how long the first one has:

note  2 rules closing on their own, the first of them in 19m

Worth knowing before one closes rather than after — a rule that goes on its own is the point of giving it a clock, and also the thing somebody forgets they did.

A swept rule is out of warden's book and still in the kernel until the next warden firewall apply, exactly as written down is not applied describes. warden firewall pending counts it, and it is the same sentence in both places on purpose.

Rules that belong to a service

This is the part no other firewall tool can do, and the reason the registry and the firewall are one program: warden knows which port a service holds, for how long, and whether its holder is still there.

$ warden firewall open shop-api
NAME            DIR  ACTION  WHAT      FROM        ORIGIN    UNTIL
allow-shop-api  in   allow   tcp/8300  10.0.0.0/8  registry  58s

The rule takes the port the registry actually handed out, and the lease with it. When the registration lapses or is released, the rule goes:

$ warden firewall list
closed allow-shop-api - its service is gone
no rules yet

warden firewall dev-mode --for 2 opens the whole pool from one network for two hours, for the afternoon somebody else needs to reach what you are running. It closes itself, and warden doctor says it is open for as long as it is.

What a rule from the registry can never do

The registry listens on loopback and asks for no token there. If registering a service could open the machine to the network, then anything able to register could open the machine to the network — which is how UPnP became a byword. So a rule whose origin is the registry passes through one place, and that place decides:

  1. Registering opens nothing. A person types warden firewall open.
  2. The pool is the ceiling. Only ports between pool_start and pool_end. 22, 3389 and 445 are outside it and stay unreachable — to a mistake, and to a registry somebody else is driving.
  3. Loopback services have nothing to open, and are told so.
  4. Off until switched on. firewall_from_registry is false.
  5. Where from is decided once, by the operator, in firewall_allow_from. Not by the service.
  6. The lease is the rule's lease. A rule may not outlive it.
  7. Every rule says where it came frommanual, adopted, catalogue, registry — in the listing, the history, the event stream and the webhook.
  8. A development window is bounded and temporary, never a standing hole.

Bounds 2 and 6 carry the weight: one limits what can ever be opened, the other how long. Both are checked in one place, and there is a test for each of the eight that tries to get through it.

From another machine

Everything above is a person at a keyboard. The thing that most wants to open a port is somewhere else: a deploy that has just registered a service.

from warden import WardenClient

with WardenClient("http://build-01:7010", token="...") as client:
    rule = client.firewall_open("shop-api", source="10.0.0.0/8")
    client.firewall_apply(rollback=60)   # undoes itself unless confirmed
    client.firewall_confirm()
warden firewall status --on http://build-01:7010
warden firewall list   --on http://build-01:7010
warden firewall open shop-api --on http://build-01:7010

The switch

Reading is what a token already allows. Changing needs one setting on the machine being asked, and it is off out of the box:

warden settings set allow_remote_firewall true

That covers everything a caller can change: opening a registered service's port, writing a rule down by hand, applying, confirming, restoring. Without it, each of them comes back 403:

changing the firewall over the API is switched off -
set allow_remote_firewall on this warden to allow it

It is deliberately separate from firewall_from_registry. One decides what a rule may be, the other decides who may ask for one. A machine that never wants to be asked says so once, rather than relying on the other being off.

warden doctor fails — not warns — a warden that will change its own firewall on request, listens beyond loopback and asks for no token. That combination is a way through a firewall rather than a firewall.

It is not a way round the bounds

A rule asked for over the network is built by the same code as one asked for at the keyboard, so it meets the same eight bounds:

Asked for Answer
a source outside firewall_allow_from 403, naming what is allowed
a service bound to 127.0.0.1 403, "there is nothing to open"
a port outside the pool nothing can hold it, so nothing can open it
a comment with a newline in it 422, before it is stored
nothing named, and two networks declared 403 — warden does not pick for you

Endpoints

Method Path Needs Purpose
GET /v1/firewall token Backend, whether it is available, rule counts, what is waiting to roll back
GET /v1/firewall/rules token Every rule, ?origin= to filter
POST /v1/firewall/open token + switch The port a registered service holds
DELETE /v1/firewall/rules/{name} token + switch Take one back out
POST /v1/firewall/apply token + switch Make them true, ?rollback= in seconds
POST /v1/firewall/confirm token + switch Keep what was applied
POST /v1/firewall/restore token + switch Put a snapshot back, ?snapshot=

The rollback matters more here than at a keyboard: the caller is somewhere else, and a rule that shuts the door shuts it on them. apply arms it, a detached watchdog counts it down, and nothing is kept until confirm.

The whole fleet, from the hub

One firewall at a time is what nobody does at forty machines. The hub sees all of them and can drive all of them:

warden firewall status --all            # one line per node
warden firewall list --all              # every rule anywhere, with its node
warden firewall open shop-api --all     # on every node that holds it
warden firewall apply --fleet --rollback 120
warden firewall confirm --fleet
$ warden firewall status --all
NODE      BACKEND   RULES  REGISTRY  REMOTE  ROLLING BACK
build-01  nftables  12     3         yes     2m
hub       nftables  8      0         yes     -
web-02    iptables  0      0         no      -
db-03 (http://db-03:7010) could not be reached

A node that did not answer is named rather than quietly left out — the same rule the rest of the fleet views follow.

Applying across a fleet

$ warden firewall apply --fleet --rollback 120
NODE      RESULT   DETAIL
build-01  applied  12 rules, rolling back at 14:22:31
db-03     refused  changing the firewall over the API is switched off
hub       applied  8 rules, rolling back at 14:22:31
web-02    applied  4 rules, rolling back at 14:22:31

3 of 4 applied
`warden firewall confirm --fleet` keeps them; anything not confirmed puts itself back

Every node does it to its own firewall: its own snapshot, its own ruleset, its own watchdog counting down on its own machine. Nobody waits for anybody, and a node that refuses does not stop the rest.

A fleet-wide apply will not give up its rollback. --rollback 0 is refused, and it is the only place in warden where leaving the window out is not allowed. One wrong rule across forty machines is forty machines you cannot reach, and the watchdog on each of them is the only thing that opens them again without somebody driving there.

Confirming is a separate act. The hub never confirms on its own, however cleanly every node reported — the point of the window is that a person checks they can still reach the machines. Anything left unconfirmed comes back by itself.

Opening a service everywhere it lives

$ warden firewall open shop-api --all
NODE      RESULT   DETAIL
build-01  opened   allow-shop-api - 8000 from 10.0.0.0/8
hub       skipped  does not hold shop-api
web-02    opened   allow-shop-api - 8143 from 10.0.0.0/8

A name means a different port on every machine, and nothing at all on most of them. The hub asks who holds it first; a node that never registered it is skipped and said so rather than treated as a failure. Each node still resolves its own port and its own lease, and each rule still meets every bound.

One node at a time

warden firewall open shop-api --node build-01
warden firewall delete allow-shop-api --node build-01
warden firewall apply --node build-01 --rollback 120
warden firewall confirm --node build-01

--node goes through the hub. --on http://host:7010 goes straight to one warden without a hub in the middle. Both end up asking the same machine, and that machine decides.

What the hub cannot do

  • It cannot overrule allow_remote_firewall. A node with it off refuses, and the refusal is printed in that node's own words.
  • It cannot send a rule. open names a service; which port that is, and for how long, is the node's own answer.
  • It cannot get round the eight bounds. They are checked where the rule is built, which is on the node.
  • It cannot confirm for you. See above.

Written down is not applied

Writing a rule down does not put it in the kernel. warden firewall allow says so once, and after that nothing does — so a machine with three rules sitting in the book since yesterday looks exactly like a machine with three rules in force.

$ warden firewall pending
  +  allow-shop-api
  +  allow-grafana
  -  deny-8080

last applied 19h ago  -  `warden firewall apply` makes them true

+ is written down and not live, - is live and no longer written down. The second one is why this is a real answer rather than a guess at timestamps: a rule that was closed leaves no timestamp behind at all.

warden knows because it writes down what it applied, at the moment it applies it. Not what the kernel holds — that is the kernel's business, and asking it would mean translating four dialects back into rules. What this compares is what warden last made true against what warden now says is true.

After a rollback it says it does not know

$ warden firewall pending
nothing has been applied from here, so there is nothing to compare against

A rollback puts back a snapshot — a ruleset warden did not compose and cannot name. Rather than claim the rules in its book are live, it says it has nothing to compare against until the next apply. The same answer comes back on a machine that has never applied at all.

Where else it says so

  • warden doctor — a note, and a warning once the last apply is more than a day old. "The rule you wrote is not in force" is the answer to why is this not working more often than any other single thing.
  • warden firewall status, and status --all, which grows a PENDING column so drift across a fleet is one command rather than a walk.
  • The dashboard's firewall view, in the line under the table.

When a service dies while the ruleset is applied

warden closes the rule in its own book straight away, so it can never be applied again. The kernel still holds it until the next warden firewall apply, and warden doctor says so. Re-applying a firewall without being asked is not something warden does quietly.

IPv6

warden holds both families. What that means, in each place it could mean something different:

Declaring where the registry may open to. firewall_allow_from takes v6 networks, v4 networks, or both at once:

firewall_allow_from = ["10.0.0.0/8", "2001:db8::/32"]

The bounds. Every one of the eight is asked the same question of a v6 address as of a v4 one. A v6 source is inside a v6 network and outside a v4 one — neither family contains the other, and asking is a refusal rather than a match. ::1 sits in the loopback list beside 127.0.0.1, so a service bound to it is told there is nothing to open, in the same words. The pool bound is about ports and does not care which family it is.

The backends, which is where they differ:

Backend
nftables Both families in one ruleset — ip saddr or ip6 saddr, chosen per rule
pf Takes a v6 address as it is
Windows Takes one too
iptables Refuses the rule by name

iptables-restore is IPv4. Handing it a table with a v6 address in it makes it refuse the whole table — which is every other rule as well — so warden refuses the one rule instead, and says which:

allow-shop-api names 2001:db8::/32, and iptables is IPv4 only -
nftables holds both families in one ruleset

ip6tables exists, and warden does not drive it. Two rulesets that have to be applied and rolled back together is a second transaction to get right, and nftables already solves it on every machine that has a choice.

Adopting. ufw writes a v6 twin beside every v4 rule; warden reads both and keeps one, because the pair is one decision written twice.

What this needs

Root, or an elevated prompt on Windows. Every backend says which it is rather than failing obscurely:

nft refused: Operation not permitted - a firewall needs root
netsh refused: a firewall needs an elevated prompt on Windows

Clone this wiki locally