diff --git a/README.md b/README.md index ec2f832..e1b2237 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Flags: -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) @@ -192,6 +193,12 @@ See also: https://www.noip.com/integrate/request > [!NOTE] > If no `myip` nor `myipv6` provided, a client IP would be used. +> [!NOTE] +> With `--ddns-manage-ptr` the 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 | @@ -368,6 +375,51 @@ Response status codes: | 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 returns `404` when 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 ----------- diff --git a/doc/openapi.json b/doc/openapi.json index 6738a71..e3bb301 100644 --- a/doc/openapi.json +++ b/doc/openapi.json @@ -24,6 +24,36 @@ "type": "string" } }, + "required": [ + "txt" + ], + "type": "object" + }, + "ErrorItem": { + "properties": { + "more": { + "additionalProperties": { + "description": "Additional information about the error" + }, + "description": "Additional information about the error", + "type": [ + "object", + "null" + ] + }, + "name": { + "description": "For example, name of the parameter that caused the error", + "type": "string" + }, + "reason": { + "description": "Human readable error message", + "type": "string" + } + }, + "required": [ + "name", + "reason" + ], "type": "object" }, "HTTPError": { @@ -31,54 +61,31 @@ "properties": { "detail": { "description": "Human readable error message", - "nullable": true, "type": "string" }, "errors": { "items": { - "nullable": true, - "properties": { - "more": { - "additionalProperties": { - "description": "Additional information about the error", - "nullable": true - }, - "description": "Additional information about the error", - "nullable": true, - "type": "object" - }, - "name": { - "description": "For example, name of the parameter that caused the error", - "type": "string" - }, - "reason": { - "description": "Human readable error message", - "type": "string" - } - }, - "type": "object" + "$ref": "#/components/schemas/ErrorItem" }, - "nullable": true, - "type": "array" + "type": [ + "array", + "null" + ] }, "instance": { - "nullable": true, "type": "string" }, "status": { "description": "HTTP status code", "example": 403, - "nullable": true, "type": "integer" }, "title": { "description": "Short title of the error", - "nullable": true, "type": "string" }, "type": { "description": "URL of the error type. Can be used to lookup the error in a documentation", - "nullable": true, "type": "string" } }, @@ -95,7 +102,8 @@ } }, "required": [ - "fqdn" + "fqdn", + "value" ], "type": "object" }, @@ -109,6 +117,62 @@ "type": "string" } }, + "required": [ + "fqdn", + "value" + ], + "type": "object" + }, + "ZMUpdatePTRRequest": { + "description": "ZMUpdatePTRRequest schema", + "properties": { + "addresses": { + "items": { + "type": "string" + }, + "type": "array" + }, + "mode": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "addresses", + "target" + ], + "type": "object" + }, + "ZMUpdatePTRResponse": { + "description": "ZMUpdatePTRResponse schema", + "properties": { + "addresses": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "changed": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "addresses", + "changed", + "mode", + "target" + ], "type": "object" }, "ZMUpdateRequest": { @@ -118,7 +182,6 @@ "type": "string" }, "ttl": { - "nullable": true, "type": "integer" }, "type": { @@ -126,8 +189,7 @@ }, "values": { "items": { - "type": "string", - "x-fuego-required-marker": true + "type": "string" }, "type": "array" } @@ -149,6 +211,10 @@ "type": "string" } }, + "required": [ + "changed", + "fqdn" + ], "type": "object" }, "pdnsHTTPError": { @@ -159,13 +225,17 @@ }, "errors": { "items": { - "nullable": true, "type": "string" }, - "nullable": true, - "type": "array" + "type": [ + "array", + "null" + ] } }, + "required": [ + "error" + ], "type": "object" }, "pdnsNoContentResponse": { @@ -176,48 +246,70 @@ "properties": { "rrsets": { "items": { - "properties": { - "changetype": { - "nullable": true, - "type": "string" - }, - "comments": { - "items": { - "nullable": true - }, - "nullable": true, - "type": "array" - }, - "name": { - "type": "string" - }, - "records": { - "items": { - "properties": { - "content": { - "type": "string" - }, - "disabled": { - "type": "boolean" - } - }, - "type": "object" - }, - "type": "array" - }, - "ttl": { - "nullable": true, - "type": "integer" - }, - "type": { - "type": "string" - } - }, - "type": "object" + "$ref": "#/components/schemas/pdnsRRSet" }, - "type": "array" + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "rrsets" + ], + "type": "object" + }, + "pdnsRRSet": { + "properties": { + "changetype": { + "type": "string" + }, + "comments": { + "items": {}, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "records": { + "items": { + "$ref": "#/components/schemas/pdnsRecord" + }, + "type": [ + "array", + "null" + ] + }, + "ttl": { + "type": "integer" + }, + "type": { + "type": "string" + } + }, + "required": [ + "name", + "records", + "type" + ], + "type": "object" + }, + "pdnsRecord": { + "properties": { + "content": { + "type": "string" + }, + "disabled": { + "type": "boolean" } }, + "required": [ + "content", + "disabled" + ], "type": "object" }, "pdnsServer": { @@ -245,6 +337,15 @@ "type": "string" } }, + "required": [ + "config_url", + "daemon_type", + "id", + "type", + "url", + "version", + "zones_url" + ], "type": "object" }, "pdnsZone": { @@ -257,7 +358,6 @@ "type": "boolean" }, "catalog": { - "nullable": true, "type": "string" }, "dnssec": { @@ -283,24 +383,31 @@ "items": { "type": "string" }, - "type": "array" + "type": [ + "array", + "null" + ] }, "masters": { "items": { "type": "string" }, - "type": "array" + "type": [ + "array", + "null" + ] }, "name": { "type": "string" }, "nameservers": { "items": { - "nullable": true, "type": "string" }, - "nullable": true, - "type": "array" + "type": [ + "array", + "null" + ] }, "notified_serial": { "maximum": 4294967295, @@ -318,48 +425,12 @@ }, "rrsets": { "items": { - "nullable": true, - "properties": { - "changetype": { - "nullable": true, - "type": "string" - }, - "comments": { - "items": { - "nullable": true - }, - "nullable": true, - "type": "array" - }, - "name": { - "type": "string" - }, - "records": { - "items": { - "properties": { - "content": { - "type": "string" - }, - "disabled": { - "type": "boolean" - } - }, - "type": "object" - }, - "type": "array" - }, - "ttl": { - "nullable": true, - "type": "integer" - }, - "type": { - "type": "string" - } - }, - "type": "object" + "$ref": "#/components/schemas/pdnsRRSet" }, - "nullable": true, - "type": "array" + "type": [ + "array", + "null" + ] }, "serial": { "maximum": 4294967295, @@ -370,10 +441,12 @@ "items": { "type": "string" }, - "type": "array" + "type": [ + "array", + "null" + ] }, "soa_edit_api": { - "nullable": true, "type": "string" }, "type": { @@ -383,10 +456,29 @@ "type": "string" }, "zone": { - "nullable": true, "type": "string" } }, + "required": [ + "account", + "api_rectify", + "dnssec", + "edited_serial", + "id", + "kind", + "last_check", + "master_tsig_key_ids", + "masters", + "name", + "notified_serial", + "nsec3narrow", + "nsec3param", + "presigned", + "serial", + "slave_tsig_key_ids", + "type", + "url" + ], "type": "object" }, "string": { @@ -431,7 +523,7 @@ "paths": { "/acme/update": { "post": { - "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func4`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nUpdate ACME challenge TXT record", + "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func4`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate ACME challenge TXT record", "operationId": "POST_/acme/update", "requestBody": { "content": { @@ -1385,7 +1477,7 @@ }, "/cleanup": { "post": { - "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func6`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nClean up ACME challenge TXT record using LEGO HTTP-REQ", + "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func6`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nClean up ACME challenge TXT record using LEGO HTTP-REQ", "operationId": "POST_/cleanup", "requestBody": { "content": { @@ -1581,7 +1673,7 @@ }, "/nic/update": { "get": { - "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func3`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nUpdate DDNS record", + "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func3`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate DDNS record", "operationId": "GET_/nic/update", "parameters": [ { @@ -1682,7 +1774,7 @@ }, "/present": { "post": { - "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func5`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nUpdate ACME challenge TXT record using LEGO HTTP-REQ", + "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func5`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate ACME challenge TXT record using LEGO HTTP-REQ", "operationId": "POST_/present", "requestBody": { "content": { @@ -1752,7 +1844,7 @@ }, "/zm/update": { "post": { - "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func7`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nReplace any existing DNS record value", + "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func7`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nReplace any existing DNS record value", "operationId": "POST_/zm/update", "parameters": [ { @@ -1828,12 +1920,85 @@ ], "summary": "update any dns record" } + }, + "/zm/update-ptr": { + "post": { + "description": "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func8`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate PTR records in matching reverse zones for the requested addresses, pointing them to the target host. The mode controls whether existing PTR records are appended, replaced or fully synced.", + "operationId": "POST_/zm/update-ptr", + "parameters": [ + { + "in": "header", + "name": "Accept", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ZMUpdatePTRRequest" + } + } + }, + "description": "Request body for server.ZMUpdatePTRRequest", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZMUpdatePTRResponse" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/ZMUpdatePTRResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Bad Request _(validation or deserialization error)_" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Internal Server Error _(panics)_" + } + }, + "security": [ + { + "basicAuth": [] + } + ], + "summary": "update ptr records" + } } - }, - "servers": [ - { - "description": "local server", - "url": "http://127.0.0.1:9999" - } - ] + } } \ No newline at end of file diff --git a/doc/openapi.yaml b/doc/openapi.yaml index e7f13c2..8231c18 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -1,1146 +1,1255 @@ +openapi: 3.1.0 components: - schemas: - ACMEUpdateRequest: - description: ACMEUpdateRequest schema - properties: - subdomain: - type: string - txt: - type: string - required: - - subdomain - - txt - type: object - ACMEUpdateResponse: - description: ACMEUpdateResponse schema - properties: - txt: - type: string - type: object - HTTPError: - description: HTTPError schema - properties: - detail: - description: Human readable error message - nullable: true - type: string - errors: - items: - nullable: true + schemas: + ACMEUpdateRequest: + description: ACMEUpdateRequest schema properties: - more: - additionalProperties: - description: Additional information about the error - nullable: true - description: Additional information about the error - nullable: true - type: object - name: - description: For example, name of the parameter that caused the error - type: string - reason: - description: Human readable error message - type: string + subdomain: + type: string + txt: + type: string + required: + - subdomain + - txt type: object - nullable: true - type: array - instance: - nullable: true - type: string - status: - description: HTTP status code - example: 403 - nullable: true - type: integer - title: - description: Short title of the error - nullable: true - type: string - type: - description: URL of the error type. Can be used to lookup the error in a documentation - nullable: true - type: string - type: object - LegoHttpDefaultRequest: - description: LegoHttpDefaultRequest schema - properties: - fqdn: - type: string - value: - type: string - required: - - fqdn - type: object - LegoHttpDefaultResponse: - description: LegoHttpDefaultResponse schema - properties: - fqdn: - type: string - value: - type: string - type: object - ZMUpdateRequest: - description: ZMUpdateRequest schema - properties: - fqdn: - type: string - ttl: - nullable: true - type: integer - type: - type: string - values: - items: - type: string - x-fuego-required-marker: true - type: array - required: - - fqdn - - type - - values - type: object - ZMUpdateResponse: - description: ZMUpdateResponse schema - properties: - changed: - type: boolean - fqdn: - type: string - type: object - pdnsHTTPError: - description: pdnsHTTPError schema - properties: - error: - type: string - errors: - items: - nullable: true - type: string - nullable: true - type: array - type: object - pdnsNoContentResponse: - description: pdnsNoContentResponse schema - pdnsPatchZoneRequest: - description: pdnsPatchZoneRequest schema - properties: - rrsets: - items: + ACMEUpdateResponse: + description: ACMEUpdateResponse schema properties: - changetype: - nullable: true - type: string - comments: - items: - nullable: true - nullable: true - type: array - name: - type: string - records: - items: - properties: - content: - type: string - disabled: - type: boolean - type: object - type: array - ttl: - nullable: true - type: integer - type: - type: string + txt: + type: string + required: + - txt type: object - type: array - type: object - pdnsServer: - description: pdnsServer schema - properties: - config_url: - type: string - daemon_type: - type: string - id: - type: string - type: - type: string - url: - type: string - version: - type: string - zones_url: - type: string - type: object - pdnsZone: - description: pdnsZone schema - properties: - account: - type: string - api_rectify: - type: boolean - catalog: - nullable: true - type: string - dnssec: - type: boolean - edited_serial: - maximum: 4294967295 - minimum: 0 - type: integer - id: - type: string - kind: - type: string - last_check: - maximum: 4294967295 - minimum: 0 - type: integer - master_tsig_key_ids: - items: - type: string - type: array - masters: - items: - type: string - type: array - name: - type: string - nameservers: - items: - nullable: true - type: string - nullable: true - type: array - notified_serial: - maximum: 4294967295 - minimum: 0 - type: integer - nsec3narrow: - type: boolean - nsec3param: - type: string - presigned: - type: boolean - rrsets: - items: - nullable: true + ErrorItem: properties: - changetype: - nullable: true - type: string - comments: - items: - nullable: true - nullable: true - type: array - name: - type: string - records: - items: - properties: - content: - type: string - disabled: - type: boolean - type: object - type: array - ttl: - nullable: true - type: integer - type: - type: string + more: + additionalProperties: + description: Additional information about the error + description: Additional information about the error + type: + - object + - "null" + name: + description: For example, name of the parameter that caused the error + type: string + reason: + description: Human readable error message + type: string + required: + - name + - reason + type: object + HTTPError: + description: HTTPError schema + properties: + detail: + description: Human readable error message + type: string + errors: + items: + $ref: '#/components/schemas/ErrorItem' + type: + - array + - "null" + instance: + type: string + status: + description: HTTP status code + example: 403 + type: integer + title: + description: Short title of the error + type: string + type: + description: URL of the error type. Can be used to lookup the error in a documentation + type: string + type: object + LegoHttpDefaultRequest: + description: LegoHttpDefaultRequest schema + properties: + fqdn: + type: string + value: + type: string + required: + - fqdn + - value + type: object + LegoHttpDefaultResponse: + description: LegoHttpDefaultResponse schema + properties: + fqdn: + type: string + value: + type: string + required: + - fqdn + - value + type: object + ZMUpdatePTRRequest: + description: ZMUpdatePTRRequest schema + properties: + addresses: + items: + type: string + type: array + mode: + type: string + target: + type: string + required: + - addresses + - target + type: object + ZMUpdatePTRResponse: + description: ZMUpdatePTRResponse schema + properties: + addresses: + items: + type: string + type: + - array + - "null" + changed: + type: boolean + mode: + type: string + target: + type: string + required: + - addresses + - changed + - mode + - target + type: object + ZMUpdateRequest: + description: ZMUpdateRequest schema + properties: + fqdn: + type: string + ttl: + type: integer + type: + type: string + values: + items: + type: string + type: array + required: + - fqdn + - type + - values + type: object + ZMUpdateResponse: + description: ZMUpdateResponse schema + properties: + changed: + type: boolean + fqdn: + type: string + required: + - changed + - fqdn + type: object + pdnsHTTPError: + description: pdnsHTTPError schema + properties: + error: + type: string + errors: + items: + type: string + type: + - array + - "null" + required: + - error + type: object + pdnsNoContentResponse: + description: pdnsNoContentResponse schema + pdnsPatchZoneRequest: + description: pdnsPatchZoneRequest schema + properties: + rrsets: + items: + $ref: '#/components/schemas/pdnsRRSet' + type: + - array + - "null" + required: + - rrsets + type: object + pdnsRRSet: + properties: + changetype: + type: string + comments: + items: {} + type: + - array + - "null" + name: + type: string + records: + items: + $ref: '#/components/schemas/pdnsRecord' + type: + - array + - "null" + ttl: + type: integer + type: + type: string + required: + - name + - records + - type + type: object + pdnsRecord: + properties: + content: + type: string + disabled: + type: boolean + required: + - content + - disabled + type: object + pdnsServer: + description: pdnsServer schema + properties: + config_url: + type: string + daemon_type: + type: string + id: + type: string + type: + type: string + url: + type: string + version: + type: string + zones_url: + type: string + required: + - config_url + - daemon_type + - id + - type + - url + - version + - zones_url + type: object + pdnsZone: + description: pdnsZone schema + properties: + account: + type: string + api_rectify: + type: boolean + catalog: + type: string + dnssec: + type: boolean + edited_serial: + maximum: 4.294967295e+09 + minimum: 0 + type: integer + id: + type: string + kind: + type: string + last_check: + maximum: 4.294967295e+09 + minimum: 0 + type: integer + master_tsig_key_ids: + items: + type: string + type: + - array + - "null" + masters: + items: + type: string + type: + - array + - "null" + name: + type: string + nameservers: + items: + type: string + type: + - array + - "null" + notified_serial: + maximum: 4.294967295e+09 + minimum: 0 + type: integer + nsec3narrow: + type: boolean + nsec3param: + type: string + presigned: + type: boolean + rrsets: + items: + $ref: '#/components/schemas/pdnsRRSet' + type: + - array + - "null" + serial: + maximum: 4.294967295e+09 + minimum: 0 + type: integer + slave_tsig_key_ids: + items: + type: string + type: + - array + - "null" + soa_edit_api: + type: string + type: + type: string + url: + type: string + zone: + type: string + required: + - account + - api_rectify + - dnssec + - edited_serial + - id + - kind + - last_check + - master_tsig_key_ids + - masters + - name + - notified_serial + - nsec3narrow + - nsec3param + - presigned + - serial + - slave_tsig_key_ids + - type + - url type: object - nullable: true - type: array - serial: - maximum: 4294967295 - minimum: 0 - type: integer - slave_tsig_key_ids: - items: + string: + description: string schema type: string - type: array - soa_edit_api: - nullable: true - type: string - type: - type: string - url: - type: string - zone: - nullable: true - type: string - type: object - string: - description: string schema - type: string - unknown-interface: - description: unknown-interface schema - securitySchemes: - apiKeyAuth: - description: password - in: header - name: X-Api-Key - type: apiKey - apiUserAuth: - description: username - in: header - name: X-Api-User - type: apiKey - basicAuth: - scheme: basic - type: http - pdnsApiKeyAuth: - description: static api key - in: header - name: X-API-Key - type: apiKey + unknown-interface: + description: unknown-interface schema + securitySchemes: + apiKeyAuth: + description: password + in: header + name: X-Api-Key + type: apiKey + apiUserAuth: + description: username + in: header + name: X-Api-User + type: apiKey + basicAuth: + scheme: basic + type: http + pdnsApiKeyAuth: + description: static api key + in: header + name: X-API-Key + type: apiKey info: - description: HTTP API for zoneomatic DNS updates and PowerDNS-compatible zone management. - title: Zoneomatic API - version: 0.0.1 -openapi: 3.1.0 + description: HTTP API for zoneomatic DNS updates and PowerDNS-compatible zone management. + title: Zoneomatic API + version: 0.0.1 paths: - /acme/update: - post: - description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func4`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nUpdate ACME challenge TXT record" - operationId: POST_/acme/update - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ACMEUpdateRequest' - description: Request body for *server.ACMEUpdateRequest - required: true - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/ACMEUpdateResponse' - application/xml: - schema: - $ref: '#/components/schemas/ACMEUpdateResponse' - description: Record updated - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - apiKeyAuth: [] - apiUserAuth: [] - - basicAuth: [] - summary: update acme - /api/v1/servers: - get: - description: List the forged PowerDNS-compatible server instance. - operationId: pdnsListServers - parameters: - - in: header - name: Accept - schema: - type: string - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/pdnsServer' - type: array - application/xml: - schema: - items: - $ref: '#/components/schemas/pdnsServer' - type: array - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns list servers - /api/v1/servers/{server_id}: - get: - description: Return the forged PowerDNS-compatible server instance. - operationId: pdnsGetServer - parameters: - - in: header - name: Accept - schema: - type: string - - in: path - name: server_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsServer' - application/xml: - schema: - $ref: '#/components/schemas/pdnsServer' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns get server - /api/v1/servers/{server_id}/zones: - get: - description: List managed zones in a PowerDNS-compatible format. - operationId: pdnsListZones - parameters: - - description: Filter zones by fully-qualified zone name - in: query - name: zone - schema: - type: string - - in: header - name: Accept - schema: - type: string - - in: path - name: server_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/pdnsZone' - type: array - application/xml: - schema: - items: - $ref: '#/components/schemas/pdnsZone' - type: array - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns list zones - post: - description: create zone is not implemented for existing-file managed zones. - operationId: pdnsCreateZone - parameters: - - in: path - name: server_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/unknown-interface' - application/xml: - schema: - $ref: '#/components/schemas/unknown-interface' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns create zone - /api/v1/servers/{server_id}/zones/{zone_id}: - delete: - description: delete zone is not implemented for existing-file managed zones. - operationId: pdnsDeleteZone - parameters: - - in: path - name: server_id - required: true - schema: - type: string - - in: path - name: zone_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/unknown-interface' - application/xml: - schema: - $ref: '#/components/schemas/unknown-interface' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns delete zone - get: - description: Return a managed zone in PowerDNS-compatible format. - operationId: pdnsGetZone - parameters: - - description: Include rrsets in the zone response. Defaults to true. - in: query - name: rrsets - schema: - type: boolean - - description: Filter returned rrsets by fully-qualified record name - in: query - name: rrset_name - schema: - type: string - - description: Filter returned rrsets by record type; requires rrset_name - in: query - name: rrset_type - schema: - type: string - - in: header - name: Accept - schema: - type: string - - in: path - name: server_id - required: true - schema: - type: string - - in: path - name: zone_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsZone' - application/xml: - schema: - $ref: '#/components/schemas/pdnsZone' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns get zone - patch: - description: Replace or delete managed RRSets in PowerDNS-compatible format. - operationId: pdnsPatchZone - parameters: - - in: path - name: server_id - required: true - schema: - type: string - - in: path - name: zone_id - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsPatchZoneRequest' - description: Request body for *server.pdnsPatchZoneRequest - required: true - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/unknown-interface' - application/xml: - schema: - $ref: '#/components/schemas/unknown-interface' - description: OK - "204": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsNoContentResponse' - application/xml: - schema: - $ref: '#/components/schemas/pdnsNoContentResponse' - description: RRSets updated - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - application/xml: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - description: Invalid request body - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - application/xml: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - description: Unauthorized - "404": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - application/xml: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - description: Zone or server not found - "422": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - application/xml: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - description: Invalid rrset request - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - "501": - content: - application/json: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - application/xml: - schema: - $ref: '#/components/schemas/pdnsHTTPError' - description: Unsupported patch request - security: - - pdnsApiKeyAuth: [] - summary: pdns patch zone - put: - description: update zone is not implemented for existing-file managed zones. - operationId: pdnsUpdateZone - parameters: - - in: path - name: server_id - required: true - schema: - type: string - - in: path - name: zone_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/unknown-interface' - application/xml: - schema: - $ref: '#/components/schemas/unknown-interface' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns update zone - /api/v1/servers/{server_id}/zones/{zone_id}/notify: - put: - description: notify zone is not implemented for existing-file managed zones. - operationId: pdnsNotifyZone - parameters: - - in: path - name: server_id - required: true - schema: - type: string - - in: path - name: zone_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/unknown-interface' - application/xml: - schema: - $ref: '#/components/schemas/unknown-interface' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns notify zone - /api/v1/servers/{server_id}/zones/{zone_id}/rectify: - put: - description: rectify zone is not implemented for existing-file managed zones. - operationId: pdnsRectifyZone - parameters: - - in: path - name: server_id - required: true - schema: - type: string - - in: path - name: zone_id - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/unknown-interface' - application/xml: - schema: - $ref: '#/components/schemas/unknown-interface' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - pdnsApiKeyAuth: [] - summary: pdns rectify zone - /cleanup: - post: - description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func6`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nClean up ACME challenge TXT record using LEGO HTTP-REQ" - operationId: POST_/cleanup - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/LegoHttpDefaultRequest' - description: Request body for *server.LegoHttpDefaultRequest - required: true - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/LegoHttpDefaultResponse' - application/xml: - schema: - $ref: '#/components/schemas/LegoHttpDefaultResponse' - description: Record updated - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - basicAuth: [] - summary: cleanup acme via lego httpreq - /health: - get: - description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func1`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\nHealth check endpoint" - operationId: GET_/health - parameters: - - in: header - name: Accept - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/string' - application/xml: - schema: - $ref: '#/components/schemas/string' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - summary: health - /myip: - get: - description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func2`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\nReturn client's connection IP address" - operationId: GET_/myip - parameters: - - in: header - name: Accept - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/string' - application/xml: - schema: - $ref: '#/components/schemas/string' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - summary: myip - /nic/update: - get: - description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func3`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nUpdate DDNS record" - operationId: GET_/nic/update - parameters: - - description: record domain to update - in: query - name: hostname - required: true - schema: - type: string - - description: IP address to set - in: query - name: myip - schema: - type: string - - description: IPv6 address to set - in: query - name: myipv6 - schema: - type: string - - description: Not supported, a no-op for compatibility. - in: query - name: offline - schema: - type: boolean - - in: header - name: Accept - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/string' - application/xml: - schema: - $ref: '#/components/schemas/string' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - basicAuth: [] - summary: update ddns - /present: - post: - description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func5`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nUpdate ACME challenge TXT record using LEGO HTTP-REQ" - operationId: POST_/present - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/LegoHttpDefaultRequest' - description: Request body for *server.LegoHttpDefaultRequest - required: true - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/LegoHttpDefaultResponse' - application/xml: - schema: - $ref: '#/components/schemas/LegoHttpDefaultResponse' - description: Record updated - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - basicAuth: [] - summary: update acme via lego httpreq - /zm/update: - post: - description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func7`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func8`\n\n---\n\nReplace any existing DNS record value" - operationId: POST_/zm/update - parameters: - - in: header - name: Accept - schema: - type: string - requestBody: - content: - '*/*': - schema: - $ref: '#/components/schemas/ZMUpdateRequest' - description: Request body for server.ZMUpdateRequest - required: true - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/ZMUpdateResponse' - application/xml: - schema: - $ref: '#/components/schemas/ZMUpdateResponse' - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Bad Request _(validation or deserialization error)_ - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPError' - application/xml: - schema: - $ref: '#/components/schemas/HTTPError' - description: Internal Server Error _(panics)_ - security: - - basicAuth: [] - summary: update any dns record -servers: - - description: local server - url: http://127.0.0.1:9999 + /acme/update: + post: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func4`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate ACME challenge TXT record" + operationId: POST_/acme/update + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ACMEUpdateRequest' + description: Request body for *server.ACMEUpdateRequest + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ACMEUpdateResponse' + application/xml: + schema: + $ref: '#/components/schemas/ACMEUpdateResponse' + description: Record updated + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - apiKeyAuth: [] + apiUserAuth: [] + - basicAuth: [] + summary: update acme + /api/v1/servers: + get: + description: List the forged PowerDNS-compatible server instance. + operationId: pdnsListServers + parameters: + - in: header + name: Accept + schema: + type: string + responses: + "200": + content: + application/json: + schema: + items: + $ref: '#/components/schemas/pdnsServer' + type: array + application/xml: + schema: + items: + $ref: '#/components/schemas/pdnsServer' + type: array + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns list servers + /api/v1/servers/{server_id}: + get: + description: Return the forged PowerDNS-compatible server instance. + operationId: pdnsGetServer + parameters: + - in: header + name: Accept + schema: + type: string + - in: path + name: server_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsServer' + application/xml: + schema: + $ref: '#/components/schemas/pdnsServer' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns get server + /api/v1/servers/{server_id}/zones: + get: + description: List managed zones in a PowerDNS-compatible format. + operationId: pdnsListZones + parameters: + - description: Filter zones by fully-qualified zone name + in: query + name: zone + schema: + type: string + - in: header + name: Accept + schema: + type: string + - in: path + name: server_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + items: + $ref: '#/components/schemas/pdnsZone' + type: array + application/xml: + schema: + items: + $ref: '#/components/schemas/pdnsZone' + type: array + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns list zones + post: + description: create zone is not implemented for existing-file managed zones. + operationId: pdnsCreateZone + parameters: + - in: path + name: server_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/unknown-interface' + application/xml: + schema: + $ref: '#/components/schemas/unknown-interface' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns create zone + /api/v1/servers/{server_id}/zones/{zone_id}: + delete: + description: delete zone is not implemented for existing-file managed zones. + operationId: pdnsDeleteZone + parameters: + - in: path + name: server_id + required: true + schema: + type: string + - in: path + name: zone_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/unknown-interface' + application/xml: + schema: + $ref: '#/components/schemas/unknown-interface' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns delete zone + get: + description: Return a managed zone in PowerDNS-compatible format. + operationId: pdnsGetZone + parameters: + - description: Include rrsets in the zone response. Defaults to true. + in: query + name: rrsets + schema: + type: boolean + - description: Filter returned rrsets by fully-qualified record name + in: query + name: rrset_name + schema: + type: string + - description: Filter returned rrsets by record type; requires rrset_name + in: query + name: rrset_type + schema: + type: string + - in: header + name: Accept + schema: + type: string + - in: path + name: server_id + required: true + schema: + type: string + - in: path + name: zone_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsZone' + application/xml: + schema: + $ref: '#/components/schemas/pdnsZone' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns get zone + patch: + description: Replace or delete managed RRSets in PowerDNS-compatible format. + operationId: pdnsPatchZone + parameters: + - in: path + name: server_id + required: true + schema: + type: string + - in: path + name: zone_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsPatchZoneRequest' + description: Request body for *server.pdnsPatchZoneRequest + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/unknown-interface' + application/xml: + schema: + $ref: '#/components/schemas/unknown-interface' + description: OK + "204": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsNoContentResponse' + application/xml: + schema: + $ref: '#/components/schemas/pdnsNoContentResponse' + description: RRSets updated + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + application/xml: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + description: Invalid request body + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + application/xml: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + application/xml: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + description: Zone or server not found + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + application/xml: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + description: Invalid rrset request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + application/xml: + schema: + $ref: '#/components/schemas/pdnsHTTPError' + description: Unsupported patch request + security: + - pdnsApiKeyAuth: [] + summary: pdns patch zone + put: + description: update zone is not implemented for existing-file managed zones. + operationId: pdnsUpdateZone + parameters: + - in: path + name: server_id + required: true + schema: + type: string + - in: path + name: zone_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/unknown-interface' + application/xml: + schema: + $ref: '#/components/schemas/unknown-interface' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns update zone + /api/v1/servers/{server_id}/zones/{zone_id}/notify: + put: + description: notify zone is not implemented for existing-file managed zones. + operationId: pdnsNotifyZone + parameters: + - in: path + name: server_id + required: true + schema: + type: string + - in: path + name: zone_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/unknown-interface' + application/xml: + schema: + $ref: '#/components/schemas/unknown-interface' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns notify zone + /api/v1/servers/{server_id}/zones/{zone_id}/rectify: + put: + description: rectify zone is not implemented for existing-file managed zones. + operationId: pdnsRectifyZone + parameters: + - in: path + name: server_id + required: true + schema: + type: string + - in: path + name: zone_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/unknown-interface' + application/xml: + schema: + $ref: '#/components/schemas/unknown-interface' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - pdnsApiKeyAuth: [] + summary: pdns rectify zone + /cleanup: + post: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func6`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nClean up ACME challenge TXT record using LEGO HTTP-REQ" + operationId: POST_/cleanup + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LegoHttpDefaultRequest' + description: Request body for *server.LegoHttpDefaultRequest + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/LegoHttpDefaultResponse' + application/xml: + schema: + $ref: '#/components/schemas/LegoHttpDefaultResponse' + description: Record updated + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - basicAuth: [] + summary: cleanup acme via lego httpreq + /health: + get: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func1`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\nHealth check endpoint" + operationId: GET_/health + parameters: + - in: header + name: Accept + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/string' + application/xml: + schema: + $ref: '#/components/schemas/string' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + summary: health + /myip: + get: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func2`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\nReturn client's connection IP address" + operationId: GET_/myip + parameters: + - in: header + name: Accept + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/string' + application/xml: + schema: + $ref: '#/components/schemas/string' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + summary: myip + /nic/update: + get: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func3`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate DDNS record" + operationId: GET_/nic/update + parameters: + - description: record domain to update + in: query + name: hostname + required: true + schema: + type: string + - description: IP address to set + in: query + name: myip + schema: + type: string + - description: IPv6 address to set + in: query + name: myipv6 + schema: + type: string + - description: Not supported, a no-op for compatibility. + in: query + name: offline + schema: + type: boolean + - in: header + name: Accept + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/string' + application/xml: + schema: + $ref: '#/components/schemas/string' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - basicAuth: [] + summary: update ddns + /present: + post: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func5`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate ACME challenge TXT record using LEGO HTTP-REQ" + operationId: POST_/present + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LegoHttpDefaultRequest' + description: Request body for *server.LegoHttpDefaultRequest + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/LegoHttpDefaultResponse' + application/xml: + schema: + $ref: '#/components/schemas/LegoHttpDefaultResponse' + description: Record updated + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - basicAuth: [] + summary: update acme via lego httpreq + /zm/update: + post: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func7`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nReplace any existing DNS record value" + operationId: POST_/zm/update + parameters: + - in: header + name: Accept + schema: + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/ZMUpdateRequest' + description: Request body for server.ZMUpdateRequest + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ZMUpdateResponse' + application/xml: + schema: + $ref: '#/components/schemas/ZMUpdateResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - basicAuth: [] + summary: update any dns record + /zm/update-ptr: + post: + description: "#### Controller: \n\n`github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.func8`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/vooon/zoneomatic/internal/server.RegisterEndpoints.NewBasicAuthMiddleware.func9`\n\n---\n\nUpdate PTR records in matching reverse zones for the requested addresses, pointing them to the target host. The mode controls whether existing PTR records are appended, replaced or fully synced." + operationId: POST_/zm/update-ptr + parameters: + - in: header + name: Accept + schema: + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/ZMUpdatePTRRequest' + description: Request body for server.ZMUpdatePTRRequest + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ZMUpdatePTRResponse' + application/xml: + schema: + $ref: '#/components/schemas/ZMUpdatePTRResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Bad Request _(validation or deserialization error)_ + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + application/xml: + schema: + $ref: '#/components/schemas/HTTPError' + description: Internal Server Error _(panics)_ + security: + - basicAuth: [] + summary: update ptr records diff --git a/internal/server/main.go b/internal/server/main.go index 295bca4..b5b25a4 100644 --- a/internal/server/main.go +++ b/internal/server/main.go @@ -24,6 +24,7 @@ type Cli struct { HTPasswdFile string `short:"p" name:"htpasswd" required:"" type:"existingfile" placeholder:"FILE" help:"Passwords file (bcrypt only)"` ZoneFiles []string `short:"z" name:"zone" required:"" type:"existingfile" placeholder:"FILE,..." help:"Zone files to update"` AcmeTTL int `name:"acme-ttl" default:"0" help:"TTL (seconds) for ACME challenge TXT records; 0 = use zone $TTL"` + DDNSManagePTR bool `name:"ddns-manage-ptr" help:"Update PTR records in matching reverse zones on DDNS update; missing reverse zone is ignored"` Debug bool `name:"debug" help:"Enable debug logging"` Version kong.VersionFlag `help:"Print version and exit"` @@ -68,7 +69,7 @@ func Main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - zctl, err := zone.NewWithOptions([]zone.Option{zone.WithAcmeTTL(cli.AcmeTTL)}, cli.ZoneFiles...) + zctl, err := zone.NewWithOptions([]zone.Option{zone.WithAcmeTTL(cli.AcmeTTL), zone.WithDDNSManagePTR(cli.DDNSManagePTR)}, cli.ZoneFiles...) kctx.FatalIfErrorf(err) htp, err := htpasswd.NewFromFile(cli.HTPasswdFile) diff --git a/internal/server/server.go b/internal/server/server.go index 0a97615..6cf6139 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -51,6 +51,19 @@ type ZMUpdateResponse struct { Changed bool `json:"changed"` } +type ZMUpdatePTRRequest struct { + Target string `json:"target" validate:"required"` + Addresses []string `json:"addresses" validate:"required"` + Mode zone.PTRUpdateMode `json:"mode,omitempty"` +} + +type ZMUpdatePTRResponse struct { + Target string `json:"target"` + Addresses []string `json:"addresses"` + Mode zone.PTRUpdateMode `json:"mode"` + Changed bool `json:"changed"` +} + func NewServer(cli *Cli) (*fuego.Server, net.Listener, error) { listener, err := net.Listen("tcp", cli.Listen) @@ -360,6 +373,57 @@ func RegisterEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.Contr ), ) + fuego.Post(srv, "/zm/update-ptr", + func(ctx fuego.ContextWithBody[ZMUpdatePTRRequest]) (*ZMUpdatePTRResponse, error) { + req, err := ctx.Body() + if err != nil { + return nil, err + } + + mode := req.Mode + if mode == "" { + mode = zone.PTRUpdateReplaceAll + } + switch mode { + case zone.PTRUpdateAppend, zone.PTRUpdateReplace, zone.PTRUpdateReplaceAll: + default: + return nil, badRequestError(fmt.Sprintf("invalid mode: %s", mode)) + } + + addrs := make([]netip.Addr, 0, len(req.Addresses)) + for _, a := range req.Addresses { + addr, err := netip.ParseAddr(a) + if err != nil { + return nil, badRequestError(fmt.Sprintf("invalid address %q: %v", a, err)) + } + addrs = append(addrs, addr) + } + if len(addrs) == 0 { + return nil, badRequestError("no addresses provided") + } + + changed, err := zctl.UpdatePTR(ctx, req.Target, addrs, mode) + if err != nil { + return nil, zoneErrorToHTTPError(err) + } + + return &ZMUpdatePTRResponse{ + Target: req.Target, + Addresses: req.Addresses, + Mode: mode, + Changed: changed, + }, nil + }, + option.Summary("update ptr records"), + option.Description("Update PTR records in matching reverse zones for the requested addresses, pointing them to the target host. The mode controls whether existing PTR records are appended, replaced or fully synced."), + option.Middleware(authMw), + option.Security( + openapi3.SecurityRequirement{ + "basicAuth": []string{}, + }, + ), + ) + } func badRequestError(detail string) *fuego.HTTPError { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6688435..7ebf449 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -57,6 +57,11 @@ type fakeZoneController struct { deleteErr error replaced []fakeRRSetReplaceCall deleted []fakeRRSetDeleteCall + ptrTarget string + ptrAddrs []netip.Addr + ptrMode zone.PTRUpdateMode + ptrChanged bool + ptrErr error } func (f *fakeZoneController) ListZones(_ context.Context) ([]zone.ZoneSnapshot, error) { @@ -127,6 +132,16 @@ func (f *fakeZoneController) ZMUpdateRecord(_ context.Context, _ string, _ strin return false, nil } +func (f *fakeZoneController) UpdatePTR(_ context.Context, target string, addresses []netip.Addr, mode zone.PTRUpdateMode) (changed bool, err error) { + if f.ptrErr != nil { + return false, f.ptrErr + } + f.ptrTarget = target + f.ptrAddrs = addresses + f.ptrMode = mode + return f.ptrChanged, nil +} + func newTestServer(htp fakeHTPasswd, zctl *fakeZoneController) *fuego.Server { srv := fuego.NewServer( fuego.WithSecurity( @@ -412,6 +427,64 @@ func TestOpenAPISpecFormattingAndDescription(t *testing.T) { assert.NotContains(t, yamlRec.Body.String(), "Fuego Cheatsheet") } +func TestZMUpdatePTR(t *testing.T) { + htp := fakeHTPasswd{user: "u", pass: "p"} + + run := func(t *testing.T, zctl *fakeZoneController, body string) *httptest.ResponseRecorder { + t.Helper() + srv := newTestServer(htp, zctl) + req := httptest.NewRequest(http.MethodPost, "/zm/update-ptr", strings.NewReader(body)) + req.SetBasicAuth("u", "p") + rec := httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + return rec + } + + t.Run("happy path", func(t *testing.T) { + zctl := &fakeZoneController{ptrChanged: true} + rec := run(t, zctl, `{"target":"hub.example.com.","addresses":["192.0.2.55","2001:db8::1"],"mode":"replace-all"}`) + assert.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{"target":"hub.example.com.","addresses":["192.0.2.55","2001:db8::1"],"mode":"replace-all","changed":true}`, rec.Body.String()) + assert.Equal(t, "hub.example.com.", zctl.ptrTarget) + assert.Equal(t, []netip.Addr{netip.MustParseAddr("192.0.2.55"), netip.MustParseAddr("2001:db8::1")}, zctl.ptrAddrs) + assert.Equal(t, zone.PTRUpdateReplaceAll, zctl.ptrMode) + }) + + t.Run("default mode is replace-all", func(t *testing.T) { + zctl := &fakeZoneController{} + rec := run(t, zctl, `{"target":"hub.example.com.","addresses":["192.0.2.55"]}`) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, zone.PTRUpdateReplaceAll, zctl.ptrMode) + }) + + t.Run("invalid address", func(t *testing.T) { + zctl := &fakeZoneController{} + rec := run(t, zctl, `{"target":"hub.example.com.","addresses":["not-an-ip"]}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("invalid mode", func(t *testing.T) { + zctl := &fakeZoneController{} + rec := run(t, zctl, `{"target":"hub.example.com.","addresses":["192.0.2.55"],"mode":"banana"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("zone not found mapped to 404", func(t *testing.T) { + zctl := &fakeZoneController{ptrErr: fmt.Errorf("wrapped: %w", zone.ErrZoneNotFound)} + rec := run(t, zctl, `{"target":"hub.example.com.","addresses":["192.0.2.55"]}`) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("unauthorized", func(t *testing.T) { + zctl := &fakeZoneController{} + srv := newTestServer(htp, zctl) + req := httptest.NewRequest(http.MethodPost, "/zm/update-ptr", strings.NewReader(`{"target":"hub.example.com.","addresses":["192.0.2.55"]}`)) + rec := httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} + func testPDNSAPIKey(user, password string) string { return base64.StdEncoding.EncodeToString([]byte(user + ":" + password)) } diff --git a/internal/zone/controller.go b/internal/zone/controller.go index 56ec183..f587fb4 100644 --- a/internal/zone/controller.go +++ b/internal/zone/controller.go @@ -49,8 +49,27 @@ type Controller interface { DeleteRRSet(ctx context.Context, zoneName, name, typ string) (changed bool, err error) // ZMUpdateRecord replace record values ZMUpdateRecord(ctx context.Context, domain string, typ string, ttl int, values []string) (changed bool, err error) + // UpdatePTR updates PTR records for the given addresses in matching + // reverse zones, pointing them to the target host. addresses must contain + // at least one address; mode controls how the PTR records are managed. + UpdatePTR(ctx context.Context, target string, addresses []netip.Addr, mode PTRUpdateMode) (changed bool, err error) } +// PTRUpdateMode controls how /zm/update-ptr manages PTR records for a target +// host across the requested addresses. +type PTRUpdateMode string + +const ( + // PTRUpdateAppend only adds missing PTR records, never removes anything. + PTRUpdateAppend PTRUpdateMode = "append" + // PTRUpdateReplace sets the PTR record for each requested address in + // place (single record per address, keeping unrelated PTRs on the name). + PTRUpdateReplace PTRUpdateMode = "replace" + // PTRUpdateReplaceAll makes the PTR set of the target exactly match the + // requested addresses, removing stale PTR records pointing to the target. + PTRUpdateReplaceAll PTRUpdateMode = "replace-all" +) + type Matcher struct { Domain []byte RRType uint16 @@ -70,6 +89,15 @@ func WithAcmeTTL(ttl int) Option { } } +// WithDDNSManagePTR enables updating PTR records in matching reverse zones on +// DDNS address updates. Reverse zones are matched by the reversed address name +// (in-addr.arpa / ip6.arpa); zones without a matching file are skipped. +func WithDDNSManagePTR(enable bool) Option { + return func(d *DomainCtrl) { + d.ddnsPTR = enable + } +} + type File struct { origin string path string @@ -81,6 +109,7 @@ type File struct { type DomainCtrl struct { files []*File acmeTTL int + ddnsPTR bool } func New(zonefiles ...string) (Controller, error) { @@ -138,7 +167,13 @@ func (s *DomainCtrl) UpdateDDNSAddress(ctx context.Context, domain string, addrs if fl != nil { span.SetAttributes(attribute.String("zone.file", path.Base(fl.path))) lg.InfoContext(ctx, "Zone file found", "zonefile", path.Base(fl.path)) - return fl.UpdateDDNSAddress(ctx, domainDot, addrs) + if err = fl.UpdateDDNSAddress(ctx, domainDot, addrs); err != nil { + return err + } + if s.ddnsPTR { + s.updatePTRRecords(ctx, lg, domainDot, addrs) + } + return nil } err = fmt.Errorf("%w: %s", ErrZoneNotFound, domain) @@ -352,11 +387,12 @@ func (s *File) load() (zf *zonefile.Zonefile, soa *zonefile.Entry, err error) { return } -func (s *File) updateRecords(ctx context.Context, lg1 *slog.Logger, matchers Matchers, values []zonefile.Entry, anchor []byte, allowNew bool) (changed bool, err error) { +func (s *File) updateRecords(ctx context.Context, lg1 *slog.Logger, matchers Matchers, values []zonefile.Entry, replaceAll bool, anchor []byte, allowNew bool) (changed bool, err error) { ctx, span := zoneTracer.Start(ctx, "zone.file.update_records") span.SetAttributes( attribute.String("zone.file", path.Base(s.path)), attribute.Bool("zone.allow_new", allowNew), + attribute.Bool("zone.replace_all", replaceAll), attribute.Int("zone.matcher_count", len(matchers)), attribute.Int("zone.new_entry_count", len(values)), ) @@ -389,10 +425,13 @@ func (s *File) updateRecords(ctx context.Context, lg1 *slog.Logger, matchers Mat lg.DebugContext(ctx, "First matching record found", "index", idx, "old_values", ent.ValuesStrings()) newEntries = append(newEntries, values...) found = true - } else { - lg.DebugContext(ctx, "Remove matching record", "index", idx, "old_values", ent.ValuesStrings()) + continue } - continue + if replaceAll { + lg.DebugContext(ctx, "Remove duplicate matching record", "index", idx, "old_values", ent.ValuesStrings()) + continue + } + lg.DebugContext(ctx, "Keep extra matching record", "index", idx, "old_values", ent.ValuesStrings()) } newEntries = append(newEntries, ent) @@ -485,6 +524,244 @@ func anchorInsertIndex(entries []zonefile.Entry, anchor []byte) int { return acme } +// UpdatePTR updates PTR records for the requested addresses in matching reverse +// zones, pointing them to the target host. All addresses must have a matching +// reverse zone, otherwise ErrZoneNotFound is returned; when target, addresses +// set is empty or mode is invalid an error is returned without changes. +func (s *DomainCtrl) UpdatePTR(ctx context.Context, target string, addresses []netip.Addr, mode PTRUpdateMode) (changed bool, err error) { + ctx, span := zoneTracer.Start(ctx, "zone.domain_ctrl.update_ptr") + span.SetAttributes( + attribute.String("zone.ptr.target", target), + attribute.String("zone.ptr.mode", string(mode)), + attribute.Int("zone.addr_count", len(addresses)), + ) + defer func() { + span.SetAttributes(attribute.Bool("zone.changed", changed)) + recordSpanError(span, err) + span.End() + }() + + lg := slog.Default().With("target", target) + + if len(addresses) == 0 { + return false, fmt.Errorf("no addresses provided for PTR update") + } + switch mode { + case PTRUpdateAppend, PTRUpdateReplace, PTRUpdateReplaceAll: + default: + return false, fmt.Errorf("invalid PTR update mode: %q", mode) + } + + targetDot := target + if !strings.HasSuffix(targetDot, ".") { + targetDot += "." + } + + revNames := make([]string, 0, len(addresses)) + zones := make([]*File, 0, len(addresses)) + for _, addr := range addresses { + name, err := dns.ReverseAddr(addr.String()) + if err != nil { + return false, fmt.Errorf("failed to build reverse name for %s: %w", addr, err) + } + revNames = append(revNames, name) + + fl := s.findZoneFile(ctx, lg.With("domain", name), name) + if fl == nil { + return false, fmt.Errorf("%w: %s", ErrZoneNotFound, name) + } + zones = append(zones, fl) + } + + for i, name := range revNames { + c, err := zones[i].UpdatePTRAddress(ctx, name, targetDot, mode) + if err != nil { + lg.WarnContext(ctx, "Failed to update PTR record", "reverse", name, "zonefile", path.Base(zones[i].path), "error", err) + return changed, err + } + changed = changed || c + } + + if mode == PTRUpdateReplaceAll { + c, err := s.removeStalePTR(ctx, lg, targetDot, revNames) + if err != nil { + return changed, err + } + changed = changed || c + } + + return changed, nil +} + +// updatePTRRecords is a best-effort PTR sync for DDNS address updates +// (--ddns-manage-ptr): it updates PTR records for the current addresses and +// removes stale PTR records pointing to the target, skipping addresses without +// a matching reverse zone. +func (s *DomainCtrl) updatePTRRecords(ctx context.Context, lg *slog.Logger, target string, addrs []netip.Addr) { + targetDot := target + if !strings.HasSuffix(targetDot, ".") { + targetDot += "." + } + + revNames := make([]string, 0, len(addrs)) + for _, addr := range addrs { + name, err := dns.ReverseAddr(addr.String()) + if err != nil { + lg.WarnContext(ctx, "Failed to build reverse name", "addr", addr, "error", err) + continue + } + revNames = append(revNames, name) + + fl := s.findZoneFile(ctx, lg.With("domain", name), name) + if fl == nil { + lg.DebugContext(ctx, "No reverse zone for address", "reverse", name) + continue + } + if _, err := fl.UpdatePTRAddress(ctx, name, targetDot, PTRUpdateReplaceAll); err != nil { + lg.WarnContext(ctx, "Failed to update PTR record", "reverse", name, "zonefile", path.Base(fl.path), "error", err) + continue + } + lg.InfoContext(ctx, "PTR record updated", "zonefile", path.Base(fl.path), "reverse", name, "target", targetDot) + } + + changed, err := s.removeStalePTR(ctx, lg, targetDot, revNames) + if err != nil { + lg.WarnContext(ctx, "Failed to clean stale PTR records", "target", targetDot, "error", err) + return + } + if changed { + lg.InfoContext(ctx, "Stale PTR records cleaned", "target", targetDot) + } +} + +// removeStalePTR removes PTR records pointing to target in managed reverse +// zones whose (absolute) name is not among keep. +func (s *DomainCtrl) removeStalePTR(ctx context.Context, lg *slog.Logger, target string, keep []string) (changed bool, err error) { + keepSet := make(map[string]struct{}, len(keep)) + for _, k := range keep { + keepSet[strings.ToLower(k)] = struct{}{} + } + + for _, fl := range s.files { + if !isReverseZone(fl.origin) { + continue + } + names, err := fl.findPTRRecords(ctx, target) + if err != nil { + return changed, err + } + for _, name := range names { + if _, ok := keepSet[strings.ToLower(name)]; ok { + continue + } + c, err := fl.DeletePTRRecord(ctx, name, target) + if err != nil { + lg.WarnContext(ctx, "Failed to delete stale PTR record", "reverse", name, "zonefile", path.Base(fl.path), "error", err) + continue + } + changed = changed || c + } + } + return changed, nil +} + +// isReverseZone reports whether the zone origin is a reverse zone +// (in-addr.arpa or ip6.arpa tree). +func isReverseZone(origin string) bool { + o := strings.ToLower(normalizeZoneName(origin)) + return strings.HasSuffix(o, ".in-addr.arpa.") || strings.HasSuffix(o, ".ip6.arpa.") +} + +// UpdatePTRAddress sets the PTR record for ptrName to target in the zone, +// according to mode: +// +// - append: insert the record only if it is missing, keeping existing PTRs; +// - replace: set a single PTR record in place, keeping unrelated PTRs on the +// same name; +// - replace-all: the PTR record set at that name becomes exactly {target}. +func (s *File) UpdatePTRAddress(ctx context.Context, ptrName, target string, mode PTRUpdateMode) (changed bool, err error) { + ctx, span := zoneTracer.Start(ctx, "zone.file.update_ptr") + span.SetAttributes( + attribute.String("zone.file", path.Base(s.path)), + attribute.String("zone.domain", ptrName), + attribute.String("zone.ptr.mode", string(mode)), + ) + defer func() { + span.SetAttributes(attribute.Bool("zone.changed", changed)) + recordSpanError(span, err) + span.End() + }() + + s.mu.Lock() + defer s.mu.Unlock() + + lg := s.lg.With("ptr_name", ptrName, "ptr_target", target, "mode", string(mode)) + + shortName := []byte(StripOrigin(ptrName, s.origin)) + + var matchers Matchers + replaceAll := true + switch mode { + case PTRUpdateAppend: + matchers = Matchers{{Domain: shortName, RRType: dns.TypePTR, Values: [][]byte{[]byte(target)}}} + case PTRUpdateReplace: + matchers = Matchers{{Domain: shortName, RRType: dns.TypePTR}} + replaceAll = false + case PTRUpdateReplaceAll: + matchers = Matchers{{Domain: shortName, RRType: dns.TypePTR}} + default: + return false, fmt.Errorf("invalid PTR update mode: %q", mode) + } + + values, err := parseEntries(bytes.NewBufferString(fmt.Sprintf("\n%s IN PTR %s\n", shortName, target))) + if err != nil { + return false, err + } + + return s.updateRecords(ctx, lg, matchers, values, replaceAll, shortName, true) +} + +// DeletePTRRecord removes the PTR record for ptrName pointing to target. +func (s *File) DeletePTRRecord(ctx context.Context, ptrName, target string) (changed bool, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + lg := s.lg.With("ptr_name", ptrName, "ptr_target", target) + + matchers := Matchers{{Domain: []byte(StripOrigin(ptrName, s.origin)), RRType: dns.TypePTR, Values: [][]byte{[]byte(target)}}} + return s.updateRecords(ctx, lg, matchers, nil, true, nil, false) +} + +// findPTRRecords returns the absolute names of PTR records in the zone that +// point to target. +func (s *File) findPTRRecords(ctx context.Context, target string) (names []string, err error) { + zf, _, err := s.load() + if err != nil { + return nil, err + } + + prevDomain := []byte{} + for _, ent := range zf.Entries() { + if ent.IsComment || ent.IsControl { + continue + } + d := ent.Domain() + if d == nil { + d = prevDomain + } else { + prevDomain = d + } + if ent.RRType() != dns.TypePTR { + continue + } + vals := ent.Values() + if len(vals) == 1 && bytes.Equal(vals[0], []byte(target)) { + names = append(names, absoluteRecordName(d, s.origin)) + } + } + return names, nil +} + func (s *File) UpdateDDNSAddress(ctx context.Context, domain string, addrs []netip.Addr) error { ctx, span := zoneTracer.Start(ctx, "zone.file.update_ddns_address") span.SetAttributes( @@ -541,7 +818,7 @@ func (s *File) UpdateDDNSAddress(ctx context.Context, domain string, addrs []net matchers = append(matchers, Matcher{Domain: shortDomain, RRType: dns.TypeAAAA}) } - _, err = s.updateRecords(ctx, lg, matchers, values, nil, true) + _, err = s.updateRecords(ctx, lg, matchers, values, true, nil, true) if err != nil { recordSpanError(span, err) return err @@ -601,7 +878,7 @@ func (s *File) UpdateACMEChallenge(ctx context.Context, domain string, newToken, }, } - _, err = s.updateRecords(ctx, lg, matchers, values, shortDomain, true) + _, err = s.updateRecords(ctx, lg, matchers, values, true, shortDomain, true) if err != nil { return err } @@ -657,7 +934,7 @@ func (s *File) ZMUpdateRecord(ctx context.Context, domain string, typ string, tt }, } - return s.updateRecords(ctx, lg, matchers, values, nil, false) + return s.updateRecords(ctx, lg, matchers, values, true, nil, false) } func StripOrigin(name, origin string) string { diff --git a/internal/zone/controller_test.go b/internal/zone/controller_test.go index ce2a33d..359a822 100644 --- a/internal/zone/controller_test.go +++ b/internal/zone/controller_test.go @@ -168,6 +168,228 @@ func TestFile_UpdateACMEChallenge_WildcardPlacement(t *testing.T) { }) } +func TestFile_UpdatePTRAddress(t *testing.T) { + t.Run("append is idempotent for existing target", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + f := newZoneTemp(t, "./testdata/3.2.1.in-addr.arpa.zone") + changed, err := f.UpdatePTRAddress(ctx, "4.3.2.1.in-addr.arpa.", "hub.example.com.", PTRUpdateAppend) + assert.NoError(t, err) + assert.False(t, changed) + assertFiles(t, "./testdata/3.2.1.in-addr.arpa.zone", f.path) + }) + }) + + t.Run("append keeps existing and adds new after", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + f := newZoneTemp(t, "./testdata/3.2.1.in-addr.arpa.zone") + _, err := f.UpdatePTRAddress(ctx, "4.3.2.1.in-addr.arpa.", "new.example.com.", PTRUpdateAppend) + assert.NoError(t, err) + assertFiles(t, "./testdata/expected-ptr-append.zone", f.path) + }) + }) + + t.Run("replace swaps single record keeping siblings", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + f := newZoneTemp(t, "./testdata/3.2.1.in-addr.arpa.zone") + _, err := f.UpdatePTRAddress(ctx, "4.3.2.1.in-addr.arpa.", "rebooted.example.com.", PTRUpdateReplace) + assert.NoError(t, err) + assertFiles(t, "./testdata/expected-ptr-replace.zone", f.path) + }) + }) + + t.Run("replace-all leaves exactly one record", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + f := newZoneTemp(t, "./testdata/3.2.1.in-addr.arpa.zone") + _, err := f.UpdatePTRAddress(ctx, "4.3.2.1.in-addr.arpa.", "clean.example.com.", PTRUpdateReplaceAll) + assert.NoError(t, err) + assertFiles(t, "./testdata/expected-ptr-replace-all.zone", f.path) + }) + }) + + t.Run("creates record for new name", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + f := newZoneTemp(t, "./testdata/3.2.1.in-addr.arpa.zone") + _, err := f.UpdatePTRAddress(ctx, "5.3.2.1.in-addr.arpa.", "five.example.com.", PTRUpdateReplaceAll) + assert.NoError(t, err) + assertFiles(t, "./testdata/expected-ptr-create.zone", f.path) + }) + }) +} + +func TestDomainCtrl_UpdatePTR(t *testing.T) { + t.Run("replace-all single address", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + dc := newDomainCtrlTemp(t, []string{"./testdata/3.2.1.in-addr.arpa.zone"}) + + changed, err := dc.UpdatePTR(ctx, "clean.example.com.", []netip.Addr{netip.MustParseAddr("1.2.3.4")}, PTRUpdateReplaceAll) + assert.NoError(t, err) + assert.True(t, changed) + + snap := mustZoneSnapshot(t, dc, "3.2.1.in-addr.arpa.") + assert.Equal(t, []string{"clean.example.com."}, findRRSet(t, snap.RRsets, "4.3.2.1.in-addr.arpa.", "PTR").Records) + assert.Equal(t, 1, countRRSet(snap.RRsets, "4.3.2.1.in-addr.arpa.", "PTR")) + }) + }) + + t.Run("append adds after existing", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + dc := newDomainCtrlTemp(t, []string{"./testdata/3.2.1.in-addr.arpa.zone"}) + + changed, err := dc.UpdatePTR(ctx, "new.example.com.", []netip.Addr{netip.MustParseAddr("1.2.3.4")}, PTRUpdateAppend) + assert.NoError(t, err) + assert.True(t, changed) + + snap := mustZoneSnapshot(t, dc, "3.2.1.in-addr.arpa.") + assert.Equal(t, []string{"hub.example.com.", "legacy.example.com.", "new.example.com."}, findRRSet(t, snap.RRsets, "4.3.2.1.in-addr.arpa.", "PTR").Records) + }) + }) + + t.Run("no matching reverse zone", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + dc := newDomainCtrlTemp(t, []string{"./testdata/at.example.com.zone"}) + + _, err := dc.UpdatePTR(ctx, "hub.example.com.", []netip.Addr{netip.MustParseAddr("1.2.3.4")}, PTRUpdateReplaceAll) + assert.ErrorIs(t, err, ErrZoneNotFound) + }) + }) + + t.Run("empty addresses", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + dc := newDomainCtrlTemp(t, []string{"./testdata/3.2.1.in-addr.arpa.zone"}) + + _, err := dc.UpdatePTR(ctx, "hub.example.com.", nil, PTRUpdateReplaceAll) + assert.Error(t, err) + }) + }) + + t.Run("invalid mode", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + dc := newDomainCtrlTemp(t, []string{"./testdata/3.2.1.in-addr.arpa.zone"}) + + _, err := dc.UpdatePTR(ctx, "hub.example.com.", []netip.Addr{netip.MustParseAddr("1.2.3.4")}, "banana") + assert.Error(t, err) + }) + }) +} + +func TestDomainCtrl_UpdateDDNSAddress_ManagePTR(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + + dc := newDomainCtrlTemp(t, []string{ + "./testdata/at.example.com.zone", + "./testdata/3.2.1.in-addr.arpa.zone", + "./testdata/8.b.d.0.1.0.0.2.ip6.arpa.zone", + }, WithDDNSManagePTR(true)) + + // dual stack host: forward A/AAAA + v4 PTR + v6 PTR + err := dc.UpdateDDNSAddress(ctx, "hub.at.example.com.", []netip.Addr{ + netip.MustParseAddr("1.2.3.4"), + netip.MustParseAddr("2001:db8::1"), + }) + require.NoError(t, err) + + fwd := mustZoneSnapshot(t, dc, "at.example.com.") + assert.Equal(t, []string{"1.2.3.4"}, findRRSet(t, fwd.RRsets, "hub.at.example.com.", "A").Records) + assert.Equal(t, []string{"2001:db8::1"}, findRRSet(t, fwd.RRsets, "hub.at.example.com.", "AAAA").Records) + + v4 := mustZoneSnapshot(t, dc, "3.2.1.in-addr.arpa.") + assert.Equal(t, []string{"router.example.com."}, findRRSet(t, v4.RRsets, "1.3.2.1.in-addr.arpa.", "PTR").Records) + assert.Equal(t, []string{"hub.at.example.com."}, findRRSet(t, v4.RRsets, "4.3.2.1.in-addr.arpa.", "PTR").Records) + assert.Equal(t, 1, countRRSet(v4.RRsets, "4.3.2.1.in-addr.arpa.", "PTR")) + + v6 := mustZoneSnapshot(t, dc, "8.b.d.0.1.0.0.2.ip6.arpa.") + assert.Equal(t, []string{"hub.at.example.com."}, findRRSet(t, v6.RRsets, "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", "PTR").Records) + + // same update again: no growth + err = dc.UpdateDDNSAddress(ctx, "hub.at.example.com.", []netip.Addr{ + netip.MustParseAddr("1.2.3.4"), + netip.MustParseAddr("2001:db8::1"), + }) + require.NoError(t, err) + v4 = mustZoneSnapshot(t, dc, "3.2.1.in-addr.arpa.") + assert.Equal(t, []string{"hub.at.example.com."}, findRRSet(t, v4.RRsets, "4.3.2.1.in-addr.arpa.", "PTR").Records) + assert.Equal(t, 1, countRRSet(v4.RRsets, "4.3.2.1.in-addr.arpa.", "PTR")) + + // host moved to a new IPv4: stale PTR removed, new one added + err = dc.UpdateDDNSAddress(ctx, "hub.at.example.com.", []netip.Addr{ + netip.MustParseAddr("1.2.3.5"), + netip.MustParseAddr("2001:db8::1"), + }) + require.NoError(t, err) + v4 = mustZoneSnapshot(t, dc, "3.2.1.in-addr.arpa.") + assert.False(t, hasRRSet(v4.RRsets, "4.3.2.1.in-addr.arpa.", "PTR")) + assert.Equal(t, []string{"hub.at.example.com."}, findRRSet(t, v4.RRsets, "5.3.2.1.in-addr.arpa.", "PTR").Records) + }) +} + +func TestDomainCtrl_UpdateDDNSAddress_ManagePTR_Disabled(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + + dc := newDomainCtrlTemp(t, []string{ + "./testdata/at.example.com.zone", + "./testdata/3.2.1.in-addr.arpa.zone", + }) + + err := dc.UpdateDDNSAddress(ctx, "hub.at.example.com.", []netip.Addr{netip.MustParseAddr("1.2.3.4")}) + require.NoError(t, err) + + // reverse zone must be untouched + v4 := mustZoneSnapshot(t, dc, "3.2.1.in-addr.arpa.") + assert.Equal(t, []string{"hub.example.com.", "legacy.example.com."}, findRRSet(t, v4.RRsets, "4.3.2.1.in-addr.arpa.", "PTR").Records) + }) +} + +func TestDomainCtrl_UpdateDDNSAddress_ManagePTR_NoReverseZone(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.TODO() + + dc := newDomainCtrlTemp(t, []string{"./testdata/at.example.com.zone"}, WithDDNSManagePTR(true)) + + // missing reverse zone must not be an issue + err := dc.UpdateDDNSAddress(ctx, "hub.at.example.com.", []netip.Addr{netip.MustParseAddr("1.2.3.4")}) + require.NoError(t, err) + }) +} + +func newDomainCtrlTemp(t *testing.T, files []string, opts ...Option) *DomainCtrl { + t.Helper() + require := require.New(t) + + tmp := t.TempDir() + paths := make([]string, 0, len(files)) + for _, fl := range files { + dest := path.Join(tmp, path.Base(fl)) + require.NoError(fcopy.Copy(fl, dest)) + paths = append(paths, dest) + } + + ctrl, err := NewWithOptions(opts, paths...) + require.NoError(err) + + dc, ok := ctrl.(*DomainCtrl) + require.True(ok) + return dc +} + +func mustZoneSnapshot(t *testing.T, dc *DomainCtrl, zoneName string) ZoneSnapshot { + t.Helper() + snap, err := dc.GetZone(context.Background(), zoneName) + require.NoError(t, err) + return snap +} + func TestFile_ZMUpdateRecord_TypeCaseInsensitive(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx := context.TODO() diff --git a/internal/zone/pdns.go b/internal/zone/pdns.go index 132d216..f8841b3 100644 --- a/internal/zone/pdns.go +++ b/internal/zone/pdns.go @@ -276,7 +276,7 @@ func (s *File) ReplaceRRSet(ctx context.Context, name, typ string, ttl int, valu RRType: rrType, }} - return s.updateRecords(ctx, lg, matchers, entries, nil, true) + return s.updateRecords(ctx, lg, matchers, entries, true, nil, true) } func (s *File) DeleteRRSet(ctx context.Context, name, typ string) (changed bool, err error) { @@ -313,7 +313,7 @@ func (s *File) DeleteRRSet(ctx context.Context, name, typ string) (changed bool, RRType: rrType, }} - changed, err = s.updateRecords(ctx, lg, matchers, nil, nil, false) + changed, err = s.updateRecords(ctx, lg, matchers, nil, true, nil, false) if errors.Is(err, ErrRecordNotFound) { return false, nil } diff --git a/internal/zone/testdata/3.2.1.in-addr.arpa.zone b/internal/zone/testdata/3.2.1.in-addr.arpa.zone new file mode 100644 index 0000000..a6f77a2 --- /dev/null +++ b/internal/zone/testdata/3.2.1.in-addr.arpa.zone @@ -0,0 +1,19 @@ +$ORIGIN 3.2.1.in-addr.arpa. +$TTL 60 +; SOA Record +@ IN SOA ns1.example.com. hostmaster.example.com. ( + 1763822925 ; serial Sat, 22 Nov 2025 14:48:45 UTC + 1H ; refresh + 600 ; retry + 1W ; expire + 1D ; minimum + ) + +; NS Records +@ IN NS ns1.example.com. +@ IN NS ns2.example.com. + +; PTR records +1 IN PTR router.example.com. +4 IN PTR hub.example.com. +4 IN PTR legacy.example.com. \ No newline at end of file diff --git a/internal/zone/testdata/8.b.d.0.1.0.0.2.ip6.arpa.zone b/internal/zone/testdata/8.b.d.0.1.0.0.2.ip6.arpa.zone new file mode 100644 index 0000000..11b0fa6 --- /dev/null +++ b/internal/zone/testdata/8.b.d.0.1.0.0.2.ip6.arpa.zone @@ -0,0 +1,14 @@ +$ORIGIN 8.b.d.0.1.0.0.2.ip6.arpa. +$TTL 60 +; SOA Record +@ IN SOA ns1.example.com. hostmaster.example.com. ( + 1763822925 ; serial Sat, 22 Nov 2025 14:48:45 UTC + 1H ; refresh + 600 ; retry + 1W ; expire + 1D ; minimum + ) + +; NS Records +@ IN NS ns1.example.com. +@ IN NS ns2.example.com. \ No newline at end of file diff --git a/internal/zone/testdata/expected-ptr-append.zone b/internal/zone/testdata/expected-ptr-append.zone new file mode 100644 index 0000000..b56ed6c --- /dev/null +++ b/internal/zone/testdata/expected-ptr-append.zone @@ -0,0 +1,20 @@ +$ORIGIN 3.2.1.in-addr.arpa. +$TTL 60 +; SOA Record +@ IN SOA ns1.example.com. hostmaster.example.com. ( + 1763822926 ; serial Sun, 29 Oct 1769 06:04:00 UTC + 1H ; refresh + 600 ; retry + 1W ; expire + 1D ; minimum + ) + +; NS Records +@ IN NS ns1.example.com. + IN NS ns2.example.com. + +; PTR records +1 IN PTR router.example.com. +4 IN PTR hub.example.com. + IN PTR legacy.example.com. + IN PTR new.example.com. diff --git a/internal/zone/testdata/expected-ptr-create.zone b/internal/zone/testdata/expected-ptr-create.zone new file mode 100644 index 0000000..2788ecd --- /dev/null +++ b/internal/zone/testdata/expected-ptr-create.zone @@ -0,0 +1,20 @@ +$ORIGIN 3.2.1.in-addr.arpa. +$TTL 60 +; SOA Record +@ IN SOA ns1.example.com. hostmaster.example.com. ( + 1763822926 ; serial Sun, 29 Oct 1769 06:04:00 UTC + 1H ; refresh + 600 ; retry + 1W ; expire + 1D ; minimum + ) + +; NS Records +@ IN NS ns1.example.com. + IN NS ns2.example.com. + +; PTR records +1 IN PTR router.example.com. +4 IN PTR hub.example.com. + IN PTR legacy.example.com. +5 IN PTR five.example.com. diff --git a/internal/zone/testdata/expected-ptr-replace-all.zone b/internal/zone/testdata/expected-ptr-replace-all.zone new file mode 100644 index 0000000..f1062c2 --- /dev/null +++ b/internal/zone/testdata/expected-ptr-replace-all.zone @@ -0,0 +1,18 @@ +$ORIGIN 3.2.1.in-addr.arpa. +$TTL 60 +; SOA Record +@ IN SOA ns1.example.com. hostmaster.example.com. ( + 1763822926 ; serial Sun, 29 Oct 1769 06:04:00 UTC + 1H ; refresh + 600 ; retry + 1W ; expire + 1D ; minimum + ) + +; NS Records +@ IN NS ns1.example.com. + IN NS ns2.example.com. + +; PTR records +1 IN PTR router.example.com. +4 IN PTR clean.example.com. diff --git a/internal/zone/testdata/expected-ptr-replace.zone b/internal/zone/testdata/expected-ptr-replace.zone new file mode 100644 index 0000000..7dfd09f --- /dev/null +++ b/internal/zone/testdata/expected-ptr-replace.zone @@ -0,0 +1,19 @@ +$ORIGIN 3.2.1.in-addr.arpa. +$TTL 60 +; SOA Record +@ IN SOA ns1.example.com. hostmaster.example.com. ( + 1763822926 ; serial Sun, 29 Oct 1769 06:04:00 UTC + 1H ; refresh + 600 ; retry + 1W ; expire + 1D ; minimum + ) + +; NS Records +@ IN NS ns1.example.com. + IN NS ns2.example.com. + +; PTR records +1 IN PTR router.example.com. +4 IN PTR rebooted.example.com. + IN PTR legacy.example.com.