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" Write-Host "Integration test providers: $Providers"
echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT
env: 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) }} ENV_CONTEXT: ${{ toJson(env) }}
VARS_CONTEXT: ${{ toJson(vars) }} VARS_CONTEXT: ${{ toJson(vars) }}
SECRETS_CONTEXT: ${{ toJson(secrets) }} SECRETS_CONTEXT: ${{ toJson(secrets) }}
@ -83,6 +83,7 @@ jobs:
CNR_DOMAIN: ${{ vars.CNR_DOMAIN }} CNR_DOMAIN: ${{ vars.CNR_DOMAIN }}
CSCGLOBAL_DOMAIN: ${{ vars.CSCGLOBAL_DOMAIN }} CSCGLOBAL_DOMAIN: ${{ vars.CSCGLOBAL_DOMAIN }}
DIGITALOCEAN_DOMAIN: ${{ vars.DIGITALOCEAN_DOMAIN }} DIGITALOCEAN_DOMAIN: ${{ vars.DIGITALOCEAN_DOMAIN }}
FORTIGATE_DOMAIN: ${{ vars.FORTIGATE_DOMAIN }}
GANDI_V5_DOMAIN: ${{ vars.GANDI_V5_DOMAIN }} GANDI_V5_DOMAIN: ${{ vars.GANDI_V5_DOMAIN }}
GCLOUD_DOMAIN: ${{ vars.GCLOUD_DOMAIN }} GCLOUD_DOMAIN: ${{ vars.GCLOUD_DOMAIN }}
HEDNS_DOMAIN: ${{ vars.HEDNS_DOMAIN }} HEDNS_DOMAIN: ${{ vars.HEDNS_DOMAIN }}
@ -137,6 +138,10 @@ jobs:
# #
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} 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 }} GANDI_V5_APIKEY: ${{ secrets.GANDI_V5_APIKEY }}
# #
GCLOUD_EMAIL: ${{ secrets.GCLOUD_EMAIL }} GCLOUD_EMAIL: ${{ secrets.GCLOUD_EMAIL }}

View file

@ -39,7 +39,7 @@ changelog:
regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$"
order: 1 order: 1
- title: 'Provider-specific changes:' - 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 order: 2
- title: 'Documentation:' - title: 'Documentation:'
regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" regexp: "(?i)^.*(docs)[(\\w)]*:+.*$"

1
OWNERS
View file

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

View file

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

View file

@ -123,6 +123,7 @@
* [Dynadot](provider/dynadot.md) * [Dynadot](provider/dynadot.md)
* [easyname](provider/easyname.md) * [easyname](provider/easyname.md)
* [Exoscale](provider/exoscale.md) * [Exoscale](provider/exoscale.md)
* [Fortigate](provider/fortigate.md)
* [Gandi_v5](provider/gandi_v5.md) * [Gandi_v5](provider/gandi_v5.md)
* [Gcore](provider/gcore.md) * [Gcore](provider/gcore.md)
* [Google Cloud DNS](provider/gcloud.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) | ❌ | ❌ | ✅ | | [`DYNADOT`](dynadot.md) | ❌ | ❌ | ✅ |
| [`EASYNAME`](easyname.md) | ❌ | ❌ | ✅ | | [`EASYNAME`](easyname.md) | ❌ | ❌ | ✅ |
| [`EXOSCALE`](exoscale.md) | ❌ | ✅ | ❌ | | [`EXOSCALE`](exoscale.md) | ❌ | ✅ | ❌ |
| [`FORTIGATE`](fortigate.md) | ❌ | ✅ | ❌ |
| [`GANDI_V5`](gandi_v5.md) | ❌ | ✅ | ✅ | | [`GANDI_V5`](gandi_v5.md) | ❌ | ✅ | ✅ |
| [`GCLOUD`](gcloud.md) | ✅ | ✅ | ❌ | | [`GCLOUD`](gcloud.md) | ✅ | ✅ | ❌ |
| [`GCORE`](gcore.md) | ❌ | ✅ | ❌ | | [`GCORE`](gcore.md) | ❌ | ✅ | ❌ |
@ -103,6 +104,7 @@ Jump to a table:
| [`DYNADOT`](dynadot.md) | ❔ | ❔ | ❌ | ❔ | | [`DYNADOT`](dynadot.md) | ❔ | ❔ | ❌ | ❔ |
| [`EASYNAME`](easyname.md) | ❔ | ❔ | ❌ | ❔ | | [`EASYNAME`](easyname.md) | ❔ | ❔ | ❌ | ❔ |
| [`EXOSCALE`](exoscale.md) | ❔ | ❌ | ❌ | ❔ | | [`EXOSCALE`](exoscale.md) | ❔ | ❌ | ❌ | ❔ |
| [`FORTIGATE`](fortigate.md) | ❔ | ❔ | ✅ | ✅ |
| [`GANDI_V5`](gandi_v5.md) | ✅ | ❔ | ❌ | ✅ | | [`GANDI_V5`](gandi_v5.md) | ✅ | ❔ | ❌ | ✅ |
| [`GCLOUD`](gcloud.md) | ✅ | ✅ | ✅ | ✅ | | [`GCLOUD`](gcloud.md) | ✅ | ✅ | ✅ | ✅ |
| [`GCORE`](gcore.md) | ✅ | ✅ | ✅ | ✅ | | [`GCORE`](gcore.md) | ✅ | ✅ | ✅ | ✅ |
@ -158,6 +160,7 @@ Jump to a table:
| [`DNSMADEEASY`](dnsmadeeasy.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`DNSMADEEASY`](dnsmadeeasy.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`DOMAINNAMESHOP`](domainnameshop.md) | ❔ | ❔ | ❌ | ❌ | ❌ | | [`DOMAINNAMESHOP`](domainnameshop.md) | ❔ | ❔ | ❌ | ❌ | ❌ |
| [`EXOSCALE`](exoscale.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`EXOSCALE`](exoscale.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`FORTIGATE`](fortigate.md) | ❔ | ❔ | ❌ | ❌ | ❔ |
| [`GANDI_V5`](gandi_v5.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`GANDI_V5`](gandi_v5.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`GCLOUD`](gcloud.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`GCLOUD`](gcloud.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`GCORE`](gcore.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`GCORE`](gcore.md) | ✅ | ❔ | ❌ | ✅ | ❔ |

View file

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

View file

@ -128,6 +128,14 @@
"domain": "$EXOSCALE_DOMAIN", "domain": "$EXOSCALE_DOMAIN",
"secretkey": "$EXOSCALE_SECRET_KEY" "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": { "GANDI_V5": {
"TYPE": "GANDI_V5", "TYPE": "GANDI_V5",
"apikey": "$GANDI_V5_APIKEY", "apikey": "$GANDI_V5_APIKEY",

View file

@ -23,6 +23,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v4/providers/dynadot" _ "github.com/StackExchange/dnscontrol/v4/providers/dynadot"
_ "github.com/StackExchange/dnscontrol/v4/providers/easyname" _ "github.com/StackExchange/dnscontrol/v4/providers/easyname"
_ "github.com/StackExchange/dnscontrol/v4/providers/exoscale" _ "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/gandiv5"
_ "github.com/StackExchange/dnscontrol/v4/providers/gcloud" _ "github.com/StackExchange/dnscontrol/v4/providers/gcloud"
_ "github.com/StackExchange/dnscontrol/v4/providers/gcore" _ "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
}