openapi: 3.1.0
info:
  title: Hub.gripe DDNS API
  description: |
    Dynamic DNS service API for managing DNS records via subdomain updates,
    ACME DNS-01 challenge validation, and hostname queries.

    ## Authentication

    Most endpoints require a Bearer token. Include it in the `Authorization` header:
    ```
    Authorization: Bearer <your-token>
    ```
    Tokens can also be passed as a `?token=` query parameter for update endpoints.

    The DynDNS2 endpoint (`/nic/update`) supports HTTP Basic Auth (preferred) or query parameter
    authentication (`username` and `password` parameters) for compatibility with Synology NAS and similar clients.

    ## Rate Limiting

    All endpoints are rate-limited per IP address. Exceeding the limit returns `429 Too Many Requests`.
  version: 1.0.0
  license:
    name: MIT
    identifier: MIT

servers:
  - url: https://hub.gripe
    description: Production

security:
  - bearerAuth: []

paths:
  /api/v1/update:
    get:
      operationId: updateDNS
      summary: Update DNS record
      description: |
        Update the IP address for one or more hostnames. If no IP is provided,
        the client's public IP is used automatically.
      tags:
        - DNS Updates
      parameters:
        - name: hostname
          in: query
          required: true
          description: FQDN or comma-separated FQDNs (max 20)
          schema:
            type: string
          example: myserver.hub.gripe
        - name: ip
          in: query
          description: IPv4 address. Auto-detected if omitted. Rejects IPv6 values.
          schema:
            type: string
          example: 203.0.113.5
        - name: myip
          in: query
          description: Alias for `ip` parameter
          schema:
            type: string
        - name: ipv6
          in: query
          description: IPv6 address. Can be used alongside `ip` for dual-stack updates. Rejects IPv4 values.
          schema:
            type: string
          example: "2001:db8::1"
        - name: myipv6
          in: query
          description: Alias for `ipv6` parameter
          schema:
            type: string
        - name: token
          in: query
          description: Bearer token (alternative to Authorization header)
          schema:
            type: string
      responses:
        "200":
          description: Update successful or IP unchanged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpdateResponse"
              examples:
                updated:
                  summary: IP updated
                  value:
                    status: updated
                    hostname: myserver.hub.gripe
                    old_ip: 198.51.100.1
                    new_ip: 203.0.113.5
                unchanged:
                  summary: IP unchanged
                  value:
                    status: nochg
                    hostname: myserver.hub.gripe
                    new_ip: 203.0.113.5
                    message: IP unchanged
        "400":
          description: Invalid parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Authentication failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Hostname not found or not owned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded
    post:
      operationId: updateDNSPost
      summary: Update DNS record (POST)
      description: Same as GET but accepts parameters as form data.
      tags:
        - DNS Updates
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
                - hostname
              properties:
                hostname:
                  type: string
                  description: FQDN or comma-separated FQDNs
                ip:
                  type: string
                  description: IPv4 address
                ipv6:
                  type: string
                  description: IPv6 address (for dual-stack updates)
      responses:
        "200":
          description: Update successful
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpdateResponse"
        "401":
          description: Authentication failed

  /api/v1/hostnames:
    get:
      operationId: listHostnames
      summary: List hostnames
      description: Returns all hostnames owned by the authenticated user.
      tags:
        - Hostnames
      responses:
        "200":
          description: List of hostnames
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/HostnameResponse"
              example:
                - fqdn: myserver.hub.gripe
                  current_ip: 203.0.113.5
                  current_ip_v6: "2001:db8::1"
                  record_type: A+AAAA
                  last_updated: "2026-03-01T12:00:00Z"
                  created_at: "2026-02-15T08:30:00Z"
        "401":
          description: Authentication failed
    post:
      operationId: createHostname
      summary: Create hostname
      description: |
        Creates a new hostname (subdomain) for the authenticated user.
        The subdomain must contain only lowercase letters, digits, and hyphens (1-63 chars).
        Certain subdomains (e.g., `www`, `mail`, `api`) are reserved and cannot be registered.
        Subject to a per-user hostname limit and a tighter per-IP rate limit (5/min).
      tags:
        - Hostname Management
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateHostnameRequest"
            example:
              subdomain: myserver
      responses:
        "201":
          description: Hostname created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateHostnameResponse"
              example:
                status: created
                hostname: myserver.hub.gripe
        "400":
          description: Invalid or reserved subdomain, or hostname limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Authentication failed
        "409":
          description: Hostname already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded

  /api/v1/hostnames/{hostname}:
    get:
      operationId: getHostname
      summary: Get hostname details
      description: |
        Returns details for a single hostname. You can pass either the full FQDN
        (`myserver.hub.gripe`) or just the subdomain label (`myserver`).
      tags:
        - Hostnames
      parameters:
        - name: hostname
          in: path
          required: true
          description: FQDN or subdomain label
          schema:
            type: string
          example: myserver.hub.gripe
      responses:
        "200":
          description: Hostname details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HostnameResponse"
        "401":
          description: Authentication failed
        "404":
          description: Hostname not found or not owned
    delete:
      operationId: deleteHostname
      summary: Delete hostname
      description: |
        Deletes a hostname owned by the authenticated user. Both the database record
        and the Cloudflare DNS records (A and AAAA) are removed.
        You can pass either the full FQDN (`myserver.hub.gripe`) or just the subdomain label (`myserver`).
      tags:
        - Hostname Management
      parameters:
        - name: hostname
          in: path
          required: true
          description: FQDN or subdomain label
          schema:
            type: string
          example: myserver.hub.gripe
      responses:
        "200":
          description: Hostname deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateHostnameResponse"
              example:
                status: deleted
                hostname: myserver.hub.gripe
        "401":
          description: Authentication failed
        "404":
          description: Hostname not found or not owned
        "429":
          description: Rate limit exceeded

  /api/v1/acme/present:
    post:
      operationId: acmePresent
      summary: Create ACME DNS-01 challenge
      description: |
        Creates a TXT record for ACME DNS-01 challenge validation.
        Compatible with [lego's httpreq provider](https://go-acme.github.io/lego/dns/httpreq/)
        used by Traefik, and can be called from Certbot manual hooks.

        The TXT record auto-expires after 10 minutes as a safety net.
      tags:
        - ACME DNS-01
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ACMERequest"
            example:
              fqdn: _acme-challenge.myserver.hub.gripe.
              value: LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM
      responses:
        "200":
          description: TXT record created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ACMEResponse"
              example:
                status: ok
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ACMEResponse"
        "401":
          description: Authentication failed
        "403":
          description: Hostname not owned by user

  /api/v1/acme/cleanup:
    post:
      operationId: acmeCleanup
      summary: Delete ACME DNS-01 challenge
      description: |
        Deletes a TXT record after ACME DNS-01 challenge validation completes.
        Idempotent — returns success even if the record was already deleted or expired.
      tags:
        - ACME DNS-01
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ACMERequest"
            example:
              fqdn: _acme-challenge.myserver.hub.gripe.
              value: LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM
      responses:
        "200":
          description: TXT record deleted (or already gone)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ACMEResponse"
              example:
                status: ok
        "401":
          description: Authentication failed
        "403":
          description: Not authorized

  /nic/update:
    get:
      operationId: dyndns2Update
      summary: DynDNS2 protocol update
      description: |
        Standard DynDNS2 protocol endpoint. Compatible with ddclient, routers,
        NAS devices, and IoT libraries like EasyDDNS for ESP32.

        **Auto-creation:** If the hostname does not exist yet, it is automatically
        created on first update (subject to subdomain validation, reserved name checks,
        and per-user hostname limits).
      tags:
        - DynDNS2
      security:
        - basicAuth: []
      parameters:
        - name: hostname
          in: query
          required: true
          description: FQDN or comma-separated FQDNs (max 20)
          schema:
            type: string
        - name: myip
          in: query
          description: IPv4 address. Auto-detected if omitted. Rejects IPv6 values.
          schema:
            type: string
        - name: myipv6
          in: query
          description: IPv6 address. Can be used alongside `myip` for dual-stack updates. Rejects IPv4 values.
          schema:
            type: string
          example: "2001:db8::1"
        - name: username
          in: query
          description: Username (alternative to HTTP Basic Auth, for Synology-style clients)
          schema:
            type: string
        - name: password
          in: query
          description: Password (alternative to HTTP Basic Auth, for Synology-style clients)
          schema:
            type: string
      responses:
        "200":
          description: DynDNS2 result codes (one per line for batch updates)
          content:
            text/plain:
              schema:
                type: string
              examples:
                updated:
                  summary: Successfully updated
                  value: "good 203.0.113.5\n"
                unchanged:
                  summary: IP unchanged
                  value: "nochg 203.0.113.5\n"
        "401":
          description: Authentication failed
          content:
            text/plain:
              schema:
                type: string
              example: "badauth\n"

  /ip:
    get:
      operationId: getPublicIP
      summary: Get your public IP
      description: Returns the client's public IP address as plain text. No authentication required.
      tags:
        - Utility
      security: []
      responses:
        "200":
          description: Client's public IP address
          content:
            text/plain:
              schema:
                type: string
              example: "203.0.113.5\n"

  /health:
    get:
      operationId: healthCheck
      summary: Health check
      description: |
        Returns `ok` if the service is healthy. Accessible without authentication
        from localhost. Remote callers require a Bearer token (configured via
        `DDNS_METRICS_TOKEN`).
      tags:
        - Utility
      security: []
      responses:
        "200":
          description: Service is healthy
          content:
            text/plain:
              schema:
                type: string
              example: "ok\n"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API token obtained from the web dashboard
    basicAuth:
      type: http
      scheme: basic
      description: Username and password (DynDNS2 endpoint only)

  schemas:
    UpdateResponse:
      type: object
      properties:
        status:
          type: string
          enum:
            - updated
            - nochg
            - error
          description: Result of the update operation
        hostname:
          type: string
          description: The FQDN that was updated
        old_ip:
          type: string
          description: Previous IP address (only on successful update)
        new_ip:
          type: string
          description: Current IP address
        message:
          type: string
          description: Additional information

    HostnameResponse:
      type: object
      required:
        - fqdn
        - created_at
      properties:
        fqdn:
          type: string
          description: Fully qualified domain name
          example: myserver.hub.gripe
        current_ip:
          type: string
          description: Current IPv4 address
          example: 203.0.113.5
        current_ip_v6:
          type: string
          description: Current IPv6 address
          example: "2001:db8::1"
        record_type:
          type: string
          enum:
            - A
            - AAAA
            - A+AAAA
          description: Active DNS record types
        last_updated:
          type: string
          format: date-time
          description: Most recent update timestamp
        created_at:
          type: string
          format: date-time
          description: When the hostname was registered

    ACMERequest:
      type: object
      required:
        - fqdn
        - value
      properties:
        fqdn:
          type: string
          description: |
            Challenge FQDN including `_acme-challenge.` prefix.
            Trailing dot is optional (lego sends it, we handle both).
          example: _acme-challenge.myserver.hub.gripe.
        value:
          type: string
          description: ACME challenge token value (max 255 chars)
          example: LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM

    ACMEResponse:
      type: object
      properties:
        status:
          type: string
          enum:
            - ok
            - error
        message:
          type: string
          description: Error details (only present on error)

    CreateHostnameRequest:
      type: object
      required:
        - subdomain
      properties:
        subdomain:
          type: string
          description: |
            Subdomain label (1-63 chars). Only lowercase letters (a-z), digits (0-9),
            and hyphens (-) are allowed. Cannot start or end with a hyphen.
            Certain names (www, mail, api, etc.) are reserved.
          example: myserver

    CreateHostnameResponse:
      type: object
      properties:
        status:
          type: string
          enum:
            - created
            - deleted
          description: Result of the operation
        hostname:
          type: string
          description: The FQDN that was created or deleted
          example: myserver.hub.gripe

    ErrorResponse:
      type: object
      properties:
        status:
          type: string
          example: error
        message:
          type: string
          example: hostname not found or not owned by token

tags:
  - name: DNS Updates
    description: Update DNS records for your hostnames
  - name: Hostnames
    description: Query your registered hostnames and their current state
  - name: ACME DNS-01
    description: |
      Create and clean up TXT records for Let's Encrypt DNS-01 challenge validation.
      Works with Traefik (via lego httpreq), Certbot, Caddy, and other ACME clients.
  - name: DynDNS2
    description: Standard DynDNS2 protocol for legacy clients, routers, and IoT devices
  - name: Hostname Management
    description: Create and delete hostnames via the API
  - name: Utility
    description: Health checks and IP detection
