milestone 20: declarative configuration for iac
This commit is contained in:
+137
-59
@@ -31,6 +31,9 @@ below carries all 56 of its entries.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients
|
||||
and client prefixes take effect live. Upstreams and `/api/settings` are
|
||||
restart-required.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` —
|
||||
and in file mode the `config_write` routes are refused. See
|
||||
[Configuration authority](#configuration-authority).
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -113,70 +116,140 @@ to the capacity is admitted and the long-run rate holds.
|
||||
holds at most 32 concurrent streams in total; when all slots are taken, the
|
||||
answer is a 503.
|
||||
|
||||
## Configuration authority
|
||||
|
||||
Which authority is live decides whether the API may write configuration. Under
|
||||
`nxdns run` the database is authority and every route behaves as it always has.
|
||||
Under `nxdns run --config FILE` the file is authority, and the routes that would
|
||||
edit configuration are refused: the file is the only place configuration
|
||||
changes, and a restart is what applies them.
|
||||
|
||||
### The refusal
|
||||
|
||||
A `config write` route in file mode answers **403** with the ordinary error
|
||||
envelope:
|
||||
|
||||
```json
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
There is no `code` field and no richer body. 403 is used for nothing else in
|
||||
this API, so the status alone is the machine-readable part, and a client that
|
||||
wants to know the mode in advance reads it from `GET /api/settings` rather than
|
||||
probing for errors.
|
||||
|
||||
**401 comes first.** The router matches the path, spends a rate-limit token,
|
||||
checks the session, and only then checks the policy. So an unauthenticated
|
||||
request to a `config write` route in file mode is a 401, not a 403 — answering
|
||||
403 first would tell an anonymous caller which routes exist.
|
||||
|
||||
`runtime action` and `read` routes are unaffected in both modes. Pausing
|
||||
blocking, refreshing blocklists, reloading certificates and logging in are
|
||||
operations on a running process, not statements about configuration, so a
|
||||
file-mode box still does all of them.
|
||||
|
||||
`DELETE /api/clients/{id}` is the one route whose answer depends on the row.
|
||||
Deleting a client the file does not declare is a runtime action and succeeds:
|
||||
without it, a mis-identified or departed device would be immortal in file mode,
|
||||
since the file can add addresses but never remove one it has never named.
|
||||
Deleting a client the file *does* declare contradicts the file, and answers the
|
||||
same 403.
|
||||
|
||||
### Discovering the authority
|
||||
|
||||
`GET /api/settings` carries an `authority` object:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `mode` | `"database"` or `"managed_file"`. |
|
||||
| `path` | The managed file's path, or `null` in database mode. |
|
||||
| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. |
|
||||
|
||||
All three keys are always present; the two nullable ones carry `null` rather
|
||||
than being omitted, so a client can read `authority.mode` without probing.
|
||||
|
||||
```json
|
||||
{"mode": "database", "path": null, "reconciled_at": null}
|
||||
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
|
||||
```
|
||||
|
||||
The route requires a session, which is why the filesystem path is here rather
|
||||
than on the open `/api/version` and `/api/health`.
|
||||
|
||||
`reconciled_at` answers exactly one question: **when did this process last read
|
||||
the file?** Compare it against the file's mtime to spot a restart that has not
|
||||
happened yet. It is a hint and not a verdict, in both directions — a clock that
|
||||
stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make
|
||||
a newer file look older, and the database can change without either timestamp
|
||||
moving. It does not tell you whether the file and the running configuration
|
||||
agree; answering that would take content hashing, which nxdns deliberately does
|
||||
not do.
|
||||
|
||||
## Operations
|
||||
|
||||
Auth `open` means no session is required; `session` means a valid session cookie
|
||||
is required whenever a password is set. Rate limit `counted` spends a token;
|
||||
`exempt` never consults the limiter.
|
||||
`exempt` never consults the limiter. Policy `config write` is the class refused
|
||||
in file mode; `read` and `runtime action` are always served.
|
||||
|
||||
| Method | Path | Auth | Rate limit | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/metrics` | open | exempt | Prometheus metrics |
|
||||
| GET | `/api/health` | open | exempt | Health rollup |
|
||||
| GET | `/api/version` | open | counted | Build and uptime |
|
||||
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document |
|
||||
| POST | `/api/auth/login` | open | counted | Log in |
|
||||
| POST | `/api/auth/logout` | session | counted | Log out |
|
||||
| GET | `/api/queries` | session | counted | Query log page |
|
||||
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) |
|
||||
| GET | `/api/stats` | session | counted | Totals for a period |
|
||||
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period |
|
||||
| GET | `/api/lookup` | session | counted | Explain a domain |
|
||||
| GET | `/api/upstream/health` | session | counted | Upstream pool health |
|
||||
| GET | `/api/groups` | session | counted | List groups |
|
||||
| POST | `/api/groups` | session | counted | Create a group |
|
||||
| GET | `/api/groups/{id}` | session | counted | Read a group |
|
||||
| PUT | `/api/groups/{id}` | session | counted | Update a group |
|
||||
| DELETE | `/api/groups/{id}` | session | counted | Delete a group |
|
||||
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group |
|
||||
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment |
|
||||
| GET | `/api/blocklists` | session | counted | List blocklist sources |
|
||||
| POST | `/api/blocklists` | session | counted | Add a blocklist source |
|
||||
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now |
|
||||
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source |
|
||||
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source |
|
||||
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source |
|
||||
| GET | `/api/rules` | session | counted | List rules |
|
||||
| POST | `/api/rules` | session | counted | Create a rule |
|
||||
| GET | `/api/rules/{id}` | session | counted | Read a rule |
|
||||
| PUT | `/api/rules/{id}` | session | counted | Update a rule |
|
||||
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule |
|
||||
| GET | `/api/local-records` | session | counted | List local DNS records |
|
||||
| POST | `/api/local-records` | session | counted | Create a local record |
|
||||
| GET | `/api/local-records/{id}` | session | counted | Read a local record |
|
||||
| PUT | `/api/local-records/{id}` | session | counted | Update a local record |
|
||||
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record |
|
||||
| GET | `/api/forward-zones` | session | counted | List forward zones |
|
||||
| POST | `/api/forward-zones` | session | counted | Create a forward zone |
|
||||
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone |
|
||||
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone |
|
||||
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone |
|
||||
| GET | `/api/clients` | session | counted | List clients |
|
||||
| GET | `/api/clients/{id}` | session | counted | Read a client |
|
||||
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client |
|
||||
| DELETE | `/api/clients/{id}` | session | counted | Forget a client |
|
||||
| GET | `/api/client-prefixes` | session | counted | List client prefixes |
|
||||
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table |
|
||||
| GET | `/api/upstreams` | session | counted | List upstream resolvers |
|
||||
| POST | `/api/upstreams` | session | counted | Add an upstream |
|
||||
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream |
|
||||
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream |
|
||||
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream |
|
||||
| GET | `/api/pause` | session | counted | Read the pause state |
|
||||
| POST | `/api/pause` | session | counted | Pause or resume blocking |
|
||||
| GET | `/api/settings` | session | counted | Read the scalar settings |
|
||||
| PUT | `/api/settings` | session | counted | Update settings |
|
||||
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk |
|
||||
| Method | Path | Auth | Rate limit | Policy | Purpose |
|
||||
|---|---|---|---|---|---|
|
||||
| GET | `/metrics` | open | exempt | read | Prometheus metrics |
|
||||
| GET | `/api/health` | open | exempt | read | Health rollup |
|
||||
| GET | `/api/version` | open | counted | read | Build and uptime |
|
||||
| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
|
||||
| POST | `/api/auth/login` | open | counted | runtime action | Log in |
|
||||
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
|
||||
| GET | `/api/queries` | session | counted | read | Query log page |
|
||||
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
|
||||
| GET | `/api/stats` | session | counted | read | Totals for a period |
|
||||
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
|
||||
| GET | `/api/lookup` | session | counted | read | Explain a domain |
|
||||
| GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
|
||||
| GET | `/api/groups` | session | counted | read | List groups |
|
||||
| POST | `/api/groups` | session | counted | config write | Create a group |
|
||||
| GET | `/api/groups/{id}` | session | counted | read | Read a group |
|
||||
| PUT | `/api/groups/{id}` | session | counted | config write | Update a group |
|
||||
| DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group |
|
||||
| GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group |
|
||||
| PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment |
|
||||
| GET | `/api/blocklists` | session | counted | read | List blocklist sources |
|
||||
| POST | `/api/blocklists` | session | counted | config write | Add a blocklist source |
|
||||
| POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now |
|
||||
| GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source |
|
||||
| PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source |
|
||||
| DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source |
|
||||
| GET | `/api/rules` | session | counted | read | List rules |
|
||||
| POST | `/api/rules` | session | counted | config write | Create a rule |
|
||||
| GET | `/api/rules/{id}` | session | counted | read | Read a rule |
|
||||
| PUT | `/api/rules/{id}` | session | counted | config write | Update a rule |
|
||||
| DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule |
|
||||
| GET | `/api/local-records` | session | counted | read | List local DNS records |
|
||||
| POST | `/api/local-records` | session | counted | config write | Create a local record |
|
||||
| GET | `/api/local-records/{id}` | session | counted | read | Read a local record |
|
||||
| PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record |
|
||||
| DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record |
|
||||
| GET | `/api/forward-zones` | session | counted | read | List forward zones |
|
||||
| POST | `/api/forward-zones` | session | counted | config write | Create a forward zone |
|
||||
| GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone |
|
||||
| PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone |
|
||||
| DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone |
|
||||
| GET | `/api/clients` | session | counted | read | List clients |
|
||||
| GET | `/api/clients/{id}` | session | counted | read | Read a client |
|
||||
| PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client |
|
||||
| DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client |
|
||||
| GET | `/api/client-prefixes` | session | counted | read | List client prefixes |
|
||||
| PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table |
|
||||
| GET | `/api/upstreams` | session | counted | read | List upstream resolvers |
|
||||
| POST | `/api/upstreams` | session | counted | config write | Add an upstream |
|
||||
| GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream |
|
||||
| PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream |
|
||||
| DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream |
|
||||
| GET | `/api/pause` | session | counted | read | Read the pause state |
|
||||
| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
|
||||
| GET | `/api/settings` | session | counted | read | Read the scalar settings |
|
||||
| PUT | `/api/settings` | session | counted | config write | Update settings |
|
||||
| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk |
|
||||
|
||||
There is no `POST /api/clients`: client rows come from DNS activity or import,
|
||||
never from the API.
|
||||
@@ -194,6 +267,11 @@ write-only (accepted on a `PUT`, never returned, hashed before storage), and
|
||||
`web.password_hash` is neither readable nor directly writable, because a client
|
||||
that could install a hash could install one whose password it already knows.
|
||||
|
||||
In file mode `PUT /api/settings` is refused with the 403 above, password changes
|
||||
included. The password then lives where the rest of the configuration lives: set
|
||||
`web.password` in the file and restart. See
|
||||
[Password and hash](configuration.md#password-and-hash).
|
||||
|
||||
## Schemas
|
||||
|
||||
Request and response schemas for every operation live in the OpenAPI document:
|
||||
|
||||
Reference in New Issue
Block a user