-
-
Notifications
You must be signed in to change notification settings - Fork 252
add hetznernetworking provider #1046
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,45 @@ | ||||||||
| # Hetzner Networking | ||||||||
|
|
||||||||
| ## Configuration | ||||||||
|
|
||||||||
| ### Example | ||||||||
|
|
||||||||
| ```json | ||||||||
| { | ||||||||
| "settings": [ | ||||||||
| { | ||||||||
| "provider": "hetznernetworking", | ||||||||
| "zone_identifier": "example.com", | ||||||||
| "domain": "example.com", | ||||||||
| "ttl": 600, | ||||||||
| "token": "yourtoken", | ||||||||
| "ip_version": "ipv4", | ||||||||
| "ipv6_suffix": "" | ||||||||
| } | ||||||||
| ] | ||||||||
| } | ||||||||
| ``` | ||||||||
|
|
||||||||
| ### Compulsory parameters | ||||||||
|
|
||||||||
| - `"zone_identifier"` is the DNS zone name (e.g., `example.com`), not a zone ID | ||||||||
| - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard. | ||||||||
| - `"ttl"` optional integer value corresponding to a number of seconds | ||||||||
| - One of the following ([how to find API keys](https://docs.hetzner.cloud/api/getting-started/generating-api-token)): | ||||||||
| - API Token `"token"`, configured with DNS write permissions for your DNS zone | ||||||||
|
Comment on lines
+28
to
+29
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
|
|
||||||||
| ### Optional parameters | ||||||||
|
|
||||||||
| - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`. | ||||||||
| - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating. | ||||||||
|
|
||||||||
| ## Notes | ||||||||
|
|
||||||||
| This provider uses the Hetzner Networking DNS API (https://api.hetzner.cloud/v1/) which is different from the legacy Hetzner DNS API. | ||||||||
|
|
||||||||
| - The `zone_identifier` should be the DNS zone name (e.g., `example.com`), not a zone ID | ||||||||
| - For subdomains, the provider automatically extracts the subdomain part relative to the zone | ||||||||
| - For apex records (root domain), the provider uses `@` as the record name | ||||||||
| - The API uses RRSet-based operations for managing DNS records | ||||||||
|
|
||||||||
| For more information about the Hetzner Networking DNS API, see the [official documentation](https://docs.hetzner.cloud/reference/cloud#dns). | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| package hetznercloud | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please remove, not sure why this is here |
||
|
|
||
| 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 | ||
| owner string | ||
| ipVersion ipversion.IPVersion | ||
| ipv6Suffix netip.Prefix | ||
| token string | ||
| zoneIdentifier string | ||
| ttl uint32 | ||
| } | ||
|
|
||
| func New(data json.RawMessage, domain, owner string, | ||
| ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) ( | ||
| p *Provider, err error, | ||
| ) { | ||
| extraSettings := struct { | ||
| Token string `json:"token"` | ||
| ZoneIdentifier string `json:"zone_identifier"` | ||
| TTL uint32 `json:"ttl"` | ||
| }{} | ||
| err = json.Unmarshal(data, &extraSettings) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| ttl := uint32(1) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this ok for the default ttl? Shouldn't it be i.e. 5 minutes? |
||
| if extraSettings.TTL > 0 { | ||
| ttl = extraSettings.TTL | ||
| } | ||
|
|
||
| err = validateSettings(domain, extraSettings.ZoneIdentifier, extraSettings.Token) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("validating provider specific settings: %w", err) | ||
| } | ||
|
|
||
| return &Provider{ | ||
| domain: domain, | ||
| owner: owner, | ||
| ipVersion: ipVersion, | ||
| ipv6Suffix: ipv6Suffix, | ||
| token: extraSettings.Token, | ||
| zoneIdentifier: extraSettings.ZoneIdentifier, | ||
| ttl: ttl, | ||
| }, nil | ||
| } | ||
|
|
||
| func validateSettings(domain, zoneIdentifier, token string) (err error) { | ||
| err = utils.CheckDomain(domain) | ||
| if err != nil { | ||
| return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err) | ||
| } | ||
|
|
||
| switch { | ||
| case zoneIdentifier == "": | ||
| return fmt.Errorf("%w", errors.ErrZoneIdentifierNotSet) | ||
| case token == "": | ||
| return fmt.Errorf("%w", errors.ErrTokenNotSet) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (p *Provider) String() string { | ||
| return utils.ToString(p.domain, p.owner, constants.HetznerCloud, p.ipVersion) | ||
| } | ||
|
|
||
| func (p *Provider) Domain() string { | ||
| return p.domain | ||
| } | ||
|
|
||
| func (p *Provider) Owner() string { | ||
| return p.owner | ||
| } | ||
|
|
||
| func (p *Provider) IPVersion() ipversion.IPVersion { | ||
| return p.ipVersion | ||
| } | ||
|
|
||
| func (p *Provider) IPv6Suffix() netip.Prefix { | ||
| return p.ipv6Suffix | ||
| } | ||
|
|
||
| func (p *Provider) Proxied() bool { | ||
| return false | ||
| } | ||
|
|
||
| func (p *Provider) BuildDomainName() string { | ||
| // Override to preserve wildcard characters for Hetzner Cloud API | ||
|
Comment on lines
+102
to
+103
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that will cause issues; BuildDomainName() is used in a few places to resolve the domain name and compare it with the current public ip address. |
||
| if p.owner == "@" { | ||
| return p.domain | ||
| } | ||
| // Don't replace * with "any" for wildcard domains | ||
| return p.owner + "." + p.domain | ||
| } | ||
|
|
||
| func (p *Provider) HTML() models.HTMLRow { | ||
| return models.HTMLRow{ | ||
| Domain: fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()), | ||
| Owner: p.Owner(), | ||
| Provider: "<a href=\"https://www.hetzner.cloud\">Hetzner Cloud</a>", | ||
| IPVersion: p.ipVersion.String(), | ||
| } | ||
| } | ||
|
|
||
| // Update updates the DNS record with the given IP address. | ||
| // It first checks if a record exists and if the IP is up to date. | ||
| // If the record doesn't exist, it creates a new one. | ||
| // If the record exists but has a different IP, it updates the record. | ||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,14 @@ | ||||||
| package hetznernetworking | ||||||
|
|
||||||
| 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("Authorization", "Bearer "+p.token) | ||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| package hetznernetworking | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "net/netip" | ||
|
|
||
| "github.com/qdm12/ddns-updater/internal/provider/constants" | ||
| "github.com/qdm12/ddns-updater/internal/provider/errors" | ||
| "github.com/qdm12/ddns-updater/internal/provider/utils" | ||
| ) | ||
|
|
||
| // createRecord creates a new DNS record using the add_records action. | ||
| // It adds the new IP address to the existing RRSet or creates a new RRSet. | ||
| func (p *Provider) createRecord(ctx context.Context, client *http.Client, ip netip.Addr) (err error) { | ||
| recordType := constants.A | ||
| if ip.Is6() { | ||
| recordType = constants.AAAA | ||
| } | ||
|
|
||
| // Extract RR name from domain relative to zone | ||
| rrName, err := p.extractRRName() | ||
| if err != nil { | ||
| return fmt.Errorf("extracting RR name: %w", err) | ||
| } | ||
|
|
||
| urlString := fmt.Sprintf("https://api.hetzner.cloud/v1/zones/%s/rrsets/%s/%s/actions/add_records", p.zoneIdentifier, rrName, recordType) | ||
|
|
||
| requestData := recordsRequest{ | ||
| TTL: p.ttl, | ||
| Records: []recordValue{ | ||
| {Value: ip.String()}, | ||
| }, | ||
| } | ||
|
|
||
| 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, urlString, 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.StatusCreated { | ||
| return fmt.Errorf("%w: %d: %s", | ||
| errors.ErrHTTPStatusNotValid, response.StatusCode, | ||
| utils.BodyToSingleLine(response.Body)) | ||
| } | ||
|
|
||
| decoder := json.NewDecoder(response.Body) | ||
| var actionResp actionResponse | ||
| err = decoder.Decode(&actionResp) | ||
| if err != nil { | ||
| return fmt.Errorf("json decoding response body: %w", err) | ||
| } | ||
|
|
||
| // Verify the action was created successfully | ||
| if actionResp.Action.ID == 0 { | ||
| return fmt.Errorf("%w", errors.ErrReceivedNoResult) | ||
| } | ||
|
|
||
| // Check if action status indicates success or is still running | ||
| if actionResp.Action.Status != "running" && actionResp.Action.Status != "success" { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if the action is still running, we might want to run this in a loop with a fixed amount of retries and sleeps between each request, to make sure it completes successfully for example if the hetzner api is malfunctioning, and stalls, we would indicate as "success" although it would not update the record. |
||
| return fmt.Errorf("%w: action status %s", errors.ErrUnsuccessful, actionResp.Action.Status) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we extract zone_identifier from domain instead? i.e.
x.y.com->y.com- it looks likeextractRRNamedoes it?