diff --git a/docs/apertodns.md b/docs/apertodns.md
new file mode 100644
index 000000000..e77be845d
--- /dev/null
+++ b/docs/apertodns.md
@@ -0,0 +1,147 @@
+# ApertoDNS
+
+## Configuration
+
+### Example
+
+```json
+{
+ "settings": [
+ {
+ "provider": "apertodns",
+ "domain": "apertodns.com",
+ "owner": "home",
+ "token": "apertodns_live_xxxxxxxxxxxxx",
+ "ip_version": "ipv4",
+ "ipv6_suffix": ""
+ }
+ ]
+}
+```
+
+### Compulsory parameters
+
+- `"provider"`: `"apertodns"`
+- `"domain"`: Your ApertoDNS domain (e.g., `"apertodns.com"` or your custom domain)
+- `"owner"`: The subdomain/hostname (e.g., `"home"`, `"office"`, `"@"` for root)
+- `"token"`: Your ApertoDNS API token (starts with `apertodns_live_` or `apertodns_test_`)
+
+### Optional parameters
+
+- `"ip_version"`: `"ipv4"` (A records), `"ipv6"` (AAAA records), or `"ipv4 or ipv6"`
+- `"ipv6_suffix"`: IPv6 suffix for EUI-64 addressing
+- `"base_url"`: Custom API endpoint (default: `"https://api.apertodns.com"`)
+
+## Domain setup
+
+1. Sign up at [apertodns.com](https://apertodns.com)
+2. Create a domain in your dashboard
+3. Generate an API token from Settings -> API Keys
+4. Use the token in your configuration
+
+## Protocol Support
+
+This provider implements the full [ApertoDNS Protocol v1.2](https://docs.apertodns.com/protocol) with automatic fallback:
+
+1. **Modern API** (primary): `POST /.well-known/apertodns/v1/update`
+ - Bearer token authentication
+ - JSON request/response
+
+2. **Legacy DynDNS2** (fallback): `GET /nic/update`
+ - Basic authentication
+ - Text response ("good", "nochg")
+
+The provider automatically uses the modern API first. If the server doesn't
+support the modern endpoint (404) or has a temporary error (500), it falls
+back to the legacy DynDNS2 endpoint.
+
+**Note**: Authentication errors (invalid token, hostname not found) do NOT
+trigger fallback, as these would fail on both endpoints.
+
+## Modern API Endpoint
+
+```
+POST {base_url}/.well-known/apertodns/v1/update
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+ "hostname": "home.apertodns.com",
+ "ipv4": "93.44.241.82"
+}
+```
+
+### Response (Success)
+
+```json
+{
+ "success": true,
+ "data": {
+ "hostname": "home.apertodns.com",
+ "ipv4": "93.44.241.82",
+ "ipv6": null,
+ "ttl": 300,
+ "updated_at": "2025-01-02T12:00:00.000Z"
+ }
+}
+```
+
+### Response (Error)
+
+```json
+{
+ "success": false,
+ "error": {
+ "code": "invalid_token",
+ "message": "Invalid or expired token"
+ }
+}
+```
+
+## Legacy DynDNS2 Endpoint
+
+```
+GET {base_url}/nic/update?hostname={hostname}&myip={ip}
+Authorization: Basic base64(token:{token})
+```
+
+### Response
+
+```
+good 93.44.241.82
+nochg 93.44.241.82
+```
+
+## Error Codes
+
+| Code | HTTP Status | Meaning |
+|------|-------------|---------|
+| `invalid_token` | 401 | Invalid or expired token |
+| `unauthorized` | 401 | Missing authentication |
+| `hostname_not_found` | 404 | Hostname not found |
+| `invalid_ip` | 400 | Invalid IP address format |
+| `rate_limited` | 429 | Rate limit exceeded |
+
+## Custom Server
+
+ApertoDNS is an open protocol. You can use any compatible server by setting the `base_url` parameter:
+
+```json
+{
+ "settings": [
+ {
+ "provider": "apertodns",
+ "base_url": "https://ddns.example.com",
+ "domain": "example.com",
+ "owner": "home",
+ "token": "your_token_here"
+ }
+ ]
+}
+```
+
+## Links
+
+- Website: [apertodns.com](https://apertodns.com)
+- Documentation: [docs.apertodns.com](https://docs.apertodns.com)
+- Protocol Spec: [ApertoDNS Protocol v1.2](https://docs.apertodns.com/protocol)
diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go
index 71b48c9d1..3da2367a8 100644
--- a/internal/provider/constants/providers.go
+++ b/internal/provider/constants/providers.go
@@ -6,6 +6,7 @@ import "github.com/qdm12/ddns-updater/internal/models"
const (
Aliyun models.Provider = "aliyun"
AllInkl models.Provider = "allinkl"
+ ApertoDNS models.Provider = "apertodns"
Changeip models.Provider = "changeip"
Cloudflare models.Provider = "cloudflare"
Custom models.Provider = "custom"
@@ -62,6 +63,7 @@ func ProviderChoices() []models.Provider {
return []models.Provider{
Aliyun,
AllInkl,
+ ApertoDNS,
Changeip,
Cloudflare,
Dd24,
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 06e002845..61c1aa18f 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -12,6 +12,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/providers/aliyun"
"github.com/qdm12/ddns-updater/internal/provider/providers/allinkl"
+ "github.com/qdm12/ddns-updater/internal/provider/providers/apertodns"
"github.com/qdm12/ddns-updater/internal/provider/providers/changeip"
"github.com/qdm12/ddns-updater/internal/provider/providers/cloudflare"
"github.com/qdm12/ddns-updater/internal/provider/providers/custom"
@@ -88,6 +89,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return aliyun.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.AllInkl:
return allinkl.New(data, domain, owner, ipVersion, ipv6Suffix)
+ case constants.ApertoDNS:
+ return apertodns.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Changeip:
return changeip.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Cloudflare:
diff --git a/internal/provider/providers/apertodns/provider.go b/internal/provider/providers/apertodns/provider.go
new file mode 100644
index 000000000..5ea76b097
--- /dev/null
+++ b/internal/provider/providers/apertodns/provider.go
@@ -0,0 +1,342 @@
+package apertodns
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/netip"
+ "net/url"
+
+ "github.com/qdm12/ddns-updater/internal/models"
+ "github.com/qdm12/ddns-updater/internal/provider/constants"
+ providerErrors "github.com/qdm12/ddns-updater/internal/provider/errors"
+ "github.com/qdm12/ddns-updater/internal/provider/headers"
+ "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
+ baseURL string
+}
+
+func New(data json.RawMessage, domain, owner string,
+ ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
+ *Provider, error) {
+ extraSettings := struct {
+ Token string `json:"token"`
+ BaseURL string `json:"base_url"`
+ }{}
+ err := json.Unmarshal(data, &extraSettings)
+ if err != nil {
+ return nil, err
+ }
+
+ baseURL := extraSettings.BaseURL
+ if baseURL == "" {
+ baseURL = "https://api.apertodns.com"
+ }
+
+ p := &Provider{
+ domain: domain,
+ owner: owner,
+ ipVersion: ipVersion,
+ ipv6Suffix: ipv6Suffix,
+ token: extraSettings.Token,
+ baseURL: baseURL,
+ }
+
+ err = p.isValid()
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+}
+
+func (p *Provider) isValid() error {
+ switch {
+ case p.token == "":
+ return fmt.Errorf("%w", providerErrors.ErrTokenNotSet)
+ }
+ return nil
+}
+
+func (p *Provider) String() string {
+ return utils.ToString(p.domain, p.owner, constants.ApertoDNS, 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 {
+ return utils.BuildDomainName(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: "ApertoDNS",
+ IPVersion: p.ipVersion.String(),
+ }
+}
+
+func (p *Provider) setHeaders(request *http.Request) {
+ headers.SetUserAgent(request)
+ headers.SetContentType(request, "application/json")
+ headers.SetAccept(request, "application/json")
+ headers.SetAuthBearer(request, p.token)
+}
+
+// Update implements the ApertoDNS Protocol v1.2 with intelligent fallback.
+// It first tries the modern JSON API, and falls back to legacy DynDNS2
+// only for infrastructure errors (not for auth/validation errors).
+func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (netip.Addr, error) {
+ // 1. Try modern protocol first
+ newIP, err := p.updateModern(ctx, client, ip)
+ if err == nil {
+ return newIP, nil
+ }
+
+ // 2. Do NOT fallback for "real" errors that would fail on both endpoints
+ if errors.Is(err, providerErrors.ErrAuth) ||
+ errors.Is(err, providerErrors.ErrHostnameNotExists) ||
+ errors.Is(err, providerErrors.ErrBannedAbuse) ||
+ errors.Is(err, providerErrors.ErrBadRequest) {
+ return netip.Addr{}, err
+ }
+
+ // 3. Fallback to legacy DynDNS2 for infrastructure errors
+ // (e.g., 404 endpoint not found, 500 server error, network issues)
+ return p.updateLegacy(ctx, client, ip)
+}
+
+// updateModern uses the ApertoDNS Protocol v1.2 modern JSON API.
+// Endpoint: POST /.well-known/apertodns/v1/update
+// Auth: Bearer token
+func (p *Provider) updateModern(ctx context.Context, client *http.Client, ip netip.Addr) (netip.Addr, error) {
+ u, err := url.Parse(p.baseURL)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("parsing base URL: %w", err)
+ }
+ u.Path = "/.well-known/apertodns/v1/update"
+
+ hostname := utils.BuildDomainName(p.owner, p.domain)
+
+ // Build request body
+ requestData := struct {
+ Hostname string `json:"hostname"`
+ IPv4 *string `json:"ipv4,omitempty"`
+ IPv6 *string `json:"ipv6,omitempty"`
+ }{
+ Hostname: hostname,
+ }
+
+ ipStr := ip.String()
+ if ip.Is4() {
+ requestData.IPv4 = &ipStr
+ } else {
+ requestData.IPv6 = &ipStr
+ }
+
+ 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, u.String(), 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()
+
+ // Parse JSON response
+ decoder := json.NewDecoder(response.Body)
+ var apiResponse struct {
+ Success bool `json:"success"`
+ Data *struct {
+ Hostname string `json:"hostname"`
+ IPv4 *string `json:"ipv4"`
+ IPv6 *string `json:"ipv6"`
+ } `json:"data"`
+ Error *struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+
+ err = decoder.Decode(&apiResponse)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("json decoding response body: %w", err)
+ }
+
+ // Handle error responses
+ if !apiResponse.Success {
+ if apiResponse.Error == nil {
+ return netip.Addr{}, fmt.Errorf("%w: unknown error", providerErrors.ErrUnsuccessful)
+ }
+
+ switch apiResponse.Error.Code {
+ case "invalid_token", "unauthorized":
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrAuth, apiResponse.Error.Message)
+ case "hostname_not_found":
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrHostnameNotExists, apiResponse.Error.Message)
+ case "invalid_hostname", "not_fqdn":
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrBadRequest, apiResponse.Error.Message)
+ case "rate_limited":
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrBannedAbuse, apiResponse.Error.Message)
+ case "invalid_ip":
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrBadRequest, apiResponse.Error.Message)
+ case "server_error":
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrUnknownResponse, apiResponse.Error.Message)
+ default:
+ return netip.Addr{}, fmt.Errorf("%w: %s: %s",
+ providerErrors.ErrUnsuccessful, apiResponse.Error.Code, apiResponse.Error.Message)
+ }
+ }
+
+ // Handle success response
+ if apiResponse.Data == nil {
+ return netip.Addr{}, fmt.Errorf("%w: missing data in response", providerErrors.ErrUnknownResponse)
+ }
+
+ // Get the returned IP based on what we sent
+ var returnedIPStr string
+ if ip.Is4() {
+ if apiResponse.Data.IPv4 == nil {
+ return netip.Addr{}, fmt.Errorf("%w: missing ipv4 in response", providerErrors.ErrUnknownResponse)
+ }
+ returnedIPStr = *apiResponse.Data.IPv4
+ } else {
+ if apiResponse.Data.IPv6 == nil {
+ return netip.Addr{}, fmt.Errorf("%w: missing ipv6 in response", providerErrors.ErrUnknownResponse)
+ }
+ returnedIPStr = *apiResponse.Data.IPv6
+ }
+
+ newIP, err := netip.ParseAddr(returnedIPStr)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrIPReceivedMalformed, returnedIPStr)
+ }
+
+ if ip.Compare(newIP) != 0 {
+ return netip.Addr{}, fmt.Errorf("%w: sent %s but received %s",
+ providerErrors.ErrIPReceivedMismatch, ip, newIP)
+ }
+
+ return newIP, nil
+}
+
+// updateLegacy uses the DynDNS2 compatible endpoint (Layer 1).
+// Endpoint: GET /nic/update
+// Auth: Basic (username="token", password=token)
+func (p *Provider) updateLegacy(ctx context.Context, client *http.Client, ip netip.Addr) (netip.Addr, error) {
+ u, err := url.Parse(p.baseURL)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("parsing base URL: %w", err)
+ }
+ u.Path = "/nic/update"
+
+ hostname := utils.BuildDomainName(p.owner, p.domain)
+ values := url.Values{}
+ values.Set("hostname", hostname)
+ values.Set("myip", ip.String())
+ u.RawQuery = values.Encode()
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("creating http request: %w", err)
+ }
+ headers.SetUserAgent(request)
+ request.SetBasicAuth("token", p.token)
+
+ response, err := client.Do(request)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("doing http request: %w", err)
+ }
+ defer response.Body.Close()
+
+ s, err := utils.ReadAndCleanBody(response.Body)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("reading response body: %w", err)
+ }
+
+ switch response.StatusCode {
+ case http.StatusOK:
+ // Continue processing
+ case http.StatusUnauthorized:
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrAuth, s)
+ default:
+ return netip.Addr{}, fmt.Errorf("%w: %d: %s",
+ providerErrors.ErrHTTPStatusNotValid, response.StatusCode, s)
+ }
+
+ switch {
+ case s == "":
+ return netip.Addr{}, fmt.Errorf("%w: empty response", providerErrors.ErrUnknownResponse)
+ case s == "badauth":
+ return netip.Addr{}, fmt.Errorf("%w", providerErrors.ErrAuth)
+ case s == "nohost":
+ return netip.Addr{}, fmt.Errorf("%w", providerErrors.ErrHostnameNotExists)
+ case s == "notfqdn":
+ return netip.Addr{}, fmt.Errorf("%w: hostname is not a valid FQDN", providerErrors.ErrBadRequest)
+ case s == "abuse":
+ return netip.Addr{}, fmt.Errorf("%w", providerErrors.ErrBannedAbuse)
+ case s == "911":
+ return netip.Addr{}, fmt.Errorf("%w: server error, retry later", providerErrors.ErrUnknownResponse)
+ }
+
+ var returnedIP string
+ if n, _ := fmt.Sscanf(s, "good %s", &returnedIP); n == 1 {
+ // IP was updated
+ } else if n, _ := fmt.Sscanf(s, "nochg %s", &returnedIP); n == 1 {
+ // IP unchanged
+ } else {
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrUnknownResponse, s)
+ }
+
+ newIP, err := netip.ParseAddr(returnedIP)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("%w: %s", providerErrors.ErrIPReceivedMalformed, returnedIP)
+ }
+
+ if ip.Compare(newIP) != 0 {
+ return netip.Addr{}, fmt.Errorf("%w: sent %s but received %s",
+ providerErrors.ErrIPReceivedMismatch, ip, newIP)
+ }
+
+ return newIP, nil
+}
diff --git a/internal/provider/providers/apertodns/provider_test.go b/internal/provider/providers/apertodns/provider_test.go
new file mode 100644
index 000000000..9c59d8422
--- /dev/null
+++ b/internal/provider/providers/apertodns/provider_test.go
@@ -0,0 +1,190 @@
+package apertodns
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/netip"
+ "testing"
+ "time"
+
+ "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
+)
+
+func TestUpdate(t *testing.T) {
+ // Skip if not running integration tests
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // Real test token for brave-panda.apertodns.com
+ token := "test-doc-2956db1b53af3cbe601202784062ce05"
+
+ data, _ := json.Marshal(map[string]string{
+ "token": token,
+ })
+
+ p, err := New(data, "apertodns.com", "brave-panda", ipversion.IP4, netip.Prefix{})
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ // Create HTTP client
+ client := &http.Client{Timeout: 20 * time.Second}
+
+ // Test with a real IP
+ testIP := netip.MustParseAddr("93.44.241.82")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Call Update (uses modern first, fallback if needed)
+ returnedIP, err := p.Update(ctx, client, testIP)
+ if err != nil {
+ t.Fatalf("Update failed: %v", err)
+ }
+
+ if returnedIP.Compare(testIP) != 0 {
+ t.Errorf("Expected IP %s, got %s", testIP, returnedIP)
+ }
+
+ t.Logf("SUCCESS: Updated to %s", returnedIP)
+}
+
+func TestUpdateModern(t *testing.T) {
+ // Skip if not running integration tests
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // Real test token for brave-panda.apertodns.com
+ token := "test-doc-2956db1b53af3cbe601202784062ce05"
+
+ data, _ := json.Marshal(map[string]string{
+ "token": token,
+ })
+
+ p, err := New(data, "apertodns.com", "brave-panda", ipversion.IP4, netip.Prefix{})
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ client := &http.Client{Timeout: 20 * time.Second}
+ testIP := netip.MustParseAddr("93.44.241.82")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Call updateModern directly
+ returnedIP, err := p.updateModern(ctx, client, testIP)
+ if err != nil {
+ t.Fatalf("Modern update failed: %v", err)
+ }
+
+ if returnedIP.Compare(testIP) != 0 {
+ t.Errorf("Expected IP %s, got %s", testIP, returnedIP)
+ }
+
+ t.Logf("SUCCESS: Modern updated to %s", returnedIP)
+}
+
+func TestUpdateLegacy(t *testing.T) {
+ // Skip if not running integration tests
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // Real test token for brave-panda.apertodns.com
+ token := "test-doc-2956db1b53af3cbe601202784062ce05"
+
+ data, _ := json.Marshal(map[string]string{
+ "token": token,
+ })
+
+ p, err := New(data, "apertodns.com", "brave-panda", ipversion.IP4, netip.Prefix{})
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ client := &http.Client{Timeout: 20 * time.Second}
+ testIP := netip.MustParseAddr("93.44.241.82")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Call updateLegacy directly
+ returnedIP, err := p.updateLegacy(ctx, client, testIP)
+ if err != nil {
+ t.Fatalf("Legacy update failed: %v", err)
+ }
+
+ if returnedIP.Compare(testIP) != 0 {
+ t.Errorf("Expected IP %s, got %s", testIP, returnedIP)
+ }
+
+ t.Logf("SUCCESS: Legacy updated to %s", returnedIP)
+}
+
+func TestUpdateInvalidToken(t *testing.T) {
+ // Skip if not running integration tests
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // Invalid token
+ data, _ := json.Marshal(map[string]string{
+ "token": "invalid_token_12345",
+ })
+
+ p, err := New(data, "apertodns.com", "brave-panda", ipversion.IP4, netip.Prefix{})
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ client := &http.Client{Timeout: 20 * time.Second}
+ testIP := netip.MustParseAddr("93.44.241.82")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Should fail on modern and NOT fallback (auth error)
+ _, err = p.Update(ctx, client, testIP)
+ if err == nil {
+ t.Fatal("Expected error for invalid token, got nil")
+ }
+
+ t.Logf("SUCCESS: Got expected error (no fallback for auth): %v", err)
+}
+
+func TestUpdateHostnameNotFound(t *testing.T) {
+ // Skip if not running integration tests
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // Valid token but wrong hostname
+ token := "test-doc-2956db1b53af3cbe601202784062ce05"
+
+ data, _ := json.Marshal(map[string]string{
+ "token": token,
+ })
+
+ p, err := New(data, "apertodns.com", "nonexistent-hostname-xyz", ipversion.IP4, netip.Prefix{})
+ if err != nil {
+ t.Fatalf("Failed to create provider: %v", err)
+ }
+
+ client := &http.Client{Timeout: 20 * time.Second}
+ testIP := netip.MustParseAddr("93.44.241.82")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Should fail on modern and NOT fallback (hostname not found error)
+ _, err = p.Update(ctx, client, testIP)
+ if err == nil {
+ t.Fatal("Expected error for nonexistent hostname, got nil")
+ }
+
+ t.Logf("SUCCESS: Got expected error (no fallback for hostname): %v", err)
+}