Files
abrown38andClaude Opus 5.5 4c13297f27 Quotas: reset one address, or give some back
POST /rules/{ref}/reset-quota takes an optional address (one entry of a
per-address quota starts again) and give (bytes taken off what was used,
one address's or everyone's, until the next reset). The firewall keeps
the adjustment for its next apply, which writes the new usage into the
ruleset; housecarld applies at once and saves the usage. hc rule
reset-quota --address/--give; Reset and Give back in the web UI, per
address in the usage dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:18:13 +01:00

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), with hc token create NAME, or with housecarld user token USER NAME on the server.
  • Sessions: POST /auth/login with {"username", "password"} sets a cookie and returns csrf_token. Requests other than GET must send it as X-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 unless POST /confirm comes 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 with POST /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=1 checks the change, with nftables too, and returns what it would do, with "applied": false, "dry_run": true, and diff: the ruleset's unified diff.
  • ?changeset=ID (or the header X-Housecarl-Changeset: ID) stages the change in that changeset instead (Changesets): the answer has "staged": true, changes (this operation's) and changeset.
  • before is missing for a create and after for a delete.
  • warnings are the problems the change brings; older ones are in /status.
  • object is 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 /names {"addresses": [...]} (at most 2000): {"enabled", "names": {"192.168.1.20": "laptop.example.net"}}, the names of the inside addresses among them, by reverse lookup (configuration.md). Addresses without a name, outside ones, and those still being looked up after a second are left out; ask again for those. log:read
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). Optional body: {"address": "192.168.1.23"} starts only that address's again (a per-address quota; an IPv6 address counts for its /64; 404 if it hasn't used the quota); {"give": 5368709120} takes that many bytes off what was used instead (the address's, or every address's), until the next reset
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=]