Skip to content

add: porkbun provider #217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Aug 9, 2021
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -41,6 +41,7 @@
- Njalla
- OpenDNS
- OVH
- Porkbun
- Selfhost.de
- Spdyn
- Strato.de
Expand Down Expand Up @@ -156,6 +157,7 @@ Check the documentation for your DNS provider:
- [Njalla](https://github.com/qdm12/ddns-updater/blob/master/docs/njalla.md)
- [OpenDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/opendns.md)
- [OVH](https://github.com/qdm12/ddns-updater/blob/master/docs/ovh.md)
- [Porkbun](https://github.com/qdm12/ddns-updater/blob/master/docs/porkbun.md)
- [Selfhost.de](https://github.com/qdm12/ddns-updater/blob/master/docs/selfhost.de.md)
- [Spdyn](https://github.com/qdm12/ddns-updater/blob/master/docs/spdyn.md)
- [Strato.de](https://github.com/qdm12/ddns-updater/blob/master/docs/strato.md)
Expand Down
34 changes: 34 additions & 0 deletions docs/porkbun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Porkbun

## Configuration

### Example

```json
{
"settings": [
{
"provider": "porkbun",
"domain": "domain.com",
"host": "@",
"apikey": "sk1_7d119e3f656b00ae042980302e1425a04163c476efec1833q3cb0w54fc6f5022",
"secretapikey": "pk1_5299b57125c8f3cdf347d2fe0e713311ee3a1e11f11a14942b26472593e35368",
"ip_version": "ipv4"
}
]
}
```

### Parameters

- `"domain"`
- `"host"` is your host and can be a subdomain, `"*"` or `"@"`
- `"apikey"`
- `"secretapikey"`

## Domain setup

- Create api key at <https://porkbun.com/account/api>
- From [Domain Management](https://porkbun.com/account/domainsSpeedy) page, toggle on "API ACCESS" for your domain.

Official setup documentation: https://kb.porkbun.com/article/190-getting-started-with-the-porkbun-dns-api
2 changes: 2 additions & 0 deletions internal/settings/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const (
NoIP models.Provider = "noip"
OpenDNS models.Provider = "opendns"
OVH models.Provider = "ovh"
Porkbun models.Provider = "porkbun"
SelfhostDe models.Provider = "selfhost.de"
Spdyn models.Provider = "spdyn"
Strato models.Provider = "strato"
Expand Down Expand Up @@ -60,6 +61,7 @@ func ProviderChoices() []models.Provider {
NoIP,
OpenDNS,
OVH,
Porkbun,
SelfhostDe,
Spdyn,
Strato,
Expand Down
295 changes: 295 additions & 0 deletions internal/settings/providers/porkbun/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
package porkbun

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

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

type provider struct {
domain string
host string
ttl uint
ipVersion ipversion.IPVersion
apiKey string
secretApiKey string
logger log.Logger
}

func New(data json.RawMessage, domain, host string,
ipVersion ipversion.IPVersion, logger log.Logger) (p *provider, err error) {
extraSettings := struct {
SecretApiKey string `json:"secretapikey"`
ApiKey string `json:"apikey"`
Name bool `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL uint `json:"ttl"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
p = &provider{
domain: domain,
host: host,
ipVersion: ipVersion,
secretApiKey: extraSettings.SecretApiKey,
apiKey: extraSettings.ApiKey,
ttl: extraSettings.TTL,
logger: logger,
}
if err := p.isValid(); err != nil {
return nil, err
}
return p, nil
}

func (p *provider) isValid() error {
switch {
case len(p.apiKey) == 0:
return errors.ErrEmptyAppKey
case len(p.secretApiKey) == 0:
return errors.ErrEmptyConsumerKey
}
return nil
}

func (p *provider) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Porkbun]", p.domain, p.host)
}

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

func (p *provider) Host() string {
return p.host
}

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

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

func (p *provider) BuildDomainName() string {
return utils.BuildDomainName(p.host, p.domain)
}

func (p *provider) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName())),
Host: models.HTML(p.Host()),
Provider: "<a href=\"https://www.porkbun.com/\">Porkbun DNS</a>",
IPVersion: models.HTML(p.ipVersion.String()),
}
}

func (p *provider) setHeaders(request *http.Request) {
headers.SetUserAgent(request)
headers.SetContentType(request, "application/json")
headers.SetAccept(request, "application/json")
}

func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordIDs []string, err error) {
u := url.URL{
Scheme: "https",
Host: "porkbun.com",
Path: fmt.Sprintf("/api/json/v3/dns/retrieve/%s", p.domain),
}
postRecordsParams := struct {
SecretApiKey string `json:"secretapikey"`
ApiKey string `json:"apikey"`
}{
SecretApiKey: p.secretApiKey,
ApiKey: p.apiKey,
}
bodyBytes, err := json.Marshal(postRecordsParams)
if err != nil {
return nil, fmt.Errorf("%w: %s", errors.ErrRequestMarshal, err)
}

p.logger.Debug("HTTP POST getRecords: " + u.String() + ": " + string(bodyBytes))

request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
p.setHeaders(request)

response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("%w: %s", errors.ErrUnsuccessfulResponse, err)
}

if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d: %s",
errors.ErrBadHTTPStatus, response.StatusCode, utils.BodyToSingleLine(response.Body))
}

type domainRecords struct {
Status string `json:"status"`
Records []struct {
Id string `json:"id"`
Name string `json:"title"`
Type string `json:"type"`
Content string `json:"content"`
TTL string `json:"ttl"`
Prio string `json:"prio"`
Notes string `json:"notes"`
} `json:"records"`
}
defer response.Body.Close()
var responseData domainRecords
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&responseData); err != nil {
return nil, fmt.Errorf("%w: %s", errors.ErrUnmarshalResponse, err)
}
var _recordIDs []string
for _, recordID := range responseData.Records {
if strings.HasSuffix(recordID.Content, p.domain) {
_recordIDs = append(_recordIDs, recordID.Id)
}
}
p.logger.Debug("getRecords: " + strings.Join(_recordIDs, ", "))
return _recordIDs, nil
}

