openapi: 3.0.3
info:
  title: nxdns admin API
  version: "1.0"
  description: |
    The REST API of the nxdns DNS sinkhole, served by nxdns itself on
    `web.bind:web.port` (plain HTTP; TLS termination is the operator's proxy).

    Conventions, which every endpoint follows:

    - All JSON field names are snake_case, matching settings keys and SQL
      column names.
    - Every error response carries the envelope `{"error": "<message>"}`.
      Internal detail never reaches the wire; a 500 body is generic and the
      cause goes to the server log.
    - Request bodies are strict: an unknown field is a 400, a body over
      1 MiB is a 413.
    - Authentication is enabled iff `web.password_hash` is set. When it is,
      every operation marked with `sessionCookie` security answers 401
      without a valid session cookie. When it is not, no operation requires
      authentication. Monitoring endpoints, the login, this document and the
      static assets are always open.
    - Requests spend one token of the per-address bucket
      (`web.api_rate_limit_per_min`); over-budget requests answer 429 with a
      `Retry-After` header. `/metrics` and `/api/health` are exempt so a
      Prometheus scrape can never be throttled; `/api/queries/live` is
      exempt because one long-lived stream is bounded by
      `web.sse_max_connections_per_ip` instead.
    - Mutations to groups, blocklists, rules, local records, forward zones,
      clients and client prefixes take effect live. Upstreams and
      `/api/settings` are restart-required.

servers:
  - url: /

security:
  - sessionCookie: []

paths:
  /metrics:
    get:
      summary: Prometheus metrics
      description: |
        Prometheus text exposition format 0.0.4, `nxdns_` prefix. Always
        unauthenticated and never rate limited.
      security: []
      responses:
        "200":
          description: Metrics exposition.
          content:
            text/plain:
              schema:
                type: string

  /api/health:
    get:
      summary: Health rollup
      description: |
        Always 200; `status` is `degraded` when the disk is not ok, no
        upstream is available, or the query-log writer failed. Always
        unauthenticated and never rate limited.
      security: []
      responses:
        "200":
          description: Health snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

  /api/version:
    get:
      summary: Build and uptime
      security: []
      responses:
        "200":
          description: Version information.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Version"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/openapi.yaml:
    get:
      summary: This document
      security: []
      responses:
        "200":
          description: The OpenAPI contract, verbatim.
          content:
            application/yaml:
              schema:
                type: string
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/auth/login:
    post:
      summary: Log in
      description: |
        Verifies the admin password. On success the response sets the
        `nxdns_session` cookie (`HttpOnly; SameSite=Lax; Path=/`). When no
        password is configured the request succeeds without minting a
        session and `auth_required` is false.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginRequest"
      responses:
        "200":
          description: Authenticated; the cookie is set iff `auth_required`.
          headers:
            Set-Cookie:
              schema:
                type: string
              description: "`nxdns_session=<token>; HttpOnly; SameSite=Lax; Path=/; Max-Age=<ttl>`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoginResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Wrong or empty password.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/auth/logout:
    post:
      summary: Log out
      description: Deletes the session and clears the cookie. Always succeeds.
      responses:
        "200":
          description: Logged out; the cookie is cleared.
          headers:
            Set-Cookie:
              schema:
                type: string
              description: "`nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`"
          content:
            application/json:
              schema:
                type: object
                required: [authenticated]
                properties:
                  authenticated:
                    type: boolean
                    enum: [false]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/queries:
    get:
      summary: Query log page
      description: |
        Keyset pagination over the query log, newest first. Follow
        `next_before` until it is null.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
        - name: before
          in: query
          description: Return rows with id strictly below this cursor.
          schema: { type: integer, minimum: 1 }
        - name: domain
          in: query
          description: Substring match; `%` and `_` are literal.
          schema: { type: string, maxLength: 253 }
        - name: client
          in: query
          description: Exact client address.
          schema: { type: string, maxLength: 64 }
        - name: blocked
          in: query
          schema: { type: boolean }
        - name: since
          in: query
          description: Unix seconds, inclusive.
          schema: { type: integer }
        - name: until
          in: query
          description: Unix seconds, exclusive.
          schema: { type: integer }
      responses:
        "200":
          description: One page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QueriesPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/queries/live:
    get:
      summary: Live query stream (server-sent events)
      description: |
        `text/event-stream`. The stream opens with `retry: 3000`, then sends
        one `event: query` frame per resolved query whose `data:` line is a
        JSON object with the `/api/queries` row fields minus `id` (the entry
        precedes persistence). A `: ping` comment goes out every 15 seconds.
        A client that falls more than 64 events behind is disconnected and
        should re-sync via `/api/queries` after reconnecting. Connections
        per address are capped by `web.sse_max_connections_per_ip`; the
        stream does not spend rate-limit tokens.
      responses:
        "200":
          description: An event stream; stays open until either side closes.
          content:
            text/event-stream:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          description: This address already holds its maximum number of live streams.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/stats:
    get:
      summary: Totals for a period
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Totals over the period's UTC-aligned window.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsTotals"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/stats/timeseries:
    get:
      summary: Bucketed counts for a period
      description: |
        Fixed-width UTC buckets covering the same window `/api/stats`
        reports for the period: 1h into 60 one-minute buckets, 24h into 48
        half-hour buckets, 7d into 168 one-hour buckets, 30d into 120
        six-hour buckets. Empty buckets are zero-filled.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: The bucket series.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsTimeseries"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/lookup:
    get:
      summary: Explain a domain
      description: |
        Runs the full pipeline view for a domain without answering DNS:
        local records, forward zone, block decision with reason and matched
        pattern, source URL when a blocklist matched, and any safe-search
        rewrite.
      parameters:
        - name: domain
          in: query
          required: true
          schema: { type: string, maxLength: 253 }
        - name: group_id
          in: query
          description: Group row id; the default group when absent.
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: The pipeline's view of the domain.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Lookup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/upstream/health:
    get:
      summary: Upstream pool health
      responses:
        "200":
          description: Per-upstream state and the availability rollup.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpstreamHealth"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/groups:
    get:
      summary: List groups
      responses:
        "200":
          description: Every group.
          content:
            application/json:
              schema:
                type: object
                required: [groups]
                properties:
                  groups:
                    type: array
                    items:
                      $ref: "#/components/schemas/Group"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a group
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GroupInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Group"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/groups/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a group
      responses:
        "200":
          description: The group.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Group"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a group
      description: The `default` group cannot be renamed (409).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GroupInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Group"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a group
      description: |
        The `default` group cannot be deleted, and neither can a group that
        still has clients (409 either way).
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/groups/{id}/sources:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Blocklist sources assigned to a group
      responses:
        "200":
          description: The assignment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroupSources"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Replace the assignment
      description: Idempotent full-set replace; duplicates collapse.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GroupSources"
      responses:
        "200":
          description: Replaced; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroupSources"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/blocklists:
    get:
      summary: List blocklist sources
      responses:
        "200":
          description: Every source with its load counters.
          content:
            application/json:
              schema:
                type: object
                required: [blocklists]
                properties:
                  blocklists:
                    type: array
                    items:
                      $ref: "#/components/schemas/Blocklist"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Add a blocklist source
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BlocklistInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BlocklistEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/blocklists/update:
    post:
      summary: Refresh every enabled source now
      description: |
        Runs a fetch-and-compile pass over all enabled sources, reloads the
        filter, and reports the per-source status snapshot.
      responses:
        "202":
          description: Refresh ran; the snapshot reflects it.
          content:
            application/json:
              schema:
                type: object
                required: [sources]
                properties:
                  sources:
                    type: array
                    items:
                      $ref: "#/components/schemas/SourceStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/blocklists/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a blocklist source
      responses:
        "200":
          description: The source with its load counters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Blocklist"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a blocklist source
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BlocklistInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BlocklistEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a blocklist source
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/rules:
    get:
      summary: List rules
      responses:
        "200":
          description: Every allow/block rule.
          content:
            application/json:
              schema:
                type: object
                required: [rules]
                properties:
                  rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/Rule"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a rule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RuleInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuleEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/rules/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a rule
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Rule"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a rule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RuleInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuleEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a rule
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/local-records:
    get:
      summary: List local DNS records
      responses:
        "200":
          description: Every local record.
          content:
            application/json:
              schema:
                type: object
                required: [local_records]
                properties:
                  local_records:
                    type: array
                    items:
                      $ref: "#/components/schemas/LocalRecord"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a local record
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LocalRecordInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LocalRecord"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/local-records/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a local record
      responses:
        "200":
          description: The record.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LocalRecord"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a local record
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LocalRecordInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LocalRecord"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a local record
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/forward-zones:
    get:
      summary: List forward zones
      responses:
        "200":
          description: Every forward zone.
          content:
            application/json:
              schema:
                type: object
                required: [forward_zones]
                properties:
                  forward_zones:
                    type: array
                    items:
                      $ref: "#/components/schemas/ForwardZone"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a forward zone
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ForwardZoneInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardZone"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/forward-zones/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a forward zone
      responses:
        "200":
          description: The zone.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardZone"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a forward zone
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ForwardZoneInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardZone"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a forward zone
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/clients:
    get:
      summary: List clients
      description: |
        Every known client, materialized ones included, each with
        `hand_edited`. Clients have no POST: rows appear through DNS
        activity or configuration import.
      responses:
        "200":
          description: Every client.
          content:
            application/json:
              schema:
                type: object
                required: [clients]
                properties:
                  clients:
                    type: array
                    items:
                      $ref: "#/components/schemas/Client"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/clients/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a client
      responses:
        "200":
          description: The client.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Client"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Rename or regroup a client
      description: |
        Sets `hand_edited`. The address is the client's identity and cannot
        be changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClientEdit"
      responses:
        "200":
          description: The updated row; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Client"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Forget a client
      description: A live client re-materializes on its next query.
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/client-prefixes:
    get:
      summary: List client prefixes
      responses:
        "200":
          description: The whole prefix table.
          content:
            application/json:
              schema:
                type: object
                required: [client_prefixes]
                properties:
                  client_prefixes:
                    type: array
                    items:
                      $ref: "#/components/schemas/ClientPrefix"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Replace the prefix table
      description: |
        Atomic whole-list replace; an empty array clears the table. A prefix
        listed twice is a 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_prefixes]
              properties:
                client_prefixes:
                  type: array
                  items:
                    $ref: "#/components/schemas/ClientPrefixInput"
      responses:
        "200":
          description: The stored table, re-read; applied live.
          content:
            application/json:
              schema:
                type: object
                required: [client_prefixes]
                properties:
                  client_prefixes:
                    type: array
                    items:
                      $ref: "#/components/schemas/ClientPrefix"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/upstreams:
    get:
      summary: List upstream resolvers
      responses:
        "200":
          description: Every configured upstream.
          content:
            application/json:
              schema:
                type: object
                required: [upstreams]
                properties:
                  upstreams:
                    type: array
                    items:
                      $ref: "#/components/schemas/Upstream"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Add an upstream
      description: Restart-required; the running pool is not changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpstreamInput"
      responses:
        "201":
          description: Created; takes effect on restart.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpstreamEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/upstreams/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read an upstream
      responses:
        "200":
          description: The upstream.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Upstream"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update an upstream
      description: Restart-required; the running pool is not changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpstreamInput"
      responses:
        "200":
          description: Updated; takes effect on restart.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpstreamEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete an upstream
      description: The last enabled upstream cannot be removed (409).
      responses:
        "204":
          description: Deleted; takes effect on restart.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/pause:
    get:
      summary: Read the pause state
      responses:
        "200":
          description: |
            `until` is null while unpaused and while paused indefinitely;
            `paused` disambiguates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pause"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Pause or resume blocking
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PauseRequest"
      responses:
        "200":
          description: The state after applying.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pause"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/settings:
    get:
      summary: Read the scalar settings
      description: |
        Every stored settings key, plus the derived `web.auth_enabled`.
        `restart_required` lists every key, because all scalar settings are
        restart-required this milestone; live behavior comes from the
        resource endpoints and `/api/pause`. Passwords and hashes are never
        serialized.
      responses:
        "200":
          description: The settings and the restart-required key list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SettingsEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update settings
      description: |
        Partial update: omitted sections and fields keep their stored
        values. The merged configuration is validated before anything is
        written; on any failure nothing changes. A non-empty `web.password`
        is hashed with argon2id and stored as the hash; changing it clears
        every session. `web.password_hash` itself is not accepted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SettingsPatch"
      responses:
        "200":
          description: The merged settings, as `GET` would report them.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SettingsEnvelope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/certs/reload:
    post:
      summary: Reload the TLS certificates from disk
      description: |
        Reloads the certificate and key of every enabled DoH/DoT endpoint.
        Always answers 200: the per-endpoint outcome is the payload, and a
        failed reload leaves the previous certificate serving.
      responses:
        "200":
          description: The outcome for each endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertsReload"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: nxdns_session
      description: |
        Minted by `POST /api/auth/login`. Only meaningful when
        `web.password_hash` is configured; without it, every endpoint is
        open and the cookie is ignored.

  parameters:
    RowId:
      name: id
      in: path
      required: true
      description: Positive integer row id.
      schema:
        type: integer
        minimum: 1
    Period:
      name: period
      in: query
      description: Statistics window; the default is `24h`.
      schema:
        type: string
        enum: [1h, 24h, 7d, 30d]
        default: 24h

  responses:
    BadRequest:
      description: Malformed path, query parameter or body; validation failure.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: |
        Authentication is enabled and the request carried no valid session
        cookie.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: No row has this id.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: |
        The change contradicts stored data: a duplicate key, a reference to
        a missing row, or a delete the data still depends on.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BodyTooLarge:
      description: The request body exceeds 1 MiB.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: |
        The per-address token bucket is empty. `Retry-After` says when one
        token will be available.
      headers:
        Retry-After:
          schema:
            type: integer
            minimum: 1
          description: Seconds until a retry can succeed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Internal:
      description: |
        Something on the server failed; the detail is in the server log,
        never on the wire.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unavailable:
      description: |
        A collaborator this endpoint needs is not running: no configuration
        database, no query log, no snapshot loaded, or the server is
        shutting down.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Operator-facing message.

    Health:
      type: object
      required: [status, disk, upstreams, queries_dropped, writer_failed, refreshes_gated, snapshot_generation]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        disk:
          type: object
          required: [state, free_bytes, db_bytes, log_bytes, sample_failures]
          properties:
            state:
              type: string
              enum: [ok, warn, critical]
            free_bytes: { type: integer }
            db_bytes: { type: integer }
            log_bytes: { type: integer }
            sample_failures: { type: integer }
        upstreams:
          type: object
          required: [available, total]
          properties:
            available: { type: integer }
            total: { type: integer }
        queries_dropped: { type: integer }
        writer_failed: { type: boolean }
        refreshes_gated: { type: integer }
        snapshot_generation:
          type: integer
          nullable: true
          description: Null until the first filter snapshot is published.

    Version:
      type: object
      required: [version, git_commit, zig_version, uptime_seconds]
      properties:
        version: { type: string }
        git_commit: { type: string }
        zig_version: { type: string }
        uptime_seconds: { type: integer }

    LoginRequest:
      type: object
      required: [password]
      properties:
        password: { type: string }

    LoginResponse:
      type: object
      required: [authenticated, auth_required]
      properties:
        authenticated:
          type: boolean
          enum: [true]
        auth_required:
          type: boolean
          description: False when no password is configured; no cookie is set.

    QueryRow:
      type: object
      required: [id, ts, domain, client_ip, qtype, blocked, block_reason, response_time_us, cache_hit, upstream]
      properties:
        id: { type: integer }
        ts:
          type: integer
          description: Unix seconds.
        domain: { type: string }
        client_ip: { type: string }
        qtype:
          type: integer
          nullable: true
        blocked: { type: boolean }
        block_reason:
          type: string
          description: Empty when the query was not blocked.
        response_time_us:
          type: integer
          nullable: true
        cache_hit:
          type: boolean
          nullable: true
        upstream:
          type: string
          description: Empty for cache hits and local answers.

    QueriesPage:
      type: object
      required: [queries, next_before]
      properties:
        queries:
          type: array
          items:
            $ref: "#/components/schemas/QueryRow"
        next_before:
          type: integer
          nullable: true
          description: Cursor for the next page; null on the last page.

    StatsTotals:
      type: object
      required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us]
      properties:
        period:
          type: string
          enum: [1h, 24h, 7d, 30d]
        since:
          type: integer
          description: Window start, unix seconds, inclusive.
        until:
          type: integer
          description: Window end, unix seconds, exclusive.
        queries: { type: integer }
        blocked: { type: integer }
        cached: { type: integer }
        clients:
          type: integer
          description: Distinct client addresses in the window.
        avg_response_time_us:
          type: integer
          nullable: true
          description: Null when no query in the window recorded a time.

    Bucket:
      type: object
      required: [ts, queries, blocked, cached]
      properties:
        ts:
          type: integer
          description: Bucket start, unix seconds.
        queries: { type: integer }
        blocked: { type: integer }
        cached: { type: integer }

    StatsTimeseries:
      type: object
      required: [period, since, until, bucket_seconds, buckets]
      properties:
        period:
          type: string
          enum: [1h, 24h, 7d, 30d]
        since: { type: integer }
        until: { type: integer }
        bucket_seconds: { type: integer }
        buckets:
          type: array
          items:
            $ref: "#/components/schemas/Bucket"

    Lookup:
      type: object
      required: [domain, group_id, local_records, forward_zone, blocked, reason, matched, source_url, safe_search_rewrite]
      properties:
        domain:
          type: string
          description: The normalized form of the queried domain.
        group_id: { type: integer }
        local_records:
          type: boolean
          description: Whether a local record answers this name.
        forward_zone:
          type: string
          nullable: true
        blocked: { type: boolean }
        reason:
          type: string
          description: The pipeline's decision, e.g. `none` or `blocklist_domain`.
        matched:
          type: string
          description: The matched pattern; empty when nothing matched.
        source_url:
          type: string
          nullable: true
        safe_search_rewrite:
          type: string
          nullable: true

    UpstreamHealth:
      type: object
      required: [upstreams, available, total]
      properties:
        upstreams:
          type: array
          items:
            type: object
            required: [url, enabled, available, consecutive_failures, total_successes, total_failures, success_rate, last_error]
            properties:
              url: { type: string }
              enabled: { type: boolean }
              available: { type: boolean }
              consecutive_failures: { type: integer }
              total_successes: { type: integer }
              total_failures: { type: integer }
              success_rate: { type: number }
              last_error:
                type: string
                description: Empty when the upstream never failed.
        available: { type: integer }
        total: { type: integer }

    Group:
      type: object
      required: [id, name, safe_search]
      properties:
        id: { type: integer }
        name: { type: string }
        safe_search: { type: boolean }

    GroupInput:
      type: object
      required: [name]
      properties:
        name: { type: string }
        safe_search:
          type: boolean
          default: false

    GroupSources:
      type: object
      required: [source_ids]
      properties:
        source_ids:
          type: array
          items: { type: integer }

    Blocklist:
      type: object
      required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, skipped_regex_count, checksum]
      properties:
        id: { type: integer }
        url: { type: string }
        name: { type: string }
        enabled: { type: boolean }
        is_suggested: { type: boolean }
        last_updated:
          type: integer
          nullable: true
        domain_count: { type: integer }
        wildcard_count: { type: integer }
        skipped_regex_count: { type: integer }
        checksum:
          type: string
          nullable: true

    BlocklistInput:
      type: object
      required: [url, name]
      properties:
        url: { type: string }
        name: { type: string }
        enabled:
          type: boolean
          default: true
        is_suggested:
          type: boolean
          default: false

    BlocklistEcho:
      type: object
      required: [id, url, name, enabled, is_suggested]
      properties:
        id: { type: integer }
        url: { type: string }
        name: { type: string }
        enabled: { type: boolean }
        is_suggested: { type: boolean }

    SourceStatus:
      type: object
      required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, skipped_regex]
      properties:
        id: { type: integer }
        state:
          type: string
          description: The refresh outcome, e.g. `loaded` or `fetch_failed`.
        loaded: { type: boolean }
        last_attempt: { type: integer }
        last_success: { type: integer }
        url: { type: string }
        last_error:
          type: string
          description: Empty when the last attempt succeeded.
        domains: { type: integer }
        wildcards: { type: integer }
        skipped_regex: { type: integer }

    Rule:
      type: object
      required: [id, group_id, group, pattern, kind, action, created_at]
      properties:
        id: { type: integer }
        group_id: { type: integer }
        group: { type: string }
        pattern: { type: string }
        kind:
          type: string
          enum: [exact, wildcard]
        action:
          type: string
          enum: [allow, block]
        created_at: { type: integer }

    RuleInput:
      type: object
      required: [group_id, pattern, kind, action]
      properties:
        group_id: { type: integer }
        pattern: { type: string }
        kind:
          type: string
          enum: [exact, wildcard]
        action:
          type: string
          enum: [allow, block]

    RuleEcho:
      type: object
      required: [id, group_id, pattern, kind, action]
      properties:
        id: { type: integer }
        group_id: { type: integer }
        pattern: { type: string }
        kind:
          type: string
          enum: [exact, wildcard]
        action:
          type: string
          enum: [allow, block]

    LocalRecord:
      type: object
      required: [id, name, rtype, value, ttl]
      properties:
        id: { type: integer }
        name: { type: string }
        rtype:
          type: string
          enum: [A, AAAA, CNAME]
        value: { type: string }
        ttl: { type: integer }

    LocalRecordInput:
      type: object
      required: [name, rtype, value]
      properties:
        name: { type: string }
        rtype:
          type: string
          enum: [A, AAAA, CNAME]
        value: { type: string }
        ttl:
          type: integer
          default: 300

    ForwardZone:
      type: object
      required: [id, zone, resolver]
      properties:
        id: { type: integer }
        zone: { type: string }
        resolver: { type: string }

    ForwardZoneInput:
      type: object
      required: [zone, resolver]
      properties:
        zone: { type: string }
        resolver: { type: string }

    Client:
      type: object
      required: [id, ip, name, group_id, group, hand_edited, first_seen, last_seen]
      properties:
        id: { type: integer }
        ip: { type: string }
        name:
          type: string
          description: Empty when the client was never named.
        group_id: { type: integer }
        group: { type: string }
        hand_edited: { type: boolean }
        first_seen: { type: integer }
        last_seen: { type: integer }

    ClientEdit:
      type: object
      required: [group_id]
      properties:
        name:
          type: string
          default: ""
        group_id: { type: integer }

    ClientPrefix:
      type: object
      required: [id, prefix, group_id, group, priority]
      properties:
        id: { type: integer }
        prefix: { type: string }
        group_id: { type: integer }
        group: { type: string }
        priority: { type: integer }

    ClientPrefixInput:
      type: object
      required: [prefix, group_id]
      properties:
        prefix: { type: string }
        group_id: { type: integer }
        priority:
          type: integer
          default: 100

    Upstream:
      type: object
      required: [id, url, priority, enabled, tls_name]
      properties:
        id: { type: integer }
        url: { type: string }
        priority: { type: integer }
        enabled: { type: boolean }
        tls_name:
          type: string
          description: Only meaningful for DoT upstreams; empty otherwise.

    UpstreamInput:
      type: object
      required: [url]
      properties:
        url: { type: string }
        priority:
          type: integer
          default: 100
        enabled:
          type: boolean
          default: true
        tls_name:
          type: string
          default: ""

    UpstreamEcho:
      type: object
      required: [id, url, priority, enabled, tls_name, restart_required]
      properties:
        id: { type: integer }
        url: { type: string }
        priority: { type: integer }
        enabled: { type: boolean }
        tls_name: { type: string }
        restart_required:
          type: boolean
          enum: [true]

    Pause:
      type: object
      required: [paused, until]
      properties:
        paused: { type: boolean }
        until:
          type: integer
          nullable: true
          description: |
            Unix second filtering resumes; null while unpaused and while
            paused indefinitely.

    PauseRequest:
      type: object
      required: [paused]
      properties:
        paused: { type: boolean }
        duration_seconds:
          type: integer
          nullable: true
          minimum: 1
          maximum: 604800
          description: Only meaningful with `paused = true`; absent means indefinite.

    CertReloadOutcome:
      type: object
      required: [enabled, reloaded, error]
      properties:
        enabled:
          type: boolean
          description: Whether the endpoint is enabled in the configuration.
        reloaded:
          type: boolean
        error:
          type: string
          nullable: true
          description: Why the reload failed; null on success and while disabled.

    CertsReload:
      type: object
      required: [doh, dot]
      properties:
        doh:
          $ref: "#/components/schemas/CertReloadOutcome"
        dot:
          $ref: "#/components/schemas/CertReloadOutcome"

    Settings:
      type: object
      required: [upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update]
      properties:
        upstream:
          type: object
          required: [attempt_timeout_ms, read_timeout_ms, total_timeout_ms]
          properties:
            attempt_timeout_ms: { type: integer }
            read_timeout_ms: { type: integer }
            total_timeout_ms: { type: integer }
        dns:
          type: object
          required: [bind_ipv4, bind_ipv6, port, rate_limit, rate_window_seconds]
          properties:
            bind_ipv4: { type: string }
            bind_ipv6: { type: string }
            port: { type: integer }
            rate_limit: { type: integer }
            rate_window_seconds: { type: integer }
        blocking:
          type: object
          required: [response, ttl]
          properties:
            response:
              type: string
              enum: [zero, nxdomain]
            ttl: { type: integer }
        cache:
          type: object
          required: [size, negative_ttl_max]
          properties:
            size: { type: integer }
            negative_ttl_max: { type: integer }
        web:
          type: object
          required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, trusted_proxies, auth_enabled]
          properties:
            enabled: { type: boolean }
            bind: { type: string }
            port: { type: integer }
            session_ttl_hours: { type: integer }
            api_rate_limit_per_min: { type: integer }
            api_localhost_exempt: { type: boolean }
            sse_max_connections_per_ip: { type: integer }
            trusted_proxies:
              type: string
              description: |
                Comma-separated IP literals. A request from one of these peers
                is identified by the last entry of its X-Forwarded-For header.
                Empty trusts no proxy.
            auth_enabled:
              type: boolean
              description: Derived, read-only; true iff a password hash is stored.
        doh_server:
          $ref: "#/components/schemas/TlsListenerSettings"
        dot_server:
          $ref: "#/components/schemas/TlsListenerSettings"
        edns:
          type: object
          required: [ecs_mode]
          properties:
            ecs_mode:
              type: string
              enum: [strip, forward]
        logging:
          type: object
          required: [level, retention_days, query_log_buffer_max, hide_domains, hide_client_ips, output, file_path, max_size_mb, max_files]
          properties:
            level:
              type: string
              enum: [error, warn, info, debug]
            retention_days: { type: integer }
            query_log_buffer_max: { type: integer }
            hide_domains: { type: boolean }
            hide_client_ips: { type: boolean }
            output:
              type: string
              enum: [stderr, syslog, file]
            file_path: { type: string }
            max_size_mb: { type: integer }
            max_files: { type: integer }
        disk:
          type: object
          required: [min_free_mb, warn_free_mb]
          properties:
            min_free_mb: { type: integer }
            warn_free_mb: { type: integer }
        blocklist_update:
          type: object
          required: [enabled, interval_hours]
          properties:
            enabled: { type: boolean }
            interval_hours: { type: integer }

    TlsListenerSettings:
      type: object
      required: [enabled, bind, port, cert_path, key_path]
      properties:
        enabled: { type: boolean }
        bind: { type: string }
        port: { type: integer }
        cert_path: { type: string }
        key_path: { type: string }

    SettingsEnvelope:
      type: object
      required: [settings, restart_required]
      properties:
        settings:
          $ref: "#/components/schemas/Settings"
        restart_required:
          type: array
          items: { type: string }
          description: |
            Every `section.field` key that needs a restart to take effect —
            currently all of them.

    SettingsPatch:
      type: object
      description: |
        Partial update; every section and every field is optional. The
        shape is `Settings` without the derived `web.auth_enabled`, plus
        the write-only `web.password`. `web.password_hash` is rejected as
        an unknown field.
      properties:
        upstream:
          type: object
          properties:
            attempt_timeout_ms: { type: integer }
            read_timeout_ms: { type: integer }
            total_timeout_ms: { type: integer }
        dns:
          type: object
          properties:
            bind_ipv4: { type: string }
            bind_ipv6: { type: string }
            port: { type: integer }
            rate_limit: { type: integer }
            rate_window_seconds: { type: integer }
        blocking:
          type: object
          properties:
            response: { type: string }
            ttl: { type: integer }
        cache:
          type: object
          properties:
            size: { type: integer }
            negative_ttl_max: { type: integer }
        web:
          type: object
          properties:
            enabled: { type: boolean }
            bind: { type: string }
            port: { type: integer }
            password:
              type: string
              description: |
                Write-only. Hashed with argon2id and stored as the hash;
                never echoed. An empty string is ignored, not a removal.
            session_ttl_hours: { type: integer }
            api_rate_limit_per_min: { type: integer }
            api_localhost_exempt: { type: boolean }
            sse_max_connections_per_ip: { type: integer }
            trusted_proxies: { type: string }
        doh_server:
          $ref: "#/components/schemas/TlsListenerPatch"
        dot_server:
          $ref: "#/components/schemas/TlsListenerPatch"
        edns:
          type: object
          properties:
            ecs_mode: { type: string }
        logging:
          type: object
          properties:
            level: { type: string }
            retention_days: { type: integer }
            query_log_buffer_max: { type: integer }
            hide_domains: { type: boolean }
            hide_client_ips: { type: boolean }
            output: { type: string }
            file_path: { type: string }
            max_size_mb: { type: integer }
            max_files: { type: integer }
        disk:
          type: object
          properties:
            min_free_mb: { type: integer }
            warn_free_mb: { type: integer }
        blocklist_update:
          type: object
          properties:
            enabled: { type: boolean }
            interval_hours: { type: integer }

    TlsListenerPatch:
      type: object
      properties:
        enabled: { type: boolean }
        bind: { type: string }
        port: { type: integer }
        cert_path: { type: string }
        key_path: { type: string }
