Skip to main content

Management API

OxiDNS exposes a standalone control plane for:

  • Process and startup health checks
  • Config checks and raw config text validation
  • Reload and shutdown control
  • Plugin extension APIs
  • Prometheus metrics export

This chapter covers management API enablement, authentication and transport, core endpoints, and metrics export.

How to Enable It

Shorthand

api:
http: "127.0.0.1:9088"

Listen addresses support ip:port, [ipv6]:port, and :port. http: ":9088" binds as dual-stack [::]:9088; use 0.0.0.0:9088 for IPv4-only.

Expanded Form

api:
http:
listen: "127.0.0.1:9443"
ssl:
cert: "/etc/oxidns/api.crt"
key: "/etc/oxidns/api.key"
client_ca: "/etc/oxidns/client-ca.crt"
require_client_cert: true
auth:
type: basic
username: "admin"
password: "secret"
webui:
root: "/etc/oxidns/webui"
index: "index.html"

Authentication and Transport

TLS

When both ssl.cert and ssl.key are configured, the API is served over HTTPS.

Optional hardening:

  • client_ca
    • Configures the client CA.
  • require_client_cert
    • Enforces mutual TLS.

Basic Auth

auth:
type: basic
username: "admin"
password: "secret"

When enabled, all API requests require Basic Auth.

The request header looks like this:

Authorization: Basic YWRtaW46c2VjcmV0

Encoding rules:

  • Concatenate the raw string as username:password
  • Base64-encode the whole string
  • Prefix the header value with Basic

In the example above, the Base64 value for admin:secret is YWRtaW46c2VjcmV0.

Notes
  • This uses standard Base64, not URL-safe Base64.
  • Do not encode username and password separately.
  • Do not percent-encode or URL-encode first.
  • The server compares the fully decoded value directly against username:password.

Examples:

curl -u admin:secret http://127.0.0.1:9088/api/healthz

Or:

curl -H 'Authorization: Basic YWRtaW46c2VjcmV0' \
http://127.0.0.1:9088/api/healthz

Static WebUI Files