func (p *provider) createRecord(ctx context.Context, client *http.Client,
recordType string, ipStr string) (err error) {
u := url.URL{
Scheme: "https",
Host: "porkbun.com",
Path: fmt.Sprintf("/api/json/v3/dns/create/%s", p.domain),
}
postRecordsParams := struct {
SecretApiKey string `json:"secretapikey"`
ApiKey string `json:"apikey"`
Content string `json:"content"`
Name string `json:"name,omitempty"`
Type string `json:"type"`
TTL string `json:"ttl"`
}{
SecretApiKey: p.secretApiKey,
ApiKey: p.apiKey,
Content: ipStr,
Type: recordType,
Name: p.host,
TTL: fmt.Sprint(p.ttl),
}
bodyBytes, err := json.Marshal(postRecordsParams)
if err != nil {
return fmt.Errorf("%w: %s", errors.ErrRequestMarshal, err)
}

p.logger.Debug("HTTP POST createRecord: " + u.String() + ": " + string(bodyBytes))

request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("%w: %s", errors.ErrBadRequest, err)
}
p.setHeaders(request)

response, err := client.Do(request)
if err != nil {
return fmt.Errorf("%w: %s", errors.ErrUnsuccessfulResponse, err)
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d: %s",
errors.ErrBadHTTPStatus, response.StatusCode, utils.BodyToSingleLine(response.Body))
}
return nil
}

func (p *provider) updateRecord(ctx context.Context, client *http.Client,
recordType string, ipStr string, recordID string) (err error) {
u := url.URL{
Scheme: "https",
Host: "porkbun.com",
Path: fmt.Sprintf("/api/json/v3/dns/edit/%s/%s", p.domain, recordID),
}
postRecordsParams := struct {
SecretApiKey string `json:"secretapikey"`
ApiKey string `json:"apikey"`
Content string `json:"content"`
Type string `json:"type"`
TTL string `json:"ttl"`
Name string `json:"name,omitempty"`
}{
SecretApiKey: p.secretApiKey,
ApiKey: p.apiKey,
Content: ipStr,
Type: recordType,
TTL: fmt.Sprint(p.ttl),
Name: p.host,
}
bodyBytes, err := json.Marshal(postRecordsParams)
if err != nil {
return fmt.Errorf("%w: %s", errors.ErrRequestMarshal, err)
}

p.logger.Debug("HTTP POST updateRecord: " + u.String() + ": " + string(bodyBytes))

request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("%w: %s", errors.ErrBadRequest, err)
}
p.setHeaders(request)

response, err := client.Do(request)
if err != nil {
return fmt.Errorf("%w: %s", errors.ErrUnsuccessfulResponse, err)
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d: %s",
errors.ErrBadHTTPStatus, response.StatusCode, utils.BodyToSingleLine(response.Body))
}
return nil
}

func (p *provider) Update(ctx context.Context, client *http.Client, ip net.IP) (newIP net.IP, err error) {
recordType := constants.A
var ipStr string
if ip.To4() == nil { // IPv6
recordType = constants.AAAA
ipStr = ip.To16().String()
} else {
ipStr = ip.To4().String()
}
recordIDs, err := p.getRecords(ctx, client)
if err != nil {
return nil, err
}
if len(recordIDs) == 0 {
if err := p.createRecord(ctx, client, recordType, ipStr); err != nil {
return nil, err
}
} else {
for _, recordID := range recordIDs {
if err := p.updateRecord(ctx, client, recordType, ipStr, recordID); err != nil {
return nil, err
}
}
}

return ip, nil
}
3 changes: 3 additions & 0 deletions internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/qdm12/ddns-updater/internal/settings/providers/noip"
"github.com/qdm12/ddns-updater/internal/settings/providers/opendns"
"github.com/qdm12/ddns-updater/internal/settings/providers/ovh"
"github.com/qdm12/ddns-updater/internal/settings/providers/porkbun"
"github.com/qdm12/ddns-updater/internal/settings/providers/selfhostde"
"github.com/qdm12/ddns-updater/internal/settings/providers/spdyn"
"github.com/qdm12/ddns-updater/internal/settings/providers/strato"
Expand Down Expand Up @@ -108,6 +109,8 @@ func New(provider models.Provider, data json.RawMessage, domain, host string,
return opendns.New(data, domain, host, ipVersion, logger)
case constants.OVH:
return ovh.New(data, domain, host, ipVersion, logger)
case constants.Porkbun:
return porkbun.New(data, domain, host, ipVersion, logger)
case constants.SelfhostDe:
return selfhostde.New(data, domain, host, ipVersion, logger)
case constants.Spdyn:
Expand Down