NEW PROVIDE: FORTIGATE (#3642)

This commit is contained in:
Klett IT 2025-07-08 14:37:19 +02:00 committed by GitHub
parent 4672409f0b
commit 3bdbb48164
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 663 additions and 8 deletions

View file

@ -52,7 +52,7 @@ jobs:
Write-Host "Integration test providers: $Providers"
echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT
env:
PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']"
PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']"
ENV_CONTEXT: ${{ toJson(env) }}
VARS_CONTEXT: ${{ toJson(vars) }}
SECRETS_CONTEXT: ${{ toJson(secrets) }}
@ -83,6 +83,7 @@ jobs:
CNR_DOMAIN: ${{ vars.CNR_DOMAIN }}
CSCGLOBAL_DOMAIN: ${{ vars.CSCGLOBAL_DOMAIN }}
DIGITALOCEAN_DOMAIN: ${{ vars.DIGITALOCEAN_DOMAIN }}
FORTIGATE_DOMAIN: ${{ vars.FORTIGATE_DOMAIN }}
GANDI_V5_DOMAIN: ${{ vars.GANDI_V5_DOMAIN }}
GCLOUD_DOMAIN: ${{ vars.GCLOUD_DOMAIN }}
HEDNS_DOMAIN: ${{ vars.HEDNS_DOMAIN }}
@ -137,6 +138,10 @@ jobs:
#
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
#
FORTIGATE_API_KEY: ${{ secrets.FORTIGATE_API_KEY }}
FORTIGATE_VDOM: ${{ secrets.FORTIGATE_VDOM }}
FORTIGATE_HOST: ${{ secrets.FORTIGATE_HOST }}
#
GANDI_V5_APIKEY: ${{ secrets.GANDI_V5_APIKEY }}
#
GCLOUD_EMAIL: ${{ secrets.GCLOUD_EMAIL }}

View file

@ -39,7 +39,7 @@ changelog:
regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$"
order: 1
- title: 'Provider-specific changes:'
regexp: "(?i)((akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*"
regexp: "(?i)((akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*"
order: 2
- title: 'Documentation:'
regexp: "(?i)^.*(docs)[(\\w)]*:+.*$"

1
OWNERS
View file

@ -18,6 +18,7 @@ providers/domainnameshop @SimenBai
providers/dynadot @e-im
providers/easyname @tresni
providers/exoscale @pierre-emmanuelJ
providers/fortigate @KlettIT
providers/gandiv5 @TomOnTime
providers/gcloud @riyadhalnur
providers/gcore @xddxdd

View file

@ -33,6 +33,7 @@ Currently supported DNS providers:
- DNSimple
- Domainnameshop (Domeneshop)
- Exoscale
- Fortigate
- Gandi
- Gcore
- Google DNS

View file

@ -123,6 +123,7 @@
* [Dynadot](provider/dynadot.md)
* [easyname](provider/easyname.md)
* [Exoscale](provider/exoscale.md)
* [Fortigate](provider/fortigate.md)
* [Gandi_v5](provider/gandi_v5.md)
* [Gcore](provider/gcore.md)
* [Google Cloud DNS](provider/gcloud.md)

View file

@ -0,0 +1,66 @@
# FortiGate DNS Provider
This DNS provider lets you manage DNS zones hosted on a Fortinet FortiGate device via its REST API.
## Configuration
The provider is configured using the following environment variables:
- `FORTIGATE_HOST`: The FortiGate host or IP address (e.g. `https://192.168.1.1`)
- `FORTIGATE_TOKEN`: API token with appropriate DNS permissions
- `FORTIGATE_VDOM`: (optional) Specify the virtual domain (default: `root`)
- `FORTIGATE_INSECURE_TLS`: (optional) Set to `true` to disable SSL certificate verification (useful for self-signed certs)
Example `creds.json` entry:
{% code title="creds.json" %}
```json
{
"FORTIGATE": {
"host": "https://192.168.1.1",
"token": "your-api-token",
"vdom": "root",
"insecure_tls": true
}
}
```
{% endcode %}
## Usage
To use this provider in a `dnsconfig.js`:
{% code title="dnsconfig.js" %}
```javascript
D("example.com", REG_NONE, DnsProvider("FORTIGATE"),
A("www", "192.0.2.1"),
CNAME("blog", "external.example.net.")
)
```
{% endcode %}
⚠️ TXT records are not supported. See caveats below.
## Caveats
- ❌ **PTR records are not supported.**
FortiGate does not follow the standard DNS convention of managing `in-addr.arpa` or `ip6.arpa` zones for reverse DNS. Instead, PTR entries are stored in regular forward zones, and this behavior is incompatible with how `dnscontrol` models reverse zones. Because of this mismatch, PTR support is intentionally omitted to avoid unexpected behavior or broken state synchronization.
- ❌ **NS and MX records are not supported.**
FortiGate does not support fully functional `NS` or `MX` record types in its DNS configuration system.
- ❌ **TXT records are not supported.**
The FortiGate DNS interface does not currently expose support for TXT records via the API.
- ❌ **Wildcard records (`*`) are not supported.**
FortiGate DNS does not support wildcard records.
- ✅ Supported record types: `A`, `AAAA`, `CNAME`.
## Development notes
This provider uses the FortiGate REST API (`/api/v2/cmdb/system/dns-database`) to manage zones and DNS entries. It assumes you are managing the **"shadow" view** and expects zones to be configured in **primary mode**.

View file

@ -45,6 +45,7 @@ Jump to a table:
| [`DYNADOT`](dynadot.md) | ❌ | ❌ | ✅ |
| [`EASYNAME`](easyname.md) | ❌ | ❌ | ✅ |
| [`EXOSCALE`](exoscale.md) | ❌ | ✅ | ❌ |
| [`FORTIGATE`](fortigate.md) | ❌ | ✅ | ❌ |
| [`GANDI_V5`](gandi_v5.md) | ❌ | ✅ | ✅ |
| [`GCLOUD`](gcloud.md) | ✅ | ✅ | ❌ |
| [`GCORE`](gcore.md) | ❌ | ✅ | ❌ |
@ -103,6 +104,7 @@ Jump to a table:
| [`DYNADOT`](dynadot.md) | ❔ | ❔ | ❌ | ❔ |
| [`EASYNAME`](easyname.md) | ❔ | ❔ | ❌ | ❔ |
| [`EXOSCALE`](exoscale.md) | ❔ | ❌ | ❌ | ❔ |
| [`FORTIGATE`](fortigate.md) | ❔ | ❔ | ✅ | ✅ |
| [`GANDI_V5`](gandi_v5.md) | ✅ | ❔ | ❌ | ✅ |
| [`GCLOUD`](gcloud.md) | ✅ | ✅ | ✅ | ✅ |
| [`GCORE`](gcore.md) | ✅ | ✅ | ✅ | ✅ |
@ -158,6 +160,7 @@ Jump to a table:
| [`DNSMADEEASY`](dnsmadeeasy.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`DOMAINNAMESHOP`](domainnameshop.md) | ❔ | ❔ | ❌ | ❌ | ❌ |
| [`EXOSCALE`](exoscale.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`FORTIGATE`](fortigate.md) | ❔ | ❔ | ❌ | ❌ | ❔ |
| [`GANDI_V5`](gandi_v5.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`GCLOUD`](gcloud.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`GCORE`](gcore.md) | ✅ | ❔ | ❌ | ✅ | ❔ |

View file

@ -389,9 +389,10 @@ func makeTests() []*TestGroup {
testgroup("NS",
not(
"DNSIMPLE", // Does not support NS records nor subdomains.
"EXOSCALE", // Not supported.
"NETCUP", // NS records not currently supported.
"DNSIMPLE", // Does not support NS records nor subdomains.
"EXOSCALE", // Not supported.
"NETCUP", // NS records not currently supported.
"FORTIGATE", // Not supported
),
tc("NS for subdomain", ns("xyz", "ns2.foo.com.")),
tc("Dual NS for subdomain", ns("xyz", "ns2.foo.com."), ns("xyz", "ns1.foo.com.")),
@ -617,8 +618,9 @@ func makeTests() []*TestGroup {
"NAMEDOTCOM", // Their API is so damn slow. We'll add it back as needed.
"NS1", // Free acct only allows 50 records, therefore we skip
// "ROUTE53", // Batches up changes in pages.
"TRANSIP", // Doesn't page. Works fine. Due to the slow API we skip.
"CNR", // Test beaks limits.
"TRANSIP", // Doesn't page. Works fine. Due to the slow API we skip.
"CNR", // Test beaks limits.
"FORTIGATE", // No paging
),
tc("99 records", manyA("pager101-rec%04d", "1.2.3.4", 99)...),
tc("100 records", manyA("pager101-rec%04d", "1.2.3.4", 100)...),
@ -742,7 +744,9 @@ func makeTests() []*TestGroup {
// ClouDNS provider can work with PTR records, but you need to create special type of zone
testgroup("PTR",
requires(providers.CanUsePTR),
not("CLOUDNS"),
not("CLOUDNS",
"FORTIGATE", // FortiGate does not really support ARPA Zones and handles PTR records really weired
),
tc("Create PTR record", ptr("4", "foo.com.")),
tc("Modify PTR record", ptr("4", "bar.com.")),
),

View file

@ -128,6 +128,14 @@
"domain": "$EXOSCALE_DOMAIN",
"secretkey": "$EXOSCALE_SECRET_KEY"
},
"FORTIGATE": {
"TYPE": "FORTIGATE",
"domain": "$FORTIGATE_DOMAIN",
"apiKey": "$FORTIGATE_API_KEY",
"vdom": "$FORTIGATE_VDOM",
"host": "$FORTIGATE_HOST",
"insecure_tls": "$FORTIGATE_NO_SSL_VERIFY"
},
"GANDI_V5": {
"TYPE": "GANDI_V5",
"apikey": "$GANDI_V5_APIKEY",

View file

@ -23,6 +23,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v4/providers/dynadot"
_ "github.com/StackExchange/dnscontrol/v4/providers/easyname"
_ "github.com/StackExchange/dnscontrol/v4/providers/exoscale"
_ "github.com/StackExchange/dnscontrol/v4/providers/fortigate"
_ "github.com/StackExchange/dnscontrol/v4/providers/gandiv5"
_ "github.com/StackExchange/dnscontrol/v4/providers/gcloud"
_ "github.com/StackExchange/dnscontrol/v4/providers/gcore"

167
providers/fortigate/api.go Normal file
View file

@ -0,0 +1,167 @@
package fortigate
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
//
// Structure
//
// apiClient wraps all HTTP traffic to endpoints of the form:
//
// https://<host>/api/v2/cmdb/<path>?vdom=<vdom>&datasource=1
type apiClient struct {
base string // e.g. "https://fw.example.com/api/v2/cmdb/"
vdom string // target VDOM
key string // API token (Bearer)
http *http.Client // configured HTTP client
}
// fgDNSRecord represents a single entry inside the FortiGate dns-entry array.
// It is used for both JSON decoding (GET) and encoding (PUT/POST).
type fgDNSRecord struct {
ID int `json:"id,omitempty"` // FortiGate uses 1-based IDs
Status string `json:"status"` // "enable" / "disable"
Type string `json:"type"` // A, AAAA, CNAME, NS, PTR …
TTL uint32 `json:"ttl"` // 0 = inherit zone TTL
Preference uint16 `json:"preference,omitempty"` // MX/SRV (not used yet)
IP string `json:"ip,omitempty"` // A / PTR
IPv6 string `json:"ipv6,omitempty"` // AAAA (FortiGate keeps "" for unused)
Hostname string `json:"hostname,omitempty"` // record name / label
CanonicalName string `json:"canonical-name,omitempty"` // CNAME/NS/PTR target
}
//
// Constructor
//
// newClient builds a new apiClient.
//
// Parameters:
//
// host base URL with protocol, without trailing slash
// vdom VDOM (tenant) to operate on
// key REST API token (System ▸ Administrators ▸ REST API Admin)
// insecure true = skip TLS certificate verification (selfsigned, etc.)
func newClient(host, vdom, key string, insecure bool) *apiClient {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecure,
},
}
return &apiClient{
base: strings.TrimRight(host, "/") + "/api/v2/cmdb/",
vdom: vdom,
key: key,
http: &http.Client{
Transport: tr,
Timeout: 20 * time.Second,
},
}
}
//
// Central request helper
//
// do executes a request.
//
// Arguments:
//
// method HTTP verb (GET, POST, PUT, DELETE …)
// path part after /cmdb/, e.g. "system/dns-database"
// qs optional query parameters; vdom/datasource added automatically
// body request body (struct, map, etc.) or nil
// out pointer to struct for JSON decode or nil
//
// A non2xx HTTP status is returned as error.
// If out ≠ nil, the JSON response body is decoded into it.
func (c *apiClient) do(method, path string, qs url.Values, body any, out any) error {
//
// Build query string
//
if qs == nil {
qs = url.Values{}
}
qs.Set("vdom", c.vdom) // mandatory
qs.Set("datasource", "1") // same as used by the web UI
u := c.base + strings.TrimLeft(path, "/") + "?" + qs.Encode()
//
// Serialize body (if any)
//
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
//
// Build request
//
req, err := http.NewRequest(method, u, rdr)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+c.key)
//
// Execute request
//
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
//
// Handle nonsuccess status codes
//
if resp.StatusCode >= 300 {
// Read a small chunk to include in the error message
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
return fmt.Errorf("fortigate: %s %s → %s: %s", method, path, resp.Status, strings.TrimSpace(string(b)))
}
//
// Optionally decode JSON response
//
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("fortigate: decode: %w", err)
}
}
return nil
}
//
// Helper
//
// isNotFound returns true if the error represents a 404 Not Found response.
func isNotFound(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "404") && strings.Contains(strings.ToLower(msg), "not found")
}

