Nothing binds a port without asking.
One place that decides which local port a service runs on. Services register under a name, say what they are, and get a port back. The same name keeps the same port across restarts, so a backend never wakes up on the port its frontend grabbed while it was down.
$ warden run -- npm run dev
shop-api -> 8000
VITE ready, listening on http://localhost:8000Nothing to change in the project: the port arrives as PORT, is held while the
process runs, and goes back when it exits.
Four tabs — services, ports, firewall rules, nodes — and it is not read-only:
a registers a service or writes a firewall rule, d releases, stops, closes
or forgets whatever the cursor is on, and it asks first every time. With
--all it does the same over a whole fleet, on the node you choose.
uv tool install warden-ports # the `warden` command, anywhere
uvx --from warden-ports warden ports # or just once, without installingThe distribution is called warden-ports because warden on PyPI belongs to
something else. The command it installs is warden either way.
Installation covers pipx,
pip and a checkout.
| Hands out ports | A name asks, a port comes back, and it stays that port. One machine |
| Shows what is listening | warden ports reads the machine, not the registry — no server needed. Ports and processes |
| Remembers | warden history 8000 answers what had this port last week. |
| Says it as it happens | A live event stream, and webhooks for Discord, Slack, Teams or your own endpoint. Events and webhooks |
| Writes your proxy config | warden export caddy turns the registry into a Caddyfile. Reverse proxy |
| Reads a project file | warden.project.toml says which ports a project needs; warden apply makes it true. Projects |
| Decides what may cross | A firewall over nftables, iptables, pf or Windows, where every change undoes itself unless you confirm it. Firewall |
| Spans machines | One hub, many wardens, one view. Cluster |
| Answers for itself | warden doctor replaces four commands and a guess. Troubleshooting |
$ warden register shop-api --kind backend --project shop
8000
$ warden ls
SERVICE KIND PROJECT ADDRESS PID
shop-api backend shop 127.0.0.1:8000 14204Ask again tomorrow and it is still 8000. --preferred-port wishes for one,
--require-port insists and fails if it cannot have it, and
warden register stack --count 4 takes four at once — all of them or none.
$ warden ports --port 3000
PORT PROTO PROCESS PID USER ADDRESS WARDEN
3000 tcp node.exe 25084 dev 0.0.0.0 -
$ warden kill 3000
Stop node.exe (25084) on port 3000? [y/N]: yNeither needs a warden running: they read the machine directly. The WARDEN column names the service whenever the port did come from the registry, so anything unmarked arrived some other way.
# warden.project.toml, beside the code
[project]
name = "shop"
[services.api]
kind = "backend"
[services.web]
kind = "frontend"
preferred_port = 8905$ warden apply --env .env
SERVICE KIND ADDRESS WHAT
shop-api backend 127.0.0.1:8900 taken
shop-web frontend 127.0.0.1:8905 taken
wrote .envRun it again and nothing moves — it renews rather than reshuffling a running project. A service that cannot get the port it insists on fails the whole run before anything is written. Projects has the whole file format.
$ warden events
09:41:02 registered shop-api 127.0.0.1:8600
09:41:44 released shop-api 127.0.0.1:8600warden events --known lists everything it can tell you about: fifteen things
in four scopes, from a port changing hands to a node going quiet to a firewall
rolling itself back. GET /v1/events is the same stream as server-sent events.
A webhook sends them somewhere else — discord, slack and teams post something the chat window
renders, and json posts the event as it is, signed with an HMAC over exactly
the bytes sent so the far end can tell it really came from you.
Nothing ever waits on a webhook: delivery happens after the change is
committed, off the request path, and warden doctor says when the last one did
not arrive — because from the inside, a webhook failing all day looks exactly
like a quiet day.
It also says when something is wrong, rather than waiting to be asked. The
same checks warden doctor runs happen on a timer inside warden serve, and a
finding that changes state is an event like any other:
health.worsened firewall 3 rules changed since the last apply
health.recovered firewall nothing since the last apply
On change only — a channel told every ten minutes that three rules are still
unapplied is a channel people mute within the week. The first look after a
start is quiet, because what a machine was already like is not news.
health_watch = false turns it off.
Each of the fifteen carries a colour, an icon and a line of words, and warden settings embed changes them one event at a time against a preview of the
message it would send — the rest keep what they came with:
webhook_colours = { "node.stale" = "#e5544b" }
webhook_titles = { "node.stale" = "has stopped answering" }
webhook_icons = { "node.stale" = "!" } # a single - means none at allDiscord gets an embed with the event and the mascot above the subject and the
node in the footer, Slack a coloured attachment with the facts as fields and
the time in the reader's own timezone, Teams an adaptive card whose header band
takes the nearest tone the format has a name for. Addresses are set as code
where the shape understands it, and none of the three repeats what its own
sentence already said. json carries none of it, because whatever reads it
decides how that looks.
Events and webhooks
has where to get an address, the shape of every event, and how to check the
signature.
$ warden export caddy --domain example.com
# Written by `warden export` from the warden on hub. Regenerate it; do not edit it.
shop-api.example.com {
reverse_proxy 127.0.0.1:8000
}caddy, nginx and traefik. --all takes the whole fleet and points each
service at the machine it actually runs on. It prints and stops: nothing is
written in place, and no proxy is reloaded.
And where there is no proxy at all, which is most machines somebody is
developing on, hosts writes the names a resolver will answer for:
$ sudo warden export hosts --domain test --apply
written into /etc/hosts
only the lines between `# warden: begin` and `# warden: end`A hosts file has no ports, so that gets you to the machine and the proxy shapes get you to the service. Running it again replaces rather than repeats, and every other line in the file is left exactly as it was. Reverse proxy has the rest.
warden also holds the machine's firewall, in whatever the machine actually uses — nftables, iptables, pf on macOS and the BSDs, Windows Defender Firewall:
$ warden firewall allow ssh --from 10.0.0.0/8 --limit 6/minute
$ warden firewall apply
12 rules applied
rolling back in 60s unless you run `warden firewall confirm`Every change undoes itself unless you confirm it. A snapshot is taken first, the rollback is armed second, and the change applied third. The watchdog runs detached, so it outlives the ssh session that armed it — a rule that locks you out is a minute of waiting rather than a drive to the machine.
A rule can carry a rate — --limit 6/minute — which nftables, iptables and pf
all render, and which Windows refuses by name rather than quietly applying
without it.
A rule can also carry a clock — --for 2h — and closes itself when the time is
up, the same way one bound to a service closes when its lease lapses.
Order is part of the ruleset. Every firewall warden writes for stops at the
first rule that matches, so a deny written after a wider allow never runs.
Rules are applied in the order they were written; --before and --after put
a new one somewhere else, and warden doctor names any rule that can never be
reached and which one is in front of it:
$ warden firewall allow 8000-8999 --from 10.0.0.0/8
$ warden firewall deny 8080 --before allow-8000-8999Would this get through? The question a ruleset is actually read to answer, which is the one thing a list of rules does not show:
$ warden firewall check 10.0.0.5:8000
allowed by allow-shop-api - tcp/8000 from 10.0.0.0/8, opened for shop-api
$ warden firewall check 203.0.113.9:22
denied by the policy - nothing matched, and incoming defaults to denyIt walks the rules in the order they will be applied and stops at the first
that matches. No syscall, no root, nothing applied — arithmetic over rules
warden already holds, so it answers on a laptop for a ruleset meant for a
server, and --all asks every node in the fleet, which is how the one machine
that answers differently gets found.
warden firewall adopt takes over from ufw or firewalld: it reads their rules,
shows them, applies them as its own, and turns the other one off only once you
confirm. Until then it is still enabled, so rolling back returns the machine
exactly as it was. Anything it cannot translate is named before you decide —
a rule quietly lost here is a door quietly left open.
It also reads a ruleset nothing is managing — an nft file somebody wrote
and loads at boot, which is the case where the first warden firewall apply
would otherwise flush a working firewall with only the rollback window standing
in the way. Most of a hand-written ruleset warden has no word for, and it says
so rather than quietly keeping the half it understood:
nftables is holding 20 rules, 9 of which warden can hold
9 rules warden cannot hold. Taking over drops them:
ct state established,related accept # handle 4 (matches on ct, ...)
Named in the words they were written in. --yes will not adopt a ruleset that
could not be read whole, and more than half unreadable is refused outright
unless you pass --force.
And because the registry is in the same program, a rule can belong to a service rather than to a number:
$ warden firewall open shop-api # the port the registry handed out
$ warden firewall dev-mode --for 2 # the whole pool, for the afternoonBoth close themselves: the first when the service's lease lapses, the second
when its clock runs out. Neither is in the kernel until warden firewall apply,
and warden firewall pending says which rules are still only written down. Neither can reach a port warden does not hand out —
22 and 3389 are outside the pool, and stay there.
From somewhere else, if that machine says so. A deploy that has just registered a service can ask the warden holding it to let the port through:
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:7010And through the hub, over the whole fleet. Forty machines is exactly where tending one firewall at a time stops being something anybody does:
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 --fleetEach node applies to itself, takes its own snapshot and arms its own watchdog, so a rule that shuts the door shuts it for two minutes rather than for good — a node that is never confirmed puts itself back without anybody driving there. A fleet-wide apply refuses to give that window up: it is the one place warden will not let it be left out.
And not always all of them. A node says what it is, in its own
configuration — tags = ["web", "eu-west"] — and it travels in the
announcement it already sends, so the hub knows without anybody keeping a
second list:
warden firewall apply --fleet --tag web --rollback 120
warden update --fleet --tag eu-west
warden ls --all --tag webKept on the machine rather than in groups on the hub: two places that know what a machine is disagree the first time one is rebuilt. A tag nothing carries is refused and told which ones exist — an apply that quietly touched no machines is worse than one that would not run.
Reading the rules is what a token already allows. Changing them needs
allow_remote_firewall set on the machine being asked, and it is off out of
the box — a warden that will change its own firewall on request, listens beyond
loopback and asks for no token is a way through the firewall rather than one,
and warden doctor fails on exactly that. Everything asked for this way still
passes every bound below: the pool, the declared networks, the service's lease.
Firewall has the whole of it, including the bounds a rule from the registry can never cross.
$ warden doctor
ok warden 0.2.0 answering at http://127.0.0.1:7010, role hub
ok settings from ~/.config/warden/warden.toml
ok pool 8000-8999, 3 held, 996 free
warn 1 of 3 registrations held by something that is gone - `warden reap`
ok events to https://discord.com/... as discord, 12 deliveredOne command instead of four and a guess. It exits non-zero only on fail, so a
warning about an unset token does not make a health check call the machine down.
And for a fleet, --all — each node examines itself, because half of what
this reads (its settings, its firewall, whether it can reach the hub) only
exists on the machine it is about:
$ warden doctor --all
NODE WORST SAYS
build-01 warn 3 rules changed since the last apply
db-03 fail could not be reached - connection refused
hub ok -
web-02 note a newer warden exists
1 failing, 1 warning, of 4A node nobody could reach is a fail line rather than a missing one.
--verbose prints every line each node had, which is what you want at four
machines rather than at forty.
warden setupOne screen in seven tabs: which ports to hand out, whether other machines may
reach it, which hub it reports to, where events go, what those events look like
in chat, whether it holds the firewall, and what it may do to a process.
ctrl+t posts a test event before anything is saved. Questions that nothing has
earned stay hidden — no token field until it listens beyond loopback, no webhook
shape until events go anywhere at all.
| Key | Action |
|---|---|
ctrl+left ctrl+right |
Move between tabs |
tab shift+tab |
Move between fields |
space |
Toggle a switch or a tick box |
enter |
Open a menu, or pick from it |
pgup pgdn |
Scroll without leaving the field you are in |
ctrl+t |
Post a test event to the address on screen |
ctrl+s ctrl+q |
Save · leave without writing |
It fits an 80 by 24 terminal, which is the size an ssh session usually opens at.
Without a terminal — a script piping answers in, a job on a build machine — the
same questions come one at a time, and warden setup --plain asks for that on
purpose.
warden settings opens the same screen afterwards, over what is already
written down, and naming a part goes straight there:
warden settings embed # what each event looks like in chat
warden settings firewall # which backend, and how long before it rolls back
warden settings --plain # the table instead, with where each value came from
warden settings set port 7011The difference from setup is what happens on the way out: setup writes
everything it asked about, and warden settings writes it over the file, so a
setting it never asks about survives.
Configuration
has every setting there is.
# on the hub
warden serve
# on each other machine
WARDEN_UPSTREAM=http://hub:7010 WARDEN_ADVERTISE=http://build-01:7010 warden serveEach warden still hands out its own ports and never waits on the hub. The hub
adds one view over all of them: warden ls --all, warden pool --all,
warden firewall list --all, warden tui --all, and a node that did not answer
is named rather than quietly left out. The dashboard has four tabs
across the top - services, ports, firewall rules and nodes - the same bar the
setup screen has, and a and d add and take away in whichever one you are in.
Cluster has the tokens, the
trust rules and what happens when a machine goes quiet.
from warden import reserve
with reserve("shop-api", kind="backend") as port:
serve(port) # held while the block runs, released afterPORT=$(warden register shop-api --kind backend) # or from any shellPython client has the client, the leases and the error types. HTTP API has every endpoint, for everything that is not Python.
The wiki is the long form. This page is the tour.
| Page | For |
|---|---|
| Installation | Getting the warden command |
| One machine | The usual setup: a registry for your own projects |
| Ports and processes | Seeing and freeing ports, no server needed |
| Python client | Asking for a port from your own code |
| Projects | A warden.project.toml beside the code, and warden apply |
| Events and webhooks | Hearing about it as it happens, in chat or your own endpoint |
| Reverse proxy | Turning the registry into a Caddyfile, nginx or Traefik |
| Firewall | Deciding what may cross, and taking over from ufw or firewalld |
| Cluster | Several machines, one hub that knows them all |
| Docker | The image, a compose file, and what a container can see |
| Updates | Knowing a new version is out, and rolling it across a fleet |
| Configuration | Every setting there is |
| Command line | Every command and flag |
| HTTP API | Endpoints, payloads, status codes |
| Troubleshooting | When something does not behave |
- The registry binds to loopback and has no token by default. Set
WARDEN_TOKENbefore binding it anywhere else, or give out named tokens that reach only as far as they should:tokens = [{ name = "deploy", scope = "registry", secret = "..." }].warden historythen says which one asked. - The registry cannot open a port by itself. A rule that comes from it may only ever touch a port inside the pool, may only reach networks declared in advance, and closes when the service's lease does.
firewall_from_registryis off until you turn it on, andallow_remote_firewalldecides separately whether anybody over the API may ask. WARDEN_ALLOW_KILLis off on purpose. Stopping processes over the API is a much bigger thing to hand out than a port number.warden killon the command line acts locally and never asks the API.- macOS will not let an unprivileged process enumerate sockets, so
warden ports, the dashboard's ports view,warden ls --holdersandwarden reapneedsudothere. Handing out ports does not. - Rebuilding a machine?
warden state exporttakes the registrations and the rules with you, andwarden state import --dry-runsays what would land before anything does. The history and the snapshots stay where they happened. - On a Linux server, check that your account lingers. A systemd user unit
stops when your last session ends.
warden service installlooks and says so.
The palette, if you are drawing something that has to match
It lives in warden/theme.py, so the dashboard, the setup screen and the
command line never drift apart.
| Role | Colour | |
|---|---|---|
| Ground | #08100f |
sculk black |
| Surface | #0e1a1c |
panels and tables |
| Border | #1e3538 |
|
| Text | #d9e4e2 |
|
| Muted | #6d8687 |
labels, empty cells |
| Live | #2be0d6 |
ports, focus, the banner |
frontend |
#a87fe0 |
|
worker |
#e0b457 |
also a lease about to run out |
database |
#4fd98c |
also free capacity |
| Conflict | #e5544b |
expired leases, errors |
git clone https://github.com/vxnsin/warden
cd warden
uv sync --all-groups
uv run pytest
uv run ruff check .The suite runs on Linux, macOS and Windows across Python 3.11, 3.12 and 3.13, and the Docker image and its three-warden compose file are built and brought up on every change.
MIT — see LICENSE.