-
Notifications
You must be signed in to change notification settings - Fork 0
HTTP API
Base URL http://127.0.0.1:7010. Interactive documentation at /docs, generated
from the same definitions the server uses, so it cannot drift.
| Method | Path | Token | Purpose |
|---|---|---|---|
GET |
/health |
none | Liveness, and which warden this is |
GET |
/metrics |
either | Prometheus metrics |
GET |
/v1/pool |
either | Pool size, allocated, free, reserved |
GET |
/v1/services |
either | List registrations, filter by project and kind
|
GET |
/v1/history |
either | What happened, filter by port and name
|
GET |
/v1/events |
either | What is happening, as server-sent events |
GET |
/v1/webhook |
either | Where events are posted, and whether they arrive |
POST |
/v1/services |
API | Register a service |
POST |
/v1/groups |
API | Register several ports for one thing, all or none |
GET |
/v1/services/{name} |
either | Look one up |
POST |
/v1/services/{name}/heartbeat |
API | Extend a lease |
DELETE |
/v1/services/{name} |
API | Release a port |
GET |
/v1/listeners |
either | Every socket bound on that machine |
DELETE |
/v1/listeners/{pid} |
API | Stop a process, off unless WARDEN_ALLOW_KILL
|
POST |
/v1/nodes |
cluster | A warden announces itself |
GET |
/v1/nodes |
either | Every warden this one knows |
DELETE |
/v1/nodes/{name} |
API | Forget a warden |
GET |
/v1/fleet/services |
either | Everything the fleet holds, plus what did not answer |
GET |
/v1/fleet/services/{node}/{name} |
either | One service on one named node |
GET |
/v1/firewall |
either | Backend, rule counts, what is waiting to roll back |
GET |
/v1/firewall/rules |
either | Every rule, ?origin= to filter |
POST |
/v1/firewall/open |
API + switch | Open the port a registered service holds |
DELETE |
/v1/firewall/rules/{name} |
API + switch | Take one rule back out |
POST |
/v1/firewall/apply |
API + switch | Make the rules true, ?rollback= in seconds |
POST |
/v1/firewall/confirm |
API + switch | Keep what was applied |
POST |
/v1/firewall/restore |
API + switch | Put a snapshot back, ?snapshot=
|
GET |
/v1/fleet/firewall |
either | Every node's firewall at once |
GET |
/v1/fleet/firewall/rules |
either | Every rule anywhere, each carrying its node |
POST |
/v1/fleet/firewall/open |
API + switch | Open a service on every node that holds it |
POST |
/v1/fleet/firewall/apply |
API + switch | Every node applies its own; a rollback is required |
POST |
/v1/fleet/firewall/confirm |
API + switch | Keep it, everywhere |
POST |
/v1/fleet/firewall/{node}/open |
API + switch | Open on one named node |
DELETE |
/v1/fleet/firewall/{node}/rules/{name} |
API + switch | Take one rule out on one named node |
POST |
/v1/fleet/firewall/{node}/apply |
API + switch | Apply, confirm or restore on one named node |
GET |
/v1/update |
either | Whether a newer warden exists |
POST |
/v1/update |
either | Ask this warden to update itself |
POST |
/v1/fleet/update |
API | Ask every warden to update itself |
GET |
/v1/fleet/pool |
either | How much of its pool every node has left |
GET |
/v1/fleet/listeners |
either | Every socket bound anywhere in the fleet |
POST |
/v1/fleet/services/{node} |
API | Register on one named node |
POST |
/v1/fleet/services/{node}/{name}/heartbeat |
API | Extend a lease there |
DELETE |
/v1/fleet/services/{node}/{name} |
API | Release a port there |
DELETE |
/v1/fleet/listeners/{node}/{pid} |
API | Stop a process there |
"API" means WARDEN_TOKEN, "cluster" means WARDEN_CLUSTER_TOKEN, "either"
accepts both. A check is skipped entirely when the corresponding setting is
empty. "switch" means allow_remote_firewall, which is off out of the box:
reading the rules is what a token already allows, changing them is a decision
the machine has to have made. See Firewall.
The cluster token opens announcing and reading, and nothing that changes state —
which is why forwarding a write to a node takes WARDEN_TOKEN and carries the
caller's own authorization on to that node. See
Cluster.
Authorization: Bearer <token>
curl -s localhost:7010/v1/services \
-H 'content-type: application/json' \
-d '{"name": "shop-api", "kind": "backend", "project": "shop"}'{
"name": "shop-api",
"kind": "backend",
"project": "shop",
"host": "127.0.0.1",
"port": 8000,
"pid": null,
"meta": {},
"ttl": null,
"created_at": "2026-08-31T12:00:00Z",
"updated_at": "2026-08-31T12:00:00Z",
"expires_at": null
}201 when the name is new, 200 when an existing registration was renewed. That
difference is the only way to tell a first start from a restart.
Fields you may send:
| Field | Meaning |
|---|---|
name |
Required. Unique on this warden |
kind |
Required. What the service is |
project |
Groups services of one codebase |
host |
Interface the service will bind to, default 127.0.0.1
|
preferred_port |
Wish for this port, fall back to the pool |
require_port |
Insist on this port, 409 if it is not free |
pid |
Process id, shown in listings |
ttl |
Seconds until the registration expires |
meta |
Free-form string map, stored and returned untouched |
Unknown fields are rejected with 422 rather than ignored, so a typo in
prefered_port is a loud failure instead of a silently different port.
GET /v1/services?holders=true adds a holder to every registration: running
or gone, with a holder_reason when it is gone.
{
"name": "old-job",
"port": 8001,
"pid": 9930,
"holder": "gone",
"holder_reason": "nothing is on 8001 and pid 9930 is gone"
}A holder is gone when the process it named no longer exists, or when nothing is listening on its port. Working that out costs one sweep of the machine's sockets, which is why it is asked for rather than always done, and it is only ever right on the machine the services run on. A registration touched in the last 30 seconds is never called gone.
curl -s 'localhost:7010/v1/history?port=8000&limit=5'[
{
"at": "2026-08-31T09:12:44.301Z",
"action": "released",
"name": "shop-api",
"kind": "backend",
"project": "shop",
"host": "127.0.0.1",
"port": 8000,
"pid": 14204
}
]action is one of registered, renewed, moved, released or expired.
Newest first. limit defaults to 100 and is refused above 1000. Rows are
dropped past a cap as new ones are written.
GET /v1/events holds the connection open and writes server-sent events until
the caller hangs up. A comment line arrives immediately, so a client knows it is
connected before anything has happened, and another every twenty seconds while
nothing does.
: watching
event: registered
data: {"at":"2026-03-01T09:30:00Z","action":"registered","name":"shop-api", ...}
The event name is the action: registered, renewed, moved, released or
expired. The data is the same shape GET /v1/history returns for one row.
Every action reaches the stream; filtering is the reader's business.
GET /v1/webhook says where this warden posts events and how that has gone:
{
"configured": true,
"target": "https://discord.com/...",
"format": "discord",
"actions": ["expired", "moved", "registered", "released"],
"watching": 1,
"delivered": 7,
"failed": 0,
"dropped": 0,
"last_error": null,
"last_sent": "2026-03-01T09:30:00Z"
}The address is cut back to its host on purpose: a webhook URL is a credential, and its path is the half worth stealing. See Events and webhooks.
POST /v1/groups hands out several ports for one thing, under one transaction.
{ "name": "stack", "kind": "backend", "count": 4, "contiguous": false }201 and a list of registrations named stack-1 to stack-4, or 503 and
nothing written at all. count is 1 to 64. There is no require_port: that has
no sensible answer for four ports at once.
GET /metrics in the Prometheus text format:
warden_info{version="0.1.0",node="hub",role="hub"} 1
warden_pool_ports 1000
warden_pool_allocated 2
warden_pool_available 997
warden_pool_reserved 1
warden_services{kind="backend"} 1
warden_services{kind="frontend"} 1
warden_nodes{status="online"} 2
Behind the same token as every other read. Left open on a warden bound to
0.0.0.0 it would hand out the shape of the whole fleet to anyone who asked, so
a scraper carries the token like everything else:
scrape_configs:
- job_name: warden
authorization:
credentials: your-warden-token
static_configs:
- targets: ["hub:7010"]Nothing in it costs a syscall. A scrape every fifteen seconds that swept every
socket on the machine would cost more than the numbers are worth, which is why
gone holders are not in here — warden ls --holders asks for those when a
person wants them.
- A registration that already exists keeps its port, unless another registration has taken it meanwhile.
-
require_portis granted if free, refused with409if not. -
preferred_portis granted if free, and otherwise quietly gives way to the pool. - Otherwise the lowest free port in the pool wins.
- Before a fresh port is handed out it is tested for an existing listener, so anything started outside the registry is skipped. A service keeping its own port is not probed, since it may still be bound to it.
Ports are tracked per host, so 10.0.0.5:8000 and 127.0.0.1:8000 are two
different endpoints and may be held by two different services.
ttl makes a registration expire. POST /v1/services/{name}/heartbeat pushes
the expiry out again:
curl -X POST localhost:7010/v1/services/ci-runner/heartbeat \
-H 'content-type: application/json' -d '{"ttl": 600}'Sent without a ttl, the heartbeat renews the lease the service registered with.
It can never turn a lease into a permanent registration by accident.
Expired registrations are dropped on the next request that touches the registry, not by a timer.
curl -s http://hub:7010/v1/nodes \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <cluster token>' \
-d '{"name": "build-01", "url": "http://build-01:7010",
"pool_start": 9000, "pool_end": 9099, "version": "0.1.0"}'{
"name": "build-01",
"url": "http://build-01:7010",
"pool_start": 9000,
"pool_end": 9099,
"version": "0.1.0",
"first_seen": "2026-08-31T12:00:00Z",
"last_seen": "2026-08-31T12:04:00Z",
"expires_at": "2026-08-31T12:05:30Z",
"status": "online"
}status is computed as you ask, not stored, so it is never out of date.
You do not normally call this by hand: a warden with WARDEN_UPSTREAM set does
it for you, three times per lease.
{
"status": "ok",
"version": "0.1.0",
"node": "build-01",
"role": "edge",
"services": 2,
"nodes": 0
}role is edge when this warden reports to another, hub when it does not.
/health never requires a token, so it works as a container or load-balancer
probe.
Every refusal comes back as {"detail": "..."} with a status that says what
kind of problem it is:
| Status | Means |
|---|---|
401 |
Missing or wrong token |
403 |
Allowed to ask, not allowed to do — stopping processes with WARDEN_ALLOW_KILL off, or a process this account may not touch |
404 |
No such service, node or process |
409 |
A required port is held by someone else, or a process ignored the request to stop |
422 |
The request body does not hold up: a bad name, an unknown field, both port wishes at once |
503 |
The pool has no free port left |
The detail is written to be shown to a person as-is:
{"detail": "port 8000 is held by 'shop-api'"}Everything above is wrapped, with the same errors as exceptions. Full usage on the Python client page; the short version:
from warden import WardenClient
with WardenClient("http://hub:7010", token="...") as client:
for node in client.nodes():
print(node.name, node.status)| Method | Does |
|---|---|
register(name, kind=..., node=...) |
Claim a port, optionally on another node |
lookup(name) |
One registration |
services(project=..., kind=...) |
All of them |
heartbeat(name, ttl=..., node=...) |
Extend a lease |
release(name, node=...) |
Give a port back |
pool() |
Pool usage |
listeners(udp=...) |
Sockets on that machine |
stop(pid, force=..., node=...) |
End a process |
nodes() |
The fleet |
announce(name, url=..., ...) |
Report a node |
forget(name) |
Remove a node |
fleet_services(project=..., kind=...) |
Everything the fleet holds |
fleet_lookup(node, name) |
One service on one named node |
fleet_pool() |
Every node's pool, and the totals |
fleet_listeners(udp=...) |
Every socket bound anywhere |
session(name, ...) |
Context manager that releases on exit |
Exceptions all descend from WardenError: UnknownServiceError,
UnknownNodeError, PortUnavailableError, PoolExhaustedError,
NotPermittedError, ProtectedProcessError, StillRunningError,
UnknownProcessError. A warden that cannot be reached raises the base
WardenError with an explanation, not a bare connection error.
warden — nothing binds a port without asking ·
uv tool install warden-ports
Repository · Issues · Releases · PyPI · MIT
Getting started
While it runs
Several machines
Reference