Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.8 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 |
| 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.?dry_run=1checks the change, with nftables too, and returns what it would do, with"applied": false, "dry_run": true.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": true, "version"}, without authentication |
GET /status |
build, started, 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 /ruleset |
the nftables ruleset Housecarl builds, as text |
GET /counters |
{"since", "counters": {"r12": {"packets", "bytes"}, ...}}, by mark (design.md); totals since housecarld started |
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). 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:7f3d::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
"flowtable_devices": ["wan", "enp2s0"], "probation_seconds": 120}
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} |
create; rules, forwards and NAT rules go last unless given a position |
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 /{kind}/{ref}/move |
{"before": ID}, {"after": ID} or {"to": "start"|"end"}; a rule can take "chain" too |
The objects:
{"name": "lan", "interfaces": ["enp2s0", "enp2s0.*"], "external": false, "comment": ""}
{"name": "trusted", "entries": ["10.0.10.0/24", "2001:db8:7f3d:10::/64"], "dynamic": false, "timeout": 0, "comment": ""}
{"name": "jellyfin", "entries": ["tcp/8096", "tcp/8920"], "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, "per": "minute", "burst": 5, "over": true, "per_source": true}, "conn_limit": 0,
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
"raw": "", "comment": "", "disabled": false}
{"name": "jellyfin", "protocols": ["tcp"], "ports": "8096", "public_addresses": ["203.0.113.7"], "sources": [],
"to": "10.0.10.57", "to_port": "443", "to6": "2001:db8:7f3d:10::57", "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).
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 |
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", "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 |
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=] |