mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-09-17 18:45:19 +08:00
NEW PROVIDE: FORTIGATE (#3642)
This commit is contained in:
parent
4672409f0b
commit
3bdbb48164
14 changed files with 663 additions and 8 deletions
7
.github/workflows/pr_integration_tests.yml
vendored
7
.github/workflows/pr_integration_tests.yml
vendored
|
@ -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 }}
|
||||
|
|
|
@ -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
1
OWNERS
|
@ -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
|
||||
|
|
|
@ -33,6 +33,7 @@ Currently supported DNS providers:
|
|||
- DNSimple
|
||||
- Domainnameshop (Domeneshop)
|
||||
- Exoscale
|
||||
- Fortigate
|
||||
- Gandi
|
||||
- Gcore
|
||||
- Google DNS
|
||||
|
|
|
@ -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)
|
||||
|
|
66
documentation/provider/fortigate.md
Normal file
66
documentation/provider/fortigate.md
Normal 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**.
|
|
@ -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) | ✅ | ❔ | ❌ | ✅ | ❔ |
|
||||
|
|
|
@ -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.")),
|
||||
),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
167
providers/fortigate/api.go
Normal 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 (self‑signed, 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 non‑2xx 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 non‑success 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")
|
||||
}
|
39
providers/fortigate/auditrecords.go
Normal file
39
providers/fortigate/auditrecords.go
Normal 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
|
||||
}
|
155
providers/fortigate/converter.go
Normal file
155
providers/fortigate/converter.go
Normal 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 + "."
|
||||
}
|
204
providers/fortigate/fortigateProvider.go
Normal file
204
providers/fortigate/fortigateProvider.go
Normal 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
|
||||
}
|
Loading…
Add table
Reference in a new issue