-
-
Notifications
You must be signed in to change notification settings - Fork 178
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(provider): support Hetzner (#503)
- Loading branch information
1 parent
496781a
commit 301e4d6
Showing
9 changed files
with
434 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
# Hetzner | ||
|
||
## Configuration | ||
|
||
### Example | ||
|
||
```json | ||
{ | ||
"settings": [ | ||
{ | ||
"provider": "hetzner", | ||
"zone_identifier": "some id", | ||
"domain": "domain.com", | ||
"host": "@", | ||
"ttl": 600, | ||
"token": "yourtoken", | ||
"ip_version": "ipv4" | ||
} | ||
] | ||
} | ||
``` | ||
|
||
### Compulsory parameters | ||
|
||
- `"zone_identifier"` is the Zone ID of your site, from the domain overview page written as *Zone ID* | ||
- `"domain"` | ||
- `"host"` is your host and can be `"@"`, a subdomain or the wildcard `"*"`. | ||
- `"ttl"` optional integer value corresponding to a number of seconds | ||
- One of the following ([how to find API keys](https://docs.hetzner.com/cloud/api/getting-started/generating-api-token)): | ||
- API Token `"token"`, configured with DNS edit permissions for your DNS name's zone | ||
|
||
### Optional parameters | ||
|
||
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), and defaults to `ipv4 or ipv6` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
package hetzner | ||
|
||
import ( | ||
"net/http" | ||
|
||
"github.com/qdm12/ddns-updater/internal/provider/headers" | ||
) | ||
|
||
func (p *Provider) setHeaders(request *http.Request) { | ||
headers.SetUserAgent(request) | ||
headers.SetContentType(request, "application/json") | ||
headers.SetAccept(request, "application/json") | ||
request.Header.Set("Auth-API-Token", p.token) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
package hetzner | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"net/http" | ||
"net/netip" | ||
"net/url" | ||
|
||
"github.com/qdm12/ddns-updater/internal/provider/constants" | ||
"github.com/qdm12/ddns-updater/internal/provider/errors" | ||
"github.com/qdm12/ddns-updater/internal/provider/utils" | ||
) | ||
|
||
func (p *Provider) createRecord(ctx context.Context, client *http.Client, ip netip.Addr) (err error) { | ||
recordType := constants.A | ||
if ip.Is6() { | ||
recordType = constants.AAAA | ||
} | ||
|
||
u := url.URL{ | ||
Scheme: "https", | ||
Host: "dns.hetzner.com", | ||
Path: "/api/v1/records", | ||
} | ||
|
||
requestData := struct { | ||
Type string `json:"type"` | ||
Name string `json:"name"` | ||
Value string `json:"value"` | ||
ZoneIdentifier string `json:"zone_id"` | ||
TTL uint `json:"ttl"` | ||
}{ | ||
Type: recordType, | ||
Name: p.host, | ||
Value: ip.String(), | ||
ZoneIdentifier: p.zoneIdentifier, | ||
TTL: p.ttl, | ||
} | ||
|
||
buffer := bytes.NewBuffer(nil) | ||
encoder := json.NewEncoder(buffer) | ||
err = encoder.Encode(requestData) | ||
if err != nil { | ||
return fmt.Errorf("JSON encoding request data: %w", err) | ||
} | ||
|
||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer) | ||
if err != nil { | ||
return fmt.Errorf("creating http request: %w", err) | ||
} | ||
|
||
p.setHeaders(request) | ||
|
||
response, err := client.Do(request) | ||
if err != nil { | ||
return err | ||
} | ||
defer response.Body.Close() | ||
|
||
if response.StatusCode != http.StatusOK { | ||
return fmt.Errorf("%w: %d: %s", | ||
errors.ErrHTTPStatusNotValid, response.StatusCode, | ||
utils.BodyToSingleLine(response.Body)) | ||
} | ||
|
||
decoder := json.NewDecoder(response.Body) | ||
var parsedJSON struct { | ||
Record struct { | ||
ID string `json:"id"` | ||
Value netip.Addr `json:"value"` | ||
} `json:"record"` | ||
} | ||
err = decoder.Decode(&parsedJSON) | ||
newIP := parsedJSON.Record.Value | ||
if err != nil { | ||
return fmt.Errorf("json decoding response body: %w", err) | ||
} else if newIP.Compare(ip) != 0 { | ||
return fmt.Errorf("%w: sent ip %s to update but received %s", | ||
errors.ErrIPReceivedMismatch, ip, newIP) | ||
} | ||
|
||
if parsedJSON.Record.ID == "" { | ||
return fmt.Errorf("%w", errors.ErrReceivedNoResult) | ||
} | ||
|
||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
package hetzner | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"net/http" | ||
"net/netip" | ||
"net/url" | ||
|
||
"github.com/qdm12/ddns-updater/internal/provider/constants" | ||
"github.com/qdm12/ddns-updater/internal/provider/errors" | ||
"github.com/qdm12/ddns-updater/internal/provider/utils" | ||
) | ||
|
||
// See https://dns.hetzner.com/api-docs#operation/GetZones. | ||
func (p *Provider) getRecordID(ctx context.Context, client *http.Client, ip netip.Addr) ( | ||
identifier string, upToDate bool, err error) { | ||
recordType := constants.A | ||
if ip.Is6() { | ||
recordType = constants.AAAA | ||
} | ||
|
||
u := url.URL{ | ||
Scheme: "https", | ||
Host: "dns.hetzner.com", | ||
Path: "/api/v1/records", | ||
} | ||
|
||
values := url.Values{} | ||
values.Set("zone_id", p.zoneIdentifier) | ||
values.Set("name", p.host) | ||
values.Set("type", recordType) | ||
values.Set("page", "1") | ||
values.Set("per_page", "1") | ||
u.RawQuery = values.Encode() | ||
|
||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) | ||
if err != nil { | ||
return "", false, fmt.Errorf("creating http request: %w", err) | ||
} | ||
p.setHeaders(request) | ||
|
||
response, err := client.Do(request) | ||
if err != nil { | ||
return "", false, err | ||
} | ||
defer response.Body.Close() | ||
|
||
switch response.StatusCode { | ||
case http.StatusOK: | ||
case http.StatusNotFound: | ||
return "", false, fmt.Errorf("%w", errors.ErrReceivedNoResult) | ||
default: | ||
return "", false, fmt.Errorf("%w: %d: %s", | ||
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.BodyToSingleLine(response.Body)) | ||
} | ||
|
||
decoder := json.NewDecoder(response.Body) | ||
listRecordsResponse := struct { | ||
Records []struct { | ||
ID string `json:"id"` | ||
Value netip.Addr `json:"value"` | ||
} `json:"records"` | ||
}{} | ||
err = decoder.Decode(&listRecordsResponse) | ||
if err != nil { | ||
return "", false, fmt.Errorf("json decoding response body: %w", err) | ||
} | ||
|
||
switch { | ||
case len(listRecordsResponse.Records) == 0: | ||
return "", false, fmt.Errorf("%w", errors.ErrReceivedNoResult) | ||
case len(listRecordsResponse.Records) > 1: | ||
return "", false, fmt.Errorf("%w: %d instead of 1", | ||
errors.ErrResultsCountReceived, len(listRecordsResponse.Records)) | ||
} | ||
identifier = listRecordsResponse.Records[0].ID | ||
upToDate = listRecordsResponse.Records[0].Value.Compare(ip) == 0 | ||
return identifier, upToDate, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
package hetzner | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
stderrors "errors" | ||
"fmt" | ||
"net/http" | ||
"net/netip" | ||
|
||
"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/utils" | ||
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion" | ||
) | ||
|
||
type Provider struct { | ||
domain string | ||
host string | ||
ipVersion ipversion.IPVersion | ||
token string | ||
zoneIdentifier string | ||
ttl uint | ||
} | ||
|
||
func New(data json.RawMessage, domain, host string, | ||
ipVersion ipversion.IPVersion) (p *Provider, err error) { | ||
extraSettings := struct { | ||
Token string `json:"token"` | ||
ZoneIdentifier string `json:"zone_identifier"` | ||
TTL uint `json:"ttl"` | ||
}{} | ||
err = json.Unmarshal(data, &extraSettings) | ||
if err != nil { | ||
return nil, err | ||
} | ||
p = &Provider{ | ||
domain: domain, | ||
host: host, | ||
ipVersion: ipVersion, | ||
token: extraSettings.Token, | ||
zoneIdentifier: extraSettings.ZoneIdentifier, | ||
ttl: extraSettings.TTL, | ||
} | ||
if p.ttl == 0 { | ||
p.ttl = 1 | ||
} | ||
err = p.isValid() | ||
if err != nil { | ||
return nil, err | ||
} | ||
return p, nil | ||
} | ||
|
||
func (p *Provider) isValid() error { | ||
switch { | ||
case p.zoneIdentifier == "": | ||
return fmt.Errorf("%w", errors.ErrZoneIdentifierNotSet) | ||
case p.token == "": | ||
return fmt.Errorf("%w", errors.ErrTokenNotSet) | ||
} | ||
return nil | ||
} | ||
|
||
func (p *Provider) String() string { | ||
return utils.ToString(p.domain, p.host, constants.Hetzner, p.ipVersion) | ||
} | ||
|
||
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: fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()), | ||
Host: p.Host(), | ||
Provider: "<a href=\"https://www.hetzner.com\">Hetzner</a>", | ||
IPVersion: p.ipVersion.String(), | ||
} | ||
} | ||
|
||
func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) { | ||
recordID, upToDate, err := p.getRecordID(ctx, client, ip) | ||
switch { | ||
case stderrors.Is(err, errors.ErrReceivedNoResult): | ||
err = p.createRecord(ctx, client, ip) | ||
if err != nil { | ||
return netip.Addr{}, fmt.Errorf("creating record: %w", err) | ||
} | ||
return ip, nil | ||
case err != nil: | ||
return netip.Addr{}, fmt.Errorf("getting record id: %w", err) | ||
case upToDate: | ||
return ip, nil | ||
} | ||
|
||
ip, err = p.updateRecord(ctx, client, recordID, ip) | ||
if err != nil { | ||
return newIP, fmt.Errorf("updating record: %w", err) | ||
} | ||
|
||
return ip, nil | ||
} |
Oops, something went wrong.