Skip to content

Repository files navigation

Zone-o-Matic

DNS API server for self-hosted DynDNS / ACME.

I use CoreDNS to serve my zones, unfortunately it does not support nsupdate protocol. It does auto-reload modified zone files, so an external service can update them.

This project aims to provide DDNS API similar to no-ip.com, so existing ddns-scripts can interact with it.

As a secondary feature it also provides API, which acme-sh can use to issue TLS certificates using dns-01 challenge.

It also supports LEGO HTTP-Request protocol for the same challenge.

You can use OpenWRT package from my feed: vooon/my-openwrt-feed.

Quick start

Start server:

zoneomatic --htpasswd ./htpasswd --zone ./example.com.zone --listen 0.0.0.0:9999

Update DDNS A record:

curl -u "user:password" \
"http://127.0.0.1:9999/nic/update?hostname=host.example.com&myip=203.0.113.10"

Update ACME TXT with acme-dns compatible endpoint:

curl -u "user:password" \
-H "Content-Type: application/json" \
-d '{"subdomain":"host.example.com","txt":"SomeRandomToken"}' \
"http://127.0.0.1:9999/acme/update"

Security notes

  • Authentication uses htpasswd entries with bcrypt hashes.
  • The server does not terminate TLS by itself; run it behind a reverse proxy with HTTPS.
  • If you enable --accept-proxy, only expose the service behind a trusted proxy/LB.

OpenTelemetry

OpenTelemetry supports three explicit signals:

  • --otel-enable-traces
  • --otel-enable-metrics
  • --otel-enable-logs

Use --otel-endpoint as a shared endpoint for enabled signals (recommended with OTEL Collector). If needed, override per signal with --otel-traces-endpoint, --otel-metrics-endpoint, --otel-logs-endpoint. You can enable any subset, or all three at once.

  • Service name defaults to zoneomatic; override with --otel-service-name.
  • Add custom HTTP headers (e.g. for authentication) with --otel-header Key=Value (repeatable, or via ZM_OTEL_HEADER).
  • Control the minimum log level forwarded to the OTEL receiver with --otel-logs-level (debug|info|warn|error). Useful when you want quieter console output but richer data in the collector.

Example:

zoneomatic \
--htpasswd ./htpasswd \
--zone ./example.com.zone \
--otel-endpoint http://127.0.0.1:4318 \
--otel-enable-traces \
--otel-enable-metrics \
--otel-enable-logs \
--otel-logs-level debug \
--otel-header "Authorization=Bearer mytoken" \
--otel-service-name zoneomatic-prod

Command line options

