Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
- OpenDNS
- OVH
- Porkbun
- PowerDNS
- Route53
- Selfhost.de
- Servercow.de
Expand Down Expand Up @@ -254,6 +255,7 @@ Check the documentation for your DNS provider:
- [OpenDNS](docs/opendns.md)
- [OVH](docs/ovh.md)
- [Porkbun](docs/porkbun.md)
- [PowerDNS](docs/powerdns.md)
- [Route53](docs/route53.md)
- [Selfhost.de](docs/selfhost.de.md)
- [Servercow.de](docs/servercow.md)
Expand Down
50 changes: 50 additions & 0 deletions docs/powerdns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# PowerDNS

## Configuration

### Example

```json
{
"settings": [
{
"provider": "powerdns",
"domain": "domain.com",
"server_url": "http://powerdns.example.com:8081",
"api_key": "your-api-key",
"server_id": "localhost",
"ttl": 300,
"ip_version": "ipv4",
"ipv6_suffix": ""
}
]
}
```

### Compulsory parameters

- `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
- `"server_url"` is the URL of your PowerDNS Authoritative server HTTP API (e.g. `http://powerdns.example.com:8081`)
- `"api_key"` is the API key for authentication, configured in your PowerDNS server's `api-key` setting

### Optional parameters

- `"server_id"` is the PowerDNS server ID. It defaults to `localhost`.
- `"ttl"` optional integer value corresponding to a number of seconds. It defaults to `300`.
- `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
- `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.

## PowerDNS setup

1. Enable the HTTP API in your PowerDNS Authoritative server configuration:

```
api=yes
api-key=your-api-key
webserver=yes
webserver-address=0.0.0.0
webserver-port=8081
webserver-allow-from=0.0.0.0/0
```

2. Make sure the zone for your domain already exists in PowerDNS before using ddns-updater.
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const (
OpenDNS models.Provider = "opendns"
OVH models.Provider = "ovh"
Porkbun models.Provider = "porkbun"
PowerDNS models.Provider = "powerdns"
Route53 models.Provider = "route53"
SelfhostDe models.Provider = "selfhost.de"
Servercow models.Provider = "servercow"
Expand Down Expand Up @@ -102,6 +103,7 @@ func ProviderChoices() []models.Provider {
OpenDNS,
OVH,
Porkbun,
PowerDNS,
Route53,
SelfhostDe,
Spdyn,
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/opendns"
"github.com/qdm12/ddns-updater/internal/provider/providers/ovh"
"github.com/qdm12/ddns-updater/internal/provider/providers/porkbun"
"github.com/qdm12/ddns-updater/internal/provider/providers/powerdns"
"github.com/qdm12/ddns-updater/internal/provider/providers/route53"
"github.com/qdm12/ddns-updater/internal/provider/providers/selfhostde"
"github.com/qdm12/ddns-updater/internal/provider/providers/servercow"
Expand Down Expand Up @@ -172,6 +173,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return ovh.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Porkbun:
return porkbun.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.PowerDNS:
return powerdns.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Route53:
return route53.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.SelfhostDe:
Expand Down
203 changes: 203 additions & 0 deletions internal/provider/providers/powerdns/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package powerdns

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"

"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/headers"
"github.com/qdm12/ddns-updater/internal/provider/utils"
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
)

type Provider struct {
domain string
owner string
ipVersion ipversion.IPVersion
ipv6Suffix netip.Prefix
serverURL string
apiKey string
serverID string
ttl uint32
}

func New(data json.RawMessage, domain, owner string,
ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
p *Provider, err error,
) {
extraSettings := struct {
ServerURL string `json:"server_url"`
APIKey string `json:"api_key"`
ServerID string `json:"server_id"`
TTL uint32 `json:"ttl"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
return nil, err
}

if extraSettings.ServerID == "" {
extraSettings.ServerID = "localhost"
}
if extraSettings.TTL == 0 {
extraSettings.TTL = 300
}

err = validateSettings(domain, extraSettings.ServerURL, extraSettings.APIKey)
if err != nil {
return nil, fmt.Errorf("validating provider specific settings: %w", err)
}

return &Provider{
domain: domain,
owner: owner,
ipVersion: ipVersion,
ipv6Suffix: ipv6Suffix,
serverURL: extraSettings.ServerURL,
apiKey: extraSettings.APIKey,
serverID: extraSettings.ServerID,
ttl: extraSettings.TTL,
}, nil
}

func validateSettings(domain, serverURL, apiKey string) (err error) {
err = utils.CheckDomain(domain)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
}

switch {
case serverURL == "":
return fmt.Errorf("%w", errors.ErrURLNotSet)
case apiKey == "":
return fmt.Errorf("%w", errors.ErrAPIKeyNotSet)
}

_, err = url.Parse(serverURL)
if err != nil {
return fmt.Errorf("server URL is not valid: %w", err)
}

return nil
}

func (p *Provider) String() string {
return utils.ToString(p.domain, p.owner, constants.PowerDNS, p.ipVersion)
}

func (p *Provider) Domain() string {
return p.domain
}

func (p *Provider) Owner() string {
return p.owner
}

func (p *Provider) IPVersion() ipversion.IPVersion {
return p.ipVersion
}

func (p *Provider) IPv6Suffix() netip.Prefix {
return p.ipv6Suffix
}

func (p *Provider) Proxied() bool {
return false
}

func (p *Provider) BuildDomainName() string {
return utils.BuildDomainName(p.owner, p.domain)
}

func (p *Provider) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()),
Owner: p.Owner(),
Provider: "<a href=\"https://doc.powerdns.com/authoritative/http-api/\">PowerDNS</a>",
IPVersion: p.ipVersion.String(),
}
}

func (p *Provider) setHeaders(request *http.Request) {
headers.SetUserAgent(request)
headers.SetContentType(request, "application/json")
request.Header.Set("X-API-Key", p.apiKey)
}

func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}

recordName := utils.BuildURLQueryHostname(p.owner, p.domain) + "."
zoneName := p.domain + "."

u := fmt.Sprintf("%s/api/v1/servers/%s/zones/%s", p.serverURL, p.serverID, zoneName)

type record struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}

type rrSet struct {
Name string `json:"name"`
Type string `json:"type"`
TTL uint32 `json:"ttl"`
ChangeType string `json:"changetype"`
Records []record `json:"records"`
}

requestData := struct {
RRSets []rrSet `json:"rrsets"`
}{
RRSets: []rrSet{
{
Name: recordName,
Type: recordType,
TTL: p.ttl,
ChangeType: "REPLACE",
Records: []record{
{
Content: ip.String(),
Disabled: false,
},
},
},
},
}

buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
err = encoder.Encode(requestData)
if err != nil {
return netip.Addr{}, fmt.Errorf("json encoding request data: %w", err)
}

request, err := http.NewRequestWithContext(ctx, http.MethodPatch, u, buffer)
if err != nil {
return netip.Addr{}, fmt.Errorf("creating http request: %w", err)
}

p.setHeaders(request)

response, err := client.Do(request)
if err != nil {
return netip.Addr{}, err
}
defer response.Body.Close()

if response.StatusCode != http.StatusNoContent {
return netip.Addr{}, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.BodyToSingleLine(response.Body))
}

return ip, nil
}