A change made over the network answers with a one-time confirm_token and closes its connection (HTTP/2: a GOAWAY). The web UI and hc send the token straight back to /confirm, which accepts it only for the newest change, from the same user and API token, the same address, and over a new connection: proof that the client can still reach the API through the new ruleset (an established connection would get through anyway). A change that cuts its client off can't be confirmed so, and rolls back by itself. The setting manual_confirm (hc settings set --auto-confirm off) and hc --no-confirm leave confirming to a person. Changes through the local socket still need no confirmation. e2e: 136 checks, among them a lockout change rolled back without anyone confirming. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
12 KiB
Markdown
222 lines
12 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 |
|
|
| 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.
|
|
* `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": 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](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 /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:
|
|
|
|
```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}
|
|
```
|
|
|
|
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:
|
|
|
|
```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`).
|
|
|
|
### 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). Undo, `/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", "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 |
|
|
|
|
### 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=]` | |
|