diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2efd15defe..bc12a63a07 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -26,6 +26,7 @@ providers/gandiv5 @TomOnTime providers/gcloud @riyadhalnur providers/gcore @xddxdd providers/gidinet @zupolgec +providers/gigahost @jochristian providers/hedns @rblenkinsopp providers/hetzner @das7pad providers/hetznerv2 @das7pad diff --git a/.github/labeler.yml b/.github/labeler.yml index 4e884c6032..351a07c038 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -82,6 +82,9 @@ provider-GCORE: provider-GIDINET: - changed-files: - any-glob-to-any-file: providers/gidinet/** +provider-GIGAHOST: + - changed-files: + - any-glob-to-any-file: providers/gigahost/** provider-HEDNS: - changed-files: - any-glob-to-any-file: providers/hedns/** diff --git a/.goreleaser.yml b/.goreleaser.yml index d45545b78c..a4ddd94e5f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -37,7 +37,7 @@ changelog: regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" order: 1 - title: 'Provider-specific changes:' - regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|azuredns|bind|bunny_dns|bunnydns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|doh|domainnameshop|dynadot|dynu|easyname|exoscale|fortigate|gandi|gandi_v5|gcloud|gcore|gidinet|hedns|hetzner|hetzner_v2|hexonet|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netbird|netcup|netlify|netnod|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|tencentdns|transip|unifi|vercel|vultr|websupport).*:)+.*" + regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|azuredns|bind|bunny_dns|bunnydns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|doh|domainnameshop|dynadot|dynu|easyname|exoscale|fortigate|gandi|gandi_v5|gcloud|gcore|gidinet|gigahost|hedns|hetzner|hetzner_v2|hexonet|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netbird|netcup|netlify|netnod|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|tencentdns|transip|unifi|vercel|vultr|websupport).*:)+.*" order: 2 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index 9e2b72aa13..882327bef5 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -150,6 +150,7 @@ * [Gandi_v5](provider/gandiv5.md) * [Gcore](provider/gcore.md) * [Gidinet](provider/gidinet.md) +* [Gigahost](provider/gigahost.md) * [Google Cloud DNS](provider/gcloud.md) * [Hetzner DNS API](provider/hetznerv2.md) * [Hetzner DNS Console (legacy)](provider/hetzner.md) diff --git a/documentation/provider/gigahost.md b/documentation/provider/gigahost.md new file mode 100644 index 0000000000..af9253c769 --- /dev/null +++ b/documentation/provider/gigahost.md @@ -0,0 +1,103 @@ +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `GIGAHOST` +along with your [Gigahost](https://gigahost.no/) API key. + +Example: + +{% code title="creds.json" %} +```json +{ + "gigahost": { + "TYPE": "GIGAHOST", + "apikey": "flux_live_your-api-key" + } +} +``` +{% endcode %} + +The [creds.json](../commands/creds-json.md#example-commands) page in the docs explains how you can generate this dynamically so you can pull the secret token from 1Password or the vault of your choosing. + +## Metadata + +This provider does not recognize any special metadata fields unique to Gigahost. + +## Usage + +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var REG_NONE = NewRegistrar("none"); +var DSP_GIGAHOST = NewDnsProvider("gigahost"); + +D("example.com", REG_NONE, DnsProvider(DSP_GIGAHOST), + A("test", "1.2.3.4"), +); +``` +{% endcode %} + +Gigahost is a DNS Service Provider only; it is not a registrar in DNSControl. +If your domain is registered with Gigahost, set the nameservers to Gigahost's +(see [Nameservers](#nameservers) below) at your registrar. + +## Activation + +1. Log in to the [Gigahost control panel](https://gigahost.no/). +2. Create an API key with the **DNS read-write** permission. Keys are prefixed + with `flux_live_`. +3. Put the key in `creds.json` as the `apikey` field shown above. + +## Supported record types + +This provider supports the following record types: + +| Name | Description | +| ----- | ----------- | +| A | IPv4 address record | +| AAAA | IPv6 address record | +| ALIAS | CNAME-like apex alias record | +| CAA | Certification Authority Authorization record | +| CNAME | Canonical name (alias) record | +| DNAME | Delegation name record | +| MX | Mail exchange record | +| NAPTR | Naming Authority Pointer record | +| NS | Name server record | +| PTR | Pointer record | +| SRV | Service record | +| TXT | Text record | + +Record types not in this list (for example `TLSA`, `SSHFP`, `HTTPS`, `SVCB`, +`DS`, `LOC`) are rejected by the Gigahost API and are not supported. Any +unsupported record type already present in a zone is left untouched: the +provider ignores it on read (emitting a warning) so it is neither modified nor +deleted. + +## Nameservers + +Gigahost serves every zone it hosts from a fixed set of nameservers: + +- `ns1.gigahost.no` +- `ns2.gigahost.no` +- `ns3.gigahost.no` + +The provider returns these via `GetNameservers`, so DNSControl will suggest the +correct delegation automatically. Set these nameservers at your registrar to +delegate a domain to Gigahost. + +## Limitations + +### Zone creation + +Zones must already exist in your Gigahost account. The provider does not create +new zones; create them in the Gigahost control panel first. + +### Zone apex SOA + +The zone apex `SOA` record is managed by Gigahost and is not exposed for +editing. The provider ignores it. + +### Concurrent operations + +The provider does not support concurrent API operations. Changes are applied +sequentially. diff --git a/documentation/provider/index.md b/documentation/provider/index.md index 2c35248395..deae215471 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -51,6 +51,7 @@ Jump to a table: | [`GCLOUD`](gcloud.md) | ✅ | ✅ | ❌ | | [`GCORE`](gcore.md) | ❌ | ✅ | ❌ | | [`GIDINET`](gidinet.md) | ❌ | ✅ | ✅ | +| [`GIGAHOST`](gigahost.md) | ❌ | ✅ | ❌ | | [`HEDNS`](hedns.md) | ❌ | ✅ | ❌ | | [`HETZNER`](hetzner.md) | ❌ | ✅ | ❌ | | [`HETZNER_V2`](hetznerv2.md) | ❌ | ✅ | ❌ | @@ -123,6 +124,7 @@ Jump to a table: | [`GCLOUD`](gcloud.md) | ✅ | ✅ | ✅ | ✅ | | [`GCORE`](gcore.md) | ✅ | ✅ | ✅ | ✅ | | [`GIDINET`](gidinet.md) | ✅ | ✅ | ❌ | ✅ | +| [`GIGAHOST`](gigahost.md) | ❔ | ❔ | ❌ | ✅ | | [`HEDNS`](hedns.md) | ✅ | ✅ | ✅ | ✅ | | [`HETZNER`](hetzner.md) | ✅ | ✅ | ✅ | ✅ | | [`HETZNER_V2`](hetznerv2.md) | ✅ | ✅ | ✅ | ✅ | @@ -192,6 +194,7 @@ Jump to a table: | [`GCLOUD`](gcloud.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`GCORE`](gcore.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`GIDINET`](gidinet.md) | ❌ | ❌ | ❌ | ❌ | ❌ | +| [`GIGAHOST`](gigahost.md) | ✅ | ✅ | ❔ | ✅ | ❔ | | [`HEDNS`](hedns.md) | ✅ | ❌ | ✅ | ✅ | ❌ | | [`HETZNER`](hetzner.md) | ❌ | ❔ | ❌ | ❌ | ❌ | | [`HETZNER_V2`](hetznerv2.md) | ❌ | ❔ | ❌ | ✅ | ❌ | @@ -258,6 +261,7 @@ Jump to a table: | [`GCLOUD`](gcloud.md) | ❔ | ❔ | ✅ | ✅ | | [`GCORE`](gcore.md) | ❔ | ❌ | ✅ | ✅ | | [`GIDINET`](gidinet.md) | ❌ | ❌ | ✅ | ❌ | +| [`GIGAHOST`](gigahost.md) | ❔ | ✅ | ✅ | ❔ | | [`HEDNS`](hedns.md) | ❌ | ✅ | ✅ | ✅ | | [`HETZNER`](hetzner.md) | ❔ | ❌ | ✅ | ❔ | | [`HETZNER_V2`](hetznerv2.md) | ❔ | ❌ | ✅ | ✅ | @@ -324,6 +328,7 @@ Jump to a table: | [`GCLOUD`](gcloud.md) | ✅ | ✅ | ❔ | ✅ | ✅ | | [`GCORE`](gcore.md) | ✅ | ✅ | ❔ | ❌ | ❌ | | [`GIDINET`](gidinet.md) | ❌ | ❌ | ❔ | ❌ | ❌ | +| [`GIGAHOST`](gigahost.md) | ✅ | ❔ | ❔ | ❔ | ❔ | | [`HEDNS`](hedns.md) | ✅ | ✅ | ❌ | ✅ | ❌ | | [`HETZNER`](hetzner.md) | ✅ | ❔ | ❔ | ❌ | ✅ | | [`HETZNER_V2`](hetznerv2.md) | ✅ | ✅ | ❔ | ❌ | ✅ | diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index e0f6b80cd5..8b93002896 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -195,6 +195,11 @@ "password": "$GIDINET_PASSWORD", "username": "$GIDINET_USERNAME" }, + "GIGAHOST": { + "TYPE": "GIGAHOST", + "apikey": "$GIGAHOST_APIKEY", + "domain": "$GIGAHOST_DOMAIN" + }, "HEDNS": { "TYPE": "HEDNS", "domain": "$HEDNS_DOMAIN", diff --git a/pkg/providers/_all/all.go b/pkg/providers/_all/all.go index d2934959e6..d8589c1b36 100644 --- a/pkg/providers/_all/all.go +++ b/pkg/providers/_all/all.go @@ -32,6 +32,7 @@ import ( _ "github.com/DNSControl/dnscontrol/v4/providers/gcloud" _ "github.com/DNSControl/dnscontrol/v4/providers/gcore" _ "github.com/DNSControl/dnscontrol/v4/providers/gidinet" + _ "github.com/DNSControl/dnscontrol/v4/providers/gigahost" _ "github.com/DNSControl/dnscontrol/v4/providers/hedns" _ "github.com/DNSControl/dnscontrol/v4/providers/hetzner" _ "github.com/DNSControl/dnscontrol/v4/providers/hetznerv2" diff --git a/providers/gigahost/api.go b/providers/gigahost/api.go new file mode 100644 index 0000000000..7e6348ec7a --- /dev/null +++ b/providers/gigahost/api.go @@ -0,0 +1,190 @@ +package gigahost + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const baseURL = "https://api.gigahost.no/api/v0" + +// httpClient has an explicit timeout so a hung connection can never block a +// run indefinitely. +var httpClient = &http.Client{Timeout: 30 * time.Second} + +// apiMeta is the "meta" object present in every Gigahost API response. +type apiMeta struct { + Status int `json:"status"` + Message string `json:"message"` +} + +// apiEnvelope is the standard wrapper around every Gigahost API response: +// {"meta":{...},"data":...}. +type apiEnvelope struct { + Meta apiMeta `json:"meta"` + Data json.RawMessage `json:"data"` +} + +// zone models an entry from GET /dns/zones. All IDs arrive as JSON strings. +type zone struct { + ZoneID string `json:"zone_id"` + ZoneName string `json:"zone_name"` + ExternalDNS string `json:"external_dns"` +} + +// flexUint tolerates JSON numbers, quoted numeric strings, and null. The +// Gigahost API returns numeric fields inconsistently — for example +// record_priority comes back as the string "10" for MX records but null +// otherwise — so a plain uint cannot decode them. +type flexUint struct { + Valid bool + Value uint32 +} + +func (f *flexUint) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + if s == "" || s == "null" { + return nil + } + v, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return fmt.Errorf("gigahost: cannot parse numeric field %q: %w", string(b), err) + } + f.Value = uint32(v) + f.Valid = true + return nil +} + +// record models an entry from GET /dns/zones/{zone_id}/records. All IDs arrive +// as JSON strings, and record_id may be non-numeric. Numeric fields may arrive +// as strings, so they use flexUint. +type record struct { + RecordID string `json:"record_id,omitempty"` + RecordName string `json:"record_name"` + RecordType string `json:"record_type"` + RecordValue string `json:"record_value"` + RecordTTL flexUint `json:"record_ttl"` + RecordPrio flexUint `json:"record_priority"` +} + +// recordRequest is the body sent to create (POST) and update (PUT) endpoints. +type recordRequest struct { + RecordName string `json:"record_name"` + RecordType string `json:"record_type"` + RecordValue string `json:"record_value"` + RecordTTL uint32 `json:"record_ttl"` + RecordPrio *uint16 `json:"record_priority,omitempty"` +} + +// request performs an HTTP request against the Gigahost API, unwraps the +// standard envelope, surfaces meta-level errors, and decodes data into target. +func (c *gigahostProvider) request(method, path string, query url.Values, body, target any) error { + var reqBody io.Reader + if body != nil { + j, err := json.Marshal(body) + if err != nil { + return err + } + reqBody = bytes.NewBuffer(j) + } + + u := baseURL + path + if len(query) > 0 { + u += "?" + query.Encode() + } + + req, err := http.NewRequest(method, u, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var env apiEnvelope + if len(raw) > 0 { + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("gigahost: could not decode response for %s %s (HTTP %d): %w", method, path, resp.StatusCode, err) + } + } + + // Prefer the envelope's meta.status, falling back to the HTTP status code. + status := env.Meta.Status + if status == 0 { + status = resp.StatusCode + } + if status >= 400 { + msg := env.Meta.Message + if msg == "" { + msg = string(raw) + } + return fmt.Errorf("gigahost: %s %s failed (status %d): %s", method, path, status, msg) + } + + if target != nil && len(env.Data) > 0 { + if err := json.Unmarshal(env.Data, target); err != nil { + return fmt.Errorf("gigahost: could not decode data for %s %s: %w", method, path, err) + } + } + return nil +} + +// getAllZones returns every zone in the account (GET /dns/zones). +func (c *gigahostProvider) getAllZones() ([]zone, error) { + var zones []zone + if err := c.request("GET", "/dns/zones", nil, nil, &zones); err != nil { + return nil, err + } + return zones, nil +} + +// getRecords returns all records in a zone (GET /dns/zones/{zone_id}/records). +func (c *gigahostProvider) getRecords(zoneID string) ([]record, error) { + var recs []record + if err := c.request("GET", "/dns/zones/"+url.PathEscape(zoneID)+"/records", nil, nil, &recs); err != nil { + return nil, err + } + return recs, nil +} + +// createRecord creates a record (POST /dns/zones/{zone_id}/records). +func (c *gigahostProvider) createRecord(zoneID string, r *recordRequest) error { + return c.request("POST", "/dns/zones/"+url.PathEscape(zoneID)+"/records", nil, r, nil) +} + +// updateRecord updates a record (PUT /dns/zones/{zone_id}/records/{record_id}). +func (c *gigahostProvider) updateRecord(zoneID, recordID string, r *recordRequest) error { + return c.request("PUT", "/dns/zones/"+url.PathEscape(zoneID)+"/records/"+url.PathEscape(recordID), nil, r, nil) +} + +// deleteRecord deletes a single record. The name, type, and value query params +// are all required: name+type alone deletes EVERY record in that RRset, so value +// is needed to target one record when several share a name+type (e.g. +// round-robin A records, multiple MX/TXT). The record_id in the path is a +// content-derived hash and is not the disambiguator. +func (c *gigahostProvider) deleteRecord(zoneID, recordID, name, rtype, value string) error { + q := url.Values{} + q.Set("name", name) + q.Set("type", rtype) + q.Set("value", value) + return c.request("DELETE", "/dns/zones/"+url.PathEscape(zoneID)+"/records/"+url.PathEscape(recordID), q, nil, nil) +} diff --git a/providers/gigahost/gigahostProvider.go b/providers/gigahost/gigahostProvider.go new file mode 100644 index 0000000000..0074bbb58d --- /dev/null +++ b/providers/gigahost/gigahostProvider.go @@ -0,0 +1,282 @@ +package gigahost + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/DNSControl/dnscontrol/v4/models" + "github.com/DNSControl/dnscontrol/v4/pkg/diff2" + "github.com/DNSControl/dnscontrol/v4/pkg/printer" + "github.com/DNSControl/dnscontrol/v4/pkg/providers" + "github.com/DNSControl/dnscontrol/v4/pkg/rejectif" +) + +// features describes the capabilities of the Gigahost provider. Start with +// zero optional capabilities; only A/AAAA/CNAME/MX/TXT/NS are handled. +var features = providers.DocumentationNotes{ + // The default for unlisted capabilities is 'Cannot'. + // See providers/capabilities.go for the entire list of capabilities. + providers.CanGetZones: providers.Can(), + providers.CanConcur: providers.Unimplemented(), + providers.CanUseAlias: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDNAME: providers.Can(), + providers.CanUseNAPTR: providers.Can(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSRV: providers.Can(), +} + +func init() { + const providerName = "GIGAHOST" + const providerMaintainer = "@jochristian" + fns := providers.DspFuncs{ + Initializer: newGigahost, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) + providers.RegisterCredsMetadata(providerName, providers.CredsMetadata{ + DisplayName: "Gigahost", + Kind: providers.KindDNS, + Fields: []providers.CredsField{ + { + Key: "apikey", + Label: "API key", + Help: "Gigahost API key with DNS read-write permission (flux_live_...).", + Secret: true, + Required: true, + }, + }, + }) +} + +type gigahostProvider struct { + apiKey string + zones map[string]zone // zone_name -> zone +} + +func newGigahost(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + apiKey := settings["apikey"] + if apiKey == "" { + return nil, errors.New("gigahost: missing 'apikey' in creds.json") + } + return &gigahostProvider{apiKey: apiKey}, nil +} + +// AuditRecords returns a list of errors corresponding to the records that +// aren't supported by this provider. +func AuditRecords(records []*models.RecordConfig) []error { + a := rejectif.Auditor{} + a.Add("TXT", rejectif.TxtIsEmpty) + a.Add("CAA", rejectif.CaaTargetContainsWhitespace) + a.Add("SRV", rejectif.SrvHasNullTarget) + return a.Audit(records) +} + +// findZone resolves a domain name to its Gigahost zone, caching the zone list. +func (c *gigahostProvider) findZone(domain string) (zone, error) { + if c.zones == nil { + zones, err := c.getAllZones() + if err != nil { + return zone{}, err + } + c.zones = make(map[string]zone, len(zones)) + for _, z := range zones { + c.zones[z.ZoneName] = z + } + } + z, ok := c.zones[domain] + if !ok { + return zone{}, fmt.Errorf("gigahost: %q is not a zone in this account", domain) + } + return z, nil +} + +// ListZones returns all zone names in the account. +func (c *gigahostProvider) ListZones() ([]string, error) { + zones, err := c.getAllZones() + if err != nil { + return nil, err + } + names := make([]string, 0, len(zones)) + for _, z := range zones { + names = append(names, z.ZoneName) + } + return names, nil +} + +// gigahostNameservers are Gigahost's authoritative nameservers. Gigahost has no +// per-zone parent-nameserver endpoint; every zone it hosts is served by this +// fixed set. Confirmed by the provider 2026-06-23. +var gigahostNameservers = []string{ + "ns1.gigahost.no", + "ns2.gigahost.no", + "ns3.gigahost.no", +} + +// GetNameservers returns Gigahost's authoritative nameservers for a domain. +func (c *gigahostProvider) GetNameservers(_ string) ([]*models.Nameserver, error) { + return models.ToNameservers(gigahostNameservers) +} + +// GetZoneRecords gets all records of a zone in RecordConfig format. +func (c *gigahostProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records, error) { + z, err := c.findZone(dc.Name) + if err != nil { + return nil, err + } + recs, err := c.getRecords(z.ZoneID) + if err != nil { + return nil, err + } + result := make(models.Records, 0, len(recs)) + for i := range recs { + t := recs[i].RecordType + // SOA is managed by Gigahost and not exposed as a writable record; + // silently ignore it so it is never proposed for deletion. + if t == "SOA" { + continue + } + // Leave record types we don't support untouched rather than + // clobbering them: ignore them on read with a warning. + if !supportedTypes[t] { + printer.Warnf("GIGAHOST: ignoring unsupported record type %s (%s)\n", t, recs[i].RecordName) + continue + } + rc, err := nativeToRecordConfig(dc.Name, &recs[i]) + if err != nil { + return nil, err + } + result = append(result, rc) + } + return result, nil +} + +// supportedTypes is the set of record types this provider manages. +var supportedTypes = map[string]bool{ + "A": true, "AAAA": true, "CNAME": true, "MX": true, "TXT": true, "NS": true, + "ALIAS": true, "CAA": true, "DNAME": true, "NAPTR": true, "PTR": true, "SRV": true, +} + +// GetZoneRecordsCorrections computes the changes needed to bring the zone in +// sync with dc, using the per-record diff strategy. +func (c *gigahostProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, int, error) { + z, err := c.findZone(dc.Name) + if err != nil { + return nil, 0, err + } + + instructions, actualChangeCount, err := diff2.ByRecord(existing, dc, nil) + if err != nil { + return nil, 0, err + } + + var corrections []*models.Correction + for _, inst := range instructions { + switch inst.Type { + case diff2.REPORT: + corrections = append(corrections, &models.Correction{Msg: inst.MsgsJoined}) + case diff2.CREATE: + newRec := inst.New[0] + corrections = append(corrections, &models.Correction{ + Msg: inst.Msgs[0], + F: func() error { + return c.createRecord(z.ZoneID, recordConfigToRequest(newRec)) + }, + }) + case diff2.CHANGE: + oldRec := inst.Old[0] + newRec := inst.New[0] + id := oldRec.Original.(*record).RecordID + corrections = append(corrections, &models.Correction{ + Msg: inst.Msgs[0], + F: func() error { + return c.updateRecord(z.ZoneID, id, recordConfigToRequest(newRec)) + }, + }) + case diff2.DELETE: + old := inst.Old[0].Original.(*record) + corrections = append(corrections, &models.Correction{ + Msg: inst.Msgs[0], + F: func() error { + return c.deleteRecord(z.ZoneID, old.RecordID, old.RecordName, old.RecordType, old.RecordValue) + }, + }) + default: + panic(fmt.Sprintf("unhandled inst.Type %s", inst.Type)) + } + } + + return corrections, actualChangeCount, nil +} + +// nativeToRecordConfig converts a Gigahost record into a RecordConfig. +func nativeToRecordConfig(domain string, r *record) (*models.RecordConfig, error) { + rc := &models.RecordConfig{ + Type: r.RecordType, + TTL: r.RecordTTL.Value, + Original: r, + } + rc.SetLabel(r.RecordName, domain) + + var err error + switch r.RecordType { + case "MX": + err = rc.SetTargetMX(uint16(r.RecordPrio.Value), addDot(r.RecordValue)) + case "CNAME", "NS", "ALIAS", "PTR", "DNAME": + // Gigahost stores hostname targets inconsistently (some with a trailing + // dot, some without); RecordConfig targets are always FQDNs. + err = rc.SetTarget(addDot(r.RecordValue)) + case "TXT": + err = rc.SetTargetTXT(r.RecordValue) + case "CAA": + err = rc.SetTargetCAAString(r.RecordValue) + case "SRV": + err = rc.SetTargetSRVString(r.RecordValue) + case "NAPTR": + err = rc.SetTargetNAPTRString(r.RecordValue) + default: + err = rc.PopulateFromStringFunc(r.RecordType, r.RecordValue, domain, nil) + } + if err != nil { + return nil, fmt.Errorf("gigahost: unparsable %s record %q: %w", r.RecordType, r.RecordValue, err) + } + return rc, nil +} + +// recordConfigToRequest converts a RecordConfig into a Gigahost request body. +func recordConfigToRequest(rc *models.RecordConfig) *recordRequest { + r := &recordRequest{ + RecordName: rc.GetLabel(), + RecordType: rc.Type, + RecordTTL: rc.TTL, + } + switch rc.Type { + case "MX": + pref := rc.MxPreference + r.RecordPrio = &pref + r.RecordValue = strings.TrimSuffix(rc.GetTargetField(), ".") + case "CNAME", "NS", "ALIAS", "PTR", "DNAME": + // Send hostname targets without a trailing dot; the API normalizes as + // needed and reads are re-dotted in nativeToRecordConfig. + r.RecordValue = strings.TrimSuffix(rc.GetTargetField(), ".") + case "TXT": + r.RecordValue = rc.GetTargetTXTJoined() + case "CAA", "SRV", "NAPTR": + // These are stored as full RFC1035 presentation strings in record_value. + r.RecordValue = rc.GetTargetCombined() + default: + r.RecordValue = rc.GetTargetField() + } + return r +} + +// addDot appends a trailing dot to a hostname if it lacks one. +func addDot(s string) string { + if s == "" || strings.HasSuffix(s, ".") { + return s + } + return s + "." +}