diff --git a/docs/hetznernetworking.md b/docs/hetznernetworking.md new file mode 100644 index 000000000..0dcaa884d --- /dev/null +++ b/docs/hetznernetworking.md @@ -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 + +### 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). diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go index 71b48c9d1..aa98b468f 100644 --- a/internal/provider/constants/providers.go +++ b/internal/provider/constants/providers.go @@ -31,6 +31,7 @@ const ( GoIP models.Provider = "goip" HE models.Provider = "he" Hetzner models.Provider = "hetzner" + HetznerNetworking models.Provider = "hetznernetworking" Infomaniak models.Provider = "infomaniak" INWX models.Provider = "inwx" Ionos models.Provider = "ionos" @@ -86,6 +87,7 @@ func ProviderChoices() []models.Provider { GoIP, HE, Hetzner, + HetznerNetworking, Infomaniak, INWX, Ionos, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 06e002845..87badc697 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -37,6 +37,7 @@ import ( "github.com/qdm12/ddns-updater/internal/provider/providers/goip" "github.com/qdm12/ddns-updater/internal/provider/providers/he" "github.com/qdm12/ddns-updater/internal/provider/providers/hetzner" + "github.com/qdm12/ddns-updater/internal/provider/providers/hetznernetworking" "github.com/qdm12/ddns-updater/internal/provider/providers/infomaniak" "github.com/qdm12/ddns-updater/internal/provider/providers/inwx" "github.com/qdm12/ddns-updater/internal/provider/providers/ionos" @@ -138,6 +139,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin return he.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Hetzner: return hetzner.New(data, domain, owner, ipVersion, ipv6Suffix) + case constants.HetznerNetworking: + return hetznernetworking.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Infomaniak: return infomaniak.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.INWX: diff --git a/internal/provider/providers/hetznercloud/provider.go b/internal/provider/providers/hetznercloud/provider.go new file mode 100644 index 000000000..f64ffcafd --- /dev/null +++ b/internal/provider/providers/hetznercloud/provider.go @@ -0,0 +1,145 @@ +package hetznercloud + +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) + 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 + 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("%s", p.BuildDomainName(), p.BuildDomainName()), + Owner: p.Owner(), + Provider: "Hetzner Cloud", + 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 +} diff --git a/internal/provider/providers/hetznernetworking/common.go b/internal/provider/providers/hetznernetworking/common.go new file mode 100644 index 000000000..efd30ff46 --- /dev/null +++ b/internal/provider/providers/hetznernetworking/common.go @@ -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) +} diff --git a/internal/provider/providers/hetznernetworking/create.go b/internal/provider/providers/hetznernetworking/create.go new file mode 100644 index 000000000..97842f00e --- /dev/null +++ b/internal/provider/providers/hetznernetworking/create.go @@ -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" { + return fmt.Errorf("%w: action status %s", errors.ErrUnsuccessful, actionResp.Action.Status) + } + + return nil +} diff --git a/internal/provider/providers/hetznernetworking/getrecord.go b/internal/provider/providers/hetznernetworking/getrecord.go new file mode 100644 index 000000000..da94da7d7 --- /dev/null +++ b/internal/provider/providers/hetznernetworking/getrecord.go @@ -0,0 +1,114 @@ +package hetznernetworking + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + "strings" + + "github.com/qdm12/ddns-updater/internal/provider/constants" + "github.com/qdm12/ddns-updater/internal/provider/errors" + "github.com/qdm12/ddns-updater/internal/provider/utils" +) + +// getRecordID fetches the RRSet ID and checks if the IP is up to date. +// It returns the record ID, whether the IP is up to date, and any error. +// If the record doesn't exist, it returns ErrReceivedNoResult. +// See https://docs.hetzner.cloud/reference/cloud#dns +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 + } + + // Extract RR name from domain relative to zone + rrName, err := p.extractRRName() + if err != nil { + return "", false, fmt.Errorf("extracting RR name: %w", err) + } + + urlString := fmt.Sprintf("https://api.hetzner.cloud/v1/zones/%s/rrsets/%s/%s", p.zoneIdentifier, rrName, recordType) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, 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) + var rrSetResponse rrSetResponse + err = decoder.Decode(&rrSetResponse) + if err != nil { + return "", false, fmt.Errorf("json decoding response body: %w", err) + } + + // Check if any record value matches the current IP + for _, record := range rrSetResponse.RRSet.Records { + recordIP, err := netip.ParseAddr(record.Value) + if err != nil { + continue // Skip invalid IPs + } + if recordIP.Compare(ip) == 0 { + return rrSetResponse.RRSet.ID, true, nil + } + } + + // Record exists but IP doesn't match + return rrSetResponse.RRSet.ID, false, nil +} + +// extractRRName extracts the RR name from the domain relative to the zone +// For example: domain="sub.example.com", zone="example.com" -> "sub" +// For example: domain="example.com", zone="example.com" -> "@" +// For example: domain="*.sub.example.com", zone="example.com" -> "*.sub" +func (p *Provider) extractRRName() (string, error) { + domain := p.BuildDomainName() + zone := p.zoneIdentifier + + // Normalize domain and zone to lowercase + domain = strings.ToLower(domain) + zone = strings.ToLower(zone) + + // Remove trailing dots if present + domain = strings.TrimSuffix(domain, ".") + zone = strings.TrimSuffix(zone, ".") + + // If domain equals zone, this is the apex record + if domain == zone { + return "@", nil + } + + // Check if domain is a subdomain of zone + if !strings.HasSuffix(domain, "."+zone) { + return "", fmt.Errorf("domain %s is not a subdomain of zone %s", domain, zone) + } + + // Extract subdomain part + subdomain := strings.TrimSuffix(domain, "."+zone) + if subdomain == "" { + return "@", nil + } + + // For wildcard domains, keep the * character + // For example: "*.sub" should remain "*.sub" + return subdomain, nil +} diff --git a/internal/provider/providers/hetznernetworking/provider.go b/internal/provider/providers/hetznernetworking/provider.go new file mode 100644 index 000000000..818ae9a2e --- /dev/null +++ b/internal/provider/providers/hetznernetworking/provider.go @@ -0,0 +1,145 @@ +package hetznernetworking + +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) + 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.HetznerNetworking, 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 Networking API + 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("%s", p.BuildDomainName(), p.BuildDomainName()), + Owner: p.Owner(), + Provider: "Hetzner Networking", + 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 +} diff --git a/internal/provider/providers/hetznernetworking/types.go b/internal/provider/providers/hetznernetworking/types.go new file mode 100644 index 000000000..f0cc72903 --- /dev/null +++ b/internal/provider/providers/hetznernetworking/types.go @@ -0,0 +1,30 @@ +package hetznernetworking + +// recordValue represents a single DNS record value +type recordValue struct { + Value string `json:"value"` +} + +// recordsRequest represents the request body for creating/updating DNS records +type recordsRequest struct { + TTL uint32 `json:"ttl,omitempty"` + Records []recordValue `json:"records"` +} + +// actionResponse represents the response from Hetzner Networking API actions +type actionResponse struct { + Action struct { + ID int `json:"id"` + Status string `json:"status"` + } `json:"action"` +} + +// rrSetResponse represents the response from Hetzner Networking API RRSet GET requests +type rrSetResponse struct { + RRSet struct { + ID string `json:"id"` + Records []struct { + Value string `json:"value"` + } `json:"records"` + } `json:"rrset"` +} diff --git a/internal/provider/providers/hetznernetworking/update.go b/internal/provider/providers/hetznernetworking/update.go new file mode 100644 index 000000000..470d73c53 --- /dev/null +++ b/internal/provider/providers/hetznernetworking/update.go @@ -0,0 +1,84 @@ +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" +) + +// updateRecord updates an existing DNS record using the set_records action. +// It replaces all existing records with the new IP address. +func (p *Provider) updateRecord(ctx context.Context, client *http.Client, + recordID string, ip netip.Addr, +) (newIP 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 netip.Addr{}, fmt.Errorf("extracting RR name: %w", err) + } + + urlString := fmt.Sprintf("https://api.hetzner.cloud/v1/zones/%s/rrsets/%s/%s/actions/set_records", p.zoneIdentifier, rrName, recordType) + + requestData := recordsRequest{ + Records: []recordValue{ + {Value: ip.String()}, + }, + } + + buffer := bytes.NewBuffer(nil) + encoder := json.NewEncoder(buffer) + err = encoder.Encode(requestData) + if err != nil { + return netip.Addr{}, fmt.Errorf("json encoding request data: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, urlString, buffer) + if err != nil { + return netip.Addr{}, fmt.Errorf("creating http request: %w", err) + } + + p.setHeaders(request) + + response, err := client.Do(request) + if err != nil { + return netip.Addr{}, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusCreated { + return ip, 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 netip.Addr{}, fmt.Errorf("json decoding response body: %w", err) + } + + // Verify the action was created successfully + if actionResp.Action.ID == 0 { + return netip.Addr{}, fmt.Errorf("%w", errors.ErrReceivedNoResult) + } + + // Check if action status indicates success or is still running + if actionResp.Action.Status != "running" && actionResp.Action.Status != "success" { + return netip.Addr{}, fmt.Errorf("%w: action status %s", errors.ErrUnsuccessful, actionResp.Action.Status) + } + + return ip, nil +}