From bb6ad32019cc8769b8b06c8cffa6b79c8b5f5032 Mon Sep 17 00:00:00 2001 From: Jo Christian Buvarp Date: Tue, 23 Jun 2026 10:42:55 +0200 Subject: [PATCH 1/2] GIGAHOST: add Gigahost DNS service provider Add a DnsProvider (not a registrar) for Gigahost (https://gigahost.no), the Norwegian host. Supports get-zones, preview, and push for A/AAAA/CNAME/MX/TXT/NS records using diff2.ByRecord() with Gigahost's per-record IDs. Implementation notes: - API IDs are JSON strings; record_priority/record_ttl can arrive as quoted strings or null, handled by a flexUint custom unmarshaler. - The records endpoint returns an apex SOA which is ignored on read. - DELETE requires name+type+value query params: name+type alone removes the entire RRset, so value is needed to delete a single record when several share a name+type (round-robin A, multiple MX/TXT). - GetNameservers returns Gigahost's fixed nameservers ns1/ns2/ns3.gigahost.no. - 30s HTTP client timeout and url.PathEscape on path IDs. Registration, docs (provider page + SUMMARY), and an integrationTest profile are included. Generated files (CODEOWNERS, labeler.yml, .goreleaser.yml, provider/index.md) were refreshed via `go generate`; that run also synced pre-existing upstream drift (DYNU rows, dynu/bunny goreleaser tokens) required to keep the go-generate CI check clean. Verified end-to-end against a live zone: create/change/delete round-trip for all supported types plus round-robin delete; zone restored after. --- .github/CODEOWNERS | 1 + .github/labeler.yml | 3 + .goreleaser.yml | 2 +- documentation/SUMMARY.md | 1 + documentation/provider/gigahost.md | 94 +++++++++ documentation/provider/index.md | 2 + integrationTest/profiles.json | 5 + pkg/providers/_all/all.go | 1 + providers/gigahost/api.go | 190 ++++++++++++++++++ providers/gigahost/gigahostProvider.go | 262 +++++++++++++++++++++++++ 10 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 documentation/provider/gigahost.md create mode 100644 providers/gigahost/api.go create mode 100644 providers/gigahost/gigahostProvider.go 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..0b7f685ce1 --- /dev/null +++ b/documentation/provider/gigahost.md @@ -0,0 +1,94 @@ +## 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 | +| CNAME | Canonical name (alias) record | +| MX | Mail exchange record | +| NS | Name server record | +| TXT | Text record | + +Any other record type 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..2d3161aba7 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) | ✅ | ✅ | ✅ | ✅ | 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..561aed44fc --- /dev/null +++ b/providers/gigahost/gigahostProvider.go @@ -0,0 +1,262 @@ +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(), +} + +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) + 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, +} + +// 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": + // Gigahost stores hostnames without a trailing dot; RecordConfig + // targets are FQDNs. + err = rc.SetTarget(addDot(r.RecordValue)) + case "TXT": + err = rc.SetTargetTXT(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": + r.RecordValue = strings.TrimSuffix(rc.GetTargetField(), ".") + case "TXT": + r.RecordValue = rc.GetTargetTXTJoined() + 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 + "." +} From a00aa3fcc5dfff7297c69a069086b33d98c6830d Mon Sep 17 00:00:00 2001 From: Jo Christian Buvarp Date: Tue, 23 Jun 2026 10:53:59 +0200 Subject: [PATCH 2/2] GIGAHOST: add ALIAS, CAA, DNAME, NAPTR, PTR, SRV support Enable the optional record types the Gigahost API accepts (verified by probing the live API). CAA, SRV, and NAPTR are stored as full RFC1035 presentation strings in record_value (no record_priority), mapped via GetTargetCombined on write and SetTarget{CAA,SRV,NAPTR}String on read. ALIAS/PTR/DNAME are hostname-target types handled like CNAME/NS. Adds rejectif audits for CAA (whitespace) and SRV (null target), and documents the supported/unsupported type list. Verified end-to-end on a live zone: create/change/delete round-trip and idempotency for all six new types; zone restored afterward. TLSA, SSHFP, HTTPS, SVCB, DS, LOC, SPF, ANAME, CERT are rejected by the API. --- documentation/provider/gigahost.md | 13 ++++++++++-- documentation/provider/index.md | 3 +++ providers/gigahost/gigahostProvider.go | 28 ++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/documentation/provider/gigahost.md b/documentation/provider/gigahost.md index 0b7f685ce1..af9253c769 100644 --- a/documentation/provider/gigahost.md +++ b/documentation/provider/gigahost.md @@ -56,13 +56,22 @@ This provider supports the following record types: | ----- | ----------- | | 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 | -Any other record type present in a zone is left untouched: the provider ignores -it on read (emitting a warning) so it is neither modified nor deleted. +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 diff --git a/documentation/provider/index.md b/documentation/provider/index.md index 2d3161aba7..deae215471 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -194,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) | ❌ | ❔ | ❌ | ✅ | ❌ | @@ -260,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) | ❔ | ❌ | ✅ | ✅ | @@ -326,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/providers/gigahost/gigahostProvider.go b/providers/gigahost/gigahostProvider.go index 561aed44fc..0074bbb58d 100644 --- a/providers/gigahost/gigahostProvider.go +++ b/providers/gigahost/gigahostProvider.go @@ -20,6 +20,12 @@ var features = providers.DocumentationNotes{ // 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() { @@ -64,6 +70,8 @@ func newGigahost(settings map[string]string, _ json.RawMessage) (providers.DNSSe 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) } @@ -149,6 +157,7 @@ func (c *gigahostProvider) GetZoneRecords(dc *models.DomainConfig) (models.Recor // 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 @@ -216,12 +225,18 @@ func nativeToRecordConfig(domain string, r *record) (*models.RecordConfig, error switch r.RecordType { case "MX": err = rc.SetTargetMX(uint16(r.RecordPrio.Value), addDot(r.RecordValue)) - case "CNAME", "NS": - // Gigahost stores hostnames without a trailing dot; RecordConfig - // targets are FQDNs. + 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) } @@ -243,10 +258,15 @@ func recordConfigToRequest(rc *models.RecordConfig) *recordRequest { pref := rc.MxPreference r.RecordPrio = &pref r.RecordValue = strings.TrimSuffix(rc.GetTargetField(), ".") - case "CNAME", "NS": + 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() }