goethite
GitHub

REST API

On this page

goethite has a REST API at /api/v1 for everything the config file does not cover: filter lists (and their refresh, recommended catalog and FilterLists directory), custom rules, local DNS records, clients, groups, schedules, blocked services, the filtering settings and access lists, pausing, the query log, statistics, the audit log, the cache and the cluster. The terminal UI and the web UI both use it. The API reference lists every endpoint.

By default the API listens on 127.0.0.1:8053 and answers without authentication, which only local programs can reach:

curl http://127.0.0.1:8053/api/v1/status

GET /api/v1/health never needs a token or session; sign-in and reset redemption work without one too. Once a user account exists, the other endpoints need a token or a session even on loopback.

To use it from other machines, create an admin token:

goethite token

It prints a token (gth_ followed by 64 hex digits) and a line for the config file. Only that hash goes into the config, so the config file gives nothing away:

[api]
listen = ["0.0.0.0:8053", "[::]:8053"]
token_sha256 = "…"
tls_cert = "/etc/goethite/api.crt"   # recommended beyond loopback
tls_key = "/etc/goethite/api.key"

Restart goethite. From then on every request, from anywhere, needs the token (or a signed-in session; /api/v1/health, sign-in and reset redemption stay open):

curl -H "Authorization: Bearer $GOETHITE_TOKEN" https://dns.example.lan:8053/api/v1/status

goethite refuses to listen beyond loopback without a token. Without TLS, the token crosses the network in clear text, and goethite logs a warning. Keep the API off the internet either way.

The token is for programs. For people, goethite keeps users in its store, with an Argon2id password hash and, optionally, a TOTP second factor. Create the first one on the node (goethite must not be running, since the store file is locked):

goethite user add --config /etc/goethite/goethite.toml admin   # prints a generated password once
goethite user passwd admin                                     # set another one
goethite user reset admin                                      # one-time link for the web UI, valid an hour

Users sign in with POST /api/v1/auth/login (name, password, and code when a second factor is on) and get an HttpOnly session cookie back; the salt is never echoed anywhere, and password hashes, TOTP secrets and recovery codes never leave the node through the API. Roles are admin (everything) and viewer (reads and its own account). Admin token and sessions work side by side: scripts keep the token, browsers the cookie.

curl -c jar -X POST http://127.0.0.1:8053/api/v1/auth/login \
  -H 'content-type: application/json' -d '{"name": "admin", "password": "…"}'
curl -b jar http://127.0.0.1:8053/api/v1/status

Changing a user's password, role, second factor or disabled state ends every session of that user on every node: each session names the revision it was made at. A user who lost their password gets a reset link (POST /api/v1/users/{id}/reset, admin only), or one from goethite user reset; it works once, for an hour. A user who lost their authenticator has it cleared by an admin (POST /api/v1/users/{id}/otp/disable). Sign-in attempts are rate-limited per address and per user name.

So that web pages cannot use your browser against the API, goethite refuses two kinds of request:

  • Without a token, other names. Requests must be for a loopback address (127.0.0.1, [::1]) or a localhost name (with any port). This stops DNS rebinding, where a hostile site makes its own name resolve to 127.0.0.1. With a token any name works, since such a page does not have the token.
  • Requests from other sites. A request with an Origin header must come from the API's own address. Browsers add Origin to anything that can change something; curl, scripts and the TUI send none and are not affected.

Both are answered with 403 forbidden.

Lists, rules, local records, groups, clients and schedules all work the same way. POST a spec to the collection to create one, then GET, PUT or DELETE it by ID:

api() { curl -s -H "Authorization: Bearer $GOETHITE_TOKEN" -H 'content-type: application/json' "$@"; }

api -X POST http://127.0.0.1:8053/api/v1/rules -d '{"rule": "||ads.example^"}'
{
  "id": "ru_6e687cfb77741ac2",
  "revision": 1,
  "created_at": "2026-10-08T07:20:40.011282Z",
  "updated_at": "2026-10-08T07:20:40.011282Z",
  "spec": { "rule": "||ads.example^", "enabled": true, "comment": "", "managed_by": "api" }
}

A PUT replaces the whole spec. Every resource has a revision, also sent as its ETag. Send it back in If-Match and the update or delete fails with 412 if someone changed the resource in the meantime. Changes apply at once: a new rule blocks from the next query on.

Specs reject unknown fields. Errors are JSON with a stable code:

