Files
housecarl/docs/api.md
T
abrown38andClaude Opus 5.5 a45c893830 Connections, "every packet" rules, and a packet tracer
- Connections: the kernel's tracked connections, read and closed over
  ctnetlink (internal/conntrack, raw netlink like the drop log), with
  their NAT and TCP state; closing one makes the rules check its next
  packet again (GET /connections, POST /connections/close; hc
  connections; Traffic → Connections).
- Rules can check every packet (every_packet, --every-packet): before
  established connections are accepted, so a drop or reject also stops
  connections already open. They can't accept.
- A packet tracer (internal/trace): a table of its own, inet
  housecarl-trace, marks the packets a filter picks, `nft monitor trace`
  reports every rule and policy they meet in every table, and the tracer
  names what accepted or dropped them, Housecarl's rules by name (POST,
  GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time,
  at most 200 packets and 20 a second; the table goes when it ends.

e2e: 157 checks, among them an upload a normal block doesn't stop and an
every-packet one does, and a trace naming the blocking rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:34 +01:00

13 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
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.
  • 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": 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 /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}
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
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). 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}

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 /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).

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", "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=]