The management API can serve an external WebUI static directory. The WebUI is mounted at /, and management API routes are under /api/*:

api:
http:
listen: "0.0.0.0:9199"
webui:
root: "/etc/oxidns/webui"
index: "index.html"

After enabling it, open http://server:9199/ for the WebUI. The WebUI uses same-origin /api requests to reach the backend. Static files are not protected by Basic Auth, while /api/* keeps the management API authentication and CORS behavior. If webui.root is relative, it resolves against OxiDNS -d/--working-dir, not the configuration file directory. See WebUI Deployment for the full configuration, build steps, and standalone nginx example.

CORS / WebUI Cross-Origin Access

By default, the management API infers WebUI CORS behavior from api.http.listen:

  • When listening on 0.0.0.0 or [::], it returns Access-Control-Allow-Origin: *.
  • When listening on a specific IP, it allows WebUI origins on the same host without constraining the WebUI port. For example, if the API listens on 192.168.1.10:8080, both http://192.168.1.10:3000 and http://192.168.1.10:5173 are allowed.
  • When listening on 127.0.0.1 or [::1], localhost is also allowed.

To tighten or override the automatic policy, configure cors.allowed_origins explicitly:

api:
http:
listen: "0.0.0.0:8080"
cors:
allowed_origins:
- "http://localhost:3000"
- "http://192.168.1.100:3000"

When configured explicitly, allowed_origins is matched exactly against the browser's Origin header. Use "*" to allow any origin, but browsers will not accept credentialed cross-origin requests with a wildcard origin.

Route Layout

API routes fall into three groups:

  • Global routes
    • For example /api/healthz and /api/control
  • Plugin routes
    • Uniform format: /api/plugins/<plugin_tag>/<subpath>
  • Observability routes
    • For example /api/metrics

Built-In Health Endpoints

GET /api/healthz

Purpose:

  • Checks only whether the API listener has been established.

Responses:

  • 200 OK: ok
  • 503 Service Unavailable: not_listening

GET /api/readyz

Purpose:

  • Checks whether plugin initialization and server startup are complete.

Responses:

  • 200 OK: ready
  • 503 Service Unavailable: not_ready

GET /api/health

Purpose:

  • Returns JSON health details.

Example shape:

{
"status": "ok",
"version": "x.y.z",
"build_bundle": "full",
"uptime_ms": 12345,
"checks": {
"api": "ok",
"plugin_init": "ok",
"server_startup": "ok"
},
"plugins": {
"total": 12,
"servers": 4
}
}

build_bundle is the primary build bundle of the running binary: minimal, standard, full, or custom. Use GET /api/build when you need the full feature and plugin capability snapshot.

Build Capability Endpoint

GET /api/build

Purpose:

  • Returns the package version, compiled feature bundle, enabled public Cargo features, and plugin types compiled into the running binary.
  • The WebUI uses this endpoint to decide whether the current build supports each plugin kind and disables plugin entries that were not compiled in.

Example response:

{
"ok": true,
"build": {
"version": "1.1.4",
"bundle": "standard",
"enabled_bundles": ["standard"],
"enabled_features": [
"standard",
"api",
"metrics",
"server-dot",
"server-doh"
],
"supported_plugins": {
"servers": ["tcp_server", "udp_server"],
"executors": ["cache", "fallback", "forward", "sequence"],
"matchers": ["has_resp", "qname", "qtype"],
"providers": ["domain_set", "ip_set"]
}
}
}

Field notes:

  • version
    • Cargo package version.
  • bundle
    • Primary bundle for this binary: minimal, standard, full, or custom.
  • enabled_bundles
    • Bundle features explicitly enabled at compile time. The default full build usually includes both standard and full.
  • enabled_features
    • Enabled public Cargo features. Internal _ features are intentionally omitted.
  • supported_plugins
    • Plugin types registered in the current binary, grouped into servers, executors, matchers, and providers.

Built-In Control Endpoints

GET /api/control

Purpose:

  • Returns the current process control-plane state.

The payload includes:

  • Running state
  • Uptime
  • Active config path
  • Whether shutdown has been requested
  • Reload status snapshots

GET /api/system

Purpose:

  • Returns process, platform, config path, reload status, resource usage, and compiled capability summary for the running binary.

The payload includes:

  • version
    • Cargo package version.
  • build
    • Same build capability object as the build field returned by GET /api/build.
  • os / arch
    • Current runtime platform.
  • uptime_ms
    • Process uptime.
  • config_path
    • Active config file path.
  • reload
    • Reload status snapshot.
  • process_cpu_percent / process_memory_mb / system_memory_total_mb
    • Process and system resource usage information.

POST /api/shutdown

Purpose:

  • Requests graceful shutdown.

Response:

  • 202 Accepted

POST /api/reload

Purpose:

  • Requests a config reload and reinitializes all plugins.

Responses:

  • 202 Accepted
    • The request has been accepted.
  • 409 Conflict
    • A reload is already pending or in_progress.

GET /api/reload/status

Purpose:

  • Returns the status of the most recent reload attempt.

Fields include:

  • status
    • idle
    • pending
    • in_progress
    • ok
    • failed
  • pending
  • in_progress
  • last_started_ms
  • last_completed_ms
  • last_success_ms
  • last_error

Config Check Endpoints

GET /api/config

Purpose:

  • Reads the config file referenced by the current startup options.
  • Returns the raw YAML text, config path, content version, and file update time.
  • Does not expand environment variable placeholders; content matches the file on disk.

Example response:

{
"ok": true,
"path": "/etc/oxidns/config.yaml",
"format": "yaml",
"content": "plugins:\n - tag: forward\n type: forward\n",
"version": "sha256-hex",
"updated_at_ms": 1760000000000
}

PUT /api/config

Purpose:

  • Saves the full YAML config file.
  • Runs the same validation as POST /api/config/validate before writing by default.
  • Can request an application-level reload after a successful save.
  • Writes the original request text, not the expanded values of ${VAR} placeholders.

Request body:

{
"format": "yaml",
"content": "plugins:\n - tag: debug_main\n type: debug_print\n",
"base_version": "sha256-hex",
"validate": true,
"reload": false
}

Responses:

  • 200 OK
    • The config was saved. The response includes the new version, plugin count, and init order.
  • 400 Bad Request
    • The YAML cannot be parsed, validation failed, or format is not yaml.
  • 409 Conflict
    • base_version does not match the current file version, or a reload was requested while another reload is already running.

GET /api/config/check

Purpose:

  • Validates the config file at the current config path.
  • Expands environment variable placeholders in memory for validation, without modifying the file on disk.

Good fit:

  • Check whether the on-disk config parses correctly and passes plugin dependency validation.

POST /api/config/validate

Purpose:

  • Validates YAML config text sent directly in the request body.
  • Also accepts the JSON envelope used by PUT /api/config.
  • Expands environment variable placeholders in memory for validation, without returning or saving expanded config text.

Request body requirements:

  • Non-empty UTF-8 YAML text; or
  • JSON: {"format":"yaml","content":"...yaml..."}

Good fit:

  • Validate a config in the control plane before writing it to disk.

Plugin Extension APIs

Unified Format

/api/plugins/<plugin_tag>/<route>
Notes
  • A few plugins also expose prefix routes. For example, query_recorder uses /api/plugins/<tag>/records/<id>.

cache

GET /api/plugins/<cache_tag>/entries

Reads cache entries with pagination.

Query parameters:

  • limit: Page size. Defaults to 100, maximum 500.
  • cursor: Pagination cursor.
  • qname: Case-insensitive substring filter for the query domain in the cache key.

GET /api/plugins/<cache_tag>/flush

Clears the cache.

provider

POST /api/plugins/<provider_tag>/reload

Purpose:

  • Reloads that provider's internal snapshot with the same configuration it used at startup.
  • Does not rebuild unrelated plugins and does not change provider tags, dependency topology, or config structure.

Responses:

  • 200 OK
    • The provider reload succeeded.
  • 400 Bad Request
    • The provider does not exist, is not a live provider, or returned an error while reloading.

Good fit:

  • Refreshing only the affected domain_set, ip_set, geosite, geoip, or adguard_rule provider after downloading new rule files.
  • Avoiding the blast radius of an application-wide POST /api/reload.
Notes
  • When the change also updates config.yaml, provider topology, the plugin list, or other non-provider structures, POST /api/reload is still required.

GET /api/plugins/<cache_tag>/dump

Exports a cache dump.

POST /api/plugins/<cache_tag>/load_dump

Imports a cache dump.

reverse_lookup

GET /api/plugins/<tag>?ip=<ip_addr>

Looks up the domain cached for an IP address.

Example:

GET /api/plugins/reverse_lookup_main?ip=8.8.8.8

Responses:

  • Hit: domain text, usually a fully-qualified domain name
  • Miss: empty response body
  • Invalid parameter: 400 Bad Request

query_recorder

GET /api/plugins/<tag>/records

Returns recorder rows ordered by created_at_ms descending and does not include steps.

Query parameters:

  • cursor=<created_at_ms>:<id>
    • Continue pagination after the last row from the previous page.
  • limit=<n>
    • Default 100, maximum 500.
  • since_ms=<unix_ms>
    • Only return rows at or after this timestamp.
  • until_ms=<unix_ms>
    • Only return rows at or before this timestamp.
  • qname=<text>
    • Case-insensitive substring match against request question names.
  • client_ip=<text>
    • Case-insensitive substring match against the client IP string; IPv4/IPv6 fragments are accepted.
  • qtype=<type>
    • Exact match against request question type.
  • rcode=<rcode>
    • Exact match against response code.
  • status=all|error|has_response|no_response
    • Filter by recorder row status.

client_ip is the transport peer observed by the DNS server. If record lists or /stats/top_clients show only 127.0.0.1, the queries are usually passing through a local forwarder first, such as systemd-resolved, dnsmasq, AdGuardHome, dae, or clash. Check the deployment chain, point clients directly at OxiDNS, or configure a trusted src_ip_header for HTTP/DoH reverse-proxy deployments.

Responses:

  • 200 OK
    • JSON shaped like:
{
"ok": true,
"next_cursor": "1713510000123:42",
"records": [
{
"id": 42,
"created_at_ms": 1713510000123,
"elapsed_ms": 12,
"request_id": 1234,
"client_ip": "192.0.2.10",
"questions_json": [
{ "name": "www.example.com.", "qtype": "A", "qclass": "IN" }
],
"req_rd": true,
"req_cd": false,
"req_ad": false,
"req_opcode": "Query",
"req_edns_json": null,
"error": null,
"has_response": true,
"rcode": "NoError",
"resp_aa": false,
"resp_tc": false,
"resp_ra": true,
"resp_ad": false,
"resp_cd": false,
"answer_count": 1,
"authority_count": 0,
"additional_count": 0,
"answers_json": [
{
"name": "www.example.com.",
"class": "IN",
"ttl": 300,
"rr_type": "A",
"payload_kind": "A",
"payload_text": "192.0.2.1",
"payload": { "ip": "192.0.2.1" }
}
],
"authorities_json": [],
"additionals_json": [],
"signature_json": [],
"resp_edns_json": null
}
]
}

GET /api/plugins/<tag>/records/<id>

Returns one full record plus its steps array.

Responses:

  • 200 OK
    • JSON containing a record object. record.record holds the fixed main-table fields and record.steps holds path events.
  • 404 Not Found
    • The record does not exist.

DELETE /api/plugins/<tag>/records

Clears all persisted query-history rows and steps path events for the current recorder. The operation first flushes the background writer queue, then deletes all rows from the SQLite records table and clears the in-memory tail.

Responses:

  • 200 OK
    • JSON shaped like:
{
"ok": true,
"cleared_records": 128
}

GET /api/plugins/<tag>/stats/plugins

Aggregates plugin hit information from recorded path events.

Query parameters:

  • since_ms=<unix_ms>
  • until_ms=<unix_ms>
  • kind=matcher|executor|builtin|all
  • Same as /records, supports qname, client_ip, qtype, rcode, and status filters.

Response fields:

  • kind
  • tag
  • checked
  • matched
  • executed
  • query_total
  • query_share

GET /api/plugins/<tag>/stats/top_clients

Aggregates query counts by client IP.

Query parameters:

  • limit=<n>
    • Number of buckets to return. Defaults to 20. The backend no longer enforces a 200 cap; large values increase SQLite sorting and response-size cost.
  • Same as /records, supports since_ms, until_ms, qname, client_ip, qtype, rcode, status, and matcher_tag filters.

Response fields:

  • sample_size
  • rows[].key
  • rows[].count
  • rows[].share

GET /api/plugins/<tag>/stats/top_qnames

Aggregates query counts by question name. Query parameters and response fields match /stats/top_clients.

GET /api/plugins/<tag>/stats/qtype

Aggregates distribution by QTYPE. Supports the same time range and filter parameters as /records, and returns sample_size plus rows[].key/count/share.

GET /api/plugins/<tag>/stats/rcode

Aggregates distribution by response code or special status bucket. Supports the same time range and filter parameters as /records, and returns sample_size plus rows[].key/count/share.

GET /api/plugins/<tag>/stats/latency

Returns latency summary values, histogram buckets, and slow-query ranking.

Query parameters:

  • slow_limit=<n> or limit=<n>
    • Number of slow-query rows to return. Defaults to 20. The backend no longer enforces a 200 cap.
  • Same as /records, supports time range and filter parameters.

GET /api/plugins/<tag>/stats/timeseries

Aggregates query trends into time buckets.

Query parameters:

  • bucket=minute|hour
  • buckets=<n>
    • Number of buckets to return. Defaults to 60, maximum 720.
  • Same as /records, supports time range and filter parameters.

GET /api/plugins/<tag>/stream

Streams newly written records over SSE.

Query parameters:

  • tail=<n>
    • Replay the most recent n records from the in-memory tail first, then continue streaming.
Notes
  • event: record uses the full RecordDetail JSON as data.
  • Heartbeat comment frames are sent periodically to keep the connection alive.
  • Clients should send Accept: text/event-stream and tolerate heartbeat frames, error events, empty payloads, and brief reconnects.

Prometheus Metrics

GET /api/metrics

This endpoint is registered when the API is enabled. It is the single Prometheus text endpoint; plugins do not expose separate stats/metrics HTTP endpoints.

Current exported metrics include:

  • query_total
  • query_error_total
  • query_inflight
  • query_latency_count
  • query_latency_sum_ms
  • cache_lookup_total
  • cache_hit_total
  • cache_miss_total
  • cache_expired_total
  • cache_insert_total
  • cache_skip_total
  • cache_lazy_refresh_total
  • cache_entry_count
  • forward_query_total
  • forward_success_total
  • forward_error_total
  • forward_timeout_total
  • forward_latency_count
  • forward_latency_sum_ms
  • forward_upstream_query_total
  • forward_upstream_success_total
  • forward_upstream_error_total
  • forward_upstream_timeout_total
  • forward_upstream_latency_count
  • forward_upstream_latency_sum_ms
  • fallback_primary_total
  • fallback_primary_error_total
  • fallback_secondary_total
  • blackhole_block_total
  • hosts_hit_total
  • hosts_miss_total
  • ratelimit_allowed_total
  • ratelimit_rejected_total
  • server_request_total
  • server_completed_total
  • server_controlled_total
  • server_failed_total
  • server_inflight
  • server_latency_count
  • server_latency_sum_ms
  • ipset_entries_total
  • ipset_dropped_total
  • ipset_write_total
  • ipset_write_error_total
  • nftset_entries_total
  • nftset_dropped_total
  • nftset_write_total
  • nftset_write_error_total
  • ros_address_list_observe_total
  • ros_address_list_dropped_total
  • ros_address_list_sync_error_total
  • ros_address_list_sync_timeout_total
  • reverse_lookup_ptr_hit_total
  • reverse_lookup_ptr_miss_total
  • reverse_lookup_cache_insert_total
  • reverse_lookup_cache_entries
  • download_success_total
  • download_failure_total
  • download_timeout_total
  • http_request_dispatch_total
  • http_request_error_total
  • http_request_dropped_total
  • script_run_total
  • script_success_total
  • script_error_total
  • script_timeout_total
  • reload_trigger_total
  • reload_error_total
  • reload_provider_reload_total
  • reload_provider_reload_error_total
  • cron_job_run_total
  • cron_job_skipped_total
  • cron_executor_error_total

These metrics use low-cardinality plugin-level labels such as plugin_tag, name, kind, reason, result, and protocol. server_* additionally carries a protocol label (udp/tcp/dot/quic/doh). forward_upstream_* carries an upstream label whose value is the upstream tag (or its resolved address when no tag is configured); since the upstream set is fixed at startup and bounded, this stays a low-cardinality dimension. High-cardinality values such as qname or client IP are intentionally excluded from the generic metrics layer; use query_recorder when per-query detail is needed.

Config Reference

Minimal Management Plane

api:
http: "127.0.0.1:9088"

Good fit:

  • Local operations
  • Process self-checks
  • Metrics scraping

Protected Control Plane

api:
http:
listen: "0.0.0.0:9443"
ssl:
cert: "/etc/oxidns/api.crt"
key: "/etc/oxidns/api.key"
auth:
type: basic
username: "admin"
password: "secret"

Good fit:

  • Remote control
  • Integration with external operations platforms

Mutual-TLS Control Plane

api:
http:
listen: "0.0.0.0:9443"
ssl:
cert: "/etc/oxidns/api.crt"
key: "/etc/oxidns/api.key"
client_ca: "/etc/oxidns/client-ca.crt"
require_client_cert: true

Good fit:

  • Strictly controlled automation systems
  • Multi-tenant or high-sensitivity operational environments