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>
239 lines
15 KiB
Markdown
239 lines
15 KiB
Markdown
# 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=]` | |
|