View file

@ -0,0 +1,39 @@
package fortigate
import (
"fmt"
"github.com/StackExchange/dnscontrol/v4/models"
"strings"
)
// AuditRecords performs basic validation and returns warnings for known limitations.
func AuditRecords(records []*models.RecordConfig) []error {
var problems []error
for _, rc := range records {
switch rc.Type {
case "A", "AAAA", "CNAME":
// Supported no problem.
case "NS,PTR":
// FortiGate limitations: these record types are not fully supported.
problems = append(problems,
fmt.Errorf("record type %s is not supported by FortiGate provider (name: %s)", rc.Type, rc.GetLabelFQDN()))
default:
problems = append(problems,
fmt.Errorf("record type %s is not supported by FortiGate provider (name: %s)", rc.Type, rc.GetLabelFQDN()))
}
if rc.Type == "CNAME" && rc.GetLabel() == "@" {
problems = append(problems,
fmt.Errorf("CNAME at apex (@) is not allowed (name: %s)", rc.GetLabelFQDN()))
}
// Wildcard support
if strings.Contains(rc.GetLabelFQDN(), "*") {
problems = append(problems,
fmt.Errorf("wildcard record %s is not supported by FortiGate", rc.GetLabelFQDN()))
}
}
return problems
}

View file

@ -0,0 +1,155 @@
package fortigate
import (
"fmt"
"golang.org/x/net/idna"
"net"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
)
// nativeToRecord convert an fgDNSRecord coming from FortiGate into a *models.RecordConfig that dnscontrol understands
func nativeToRecord(domain string, n fgDNSRecord) (*models.RecordConfig, error) {
rc := &models.RecordConfig{}
rc.Type = strings.ToUpper(n.Type)
rc.Original = n
// Label / Name
label := strings.TrimSuffix(n.Hostname, ".")
if label == "@" {
label = ""
}
rc.SetLabel(label, domain)
// TTL
if n.TTL == 0 {
rc.TTL = 0 // inherit
} else {
rc.TTL = n.TTL
}
// Status → Metadata
if strings.ToLower(n.Status) != "enable" {
if rc.Metadata == nil {
rc.Metadata = map[string]string{}
}
rc.Metadata["fortigate_status"] = "disable"
}
// Type-specific fields
switch rc.Type {
case "A":
ip := net.ParseIP(n.IP)
if ip == nil || ip.To4() == nil {
return nil, fmt.Errorf("invalid IPv4 address %q in %+v", n.IP, n)
}
rc.SetTargetIP(ip)
case "AAAA":
ip := net.ParseIP(n.IPv6)
if ip == nil || ip.To16() == nil || ip.To4() != nil {
return nil, fmt.Errorf("invalid IPv6 address %q in %+v", n.IPv6, n)
}
rc.SetTargetIP(ip)
case "CNAME":
if n.CanonicalName == "" {
return nil, fmt.Errorf("CNAME record without canonical-name (id=%d)", n.ID)
}
if err := rc.SetTarget(ensureDot(n.CanonicalName)); err != nil {
return nil, err
}
default:
// NS and PTR are not supported due to FortiGate limitations
return nil, fmt.Errorf("record type %q is not supported by fortigate provider", rc.Type)
}
return rc, nil
}
func recordsToNative(recs models.Records) ([]*fgDNSRecord, []error) {
var resourceRecords []*fgDNSRecord
var errors []error
id := 1
for _, record := range recs {
n := &fgDNSRecord{
Status: "enable",
Type: strings.ToUpper(record.Type),
}
// TTL
if ttl := record.TTL; ttl > 0 {
n.TTL = ttl
}
// Wildcard support
if strings.Contains(record.GetLabelFQDN(), "*") {
errors = append(errors, fmt.Errorf("wildcard records are not supported by FortiGate: %s", record.GetLabelFQDN()))
continue
}
// Status from Metadata
if v, ok := record.Metadata["fortigate_status"]; ok && strings.ToLower(v) == "disable" {
n.Status = "disable"
}
// Hostname (Label)
label := record.GetLabel()
if label == "" {
label = "@"
}
n.Hostname = label
// Type-specific fields
switch n.Type {
case "A":
ip := record.GetTargetIP()
if ip == nil || ip.To4() == nil {
errors = append(errors, fmt.Errorf("a record is missing a valid IPv4 address: %s", record.GetLabelFQDN()))
continue
}
n.IP = ip.String()
case "AAAA":
ip := record.GetTargetIP()
if ip == nil || ip.To16() == nil || ip.To4() != nil {
errors = append(errors, fmt.Errorf("AAAA record is missing a valid IPv6 address: %s", record.GetLabelFQDN()))
continue
}
n.IPv6 = ip.String()
case "CNAME":
target := strings.TrimSuffix(record.GetTargetField(), ".")
if ascii, err := idna.ToASCII(target); err == nil {
target = ascii
}
n.CanonicalName = target
default:
errors = append(errors, fmt.Errorf("record type %q is not supported by FortiGate provider: %s", n.Type, record.GetLabelFQDN()))
continue
}
n.ID = id
id++
resourceRecords = append(resourceRecords, n)
}
return resourceRecords, errors
}
// ensureDot make sure an FQDN ends with a trailing dot
func ensureDot(fqdn string) string {
if fqdn == "" || strings.HasSuffix(fqdn, ".") {
return fqdn
}
return fqdn + "."
}