Usage: zoneomatic --htpasswd=FILE --zone=FILE,... [flags]
DNS Zone file updater
Flags:
-h, --help Show context-sensitive help.
--listen="localhost:9999" Server listen address ($ZM_LISTEN)
--accept-proxy Accept PROXY protocol ($ZM_ACCEPT_PROXY)
--proxy-header-timeout=10s Timeout for PROXY headers ($ZM_PROXY_HEADER_TIMEOUT)
-p, --htpasswd=FILE Passwords file (bcrypt only) ($ZM_HTPASSWD)
-z, --zone=FILE,... Zone files to update ($ZM_ZONE)
--acme-ttl=0 TTL (seconds) for ACME challenge TXT records; 0 = use zone $TTL ($ZM_ACME_TTL)
--debug Enable debug logging ($ZM_DEBUG)
--version Print version and exit ($ZM_VERSION)
--otel-endpoint=URL Shared OTLP/HTTP endpoint URL for enabled signals (typically collector URL) ($ZM_OTEL_ENDPOINT)
--otel-header=KEY=VALUE;... Additional HTTP headers for all OTLP exporters, repeatable (e.g. Authorization=Bearer token) ($ZM_OTEL_HEADER)
--otel-enable-traces Enable OpenTelemetry traces signal ($ZM_OTEL_ENABLE_TRACES)
--otel-traces-endpoint=URL OTLP/HTTP traces endpoint URL (e.g. http://127.0.0.1:4318/v1/traces) ($ZM_OTEL_TRACES_ENDPOINT)
--otel-enable-metrics Enable OpenTelemetry metrics signal ($ZM_OTEL_ENABLE_METRICS)
--otel-metrics-endpoint=URL OTLP/HTTP metrics endpoint URL (e.g. http://127.0.0.1:4318/v1/metrics) ($ZM_OTEL_METRICS_ENDPOINT)
--otel-enable-logs Enable OpenTelemetry logs signal ($ZM_OTEL_ENABLE_LOGS)
--otel-logs-endpoint=URL OTLP/HTTP logs endpoint URL (e.g. http://127.0.0.1:4318/v1/logs) ($ZM_OTEL_LOGS_ENDPOINT)
--otel-logs-level="" Minimum log level forwarded to OTLP (debug|info|warn|error); defaults to same as console ($ZM_OTEL_LOGS_LEVEL)
--otel-service-name="zoneomatic" OpenTelemetry service name ($ZM_OTEL_SERVICE_NAME)

Note

API description also available in OpenAPI 3 format on /swagger, e.g. http://localhost:9999/swagger

PowerDNS-Compatible API

Zone-o-matic exposes a PowerDNS-compatible API subset under /api/v1. It is intended for clients that only need server discovery plus read/update access to existing zones, such as Proxmox SDN.

Authentication:

  • X-API-Key must contain base64-encoded user:password, using credentials from the htpasswd file.
  • Regular HTTP Basic Auth with the same credentials is also accepted.
  • The only server id is localhost.

Implemented operations:

  • GET /api/v1/servers
  • GET /api/v1/servers/localhost
  • GET /api/v1/servers/localhost/zones
  • GET /api/v1/servers/localhost/zones/{zone_id}
  • PATCH /api/v1/servers/localhost/zones/{zone_id}

Notes:

  • PATCH supports RRSet REPLACE and DELETE changes.
  • Zone operations work on already configured zone files only; creating new zones through the API is not supported.
  • Unsupported PowerDNS-compatible endpoints currently return 501 Not Implemented.
  • Other PowerDNS API areas such as config, metadata, export, search, and AXFR retrieval are not implemented.

X-API-Key example:

curl \
-H "X-API-Key: $(printf 'user:password'| base64 -w0)" \
"http://127.0.0.1:9999/api/v1/servers"

GET /myip

Return client's IP Address in plain text.

Response status codes:

CodeMeaning
200Success
500Unexpected server error

GET /nic/update

Update A/AAAA records.

Required HTTP Headers:

NameReqDescription
AuthorizationYesHTTP Basic Auth

Query parameters:

NameReqDescription
hostnameYesRecord name to update
myipNoIP address to set to A/AAAA
myipv6NoIPv6 address to set to AAAA
offlineNoNot supported

See also: https://www.noip.com/integrate/request

Note

If no myip nor myipv6 provided, a client IP would be used.

Response status codes:

CodeMeaning
200Updated
400Bad request (e.g. missing hostname, invalid IP)
401Unauthorized
404Zone not found
500Unexpected server error

POST /acme/update

Update ACME DNS TXT records.

Required HTTP Headers:

NameReqDescription
X-Api-UserYes*Username from the htpasswd file
X-Api-KeyYes*Password from the htpasswd file
AuthorizationYes*HTTP Basic Auth, alternative to pair above

JSON Object fields:

NameReqDescriptionExample
subdomainYesRecord name without _acme-challenge., not a UUIDfoo.example.com
txtYesValidation token content for the TXT recordSomeRandomToken

See also: https://github.com/joohoi/acme-dns

Note

Original ACME-DNS uses X-Api-User/X-Api-Key style authentication and typically a per-record API key + CNAME alias flow. This implementation additionally accepts HTTP Basic Auth for simplicity.

Note

For acme.sh option ACMEDNS_BASE_URL should be like that: https://nsapi.example.com/acme, ACMEDNS_USERNAME & ACMEDNS_PASSWORD - valid user in htpasswd file, ACMEDNS_SUBDOMAIN - base domain name for which you are requesting certificate.

Auth examples:

Authorization: Basic ... mode:

curl -u "user:password" \
-H "Content-Type: application/json" \
-d '{"subdomain":"foo.example.com","txt":"SomeRandomToken"}' \
"http://127.0.0.1:9999/acme/update"

X-Api-User/X-Api-Key mode:

curl \
-H "X-Api-User: user" \
-H "X-Api-Key: password" \
-H "Content-Type: application/json" \
-d '{"subdomain":"foo.example.com","txt":"SomeRandomToken"}' \
"http://127.0.0.1:9999/acme/update"

Response status codes:

CodeMeaning
200Updated
400Bad request
401Unauthorized
404Zone not found
500Unexpected server error

POST /present

Update ACME DNS TXT record, in LEGO HTTP-request format.

Required HTTP Headers:

NameReqDescription
AuthorizationYesHTTP Basic Auth

JSON Object fields:

NameReqDescriptionExample
fqdnYesRecord name without _acme-challenge.foo.example.com
valueYesValidation token content for the TXT recordSomeRandomToken

See also: https://go-acme.github.io/lego/dns/httpreq/

Note

Only HTTPREQ_MODE=default is supported

Response status codes:

CodeMeaning
200Updated
400Bad request
401Unauthorized
404Zone not found
500Unexpected server error

POST /cleanup

Remove ACME DNS TXT record, in LEGO HTTP-request format.

Required HTTP Headers:

NameReqDescription
AuthorizationYesHTTP Basic Auth

JSON Object fields:

NameReqDescriptionExample
fqdnYesRecord name without _acme-challenge.foo.example.com
valueNoValidation token content for the TXT record, IgnoredSomeRandomToken

See also: https://go-acme.github.io/lego/dns/httpreq/

Response status codes:

CodeMeaning
200Updated
400Bad request
401Unauthorized
404Zone not found
500Unexpected server error

POST /zm/update

Custom Zone-o-matic call. Allow to update any existing record(s). Match records by FQDN and type, then each value will be translated to a record.

Required HTTP Headers:

NameReqDescription
AuthorizationYesHTTP Basic Auth

JSON Object fields:

NameReqDescriptionExample
fqdnYesRecord domain name.foo.example.com
typeYesRecord type, case-insensitive.NS
valuesYesList of records values["ns1", "ns2"]

Note

POST /zm/update updates existing records only. If no matching record exists, it returns an error.

Response status codes:

CodeMeaning
200Updated
400Bad request
401Unauthorized
404Zone not found
500Unexpected server error

GET /health

Health check endpoint.

Response status codes:

CodeMeaning
200Healthy

dnsfmt behavior

  • Multi-part TXT records are kept in parenthesized multiline form.
  • TLSA records are kept on a single line.

About

DNS API server for self-hosted DynDNS

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages