Files
housecarl/docs/api.md
T
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
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>
2026-10-19 07:57:14 +01:00

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), 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.
  • ?dry_run=1 checks the change, with nftables too, and returns what it would do, with "applied": false, "dry_run": true.
  • 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 /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=]