{ "error": { "code": "conflict", "message": "conflict: groups[default].lists[0]: there is no list \"li_x\"" } }
Status Code When
400 bad_request The request is malformed (bad JSON, bad query string, bad If-Match).
401 unauthorized No session and the token is missing or wrong, a wrong password or code, or an expired reset link.
401 otp_required The sign-in needs a code from the authenticator app.
403 forbidden A viewer tried to change something, or the account is disabled.
404 not_found No such resource or endpoint.
405 Wrong method for an endpoint; the HTTP layer answers with an empty body.
409 conflict It refers to something missing, or something still refers to it.
412 revision_mismatch It changed since the revision in If-Match.
413 bad_request The body is larger than 1 MiB.
415 bad_request A body was sent with the wrong content type.
422 invalid A value is not valid, or a field is unknown.
429 too_many_requests Too many failed sign-ins or reset attempts; wait five minutes.
500 internal goethite failed to do the work.
503 unavailable A dependency (the store) is unavailable; retry.
api -X PUT http://127.0.0.1:8053/api/v1/pause -d '{"seconds": 600}'   # for 10 minutes
api -X DELETE http://127.0.0.1:8053/api/v1/pause                      # resume now

A pause ends by itself, and on a restart. It is per node: in a cluster pause each member, or a device that reaches another one stays filtered. The settings' protection switch turns filtering off for good.

GET and PUT /api/v1/settings read and change the replicated filtering settings: the protection switch, how blocked names are answered (null IP, NXDOMAIN, REFUSED or a custom address), the blocked-answer TTL, the list update interval and the allowed and disallowed clients.

api -X POST http://127.0.0.1:8053/api/v1/cache/flush    # names resolve again on their next query
api -X POST http://127.0.0.1:8053/api/v1/update/check   # is a newer release out?

POST /api/v1/lists/refresh downloads every URL list now, GET /api/v1/services lists the blocked-services catalog, and the cluster endpoints (GET /api/v1/cluster, promote, demote and member removal) are in High availability.

A DNS leak test is a set of names only goethite answers. POST makes one; the device being tested looks its names up; GET shows which reached this node, from where, over which protocol and as which client:

api -X POST http://127.0.0.1:8053/api/v1/leak-tests      # {"id": "…", "names": [8 names], …}
api http://127.0.0.1:8053/api/v1/leak-tests/<id>         # {"reached": 8, "lookups": [...], …}
api http://127.0.0.1:8053/api/v1/leak-tests              # every test of the last hour

Tests are kept in memory on the node that made them for an hour, and are not configuration: a cluster member does not forward them to the leader, and the audit log does not list them.

api 'http://127.0.0.1:8053/api/v1/querylog?limit=50&outcome=blocked'
api 'http://127.0.0.1:8053/api/v1/querylog?client=192.168.1.23&name=example'
api 'http://127.0.0.1:8053/api/v1/stats?hours=24'

The query log is newest first. To page, pass the response's next as before. The statistics cover the last 1 to 720 hours: counts per hour by outcome, and the names, blocked names and clients asked most.

Every change, and every pause, resume and list refresh, is in the audit log with the time, who made it (token, user, unauthenticated, cli, replication or system), their address, and the resource before and after. User changes are logged with the name, role and disabled state; password hashes, TOTP secrets and recovery codes are never written to the log:

api 'http://127.0.0.1:8053/api/v1/audit?limit=20'

/metrics serves Prometheus metrics: queries by outcome and protocol (udp, tcp, dot, doh, doq, odoh), a latency histogram, rate-limited and refused queries, failed TLS handshakes and rejected DNS over HTTPS requests, the cache, upstream health, the filter and the query log. It needs the token once one is configured:

scrape_configs:
  - job_name: goethite
    authorization:
      credentials_file: /etc/prometheus/goethite-token
    static_configs:
      - targets: ["dns.example.lan:8053"]

goethite keeps these metrics with the OpenTelemetry SDK and serves them through its Prometheus reader, under the same names as before. It can also send them to a collector over OTLP; see Metrics and telemetry. An OpenTelemetry Collector can instead read /metrics with its prometheus receiver and the same scrape configuration:

receivers:
  prometheus:
    config:
      scrape_configs:
        - job_name: goethite
          authorization:
            credentials_file: /etc/otelcol/goethite-token
          static_configs:
            - targets: ["dns.example.lan:8053"]

goethite openapi prints the OpenAPI 3.1 document, which /api/v1/openapi.json also serves. Generate a client from it, or browse it in the API reference.