diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index e63bb95fe6..ea073273f3 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -65,7 +65,7 @@ jobs: Write-Host "Integration test providers: $Providers" echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT env: - PROVIDERS: "['ALIDNS', 'AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','GIDINET','HEDNS','HETZNER_V2','HUAWEICLOUD','INWX','JOKER','MIKROTIK','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP','UNIFI']" + PROVIDERS: "['ALIDNS', 'AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','GIDINET','HEDNS','HETZNER_V2','HUAWEICLOUD','INWX','JOKER','MIKROTIK','MYTHICBEASTS', 'NAMEDOTCOM','NETBIRD','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP','UNIFI']" ENV_CONTEXT: ${{ toJson(env) }} VARS_CONTEXT: ${{ toJson(vars) }} SECRETS_CONTEXT: ${{ toJson(secrets) }} @@ -108,6 +108,7 @@ jobs: MIKROTIK_DOMAIN: ${{ vars.MIKROTIK_DOMAIN }} MYTHICBEASTS_DOMAIN: ${{ vars.MYTHICBEASTS_DOMAIN }} NAMEDOTCOM_DOMAIN: ${{ vars.NAMEDOTCOM_DOMAIN }} + NETBIRD_DOMAIN: ${{ vars.NETBIRD_DOMAIN }} NS1_DOMAIN: ${{ vars.NS1_DOMAIN }} POWERDNS_DOMAIN: ${{ vars.POWERDNS_DOMAIN }} ROUTE53_DOMAIN: ${{ vars.ROUTE53_DOMAIN }} @@ -197,6 +198,8 @@ jobs: NAMEDOTCOM_URL: ${{ secrets.NAMEDOTCOM_URL }} NAMEDOTCOM_USER: ${{ secrets.NAMEDOTCOM_USER }} # + NETBIRD_TOKEN: ${{ secrets.NETBIRD_TOKEN }} + # NS1_TOKEN: ${{ secrets.NS1_TOKEN }} # POWERDNS_APIKEY: ${{ secrets.POWERDNS_APIKEY }} diff --git a/OWNERS b/OWNERS index 13ab5ad029..6391f83613 100644 --- a/OWNERS +++ b/OWNERS @@ -42,6 +42,7 @@ providers/mikrotik @hedger providers/mythicbeasts @tomfitzhenry providers/namecheap @willpower232 # providers/namedotcom NEEDS VOLUNTEER +providers/netbird @yzqzss providers/netcup @kordianbruck providers/netlify @SphericalKat providers/ns1 @costasd diff --git a/README.md b/README.md index e580dc8715..a04191c576 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Currently supported DNS providers: - Mythic Beasts - Name.com - Namecheap +- NetBird - Netcup - Netlify - NS1 diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index d12f5e864b..9bbb75be9f 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -166,6 +166,7 @@ * [Mythic Beasts](provider/mythicbeasts.md) * [Namecheap](provider/namecheap.md) * [Name.com](provider/namedotcom.md) +* [NetBird](provider/netbird.md) * [Netcup](provider/netcup.md) * [Netlify](provider/netlify.md) * [NS1](provider/ns1.md) diff --git a/documentation/provider/index.md b/documentation/provider/index.md index d25adf4d33..fc1ff1d248 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -69,6 +69,7 @@ Jump to a table: | [`MYTHICBEASTS`](mythicbeasts.md) | ❌ | ✅ | ❌ | | [`NAMECHEAP`](namecheap.md) | ❌ | ✅ | ✅ | | [`NAMEDOTCOM`](namedotcom.md) | ❌ | ✅ | ✅ | +| [`NETBIRD`](netbird.md) | ❌ | ✅ | ❌ | | [`NETCUP`](netcup.md) | ❌ | ✅ | ❌ | | [`NETLIFY`](netlify.md) | ❌ | ✅ | ❌ | | [`NS1`](ns1.md) | ❌ | ✅ | ❌ | @@ -136,6 +137,7 @@ Jump to a table: | [`MYTHICBEASTS`](mythicbeasts.md) | ✅ | ✅ | ❌ | ✅ | | [`NAMECHEAP`](namecheap.md) | ✅ | ❌ | ❌ | ✅ | | [`NAMEDOTCOM`](namedotcom.md) | ❔ | ✅ | ❌ | ✅ | +| [`NETBIRD`](netbird.md) | ✅ | ❌ | ✅ | ✅ | | [`NETCUP`](netcup.md) | ❔ | ❌ | ❌ | ❌ | | [`NETLIFY`](netlify.md) | ✅ | ❌ | ❌ | ✅ | | [`NS1`](ns1.md) | ✅ | ✅ | ✅ | ✅ | @@ -199,6 +201,7 @@ Jump to a table: | [`MYTHICBEASTS`](mythicbeasts.md) | ❌ | ❔ | ❌ | ✅ | ❔ | | [`NAMECHEAP`](namecheap.md) | ✅ | ❔ | ❌ | ❌ | ❔ | | [`NAMEDOTCOM`](namedotcom.md) | ✅ | ❔ | ❌ | ❌ | ❔ | +| [`NETBIRD`](netbird.md) | ❌ | ❌ | ❌ | ❌ | ❌ | | [`NETCUP`](netcup.md) | ❔ | ❔ | ❌ | ❌ | ❔ | | [`NETLIFY`](netlify.md) | ✅ | ❔ | ❌ | ❌ | ❔ | | [`NS1`](ns1.md) | ✅ | ✅ | ❌ | ✅ | ❔ | @@ -260,6 +263,7 @@ Jump to a table: | [`MYTHICBEASTS`](mythicbeasts.md) | ❔ | ❔ | ✅ | ❔ | | [`NAMECHEAP`](namecheap.md) | ❔ | ❔ | ❌ | ❔ | | [`NAMEDOTCOM`](namedotcom.md) | ❔ | ❔ | ✅ | ❔ | +| [`NETBIRD`](netbird.md) | ❌ | ❌ | ❌ | ❌ | | [`NETCUP`](netcup.md) | ❔ | ❔ | ✅ | ❔ | | [`NETLIFY`](netlify.md) | ❔ | ❌ | ✅ | ❔ | | [`NS1`](ns1.md) | ✅ | ✅ | ✅ | ✅ | @@ -320,6 +324,7 @@ Jump to a table: | [`MIKROTIK`](mikrotik.md) | ❌ | ❌ | ❔ | ❌ | ❌ | | [`MYTHICBEASTS`](mythicbeasts.md) | ✅ | ❔ | ❔ | ✅ | ✅ | | [`NAMECHEAP`](namecheap.md) | ✅ | ❔ | ❔ | ❔ | ❌ | +| [`NETBIRD`](netbird.md) | ❌ | ❌ | ❌ | ❌ | ❌ | | [`NETCUP`](netcup.md) | ✅ | ❔ | ❔ | ❔ | ✅ | | [`NETLIFY`](netlify.md) | ✅ | ❔ | ❔ | ❌ | ❌ | | [`NS1`](ns1.md) | ✅ | ✅ | ❔ | ❔ | ✅ | @@ -368,6 +373,7 @@ Jump to a table: | [`JOKER`](joker.md) | ❔ | ❌ | ❌ | | [`LOOPIA`](loopia.md) | ❌ | ❌ | ❌ | | [`MIKROTIK`](mikrotik.md) | ❌ | ❔ | ❌ | +| [`NETBIRD`](netbird.md) | ❌ | ❌ | ❌ | | [`NETLIFY`](netlify.md) | ❌ | ❔ | ❌ | | [`NS1`](ns1.md) | ✅ | ❔ | ✅ | | [`ORACLE`](oracle.md) | ❔ | ❔ | ❌ | diff --git a/documentation/provider/netbird.md b/documentation/provider/netbird.md new file mode 100644 index 0000000000..729754539b --- /dev/null +++ b/documentation/provider/netbird.md @@ -0,0 +1,76 @@ +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `NETBIRD` along with a NetBird API token. + +Example: + +{% code title="creds.json" %} +```json +{ + "netbird": { + "TYPE": "NETBIRD", + "token": "your-netbird-api-token" + } +} +``` +{% endcode %} + +## Metadata + +This provider recognizes the following metadata fields: + +| Key | Type | Value | Description | +|-------|------|---------|-------------| +| `enabled` | string | `"true"`/`"false"` | Whether the zone is enabled. | +| `enable_search_domain` | string | `"true"`/`"false"` | Whether to enable this zone as a search domain. | + +**Note:** If metadata fields are not set, DNSControl will leave them unchanged in NetBird. + +## Usage + +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var DSP_NETBIRD = NewDnsProvider("netbird"); + +D("example.com", REG_DNSIMPLE, DnsProvider(DSP_NETBIRD), + { no_ns: "true" }, // NetBird does not expose nameservers + A("test", "1.2.3.4"), + AAAA("ipv6test", "2001:db8::1"), + CNAME("www", "example.com"), +); +``` +{% endcode %} + +**Note:** NetBird does not expose nameservers, so `{no_ns: "true"}` should be set on all domains to suppress the "Skipping registrar" warning. + +To configure zone options, use metadata: + +{% code title="dnsconfig.js" %} +```javascript +D("example.com", REG_DNSIMPLE, + { + no_ns: "true", + enabled: "true", + enable_search_domain: "true", + }, + DnsProvider(DSP_NETBIRD), + A("test", "1.2.3.4"), +); +``` +{% endcode %} + +## Activation + +NetBird depends on a NetBird API token. You can generate a personal access token in the NetBird dashboard. + +## Supported Record Types + +NetBird API currently supports the following DNS record types: + +- **A** +- **AAAA** +- **CNAME** + +For more information, see the [NetBird API documentation](https://docs.netbird.io/api/resources/dns-zones). diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index b20a260d29..d287befbf3 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -272,6 +272,11 @@ "apiuser": "$NAMEDOTCOM_USER", "domain": "$NAMEDOTCOM_DOMAIN" }, + "NETBIRD": { + "TYPE": "NETBIRD", + "domain": "$NETBIRD_DOMAIN", + "token": "$NETBIRD_TOKEN" + }, "NETCUP": { "TYPE": "NETCUP", "api-key": "$NETCUP_KEY", diff --git a/pkg/providers/_all/all.go b/pkg/providers/_all/all.go index 621613c877..3355e1faf4 100644 --- a/pkg/providers/_all/all.go +++ b/pkg/providers/_all/all.go @@ -47,6 +47,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v4/providers/mythicbeasts" _ "github.com/StackExchange/dnscontrol/v4/providers/namecheap" _ "github.com/StackExchange/dnscontrol/v4/providers/namedotcom" + _ "github.com/StackExchange/dnscontrol/v4/providers/netbird" _ "github.com/StackExchange/dnscontrol/v4/providers/netcup" _ "github.com/StackExchange/dnscontrol/v4/providers/netlify" _ "github.com/StackExchange/dnscontrol/v4/providers/ns1" diff --git a/providers/netbird/api.go b/providers/netbird/api.go new file mode 100644 index 0000000000..5ea1fe53bf --- /dev/null +++ b/providers/netbird/api.go @@ -0,0 +1,76 @@ +package netbird + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +var initBackoff = time.Second * 2 + +const maxBackoff = time.Second * 30 + +// doRequest makes an HTTP request to the NetBird API. +func (api *netbirdProvider) doRequest(method, path string, body interface{}, result interface{}) error { + url := api.apiURL + path + + var backoff = initBackoff + +retry: + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Token "+api.token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := api.client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Handle rate limiting + if resp.StatusCode == http.StatusTooManyRequests { + log.Printf("[NETBIRD] Rate limited. Sleeping %v before retry...", backoff) + time.Sleep(backoff) + backoff = min(backoff*2, maxBackoff) + goto retry + } + // Reset backoff on success + backoff = initBackoff + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + if result != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + } + + return nil +} diff --git a/providers/netbird/convert.go b/providers/netbird/convert.go new file mode 100644 index 0000000000..2426034df6 --- /dev/null +++ b/providers/netbird/convert.go @@ -0,0 +1,84 @@ +package netbird + +import ( + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + dnsutilv1 "github.com/miekg/dns/dnsutil" +) + +// nativeToRecordConfig converts a NetBird record to a dnscontrol RecordConfig. +func nativeToRecordConfig(domain string, r *Record) (*models.RecordConfig, error) { + // NetBird API returns FQDNs, so we need to handle them properly + name := r.Name + + // If the name doesn't end with a dot, it might be a FQDN from NetBird + // Check if it already contains the domain + if len(name) > 0 && name[len(name)-1] != '.' { + // Name doesn't end with dot, check if it's already a FQDN + if strings.HasSuffix(name, domain) { + // FQDN, add the dot + name = name + "." + } else { + // short name, use dnsutilv1.AddOrigin + name = dnsutilv1.AddOrigin(r.Name, domain) + } + } else if len(name) > 0 && name[len(name)-1] == '.' { + // FQDN, already has the dot, do nothing + } else { + // Empty name (apex record) + name = dnsutilv1.AddOrigin(r.Name, domain) + } + + target := r.Content + // Make target FQDN for CNAME records + if r.Type == "CNAME" { + if target == "@" { + target = domain + } + if target != "" && target[len(target)-1] != '.' { + target = target + "." + } + } + + rc := &models.RecordConfig{ + Type: r.Type, + TTL: uint32(r.TTL), + Original: r, + } + rc.SetLabelFromFQDN(name, domain) + + switch r.Type { + default: + if err := rc.SetTarget(target); err != nil { + return nil, err + } + } + return rc, nil +} + +// recordConfigToNative converts a dnscontrol RecordConfig to a NetBird record. +func recordConfigToNative(rc *models.RecordConfig, _ string) *CreateRecordRequest { + // Remove trailing dot as NetBird API doesn't expect it + name := rc.GetLabelFQDN() + if len(name) > 0 && name[len(name)-1] == '.' { + name = name[:len(name)-1] + } + + target := rc.GetTargetField() + + switch rc.Type { + case "CNAME": + // Remove trailing dot + if len(target) > 0 && target[len(target)-1] == '.' { + target = target[:len(target)-1] + } + } + + return &CreateRecordRequest{ + Name: name, + Type: rc.Type, + Content: target, + TTL: int(rc.TTL), + } +} diff --git a/providers/netbird/netbirdProvider.go b/providers/netbird/netbirdProvider.go new file mode 100644 index 0000000000..1c044b46a7 --- /dev/null +++ b/providers/netbird/netbirdProvider.go @@ -0,0 +1,371 @@ +package netbird + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/diff2" + "github.com/StackExchange/dnscontrol/v4/pkg/providers" +) + +// supportedRecordTypes is the set of DNS record types supported by NetBird. +var supportedRecordTypes = map[string]bool{ + "A": true, + "AAAA": true, + "CNAME": true, +} + +/* + +NetBird API DNS provider: + +Info required in `creds.json`: + - token + +API documentation: https://docs.netbird.io/api/resources/dns-zones + +*/ + +const ( + netbirdAPIURL = "https://api.netbird.io/api" +) + +// netbirdProvider is the handle for operations. +type netbirdProvider struct { + token string + client *http.Client + apiURL string + zoneMu sync.Mutex // Protects zoneMap + zoneMap map[string]*zoneInfo // Cache of zone info by domain +} + +// NewNetbird creates a NetBird-specific DNS provider. +func NewNetbird(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + if m["token"] == "" { + return nil, errors.New("no NetBird token provided") + } + + api := &netbirdProvider{ + token: m["token"], + client: &http.Client{}, + apiURL: netbirdAPIURL, + zoneMap: make(map[string]*zoneInfo), + } + + // Test the token by listing zones + _, err := api.listZones() + if err != nil { + return nil, fmt.Errorf("NetBird token validation failed: %w", err) + } + + return api, nil +} + +var features = providers.DocumentationNotes{ + // The default for unlisted capabilities is 'Cannot'. + // See providers/capabilities.go for the entire list of capabilities. + providers.CanAutoDNSSEC: providers.Cannot(), + providers.CanConcur: providers.Can(), + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseCAA: providers.Cannot(), + providers.CanUseDHCID: providers.Cannot(), + providers.CanUseDNAME: providers.Cannot(), + providers.CanUseDNSKEY: providers.Cannot(), + providers.CanUseDS: providers.Cannot(), + providers.CanUseHTTPS: providers.Cannot(), + providers.CanUseLOC: providers.Cannot(), + providers.CanUseNAPTR: providers.Cannot(), + providers.CanUsePTR: providers.Cannot(), + providers.CanUseSOA: providers.Cannot(), + providers.CanUseSRV: providers.Cannot(), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseSMIMEA: providers.Cannot(), + providers.CanUseSVCB: providers.Cannot(), + providers.CanUseTLSA: providers.Cannot(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +func init() { + const providerName = "NETBIRD" + const providerMaintainer = "@yzqzss" + fns := providers.DspFuncs{ + Initializer: NewNetbird, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) +} + +// AuditRecords returns a list of errors for records that aren't supported. +func AuditRecords(records []*models.RecordConfig) []error { + var errs []error + for _, rc := range records { + if !supportedRecordTypes[rc.Type] { + errs = append(errs, fmt.Errorf("NETBIRD does not support %s records", rc.Type)) + } + } + return errs +} + +// parseEnabled parses the "enabled" metadata field. +// Returns nil if not set (don't change), &true or &false if explicitly set. +func parseEnabled(metadata map[string]string) *bool { + if v, ok := metadata["enabled"]; ok { + result := v != "false" + return &result + } + return nil +} + +// parseEnableSearchDomain parses the "enable_search_domain" metadata field. +// Returns nil if not set (don't change), &true or &false if explicitly set. +func parseEnableSearchDomain(metadata map[string]string) *bool { + if v, ok := metadata["enable_search_domain"]; ok { + result := v == "true" + return &result + } + return nil +} + +// EnsureZoneExists creates a zone if it does not exist, or updates it if metadata specifies different settings. +func (api *netbirdProvider) EnsureZoneExists(domain string, metadata map[string]string) error { + zones, err := api.listZones() + if err != nil { + return err + } + + enabled := parseEnabled(metadata) + enableSearchDomain := parseEnableSearchDomain(metadata) + + // Check if zone already exists + for _, zone := range zones { + if zone.Domain == domain { + // Zone exists, check if we need to update settings + // Only update if metadata explicitly specifies a value that differs + if (enabled != nil && zone.Enabled != *enabled) || + (enableSearchDomain != nil && zone.EnableSearchDomain != *enableSearchDomain) { + // Update the zone settings, keeping unspecified fields as-is + req := Zone{ + ID: zone.ID, + Name: zone.Name, + Domain: zone.Domain, + Enabled: zone.Enabled, + EnableSearchDomain: zone.EnableSearchDomain, + DistributionGroups: zone.DistributionGroups, + } + if enabled != nil { + req.Enabled = *enabled + } + if enableSearchDomain != nil { + req.EnableSearchDomain = *enableSearchDomain + } + return api.updateZone(zone.ID, &req) + } + return nil + } + } + + // Create the zone with specified values or defaults + req := Zone{ + Name: domain, + Domain: domain, + Enabled: true, // Default for new zones + EnableSearchDomain: false, // Default for new zones + DistributionGroups: []string{}, + } + if enabled != nil { + req.Enabled = *enabled + } + if enableSearchDomain != nil { + req.EnableSearchDomain = *enableSearchDomain + } + + return api.createZone(&req) +} + +// ListZones returns the list of zones (domains) in this account. +func (api *netbirdProvider) ListZones() ([]string, error) { + zones, err := api.listZones() + if err != nil { + return nil, err + } + + var result []string + for _, zone := range zones { + result = append(result, zone.Domain) + } + return result, nil +} + +// GetNameservers returns the nameservers for domain. +// NetBird doesn't provide traditional nameservers as it's a peer-to-peer DNS service. +func (api *netbirdProvider) GetNameservers(_ string) ([]*models.Nameserver, error) { + // NetBird doesn't have traditional nameservers + return nil, nil +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (api *netbirdProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records, error) { + domain := dc.Name + + zone, err := api.findZoneByDomain(domain) + if err != nil { + return nil, err + } + + // Cache the zone ID for later use in corrections + api.zoneMu.Lock() + api.zoneMap[domain] = &zoneInfo{ + id: zone.ID, + domain: zone.Domain, + } + api.zoneMu.Unlock() + + // Get records for the zone + records, err := api.listRecords(zone.ID) + if err != nil { + return nil, err + } + + var existingRecords []*models.RecordConfig + for _, r := range records { + rc, err := nativeToRecordConfig(domain, &r) + if err != nil { + return nil, err + } + existingRecords = append(existingRecords, rc) + } + + return existingRecords, nil +} + +// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. +func (api *netbirdProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { + var corrections []*models.Correction + + // Check if zone settings need to be updated + zone, err := api.findZoneByDomain(dc.Name) + if err != nil { + return nil, 0, err + } + + // Parse metadata for zone settings + enabled := parseEnabled(dc.Metadata) + enableSearchDomain := parseEnableSearchDomain(dc.Metadata) + + // Build update request if any settings need to change + if enabled != nil || enableSearchDomain != nil { + // Check if values actually differ + if (enabled == nil || zone.Enabled == *enabled) && + (enableSearchDomain == nil || zone.EnableSearchDomain == *enableSearchDomain) { + // No changes needed + } else { + zoneID := zone.ID + var parts []string + if enabled != nil && zone.Enabled != *enabled { + if *enabled { + parts = append(parts, "enabled") + } else { + parts = append(parts, "disabled") + } + } + if enableSearchDomain != nil && zone.EnableSearchDomain != *enableSearchDomain { + if *enableSearchDomain { + parts = append(parts, "search domain enabled") + } else { + parts = append(parts, "search domain disabled") + } + } + + corrections = append(corrections, &models.Correction{ + Msg: fmt.Sprintf("Update zone settings: %s", strings.Join(parts, ", ")), + F: func() error { + currentZone, err := api.findZoneByDomain(dc.Name) + if err != nil { + return err + } + req := Zone{ + ID: currentZone.ID, + Name: currentZone.Name, + Domain: currentZone.Domain, + Enabled: currentZone.Enabled, + EnableSearchDomain: currentZone.EnableSearchDomain, + DistributionGroups: currentZone.DistributionGroups, + } + if enabled != nil { + req.Enabled = *enabled + } + if enableSearchDomain != nil { + req.EnableSearchDomain = *enableSearchDomain + } + return api.updateZone(zoneID, &req) + }, + }) + } + } + + instructions, actualChangeCount, err := diff2.ByRecord(existingRecords, dc, nil) + if err != nil { + return nil, 0, err + } + + // Get zone ID from cache for use in corrections + api.zoneMu.Lock() + cachedZone, ok := api.zoneMap[dc.Name] + api.zoneMu.Unlock() + if !ok { + return nil, 0, fmt.Errorf("zone not found in cache for domain: %s (was GetZoneRecords called?)", dc.Name) + } + zoneID := cachedZone.id + + addCorrection := func(msg string, f func() error) { + corrections = append(corrections, + &models.Correction{ + Msg: msg, + F: f, + }) + } + + for _, inst := range instructions { + switch inst.Type { + case diff2.REPORT: + corrections = append(corrections, + &models.Correction{ + Msg: inst.MsgsJoined, + }) + continue + + case diff2.CREATE: + req := recordConfigToNative(inst.New[0], dc.Name) + addCorrection(inst.MsgsJoined, func() error { + return api.createRecord(zoneID, req) + }) + + case diff2.CHANGE: + id := inst.Old[0].Original.(*Record).ID + req := recordConfigToNative(inst.New[0], dc.Name) + addCorrection(inst.MsgsJoined, func() error { + return api.updateRecord(zoneID, id, req) + }) + + case diff2.DELETE: + id := inst.Old[0].Original.(*Record).ID + addCorrection(inst.MsgsJoined, func() error { + return api.deleteRecord(zoneID, id) + }) + + default: + panic(fmt.Sprintf("unhandled inst.Type %s", inst.Type)) + } + } + + return corrections, actualChangeCount, nil +} diff --git a/providers/netbird/records.go b/providers/netbird/records.go new file mode 100644 index 0000000000..3270b36fc9 --- /dev/null +++ b/providers/netbird/records.go @@ -0,0 +1,25 @@ +package netbird + +import ( + "fmt" +) + +func (api *netbirdProvider) listRecords(zoneID string) ([]Record, error) { + var records []Record + err := api.doRequest("GET", fmt.Sprintf("/dns/zones/%s/records", zoneID), nil, &records) + return records, err +} + +func (api *netbirdProvider) createRecord(zoneID string, req *CreateRecordRequest) error { + var result Record + return api.doRequest("POST", fmt.Sprintf("/dns/zones/%s/records", zoneID), req, &result) +} + +func (api *netbirdProvider) updateRecord(zoneID string, recordID string, req *CreateRecordRequest) error { + var result Record + return api.doRequest("PUT", fmt.Sprintf("/dns/zones/%s/records/%s", zoneID, recordID), req, &result) +} + +func (api *netbirdProvider) deleteRecord(zoneID string, recordID string) error { + return api.doRequest("DELETE", fmt.Sprintf("/dns/zones/%s/records/%s", zoneID, recordID), nil, nil) +} diff --git a/providers/netbird/types.go b/providers/netbird/types.go new file mode 100644 index 0000000000..a3d1fea081 --- /dev/null +++ b/providers/netbird/types.go @@ -0,0 +1,35 @@ +package netbird + +// zoneInfo stores cached zone information for a domain. +type zoneInfo struct { + id string + domain string +} + +// Zone represents a NetBird DNS zone. +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + Enabled bool `json:"enabled"` + EnableSearchDomain bool `json:"enable_search_domain"` + DistributionGroups []string `json:"distribution_groups"` + Records []Record `json:"records"` +} + +// Record represents a NetBird DNS record. +type Record struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` +} + +// CreateRecordRequest is used to create a new DNS record. +type CreateRecordRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` +} diff --git a/providers/netbird/zones.go b/providers/netbird/zones.go new file mode 100644 index 0000000000..ce475bdbf3 --- /dev/null +++ b/providers/netbird/zones.go @@ -0,0 +1,40 @@ +package netbird + +import ( + "fmt" +) + +// listZones returns all zones from the NetBird API. +func (api *netbirdProvider) listZones() ([]Zone, error) { + var zones []Zone + err := api.doRequest("GET", "/dns/zones", nil, &zones) + return zones, err +} + +// findZoneByDomain finds a zone by its domain name. +func (api *netbirdProvider) findZoneByDomain(domain string) (*Zone, error) { + zones, err := api.listZones() + if err != nil { + return nil, err + } + + for _, zone := range zones { + if zone.Domain == domain { + return &zone, nil + } + } + + return nil, fmt.Errorf("zone not found for domain: %s", domain) +} + +// createZone creates a new zone. +func (api *netbirdProvider) createZone(zone *Zone) error { + var result Zone + return api.doRequest("POST", "/dns/zones", zone, &result) +} + +// updateZone updates an existing zone. +func (api *netbirdProvider) updateZone(zoneID string, zone *Zone) error { + var result Zone + return api.doRequest("PUT", fmt.Sprintf("/dns/zones/%s", zoneID), zone, &result) +}