hc rule add --before/--after/--first created the rule at the end of its chain and then moved it: two history entries, two probations, the rule briefly in force in the wrong place, and in a changeset the place was lost. POST /rules (and the other ordered kinds) now takes ?before=ID, ?after=ID or ?first=1, kept with the operation when staged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 KiB
The REST API
Everything the web UI and the CLI do goes through this API, under
/api/v1. Requests and answers are JSON.
Authentication
- API tokens:
Authorization: Bearer housecarl_.... Make one in the web UI (your account), withhc token create NAME, or withhousecarld user token USER NAMEon the server. - Sessions:
POST /auth/loginwith{"username", "password"}sets a cookie and returnscsrf_token. Requests other than GET must send it asX-CSRF-Token. - The local socket (
/run/housecarl/housecarl.sock): root and housecarld's own user need nothing; others send a token. Changes made through it aren't on probation.
Failed logins and token attempts are rate limited per address and per user name. permissions.md has what each call needs.
Errors
{"error": {"code": "validation", "message": "rule input #31: there is no set trusted",
"problems": [{"severity": "error", "code": "unknown_set", "message": "from: there is no set trusted", "object": "rule input #31"}]}}
| Status | Code | |
|---|---|---|
| 400 | bad_request |
the request can't be read |
| 401 | unauthenticated, session_expired, login_failed |
|
| 403 | forbidden, csrf |
|
| 404 | not_found |
|
| 409 | conflict |
e.g. undoing a change after the objects changed again; a takeover or closing connections in shadow mode (shadow.md) |
| 422 | validation |
the change would make the configuration invalid; problems lists why (validation.md) |
| 422 | firewall |
nftables refused the ruleset (nft -c), or applying it failed; the message has nftables' words. Nothing was changed |
| 429 | rate_limited |
Changes
Calls that change the configuration answer:
{"applied": true, "history_id": 7,
"changes": [{"kind": "rule", "id": 31, "label": "input #31 (SSH from home)", "after": {...}}],
"warnings": [{"severity": "warning", "code": "set_empty", "message": "...", "object": "..."}],
"object": {...},
"confirm_by": "2026-10-19T10:02:00Z"}
confirm_by: the change is on probation, and is rolled back at that time unlessPOST /confirmcomes first. Missing for changes made through the socket, and in shadow mode (shadow.md).confirm_token: the client that made the change can confirm it by itself withPOST /confirm {"token"}, from the same address, as the same user and token, over a new connection (the answer closes this one). Missing when a person must confirm (manual_confirm).?dry_run=1checks the change, with nftables too, and returns what it would do, with"applied": false, "dry_run": true, anddiff: the ruleset's unified diff.?changeset=ID(or the headerX-Housecarl-Changeset: ID) stages the change in that changeset instead (Changesets): the answer has"staged": true,changes(this operation's) andchangeset.beforeis missing for a create andafterfor a delete.warningsare the problems the change brings; older ones are in/status.objectis the created or changed object.
Creating answers 201, other changes 200.
Endpoints
Status
GET /healthz |
ok, version, firewall (applied, unconfigured, fallback, missing), mode (enforcing, or shadow: the ruleset blocks nothing, shadow.md), probation (changes waiting to be confirmed), warnings (codes); HTTP 503 unless ok. Without authentication; read at most every five seconds (operations.md) |
GET /status |
build, started, mode (enforcing or shadow), shadow_position (after or before, in shadow mode), applied (the ruleset is in force), apply_error, problems, probation, interfaces, counts (objects by kind), other_tables (other nftables tables, "inet main") |
GET /interfaces |
the machine's interfaces: name, up, addresses, zone |
GET /matrix[?changeset=ID] |
the zone matrix: zones, cells (from, to (a zone, or firewall), chain, summary (allowed, blocked, partly), verdict (what the rest gets: allowed, blocked), decided_by, rule_id, hits, allowed and blocked (exceptions by verdict), open (what the allowing exceptions let through, in short), same_zone and note, exceptions: rule_id, chain, label, verdict (allowed, blocked, logged, unknown), what, short, hits). hits are the rule's or policy's packets since housecarld started, all its traffic (missing: no counter) (rules.md) |
GET /ruleset[?enforcing=1] |
the nftables ruleset Housecarl builds, as text. In shadow mode it is the shadow ruleset; enforcing=1 gives the one loaded once shadow mode is off |
GET /counters |
{"since", "counters": {"r12": {"packets", "bytes"}, ...}}, by mark (design.md); totals since housecarld started |
GET /quotas |
each rule's quota: rule_id, rule, quota, used (bytes), addresses ([{"address", "used"}] for a per-address quota), last_reset, next_reset |
GET /connections[?q=&limit=] |
tracked connections: family, protocol, source, source_port, destination, destination_port (or icmp_id, icmp_type, icmp_code), dnat_to, dnat_port, snat_to, state, assured, timeout (seconds left), packets and bytes when the kernel counts them. q: all of its words in the addresses, :port, protocol, state |
POST /connections/close |
{"connections": [...]} (as listed) or {"search": "192.168.1.50"}: delete their tracking entries (config:write, audited); answers {"closed": N}. 409 in shadow mode |
POST /trace |
{"address", "port", "protocol", "seconds"} (5-600, default 60): start tracing (config:write, audited); 409 while one runs |
GET /trace |
the trace running, or the last: running, input, started, ends, error, packets: each with time, in, out, protocol, addresses and ports, tcp_flags, icmp_type, steps (table, chain, kind rule or policy, rule, mark, label, verdict), verdict (accepted, dropped) and decided_by; in shadow mode would_drop, the step where Housecarl would have dropped it |
DELETE /trace |
stop it |
GET /log[?limit=&after=&q=] |
logged packets, newest first: time, prefix, in, out, family, protocol, source, source_port, destination, destination_port, icmp_type, tcp_flags, length, mark (the rule's), verdict, shadow (logged in shadow mode: a drop or reject is what would have been done). after (a time) for those since; q searches |
GET /catalog |
built-in services, chains, actions, roles, the log group |
Settings
GET /settings, PATCH /settings with any of:
{"input_policy": "drop", "forward_policy": "drop", "output_policy": "accept",
"log_policy_drops": false,
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false,
"stats_per_device": false}
Lists replace the old ones. probation_seconds 0 is the default (120).
Objects
Zones, sets, services, rules, port forwards, source NAT and redirects
share one pattern, /{kind} with kind zones, sets, services,
rules, forwards, snats or redirects:
GET /{kind} |
all of them, in order; GET /rules?chain=input for one chain; GET /services includes the built-in ones |
POST /{kind}[?before=ID|after=ID|first=1] |
create; rules, forwards and NAT rules go last unless given a position, or placed before or after another of their chain, or first, in the same change (one history entry; staged in a changeset, the place goes with it) |
GET /{kind}/{ref} |
by ID, or name for zones, sets, services and forwards |
PATCH /{kind}/{ref} |
change the fields given (a JSON merge: null clears) |
DELETE /{kind}/{ref} |
refused while something uses it |
POST /rules/{ref}/reset-quota |
start a rule's quota again now (config:write; audited, not staged) |
POST /{kind}/{ref}/move |
{"before": ID}, {"after": ID} or {"to": "start"|"end"}; a rule can take "chain" too |
The objects:
{"name": "lan", "interfaces": ["eth1", "eth1.*"], "external": false, "comment": ""}
{"name": "trusted", "entries": ["192.168.1.0/24", "2001:db8:1::/64"], "dynamic": false, "timeout": 0, "comment": ""}
{"name": "web", "entries": ["tcp/8080", "tcp/8443"], "comment": ""}
{"chain": "input",
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
"sources": ["trusted"], "not_sources": false, "destinations": [], "not_destinations": false,
"services": ["ssh"], "family": "",
"ct_state": ["new"], "ct_status": [], "days": [], "hour_from": "", "hour_to": "",
"limit": {"rate": 3, "unit": "", "per": "minute", "burst": 5, "over": true, "per_source": true, "per_destination": false}, "conn_limit": 0,
"quota": {"bytes": 53687091200, "over": true, "per_source": true, "per_destination": false, "reset": "monthly", "day": 15},
"group_v4": 0, "group_v6": 0,
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
"raw": "", "comment": "", "disabled": false}
{"name": "web", "protocols": ["tcp"], "ports": "8443", "public_addresses": ["203.0.113.10"], "sources": [],
"to": "192.168.1.10", "to_port": "443", "to6": "2001:db8:1::10", "hairpin": true, "hairpin_sources": [],
"comment": "", "disabled": false}
A source NAT rule is a match (as a rule's, without the rule's other
fields) with "to" (empty: masquerade), a redirect a match with
"to_port". Times are seconds (timeout).
Statistics
GET /stats/status, /stats/summary, /stats/timeline, /stats/top,
/stats/hits and /stats/heatmap (log:read): see
stats.md.
Changesets
Changes staged to be reviewed and applied together (design.md).
GET /changesets[?status=open&mine=1&limit=] |
newest first |
POST /changesets |
{"description"}; answers the changeset (201) |
GET /changesets/{id} |
{"id", "username", "via", "description", "status": "open"|"applied"|"discarded", "ops": [{"seq", "op", "kind", "ref", "id", "data", "summary", "added_at"}], "created_at", "updated_at", "history_id"} |
PATCH /changesets/{id} |
{"description"} |
GET /changesets/{id}/preview |
{"valid", "changes", "warnings", "error", "diff"}: what applying it would do; error.op names an operation that no longer applies |
POST /changesets/{id}/apply |
optional {"description"}; answers like a change, with confirm_by over the network and changeset |
POST /changesets/{id}/discard |
|
DELETE /changesets/{id}/ops/{seq} |
take one operation out |
Staging (?changeset=ID on any change to objects or settings) checks
the changeset with the new operation, and nftables checks its ruleset;
an error refuses the operation. Only open changesets take operations, and
only their owner's (or an administrator's). An undo
(POST /history/{id}/revert?changeset=ID) is staged as one operation,
which checks when it is replayed that nothing it touches changed since;
applied, History links it to the entry it undoes. /import/nft and
/takeover can't be staged: with a changeset named they answer 400.
Reads of objects, settings and /ruleset with ?changeset=ID show the
configuration as the changeset will make it; objects are marked
"pending": "new", "changed" or "deleted" (deleted ones stay in the
list). A changeset that no longer applies shows the current
configuration, with the reason in the X-Housecarl-Changeset-Error
header.
Dynamic sets' entries
GET /sets/{name}/elements |
[{"address", "timeout"}], timeout the seconds left |
POST /sets/{name}/elements |
{"address": "10.0.30.17", "timeout": 3600}; timeout 0 is the set's |
DELETE /sets/{name}/elements/{address} |
These need sets:read or sets:write, for that set
(permissions.md); they aren't on probation or in the
history, but are audited.
Probation
GET /probation |
{"pending": [history IDs], "takeovers": N, "deadline", "seconds_left"} |
POST /confirm |
keep them: {"confirmed": N}; the ruleset is saved for the boot. With {"token"}, the client confirming its own change (see confirm_token): 409 if a newer change is pending, or over the change's own connection; 403 from another user or address |
POST /rollback |
undo them now |
History, import, takeover
GET /history[?limit=&before=] |
changes, newest first |
GET /history/{id} |
one, with each object before and after |
POST /history/{id}/revert |
undo it; 409 if an object it touched has changed since, or it was undone already |
POST /import/nft |
{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "zones": {"enp2s0": "lan"}, "replace": false} (import.md); answers with state (the imported configuration), notes and raw too |
POST /takeover |
{"tables": ["inet main"]}: delete these tables (administrators); on probation over the network; 409 in shadow mode |
Certificate
Administrators only (certificates.md).
GET /certificate |
{"configured", "source": "acme"|"file"|"none", "status": {"domain", "directory", "staging", "state": "none"|"ok"|"working"|"error", "not_before", "not_after", "issuer", "renew_at", "last_error", "last_attempt", "next_attempt", "check"}} |
POST /certificate/check |
run the check now (up to a few minutes); answers {"time", "domain", "ok", "steps": [{"name", "status", "detail", "queries"}]} |
POST /certificate/renew |
get a new certificate now, in the background (202); follow it with GET /certificate |
Users, tokens, audit
POST /auth/login, POST /auth/logout, GET /auth/whoami |
whoami has token_id when the caller uses a token |
POST /me/password |
{"current", "password"} |
GET /me/tokens, POST /me/tokens |
{"name", "role", "grants", "expires_in"}; the answer's secret is shown once |
GET /tokens, DELETE /tokens/{id} |
all tokens (administrators), revoke |
POST /tokens/{id}/rotate |
a new secret: {"grace": "1h", "expires_in": "90d", "no_expiry": false}, all optional; answers with secret and previous_secret_valid_until |
GET, POST /users; PATCH, DELETE /users/{id} |
administrators; {"username", "password", "admin", "role", "grants", "disabled", "comment"} |
GET, POST /users/{id}/tokens |
|
GET /audit[?user=&action=&result=&q=&before=&limit=] |