README
¶
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 viaZM_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)
--ddns-manage-ptr Update PTR records in matching reverse zones on DDNS update; missing reverse zone is ignored ($ZM_DDNS_MANAGE_PTR)
--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-Keymust contain base64-encodeduser: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/serversGET /api/v1/servers/localhostGET /api/v1/servers/localhost/zonesGET /api/v1/servers/localhost/zones/{zone_id}PATCH /api/v1/servers/localhost/zones/{zone_id}
Notes:
PATCHsupports RRSetREPLACEandDELETEchanges.- 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:
| Code | Meaning |
|---|---|
| 200 | Success |
| 500 | Unexpected server error |
GET /nic/update
Update A/AAAA records.
Required HTTP Headers:
| Name | Req | Description |
|---|---|---|
| Authorization | Yes | HTTP Basic Auth |
Query parameters:
| Name | Req | Description |
|---|---|---|
| hostname | Yes | Record name to update |
| myip | No | IP address to set to A/AAAA |
| myipv6 | No | IPv6 address to set to AAAA |
| offline | No | Not supported |
See also: https://www.noip.com/integrate/request
[!NOTE] If no
myipnormyipv6provided, a client IP would be used.
[!NOTE] With
--ddns-manage-ptrthe matching reverse zones (in-addr.arpa/ip6.arpa) are updated too: each of the current addresses gets a single PTR record pointing to the hostname, and any stale PTR pointing to it from other addresses is removed. If no suitable reverse zone exists for an address, it is silently skipped.
Response status codes:
| Code | Meaning |
|---|---|
| 200 | Updated |
| 400 | Bad request (e.g. missing hostname, invalid IP) |
| 401 | Unauthorized |
| 404 | Zone not found |
| 500 | Unexpected server error |
POST /acme/update
Update ACME DNS TXT records.
Required HTTP Headers:
| Name | Req | Description |
|---|---|---|
| X-Api-User | Yes* | Username from the htpasswd file |
| X-Api-Key | Yes* | Password from the htpasswd file |
| Authorization | Yes* | HTTP Basic Auth, alternative to pair above |
JSON Object fields:
| Name | Req | Description | Example |
|---|---|---|---|
| subdomain | Yes | Record name without _acme-challenge., not a UUID |
foo.example.com |
| txt | Yes | Validation token content for the TXT record | SomeRandomToken |
See also: https://github.com/joohoi/acme-dns
[!NOTE] Original ACME-DNS uses
X-Api-User/X-Api-Keystyle authentication and typically a per-record API key + CNAME alias flow. This implementation additionally accepts HTTP Basic Auth for simplicity.
[!NOTE] For
acme.shoptionACMEDNS_BASE_URLshould 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:
| Code | Meaning |
|---|---|
| 200 | Updated |
| 400 | Bad request |
| 401 | Unauthorized |
| 404 | Zone not found |
| 500 | Unexpected server error |
POST /present
Update ACME DNS TXT record, in LEGO HTTP-request format.
Required HTTP Headers:
| Name | Req | Description |
|---|---|---|
| Authorization | Yes | HTTP Basic Auth |
JSON Object fields:
| Name | Req | Description | Example |
|---|---|---|---|
| fqdn | Yes | Record name without _acme-challenge. |
foo.example.com |
| value | Yes | Validation token content for the TXT record | SomeRandomToken |
See also: https://go-acme.github.io/lego/dns/httpreq/
[!NOTE] Only HTTPREQ_MODE=default is supported
Response status codes:
| Code | Meaning |
|---|---|
| 200 | Updated |
| 400 | Bad request |
| 401 | Unauthorized |
| 404 | Zone not found |
| 500 | Unexpected server error |
POST /cleanup
Remove ACME DNS TXT record, in LEGO HTTP-request format.
Required HTTP Headers:
| Name | Req | Description |
|---|---|---|
| Authorization | Yes | HTTP Basic Auth |
JSON Object fields:
| Name | Req | Description | Example |
|---|---|---|---|
| fqdn | Yes | Record name without _acme-challenge. |
foo.example.com |
| value | No | Validation token content for the TXT record, Ignored | SomeRandomToken |
See also: https://go-acme.github.io/lego/dns/httpreq/
Response status codes:
| Code | Meaning |
|---|---|
| 200 | Updated |
| 400 | Bad request |
| 401 | Unauthorized |
| 404 | Zone not found |
| 500 | Unexpected 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:
| Name | Req | Description |
|---|---|---|
| Authorization | Yes | HTTP Basic Auth |
JSON Object fields:
| Name | Req | Description | Example |
|---|---|---|---|
| fqdn | Yes | Record domain name. | foo.example.com |
| type | Yes | Record type, case-insensitive. | NS |
| values | Yes | List of records values | ["ns1", "ns2"] |
[!NOTE]
POST /zm/updateupdates existing records only. If no matching record exists, it returns an error.
Response status codes:
| Code | Meaning |
|---|---|
| 200 | Updated |
| 400 | Bad request |
| 401 | Unauthorized |
| 404 | Zone not found |
| 500 | Unexpected server error |
POST /zm/update-ptr
Custom Zone-o-matic call.
Update PTR records in matching reverse zones for the requested addresses, pointing them to the target host.
Reverse names (in-addr.arpa / ip6.arpa) are calculated from the addresses automatically.
Required HTTP Headers:
| Name | Req | Description |
|---|---|---|
| Authorization | Yes | HTTP Basic Auth |
JSON Object fields:
| Name | Req | Description | Example |
|---|---|---|---|
| target | Yes | Hostname the addresses should resolve back to. | hub.example.com. |
| addresses | Yes | List of IP addresses to manage PTR records for. | ["192.0.2.55","2001:db8::1"] |
| mode | No | PTR update mode: append, replace or replace-all. Defaults to replace-all. |
replace-all |
mode semantics:
append— add a PTR record only if it is missing, never remove anything.replace— set a single PTR record for each requested address in place, keeping unrelated PTR records on the same name.replace-all— fully sync the target: exactly one PTR record per requested address, and any other PTR pointing to the target that is no longer in the address list is removed (e.g. after the host moved to a new address).
[!NOTE] Unlike
--ddns-manage-ptr, this call returns404when no matching reverse zone exists for one of the addresses.
Response status codes:
| Code | Meaning |
|---|---|
| 200 | Updated |
| 400 | Bad request |
| 401 | Unauthorized |
| 404 | Zone not found |
| 500 | Unexpected server error |
GET /health
Health check endpoint.
Response status codes:
| Code | Meaning |
|---|---|
| 200 | Healthy |
dnsfmt behavior
- Multi-part
TXTrecords are kept in parenthesized multiline form. TLSArecords are kept on a single line.