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