# 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](permissions.md) has what each call needs. ## Errors ```json {"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](shadow.md)) | | 422 | `validation` | the change would make the configuration invalid; `problems` lists why ([validation.md](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: ```json {"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](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](#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](shadow.md)), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) | | `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](rules.md#the-zone-matrix)) | | `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](design.md#its-own-table-only)); 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](configuration.md#names)). 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: ```json {"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: ```json {"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](stats.md#the-api). ### Changesets Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)). | | | |---|---| | `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](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](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](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=]` | |