View file

@ -0,0 +1,204 @@
package fortigate
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/providers"
)
// Feature Declaration
var features = providers.DocumentationNotes{
providers.CanGetZones: providers.Can(),
providers.CanUsePTR: providers.Cannot(), // FortiGate does not really support ARPA Zones and handles PTR records really weired
providers.CanUseLOC: providers.Cannot(),
providers.CanConcur: providers.Unimplemented(),
providers.DocCreateDomains: providers.Can(),
providers.DocOfficiallySupported: providers.Cannot(), // unofficial integration
}
// Provider Registration
func init() {
const name = "FORTIGATE"
providers.RegisterDomainServiceProviderType(name, providers.DspFuncs{
Initializer: NewFortiGate,
RecordAuditor: AuditRecords,
}, features)
}
// Provider Struct
type fortigateProvider struct {
vdom string
host string
apiKey string
insecure bool
client *apiClient
}
// Constructor
func NewFortiGate(m map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) {
host, vdom, apiKey := m["host"], m["vdom"], m["apiKey"]
var missing []string
if host == "" {
missing = append(missing, "host")
}
if vdom == "" {
missing = append(missing, "vdom")
}
if apiKey == "" {
missing = append(missing, "apiKey")
}
if len(missing) > 0 {
return nil, errors.New("Fortigate provider: missing required field(s): " + strings.Join(missing, ", "))
}
insecure := strings.EqualFold(m["insecure_tls"], "true")
p := &fortigateProvider{
host: host,
vdom: vdom,
apiKey: apiKey,
insecure: insecure,
}
p.client = newClient(host, vdom, apiKey, insecure)
return p, nil
}
// Record Fetching
func (p *fortigateProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
records := models.Records{}
// Request the zone object from FortiGate
path := fmt.Sprintf("system/dns-database/%s", strings.TrimSuffix(domain, "."))
// According to the API, "results" is an array of objects
var resp struct {
Results []struct {
DNSEntry []fgDNSRecord `json:"dns-entry"`
} `json:"results"`
}
err := p.client.do("GET", path, nil, nil, &resp)
if err != nil {
if strings.Contains(err.Error(), "404") {
// Zone does not exist yet return empty record list
return records, nil
}
return nil, fmt.Errorf("fortigate: fetching zone %q failed: %w", domain, err)
}
if len(resp.Results) == 0 {
// Zone exists but no dns-entry data found
return records, nil
}
// Convert native records to dnscontrol Records
for _, n := range resp.Results[0].DNSEntry {
rc, err := nativeToRecord(domain, n)
if err != nil {
return nil, err
}
records = append(records, rc)
}
return records, nil
}
// Correction Planning
func (p *fortigateProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) {
domain := dc.Name
var corrections []*models.Correction
result, err := diff2.ByZone(existingRecords, dc, nil)
if err != nil {
return nil, 0, err
}
msgs, changed, actualChangeCount := result.Msgs, result.HasChanges, result.ActualChangeCount
if changed {
msgs = append(msgs, "Zone update for "+domain)
msg := strings.Join(msgs, "\n")
resourceRecords, errs := recordsToNative(result.DesiredPlus)
if len(errs) > 0 {
return nil, 0, fmt.Errorf("failed to convert records: %v", errs)
}
if resourceRecords == nil {
resourceRecords = []*fgDNSRecord{}
}
payload := map[string]any{
"forwarder": nil,
"dns-entry": resourceRecords,
}
corrections = append(corrections,
&models.Correction{
Msg: msg,
F: func() error {
if err := p.EnsureZoneExists(dc.Name); err != nil {
return err
}
return p.client.do("PUT", "system/dns-database/"+dc.Name, nil, payload, nil)
},
})
}
return corrections, actualChangeCount, nil
}
// Zone Existence Check & Creation
func (p *fortigateProvider) EnsureZoneExists(domain string) error {
var probe struct{ Results []any }
err := p.client.do("GET", "system/dns-database/"+domain, nil, nil, &probe)
switch {
case err == nil && len(probe.Results) > 0:
return nil // already exists
case isNotFound(err):
body := map[string]any{"name": domain, "domain": domain, "forwarder": nil}
return p.client.do("POST", "system/dns-database", nil, body, nil)
default:
return err
}
}
// Misc DNSControl Plumbing
func (p *fortigateProvider) GetNameservers(string) ([]*models.Nameserver, error) {
return nil, nil // FortiGate is authoritative only internally
}
func (p *fortigateProvider) ListZones() ([]string, error) {
var resp struct {
Results []struct{ Name string } `json:"results"`
}
if err := p.client.do("GET", "system/dns-database", nil, nil, &resp); err != nil {
return nil, err
}
zones := make([]string, len(resp.Results))
for i, z := range resp.Results {
zones[i] = z.Name
}
return zones, nil
}