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
+}