Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
2 changes: 1 addition & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)]*:+.*$"
Expand Down
1 change: 1 addition & 0 deletions documentation/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions documentation/provider/gigahost.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions documentation/provider/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ❌ | ✅ | ❌ |
Expand Down Expand Up @@ -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) | ✅ | ✅ | ✅ | ✅ |
Expand Down Expand Up @@ -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) | ❌ | ❔ | ❌ | ✅ | ❌ |
Expand Down Expand Up @@ -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) | ❔ | ❌ | ✅ | ✅ |
Expand Down Expand Up @@ -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) | ✅ | ✅ | ❔ | ❌ | ✅ |
Expand Down
5 changes: 5 additions & 0 deletions integrationTest/profiles.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions pkg/providers/_all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
190 changes: 190 additions & 0 deletions providers/gigahost/api.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading