From 361905c809e1c0397d2627b0cabc698c7e7c65e7 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Sun, 18 Jul 2021 21:11:50 +0100 Subject: [PATCH 01/20] add: porkbun provider --- docs/porkbun.md | 34 ++ internal/settings/constants/providers.go | 2 + .../settings/providers/porkbun/provider.go | 317 ++++++++++++++++++ internal/settings/settings.go | 3 + 4 files changed, 356 insertions(+) create mode 100644 docs/porkbun.md create mode 100644 internal/settings/providers/porkbun/provider.go diff --git a/docs/porkbun.md b/docs/porkbun.md new file mode 100644 index 000000000..ad43910b6 --- /dev/null +++ b/docs/porkbun.md @@ -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 +- 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 diff --git a/internal/settings/constants/providers.go b/internal/settings/constants/providers.go index 9ec16621d..7800e3ced 100644 --- a/internal/settings/constants/providers.go +++ b/internal/settings/constants/providers.go @@ -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" @@ -60,6 +61,7 @@ func ProviderChoices() []models.Provider { NoIP, OpenDNS, OVH, + Porkbun, SelfhostDe, Spdyn, Strato, diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go new file mode 100644 index 000000000..fe188cb78 --- /dev/null +++ b/internal/settings/providers/porkbun/provider.go @@ -0,0 +1,317 @@ +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 + apiURL *url.URL + apiKey string + secretApiKey string + logger log.Logger +} + +type ( + porkbunRecords struct { + Status string `json:"status"` + Record []struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL uint `json:"ttl"` + Prio uint `json:"prio"` + } `json:"data"` + } + porkbunCreateReponse struct { + Status string `json:"status"` + Id int64 `json:"id"` + } + porkbunReponse struct { + Status string `json:"status"` + } +) + +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("%s", p.BuildDomainName(), p.BuildDomainName())), + Host: models.HTML(p.Host()), + Provider: "Porkbun DNS", + 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 +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index f87cfbf7d..b756d5f68 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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" @@ -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: From 013f094812bd55b42dc7e6847c318d6fa66c8e1f Mon Sep 17 00:00:00 2001 From: Frederic R Date: Sun, 18 Jul 2021 21:21:33 +0100 Subject: [PATCH 02/20] Update Readme with porkbun --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8309851ee..e3ff906a6 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ - Njalla - OpenDNS - OVH + - Porkbun - Selfhost.de - Spdyn - Strato.de @@ -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) From d6a8aee17d91a29f619cef3fe2aae22606b7114e Mon Sep 17 00:00:00 2001 From: fredericrous Date: Sun, 18 Jul 2021 21:27:05 +0100 Subject: [PATCH 03/20] porkbun fix lint --- .../settings/providers/porkbun/provider.go | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index fe188cb78..8efa3a9e3 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -24,33 +24,11 @@ type provider struct { host string ttl uint ipVersion ipversion.IPVersion - apiURL *url.URL apiKey string secretApiKey string logger log.Logger } -type ( - porkbunRecords struct { - Status string `json:"status"` - Record []struct { - Id string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Content string `json:"content"` - TTL uint `json:"ttl"` - Prio uint `json:"prio"` - } `json:"data"` - } - porkbunCreateReponse struct { - Status string `json:"status"` - Id int64 `json:"id"` - } - porkbunReponse struct { - Status string `json:"status"` - } -) - func New(data json.RawMessage, domain, host string, ipVersion ipversion.IPVersion, logger log.Logger) (p *provider, err error) { extraSettings := struct { From 450b7fb86987915c190a65939b4c70c1a8e66596 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:17:35 +0100 Subject: [PATCH 04/20] Update docs/porkbun.md review changes Co-authored-by: Quentin McGaw --- docs/porkbun.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/porkbun.md b/docs/porkbun.md index ad43910b6..6590c7f6b 100644 --- a/docs/porkbun.md +++ b/docs/porkbun.md @@ -28,7 +28,7 @@ ## Domain setup -- Create api key at -- From [Domain Management](https://porkbun.com/account/domainsSpeedy) page, toggle on "API ACCESS" for your domain. +- 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 From d8b0add8fb3c45a1b0deda89460694a539a2ed0d Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:18:01 +0100 Subject: [PATCH 05/20] Update docs/porkbun.md: add emoji Co-authored-by: Quentin McGaw --- docs/porkbun.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/porkbun.md b/docs/porkbun.md index 6590c7f6b..63f824926 100644 --- a/docs/porkbun.md +++ b/docs/porkbun.md @@ -31,4 +31,4 @@ - 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 +💁 [Official setup documentation](https://kb.porkbun.com/article/190-getting-started-with-the-porkbun-dns-api) From ac7e34af619627a39dba1afe74830d7a007a34a6 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:20:50 +0100 Subject: [PATCH 06/20] PR Review: porkbun provider input settings update apikey to api_key, same for sercretapikey Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 8efa3a9e3..2d81b7b78 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -32,8 +32,8 @@ type provider struct { 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"` + SecretApiKey string `json:"secret_api_key"` + ApiKey string `json:"api_key"` Name bool `json:"name"` Type string `json:"type"` Content string `json:"content"` From d086009e460de6d8593f27f9c05baee88de51ff9 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:22:22 +0100 Subject: [PATCH 07/20] refactor: string empty check Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 2d81b7b78..c124dc125 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -59,7 +59,7 @@ func New(data json.RawMessage, domain, host string, func (p *provider) isValid() error { switch { - case len(p.apiKey) == 0: + case p.apiKey == "": return errors.ErrEmptyAppKey case len(p.secretApiKey) == 0: return errors.ErrEmptyConsumerKey From 2b7e5ef9e0a389cef95c3b470f2e290808f1e354 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:23:14 +0100 Subject: [PATCH 08/20] refactor: string empty check Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index c124dc125..9ac231fd4 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -61,7 +61,7 @@ func (p *provider) isValid() error { switch { case p.apiKey == "": return errors.ErrEmptyAppKey - case len(p.secretApiKey) == 0: + case p.secretApiKey == "": return errors.ErrEmptyConsumerKey } return nil From 99a4c5e40e8e2bf776c916d36a8cbbee2d25f727 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:24:15 +0100 Subject: [PATCH 09/20] refactor: simplify string concat Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 9ac231fd4..347d89f14 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -110,7 +110,7 @@ func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordI u := url.URL{ Scheme: "https", Host: "porkbun.com", - Path: fmt.Sprintf("/api/json/v3/dns/retrieve/%s", p.domain), + Path: "/api/json/v3/dns/retrieve/" + p.domain, } postRecordsParams := struct { SecretApiKey string `json:"secretapikey"` From 54e20a6f82e4b3ca8ab9a04ee737bef8e1ad81de Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:26:42 +0100 Subject: [PATCH 10/20] refactor: simplify string concat Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 347d89f14..1769fdcb2 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -175,7 +175,7 @@ func (p *provider) createRecord(ctx context.Context, client *http.Client, u := url.URL{ Scheme: "https", Host: "porkbun.com", - Path: fmt.Sprintf("/api/json/v3/dns/create/%s", p.domain), + Path: "/api/json/v3/dns/create/" + p.domain, } postRecordsParams := struct { SecretApiKey string `json:"secretapikey"` From 1401fffa76d4a7496818c9ded5201327a392e0d3 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:27:32 +0100 Subject: [PATCH 11/20] refactor: less ident Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 1769fdcb2..88b59d988 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -283,11 +283,12 @@ func (p *provider) Update(ctx context.Context, client *http.Client, ip net.IP) ( 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 + } + + for _, recordID := range recordIDs { + if err := p.updateRecord(ctx, client, recordType, ipStr, recordID); err != nil { + return nil, err } } From e8be6eb3da59825aed224de78e0a06cf22c48b1b Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 09:28:20 +0100 Subject: [PATCH 12/20] refactor: simplify string concat Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 88b59d988..523dcb372 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -223,7 +223,7 @@ func (p *provider) updateRecord(ctx context.Context, client *http.Client, u := url.URL{ Scheme: "https", Host: "porkbun.com", - Path: fmt.Sprintf("/api/json/v3/dns/edit/%s/%s", p.domain, recordID), + Path: "/api/json/v3/dns/edit/" + p.domain + "/" + recordID), } postRecordsParams := struct { SecretApiKey string `json:"secretapikey"` From ba8cc0b3c6157c92fc35f9cb959f26c7b36be4bc Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 22:59:35 +0100 Subject: [PATCH 13/20] refactor: remove unused json props Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 523dcb372..6f9219123 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -34,9 +34,6 @@ func New(data json.RawMessage, domain, host string, extraSettings := struct { SecretApiKey string `json:"secret_api_key"` ApiKey string `json:"api_key"` - Name bool `json:"name"` - Type string `json:"type"` - Content string `json:"content"` TTL uint `json:"ttl"` }{} if err := json.Unmarshal(data, &extraSettings); err != nil { From a56f11d5598153b3b15c0cd624f616609edf6a9c Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 23:23:34 +0100 Subject: [PATCH 14/20] refactor: simplify response data Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 6f9219123..992298e09 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -139,20 +139,12 @@ func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordI errors.ErrBadHTTPStatus, response.StatusCode, utils.BodyToSingleLine(response.Body)) } - type domainRecords struct { - Status string `json:"status"` + var responseData struct { 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) From c5e77a33da9d8ab2210c171ade4aebe712724354 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Mon, 19 Jul 2021 23:26:30 +0100 Subject: [PATCH 15/20] refactor: simplify record assignment Co-authored-by: Quentin McGaw --- internal/settings/providers/porkbun/provider.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index 992298e09..ddf8d42b3 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -149,14 +149,15 @@ func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordI 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) + + for _, record := range responseData.Records { + if strings.HasSuffix(record.Content, p.domain) { + recordIDs = append(recordIDs, record.Id) } } - p.logger.Debug("getRecords: " + strings.Join(_recordIDs, ", ")) - return _recordIDs, nil + + p.logger.Debug("getRecords: " + strings.Join(recordIDs, ", ")) + return recordIDs, nil } func (p *provider) createRecord(ctx context.Context, client *http.Client, From 746be5a28a99b5d5e2f179cd8d35f5799590aced Mon Sep 17 00:00:00 2001 From: fredericrous Date: Mon, 19 Jul 2021 23:50:53 +0100 Subject: [PATCH 16/20] refactor: porkbun reviewed --- docs/porkbun.md | 5 ++- internal/settings/errors/validation.go | 2 + .../settings/providers/porkbun/provider.go | 45 ++++++++++--------- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/docs/porkbun.md b/docs/porkbun.md index 63f824926..c76d658c5 100644 --- a/docs/porkbun.md +++ b/docs/porkbun.md @@ -11,8 +11,8 @@ "provider": "porkbun", "domain": "domain.com", "host": "@", - "apikey": "sk1_7d119e3f656b00ae042980302e1425a04163c476efec1833q3cb0w54fc6f5022", - "secretapikey": "pk1_5299b57125c8f3cdf347d2fe0e713311ee3a1e11f11a14942b26472593e35368", + "api_key": "sk1_7d119e3f656b00ae042980302e1425a04163c476efec1833q3cb0w54fc6f5022", + "secret_api_key": "pk1_5299b57125c8f3cdf347d2fe0e713311ee3a1e11f11a14942b26472593e35368", "ip_version": "ipv4" } ] @@ -25,6 +25,7 @@ - `"host"` is your host and can be a subdomain, `"*"` or `"@"` - `"apikey"` - `"secretapikey"` +- `"ttl"` (optional) ## Domain setup diff --git a/internal/settings/errors/validation.go b/internal/settings/errors/validation.go index 96b241eea..eaeb7acf1 100644 --- a/internal/settings/errors/validation.go +++ b/internal/settings/errors/validation.go @@ -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") diff --git a/internal/settings/providers/porkbun/provider.go b/internal/settings/providers/porkbun/provider.go index ddf8d42b3..0bf12fa0f 100644 --- a/internal/settings/providers/porkbun/provider.go +++ b/internal/settings/providers/porkbun/provider.go @@ -57,9 +57,9 @@ func New(data json.RawMessage, domain, host string, func (p *provider) isValid() error { switch { case p.apiKey == "": - return errors.ErrEmptyAppKey + return errors.ErrEmptyApiKey case p.secretApiKey == "": - return errors.ErrEmptyConsumerKey + return errors.ErrEmptyApiSecret } return nil } @@ -103,7 +103,7 @@ func (p *provider) setHeaders(request *http.Request) { headers.SetAccept(request, "application/json") } -func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordIDs []string, err error) { +func (p *provider) getRecordIDs(ctx context.Context, client *http.Client) (recordIDs []string, err error) { u := url.URL{ Scheme: "https", Host: "porkbun.com", @@ -116,14 +116,15 @@ func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordI SecretApiKey: p.secretApiKey, ApiKey: p.apiKey, } - bodyBytes, err := json.Marshal(postRecordsParams) - if err != nil { + 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 getRecords: " + u.String() + ": " + string(bodyBytes)) + p.logger.Debug("HTTP POST getRecordIDs: " + u.String() + ": " + buffer.String()) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes)) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer) if err != nil { return nil, err } @@ -133,6 +134,7 @@ func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordI 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", @@ -156,7 +158,7 @@ func (p *provider) getRecords(ctx context.Context, client *http.Client) (recordI } } - p.logger.Debug("getRecords: " + strings.Join(recordIDs, ", ")) + p.logger.Debug("getRecordIDs: " + strings.Join(recordIDs, ", ")) return recordIDs, nil } @@ -182,14 +184,15 @@ func (p *provider) createRecord(ctx context.Context, client *http.Client, Name: p.host, TTL: fmt.Sprint(p.ttl), } - bodyBytes, err := json.Marshal(postRecordsParams) - if err != nil { + 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() + ": " + string(bodyBytes)) + p.logger.Debug("HTTP POST createRecord: " + u.String() + ": " + buffer.String()) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes)) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer) if err != nil { return fmt.Errorf("%w: %s", errors.ErrBadRequest, err) } @@ -213,7 +216,7 @@ func (p *provider) updateRecord(ctx context.Context, client *http.Client, u := url.URL{ Scheme: "https", Host: "porkbun.com", - Path: "/api/json/v3/dns/edit/" + p.domain + "/" + recordID), + Path: "/api/json/v3/dns/edit/" + p.domain + "/" + recordID, } postRecordsParams := struct { SecretApiKey string `json:"secretapikey"` @@ -230,14 +233,15 @@ func (p *provider) updateRecord(ctx context.Context, client *http.Client, TTL: fmt.Sprint(p.ttl), Name: p.host, } - bodyBytes, err := json.Marshal(postRecordsParams) - if err != nil { + 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() + ": " + string(bodyBytes)) + p.logger.Debug("HTTP POST updateRecord: " + u.String() + ": " + buffer.String()) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes)) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer) if err != nil { return fmt.Errorf("%w: %s", errors.ErrBadRequest, err) } @@ -258,14 +262,11 @@ func (p *provider) updateRecord(ctx context.Context, client *http.Client, 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) + ipStr := ip.String() + recordIDs, err := p.getRecordIDs(ctx, client) if err != nil { return nil, err } From 0df9dfe45a3b8d7b346d8a4c100a03777e0259e1 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Sun, 8 Aug 2021 22:58:34 +0100 Subject: [PATCH 17/20] integrate review suggest Co-authored-by: Quentin McGaw --- docs/porkbun.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/porkbun.md b/docs/porkbun.md index c76d658c5..fbe510ca5 100644 --- a/docs/porkbun.md +++ b/docs/porkbun.md @@ -25,7 +25,7 @@ - `"host"` is your host and can be a subdomain, `"*"` or `"@"` - `"apikey"` - `"secretapikey"` -- `"ttl"` (optional) +- `"ttl"` optional integer value corresponding to a number of seconds ## Domain setup From fd2e589b5debfd9b1c9146a31d0837c7c4f269bc Mon Sep 17 00:00:00 2001 From: Frederic R Date: Sun, 8 Aug 2021 22:58:42 +0100 Subject: [PATCH 18/20] integrate review suggest Co-authored-by: Quentin McGaw --- internal/settings/errors/validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/settings/errors/validation.go b/internal/settings/errors/validation.go index eaeb7acf1..b80c2f893 100644 --- a/internal/settings/errors/validation.go +++ b/internal/settings/errors/validation.go @@ -3,7 +3,7 @@ package errors import "errors" var ( - ErrEmptyApiKey = errors.New("empty aPI key") + ErrEmptyApiKey = errors.New("empty API key") ErrEmptyAppKey = errors.New("empty app key") ErrEmptyConsumerKey = errors.New("empty consumer key") ErrEmptyEmail = errors.New("empty email") From 3a5f0cd83ccfa113a8dc25a84eba9989311a7c17 Mon Sep 17 00:00:00 2001 From: Frederic R Date: Sun, 8 Aug 2021 22:58:51 +0100 Subject: [PATCH 19/20] integrate review suggest Co-authored-by: Quentin McGaw --- internal/settings/errors/validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/settings/errors/validation.go b/internal/settings/errors/validation.go index b80c2f893..78b9385b9 100644 --- a/internal/settings/errors/validation.go +++ b/internal/settings/errors/validation.go @@ -10,7 +10,7 @@ var ( ErrEmptyKey = errors.New("empty key") ErrEmptyName = errors.New("empty name") ErrEmptyPassword = errors.New("empty password") - ErrEmptyApiSecret = errors.New("empty api secret") + ErrEmptyApiSecret = errors.New("empty API secret") ErrEmptySecret = errors.New("empty secret") ErrEmptyToken = errors.New("empty token") ErrEmptyTTL = errors.New("TTL is not set") From e4301d49f72af0c7092ac22fbd3ee689f29408b5 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Mon, 9 Aug 2021 11:36:44 +0100 Subject: [PATCH 20/20] fix: line endings readme --- README.md | 596 +++++++++++++++++++++++++++--------------------------- 1 file changed, 298 insertions(+), 298 deletions(-) diff --git a/README.md b/README.md index e3ff906a6..96be39305 100644 --- a/README.md +++ b/README.md @@ -1,298 +1,298 @@ -# Lightweight universal DDNS Updater with Docker and web UI - -*Light container updating DNS A and/or AAAA records periodically for multiple DNS providers* - -DDNS Updater logo - -[![Build status](https://github.com/qdm12/ddns-updater/workflows/Buildx%20latest/badge.svg)](https://github.com/qdm12/ddns-updater/actions?query=workflow%3A%22Buildx+latest%22) -[![Docker Pulls](https://img.shields.io/docker/pulls/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater) -[![Docker Stars](https://img.shields.io/docker/stars/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater) -[![Image size](https://images.microbadger.com/badges/image/qmcgaw/ddns-updater.svg)](https://microbadger.com/images/qmcgaw/ddns-updater) -[![Image version](https://images.microbadger.com/badges/version/qmcgaw/ddns-updater.svg)](https://microbadger.com/images/qmcgaw/ddns-updater) - -[![Join Slack channel](https://img.shields.io/badge/slack-@qdm12-yellow.svg?logo=slack)](https://join.slack.com/t/qdm12/shared_invite/enQtODMwMDQyMTAxMjY1LTU1YjE1MTVhNTBmNTViNzJiZmQwZWRmMDhhZjEyNjVhZGM4YmIxOTMxOTYzN2U0N2U2YjQ2MDk3YmYxN2NiNTc) -[![GitHub last commit](https://img.shields.io/github/last-commit/qdm12/ddns-updater.svg)](https://github.com/qdm12/ddns-updater/issues) -[![GitHub commit activity](https://img.shields.io/github/commit-activity/y/qdm12/ddns-updater.svg)](https://github.com/qdm12/ddns-updater/issues) -[![GitHub issues](https://img.shields.io/github/issues/qdm12/ddns-updater.svg)](https://github.com/qdm12/ddns-updater/issues) - -## Features - -- Updates periodically A records for different DNS providers: - - Cloudflare - - DD24 - - DDNSS.de - - DigitalOcean - - DonDominio - - DNSOMatic - - DNSPod - - Dreamhost - - DuckDNS - - DynDNS - - FreeDNS - - Gandi - - GoDaddy - - Google - - He.net - - Infomaniak - - Linode - - LuaDNS - - Namecheap - - NoIP - - Njalla - - OpenDNS - - OVH - - Porkbun - - Selfhost.de - - Spdyn - - Strato.de - - Variomedia.de - - **Want more?** [Create an issue for it](https://github.com/qdm12/ddns-updater/issues/new/choose)! -- Web User interface - -![Web UI](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/webui.png) - -- 14MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data -- Persistence with a JSON file *updates.json* to store old IP addresses with change times for each record -- Docker healthcheck verifying the DNS resolution of your domains -- Highly configurable -- Send notifications with [**Shoutrrr**](https://containrrr.dev/shoutrrr/services/overview/) using `SHOUTRRR_ADDRESSES` -- Compatible with `amd64`, `386`, `arm64`, `armv7`, `armv6`, `s390x`, `ppc64le`, `riscv64` CPU architectures. - -## Setup - -The program reads the configuration from a JSON object, either from a file or from an environment variable. - -1. Create a directory of your choice, say *data* with a file named **config.json** inside: - - ```sh - mkdir data - touch data/config.json - # Owned by user ID of Docker container (1000) - chown -R 1000 data - # all access (for creating json database file data/updates.json) - chmod 700 data - # read access only - chmod 400 data/config.json - ``` - - *(You could change the user ID, for example with `1001`, by running the container with `--user=1001`)* - -1. Write a JSON configuration in *data/config.json*, for example: - - ```json - { - "settings": [ - { - "provider": "namecheap", - "domain": "example.com", - "host": "@", - "password": "e5322165c1d74692bfa6d807100c0310" - } - ] - } - ``` - - You can find more information in the [configuration section](#configuration) to customize it. - -1. Run the container with - - ```sh - docker run -d -p 8000:8000/tcp -v "$(pwd)"/data:/updater/data qmcgaw/ddns-updater - ``` - -1. ⚠️ If you use IPv6, you might need to set `-e IPV6_PREFIX=/64` (`/64` is your prefix, depending on your ISP) -1. (Optional) You can also set your JSON configuration as a single environment variable line (i.e. `{"settings": [{"provider": "namecheap", ...}]}`), which takes precedence over config.json. Note however that if you don't bind mount the `/updater/data` directory, there won't be a persistent database file `/updater/updates.json` but it will still work. - -### Next steps - -You can also use [docker-compose.yml](https://github.com/qdm12/ddns-updater/blob/master/docker-compose.yml) with: - -```sh -docker-compose up -d -``` - -You can update the image with `docker pull qmcgaw/ddns-updater`. Other [Docker image tags are available](https://hub.docker.com/repository/docker/qmcgaw/ddns-updater/tags). - -## Configuration - -Start by having the following content in *config.json*, or in your `CONFIG` environment variable: - -```json -{ - "settings": [ - { - "provider": "", - }, - { - "provider": "", - } - ] -} -``` - -For each setting, you need to fill in parameters. -Check the documentation for your DNS provider: - -- [Cloudflare](https://github.com/qdm12/ddns-updater/blob/master/docs/cloudflare.md) -- [DDNSS.de](https://github.com/qdm12/ddns-updater/blob/master/docs/ddnss.de.md) -- [DigitalOcean](https://github.com/qdm12/ddns-updater/blob/master/docs/digitalocean.md) -- [DD24](https://github.com/qdm12/ddns-updater/blob/master/docs/domaindiscount24.md) -- [DonDominio](https://github.com/qdm12/ddns-updater/blob/master/docs/dondominio.md) -- [DNSOMatic](https://github.com/qdm12/ddns-updater/blob/master/docs/dnsomatic.md) -- [DNSPod](https://github.com/qdm12/ddns-updater/blob/master/docs/dnspod.md) -- [Dreamhost](https://github.com/qdm12/ddns-updater/blob/master/docs/dreamhost.md) -- [DuckDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/duckdns.md) -- [DynDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/dyndns.md) -- [DynV6](https://github.com/qdm12/ddns-updater/blob/master/docs/dynv6.md) -- [FreeDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/freedns.md) -- [Gandi](https://github.com/qdm12/ddns-updater/blob/master/docs/gandi.md) -- [GoDaddy](https://github.com/qdm12/ddns-updater/blob/master/docs/godaddy.md) -- [Google](https://github.com/qdm12/ddns-updater/blob/master/docs/google.md) -- [He.net](https://github.com/qdm12/ddns-updater/blob/master/docs/he.net.md) -- [Infomaniak](https://github.com/qdm12/ddns-updater/blob/master/docs/infomaniak.md) -- [Linode](https://github.com/qdm12/ddns-updater/blob/master/docs/linode.md) -- [LuaDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/luadns.md) -- [Namecheap](https://github.com/qdm12/ddns-updater/blob/master/docs/namecheap.md) -- [NoIP](https://github.com/qdm12/ddns-updater/blob/master/docs/noip.md) -- [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) -- [Variomedia.de](https://github.com/qdm12/ddns-updater/blob/master/docs/variomedia.md) - -Note that: - -- you can specify multiple hosts for the same domain using a comma separated list. For example with `"host": "@,subdomain1,subdomain2",`. - -### Environment variables - -| Environment variable | Default | Description | -| --- | --- | --- | -| `CONFIG` | | One line JSON object containing the entire config (takes precendence over config.json file) if specified | -| `PERIOD` | `5m` | Default period of IP address check, following [this format](https://golang.org/pkg/time/#ParseDuration) | -| `IPV6_PREFIX` | `/128` | IPv6 prefix used to mask your public IPv6 address and your record IPv6 address. Ranges from `/0` to `/128` depending on your ISP. | -| `PUBLICIP_FETCHERS` | `all` | Comma separated fetcher types to obtain the public IP address from `http` and `dns` | -| `PUBLICIP_HTTP_PROVIDERS` | `all` | Comma separated providers to obtain the public IP address (ipv4 or ipv6). See the [Public IP section](#Public-IP) | -| `PUBLICIPV4_HTTP_PROVIDERS` | `all` | Comma separated providers to obtain the public IPv4 address only. See the [Public IP section](#Public-IP) | -| `PUBLICIPV6_HTTP_PROVIDERS` | `all` | Comma separated providers to obtain the public IPv6 address only. See the [Public IP section](#Public-IP) | -| `PUBLICIP_DNS_PROVIDERS` | `all` | Comma separated providers to obtain the public IP address (IPv4 and/or IPv6). See the [Public IP section](#Public-IP) | -| `PUBLICIP_DNS_TIMEOUT` | `3s` | Public IP DNS query timeout | -| `UPDATE_COOLDOWN_PERIOD` | `5m` | Duration to cooldown between updates for each record. This is useful to avoid being rate limited or banned. | -| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests | -| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI | -| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) | -| `HEALTH_SERVER_ADDRESS` | `127.0.0.1:9999` | Health server listening address | -| `DATADIR` | `/updater/data` | Directory to read and write data files from internally | -| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file | -| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`. | -| `LOG_LEVEL` | `info` | Level of logging, `debug`, `info`, `warning` or `error` | -| `LOG_CALLER` | `hidden` | Show caller per log line, `hidden` or `short` | -| `SHOUTRRR_ADDRESSES` | | (optional) Comma separated list of [Shoutrrr addresses](https://containrrr.dev/shoutrrr/services/overview/) (notification services) | -| `TZ` | | Timezone to have accurate times, i.e. `America/Montreal` | - -#### Public IP - -By default, all public IP fetching types are used and cycled (over DNS and over HTTPs). - -On top of that, for each fetching method, all echo services available are cycled on each request. - -This allows you not to be blocked for making too many requests. - -You can otherwise customize it with the following: - -- `PUBLICIP_HTTP_PROVIDERS` gets your public IPv4 or IPv6 address. It can be one or more of the following: - - `opendns` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip) - - `ifconfig` using [https://ifconfig.io/ip](https://ifconfig.io/ip) - - `ipinfo` using [https://ipinfo.io/ip](https://ipinfo.io/ip) - - `ddnss` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php) - - `google` using [https://domains.google.com/checkip](https://domains.google.com/checkip) - - You can also specify an HTTPS URL such as `https://ipinfo.io/ip` -- `PUBLICIPV4_HTTP_PROVIDERS` gets your public IPv4 address only. It can be one or more of the following: - - `ipify` using [https://api.ipify.org](https://api.ipify.org) - - `noip` using [http://ip1.dynupdate.no-ip.com](http://ip1.dynupdate.no-ip.com) - - You can also specify an HTTPS URL such as `https://ipinfo.io/ip` -- `PUBLICIPV6_HTTP_PROVIDERS` gets your public IPv6 address only. It can be one or more of the following: - - `ipify` using [https://api6.ipify.org](https://api6.ipify.org) - - `noip` using [http://ip1.dynupdate6.no-ip.com](http://ip1.dynupdate6.no-ip.com) - - You can also specify an HTTPS URL such as `https://ipinfo.io/ip` -- `PUBLICIP_DNS_PROVIDERS` gets your public IPv4 address only or IPv6 address only or one of them (see #136). It can be one or more of the following: - - `google` - - `cloudflare` - -### Host firewall - -If you have a host firewall in place, this container needs the following ports: - -- TCP 443 outbound for outbound HTTPS -- UDP 53 outbound for outbound DNS resolution -- TCP 8000 inbound (or other) for the WebUI - -## Architecture - -At program start and every period (5 minutes by default): - -1. Fetch your public IP address -1. For each record: - 1. DNS resolve it to obtain its current IP address(es) - - If the resolution fails, update the record with your public IP address by calling the DNS provider API and finish - 1. Check if your public IP address is within the resolved IP addresses - - Yes: skip the update - - No: update the record with your public IP address by calling the DNS provider API - -💡 We do DNS resolution every period so it detects a change made to the record manually, for example on the DNS provider web UI -💡 As DNS resolutions are essentially free and without rate limiting, these are great to avoid getting banned for too many requests. - -### Special case: Cloudflare - -For Cloudflare records with the `proxied` option, the following is done. - -At program start and every period (5 minutes by default), for each record: - -1. Fetch your public IP address -1. For each record: - 1. Check the last IP address (persisted in `updates.json`) for that record - - If it doesn't exist, update the record with your public IP address by calling the DNS provider API and finish - 1. Check if your public IP address matches the last IP address you updated the record with - - Yes: skip the update - - No: update the record with your public IP address by calling the DNS provider API - -This is the only way as doing a DNS resolution on the record will give the IP address of a Cloudflare server instead of your server. - -⚠️ This has the disadvantage that if the record is changed manually, the program will not detect it. -We could do an API call to get the record IP address every period, but that would get you banned especially with a low period duration. - -## Testing - -- The automated healthcheck verifies all your records are up to date [using DNS lookups](https://github.com/qdm12/ddns-updater/blob/master/internal/healthcheck/healthcheck.go#L15) -- You can also manually check, by: - 1. Going to your DNS management webpage - 1. Setting your record to `127.0.0.1` - 1. Run the container - 1. Refresh the DNS management webpage and verify the update happened - -## Development and contributing - -- [Contribute with code](https://github.com/qdm12/ddns-updater/blob/master/docs/contributing.md) -- [Github workflows to know what's building](https://github.com/qdm12/ddns-updater/actions) -- [List of issues and feature requests](https://github.com/qdm12/ddns-updater/issues) -- [Kanban board](https://github.com/qdm12/ddns-updater/projects/1) - -## License - -This repository is under an [MIT license](https://github.com/qdm12/ddns-updater/master/license) - -## Used in external projects - -- [Starttoaster/docker-traefik](https://github.com/Starttoaster/docker-traefik#home-networks-extra-credit-dynamic-dns) - -## Support - -Sponsor me on [Github](https://github.com/sponsors/qdm12) or donate to [paypal.me/qmcgaw](https://www.paypal.me/qmcgaw) - -[![https://github.com/sponsors/qdm12](https://raw.githubusercontent.com/qdm12/private-internet-access-docker/master/doc/sponsors.jpg)](https://github.com/sponsors/qdm12) -[![https://www.paypal.me/qmcgaw](https://raw.githubusercontent.com/qdm12/private-internet-access-docker/master/doc/paypal.jpg)](https://www.paypal.me/qmcgaw) - -Many thanks to J. Famiglietti for supporting me financially 🥇👍 +# Lightweight universal DDNS Updater with Docker and web UI + +*Light container updating DNS A and/or AAAA records periodically for multiple DNS providers* + +DDNS Updater logo + +[![Build status](https://github.com/qdm12/ddns-updater/workflows/Buildx%20latest/badge.svg)](https://github.com/qdm12/ddns-updater/actions?query=workflow%3A%22Buildx+latest%22) +[![Docker Pulls](https://img.shields.io/docker/pulls/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater) +[![Docker Stars](https://img.shields.io/docker/stars/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater) +[![Image size](https://images.microbadger.com/badges/image/qmcgaw/ddns-updater.svg)](https://microbadger.com/images/qmcgaw/ddns-updater) +[![Image version](https://images.microbadger.com/badges/version/qmcgaw/ddns-updater.svg)](https://microbadger.com/images/qmcgaw/ddns-updater) + +[![Join Slack channel](https://img.shields.io/badge/slack-@qdm12-yellow.svg?logo=slack)](https://join.slack.com/t/qdm12/shared_invite/enQtODMwMDQyMTAxMjY1LTU1YjE1MTVhNTBmNTViNzJiZmQwZWRmMDhhZjEyNjVhZGM4YmIxOTMxOTYzN2U0N2U2YjQ2MDk3YmYxN2NiNTc) +[![GitHub last commit](https://img.shields.io/github/last-commit/qdm12/ddns-updater.svg)](https://github.com/qdm12/ddns-updater/issues) +[![GitHub commit activity](https://img.shields.io/github/commit-activity/y/qdm12/ddns-updater.svg)](https://github.com/qdm12/ddns-updater/issues) +[![GitHub issues](https://img.shields.io/github/issues/qdm12/ddns-updater.svg)](https://github.com/qdm12/ddns-updater/issues) + +## Features + +- Updates periodically A records for different DNS providers: + - Cloudflare + - DD24 + - DDNSS.de + - DigitalOcean + - DonDominio + - DNSOMatic + - DNSPod + - Dreamhost + - DuckDNS + - DynDNS + - FreeDNS + - Gandi + - GoDaddy + - Google + - He.net + - Infomaniak + - Linode + - LuaDNS + - Namecheap + - NoIP + - Njalla + - OpenDNS + - OVH + - Porkbun + - Selfhost.de + - Spdyn + - Strato.de + - Variomedia.de + - **Want more?** [Create an issue for it](https://github.com/qdm12/ddns-updater/issues/new/choose)! +- Web User interface + +![Web UI](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/webui.png) + +- 14MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data +- Persistence with a JSON file *updates.json* to store old IP addresses with change times for each record +- Docker healthcheck verifying the DNS resolution of your domains +- Highly configurable +- Send notifications with [**Shoutrrr**](https://containrrr.dev/shoutrrr/services/overview/) using `SHOUTRRR_ADDRESSES` +- Compatible with `amd64`, `386`, `arm64`, `armv7`, `armv6`, `s390x`, `ppc64le`, `riscv64` CPU architectures. + +## Setup + +The program reads the configuration from a JSON object, either from a file or from an environment variable. + +1. Create a directory of your choice, say *data* with a file named **config.json** inside: + + ```sh + mkdir data + touch data/config.json + # Owned by user ID of Docker container (1000) + chown -R 1000 data + # all access (for creating json database file data/updates.json) + chmod 700 data + # read access only + chmod 400 data/config.json + ``` + + *(You could change the user ID, for example with `1001`, by running the container with `--user=1001`)* + +1. Write a JSON configuration in *data/config.json*, for example: + + ```json + { + "settings": [ + { + "provider": "namecheap", + "domain": "example.com", + "host": "@", + "password": "e5322165c1d74692bfa6d807100c0310" + } + ] + } + ``` + + You can find more information in the [configuration section](#configuration) to customize it. + +1. Run the container with + + ```sh + docker run -d -p 8000:8000/tcp -v "$(pwd)"/data:/updater/data qmcgaw/ddns-updater + ``` + +1. ⚠️ If you use IPv6, you might need to set `-e IPV6_PREFIX=/64` (`/64` is your prefix, depending on your ISP) +1. (Optional) You can also set your JSON configuration as a single environment variable line (i.e. `{"settings": [{"provider": "namecheap", ...}]}`), which takes precedence over config.json. Note however that if you don't bind mount the `/updater/data` directory, there won't be a persistent database file `/updater/updates.json` but it will still work. + +### Next steps + +You can also use [docker-compose.yml](https://github.com/qdm12/ddns-updater/blob/master/docker-compose.yml) with: + +```sh +docker-compose up -d +``` + +You can update the image with `docker pull qmcgaw/ddns-updater`. Other [Docker image tags are available](https://hub.docker.com/repository/docker/qmcgaw/ddns-updater/tags). + +## Configuration + +Start by having the following content in *config.json*, or in your `CONFIG` environment variable: + +```json +{ + "settings": [ + { + "provider": "", + }, + { + "provider": "", + } + ] +} +``` + +For each setting, you need to fill in parameters. +Check the documentation for your DNS provider: + +- [Cloudflare](https://github.com/qdm12/ddns-updater/blob/master/docs/cloudflare.md) +- [DDNSS.de](https://github.com/qdm12/ddns-updater/blob/master/docs/ddnss.de.md) +- [DigitalOcean](https://github.com/qdm12/ddns-updater/blob/master/docs/digitalocean.md) +- [DD24](https://github.com/qdm12/ddns-updater/blob/master/docs/domaindiscount24.md) +- [DonDominio](https://github.com/qdm12/ddns-updater/blob/master/docs/dondominio.md) +- [DNSOMatic](https://github.com/qdm12/ddns-updater/blob/master/docs/dnsomatic.md) +- [DNSPod](https://github.com/qdm12/ddns-updater/blob/master/docs/dnspod.md) +- [Dreamhost](https://github.com/qdm12/ddns-updater/blob/master/docs/dreamhost.md) +- [DuckDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/duckdns.md) +- [DynDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/dyndns.md) +- [DynV6](https://github.com/qdm12/ddns-updater/blob/master/docs/dynv6.md) +- [FreeDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/freedns.md) +- [Gandi](https://github.com/qdm12/ddns-updater/blob/master/docs/gandi.md) +- [GoDaddy](https://github.com/qdm12/ddns-updater/blob/master/docs/godaddy.md) +- [Google](https://github.com/qdm12/ddns-updater/blob/master/docs/google.md) +- [He.net](https://github.com/qdm12/ddns-updater/blob/master/docs/he.net.md) +- [Infomaniak](https://github.com/qdm12/ddns-updater/blob/master/docs/infomaniak.md) +- [Linode](https://github.com/qdm12/ddns-updater/blob/master/docs/linode.md) +- [LuaDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/luadns.md) +- [Namecheap](https://github.com/qdm12/ddns-updater/blob/master/docs/namecheap.md) +- [NoIP](https://github.com/qdm12/ddns-updater/blob/master/docs/noip.md) +- [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) +- [Variomedia.de](https://github.com/qdm12/ddns-updater/blob/master/docs/variomedia.md) + +Note that: + +- you can specify multiple hosts for the same domain using a comma separated list. For example with `"host": "@,subdomain1,subdomain2",`. + +### Environment variables + +| Environment variable | Default | Description | +| --- | --- | --- | +| `CONFIG` | | One line JSON object containing the entire config (takes precendence over config.json file) if specified | +| `PERIOD` | `5m` | Default period of IP address check, following [this format](https://golang.org/pkg/time/#ParseDuration) | +| `IPV6_PREFIX` | `/128` | IPv6 prefix used to mask your public IPv6 address and your record IPv6 address. Ranges from `/0` to `/128` depending on your ISP. | +| `PUBLICIP_FETCHERS` | `all` | Comma separated fetcher types to obtain the public IP address from `http` and `dns` | +| `PUBLICIP_HTTP_PROVIDERS` | `all` | Comma separated providers to obtain the public IP address (ipv4 or ipv6). See the [Public IP section](#Public-IP) | +| `PUBLICIPV4_HTTP_PROVIDERS` | `all` | Comma separated providers to obtain the public IPv4 address only. See the [Public IP section](#Public-IP) | +| `PUBLICIPV6_HTTP_PROVIDERS` | `all` | Comma separated providers to obtain the public IPv6 address only. See the [Public IP section](#Public-IP) | +| `PUBLICIP_DNS_PROVIDERS` | `all` | Comma separated providers to obtain the public IP address (IPv4 and/or IPv6). See the [Public IP section](#Public-IP) | +| `PUBLICIP_DNS_TIMEOUT` | `3s` | Public IP DNS query timeout | +| `UPDATE_COOLDOWN_PERIOD` | `5m` | Duration to cooldown between updates for each record. This is useful to avoid being rate limited or banned. | +| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests | +| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI | +| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) | +| `HEALTH_SERVER_ADDRESS` | `127.0.0.1:9999` | Health server listening address | +| `DATADIR` | `/updater/data` | Directory to read and write data files from internally | +| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file | +| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`. | +| `LOG_LEVEL` | `info` | Level of logging, `debug`, `info`, `warning` or `error` | +| `LOG_CALLER` | `hidden` | Show caller per log line, `hidden` or `short` | +| `SHOUTRRR_ADDRESSES` | | (optional) Comma separated list of [Shoutrrr addresses](https://containrrr.dev/shoutrrr/services/overview/) (notification services) | +| `TZ` | | Timezone to have accurate times, i.e. `America/Montreal` | + +#### Public IP + +By default, all public IP fetching types are used and cycled (over DNS and over HTTPs). + +On top of that, for each fetching method, all echo services available are cycled on each request. + +This allows you not to be blocked for making too many requests. + +You can otherwise customize it with the following: + +- `PUBLICIP_HTTP_PROVIDERS` gets your public IPv4 or IPv6 address. It can be one or more of the following: + - `opendns` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip) + - `ifconfig` using [https://ifconfig.io/ip](https://ifconfig.io/ip) + - `ipinfo` using [https://ipinfo.io/ip](https://ipinfo.io/ip) + - `ddnss` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php) + - `google` using [https://domains.google.com/checkip](https://domains.google.com/checkip) + - You can also specify an HTTPS URL such as `https://ipinfo.io/ip` +- `PUBLICIPV4_HTTP_PROVIDERS` gets your public IPv4 address only. It can be one or more of the following: + - `ipify` using [https://api.ipify.org](https://api.ipify.org) + - `noip` using [http://ip1.dynupdate.no-ip.com](http://ip1.dynupdate.no-ip.com) + - You can also specify an HTTPS URL such as `https://ipinfo.io/ip` +- `PUBLICIPV6_HTTP_PROVIDERS` gets your public IPv6 address only. It can be one or more of the following: + - `ipify` using [https://api6.ipify.org](https://api6.ipify.org) + - `noip` using [http://ip1.dynupdate6.no-ip.com](http://ip1.dynupdate6.no-ip.com) + - You can also specify an HTTPS URL such as `https://ipinfo.io/ip` +- `PUBLICIP_DNS_PROVIDERS` gets your public IPv4 address only or IPv6 address only or one of them (see #136). It can be one or more of the following: + - `google` + - `cloudflare` + +### Host firewall + +If you have a host firewall in place, this container needs the following ports: + +- TCP 443 outbound for outbound HTTPS +- UDP 53 outbound for outbound DNS resolution +- TCP 8000 inbound (or other) for the WebUI + +## Architecture + +At program start and every period (5 minutes by default): + +1. Fetch your public IP address +1. For each record: + 1. DNS resolve it to obtain its current IP address(es) + - If the resolution fails, update the record with your public IP address by calling the DNS provider API and finish + 1. Check if your public IP address is within the resolved IP addresses + - Yes: skip the update + - No: update the record with your public IP address by calling the DNS provider API + +💡 We do DNS resolution every period so it detects a change made to the record manually, for example on the DNS provider web UI +💡 As DNS resolutions are essentially free and without rate limiting, these are great to avoid getting banned for too many requests. + +### Special case: Cloudflare + +For Cloudflare records with the `proxied` option, the following is done. + +At program start and every period (5 minutes by default), for each record: + +1. Fetch your public IP address +1. For each record: + 1. Check the last IP address (persisted in `updates.json`) for that record + - If it doesn't exist, update the record with your public IP address by calling the DNS provider API and finish + 1. Check if your public IP address matches the last IP address you updated the record with + - Yes: skip the update + - No: update the record with your public IP address by calling the DNS provider API + +This is the only way as doing a DNS resolution on the record will give the IP address of a Cloudflare server instead of your server. + +⚠️ This has the disadvantage that if the record is changed manually, the program will not detect it. +We could do an API call to get the record IP address every period, but that would get you banned especially with a low period duration. + +## Testing + +- The automated healthcheck verifies all your records are up to date [using DNS lookups](https://github.com/qdm12/ddns-updater/blob/master/internal/healthcheck/healthcheck.go#L15) +- You can also manually check, by: + 1. Going to your DNS management webpage + 1. Setting your record to `127.0.0.1` + 1. Run the container + 1. Refresh the DNS management webpage and verify the update happened + +## Development and contributing + +- [Contribute with code](https://github.com/qdm12/ddns-updater/blob/master/docs/contributing.md) +- [Github workflows to know what's building](https://github.com/qdm12/ddns-updater/actions) +- [List of issues and feature requests](https://github.com/qdm12/ddns-updater/issues) +- [Kanban board](https://github.com/qdm12/ddns-updater/projects/1) + +## License + +This repository is under an [MIT license](https://github.com/qdm12/ddns-updater/master/license) + +## Used in external projects + +- [Starttoaster/docker-traefik](https://github.com/Starttoaster/docker-traefik#home-networks-extra-credit-dynamic-dns) + +## Support + +Sponsor me on [Github](https://github.com/sponsors/qdm12) or donate to [paypal.me/qmcgaw](https://www.paypal.me/qmcgaw) + +[![https://github.com/sponsors/qdm12](https://raw.githubusercontent.com/qdm12/private-internet-access-docker/master/doc/sponsors.jpg)](https://github.com/sponsors/qdm12) +[![https://www.paypal.me/qmcgaw](https://raw.githubusercontent.com/qdm12/private-internet-access-docker/master/doc/paypal.jpg)](https://www.paypal.me/qmcgaw) + +Many thanks to J. Famiglietti for supporting me financially 🥇👍