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 19 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
35 changes: 35 additions & 0 deletions docs/porkbun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Porkbun

## Configuration

### Example

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

### Parameters

- `"domain"`
- `"host"` is your host and can be a subdomain, `"*"` or `"@"`
- `"apikey"`
- `"secretapikey"`
- `"ttl"` optional integer value corresponding to a number of seconds

## Domain setup

- Create an API key at [porkbun.com/account/api](https://porkbun.com/account/api)
- From the [Domain Management page](https://porkbun.com/account/domainsSpeedy), 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
2 changes: 2 additions & 0 deletions internal/settings/errors/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package errors
import "errors"

var (
ErrEmptyApiKey = errors.New("empty API key")
ErrEmptyAppKey = errors.New("empty app key")
ErrEmptyConsumerKey = errors.New("empty consumer key")
ErrEmptyEmail = errors.New("empty email")
ErrEmptyKey = errors.New("empty key")
ErrEmptyName = errors.New("empty name")
ErrEmptyPassword = errors.New("empty password")
ErrEmptyApiSecret = errors.New("empty API secret")
ErrEmptySecret = errors.New("empty secret")
ErrEmptyToken = errors.New("empty token")
ErrEmptyTTL = errors.New("TTL is not set")
Expand Down
287 changes: 287 additions & 0 deletions internal/settings/providers/porkbun/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
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:"secret_api_key"`
ApiKey string `json:"api_key"`
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 p.apiKey == "":
return errors.ErrEmptyApiKey
case p.secretApiKey == "":
return errors.ErrEmptyApiSecret
}
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) getRecordIDs(ctx context.Context, client *http.Client) (recordIDs []string, err error) {
u := url.URL{
Scheme: "https",
Host: "porkbun.com",
Path: "/api/json/v3/dns/retrieve/" + p.domain,
}
postRecordsParams := struct {
SecretApiKey string `json:"secretapikey"`
ApiKey string `json:"apikey"`
}{
SecretApiKey: p.secretApiKey,
ApiKey: p.apiKey,
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(postRecordsParams); err != nil {
return nil, fmt.Errorf("%w: %s", errors.ErrRequestMarshal, err)
}

p.logger.Debug("HTTP POST getRecordIDs: " + u.String() + ": " + buffer.String())

request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer)
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)
}
defer response.Body.Close()

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

var responseData struct {
Records []struct {
Id string `json:"id"`
Content string `json:"content"`
} `json:"records"`
}
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&responseData); err != nil {
return nil, fmt.Errorf("%w: %s", errors.ErrUnmarshalResponse, err)
}

for _, record := range responseData.Records {
if strings.HasSuffix(record.Content, p.domain) {
recordIDs = append(recordIDs, record.Id)
}
}

p.logger.Debug("getRecordIDs: " + 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: "/api/json/v3/dns/create/" + 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),
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(postRecordsParams); err != nil {
return fmt.Errorf("%w: %s", errors.ErrRequestMarshal, err)
}

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

request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer)
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: "/api/json/v3/dns/edit/" + 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,
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(postRecordsParams); err != nil {
return fmt.Errorf("%w: %s", errors.ErrRequestMarshal, err)
}

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

request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer)
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
if ip.To4() == nil { // IPv6
recordType = constants.AAAA
}
ipStr := ip.String()
recordIDs, err := p.getRecordIDs(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
}
return ip, nil
}

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