From 3bfaa376414c15463461ac6161bd3a88c1813095 Mon Sep 17 00:00:00 2001 From: Luna Perego Date: Sat, 14 Mar 2026 19:33:23 +0100 Subject: [PATCH] NEW PROVIDER: OpenWrt --- .github/workflows/pr_integration_tests.yml | 7 +- .goreleaser.yml | 2 +- OWNERS | 1 + README.md | 1 + documentation/SUMMARY.md | 1 + documentation/provider/index.md | 5 + documentation/provider/openwrt.md | 54 ++++ integrationTest/profiles.json | 7 + pkg/providers/_all/all.go | 1 + providers/openwrt/api.go | 274 ++++++++++++++++++ providers/openwrt/auditrecords.go | 50 ++++ providers/openwrt/openwrtProvider.go | 309 +++++++++++++++++++++ 12 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 documentation/provider/openwrt.md create mode 100644 providers/openwrt/api.go create mode 100644 providers/openwrt/auditrecords.go create mode 100644 providers/openwrt/openwrtProvider.go diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index e63bb95fe6..98f936b623 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -65,7 +65,7 @@ jobs: Write-Host "Integration test providers: $Providers" echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT env: - PROVIDERS: "['ALIDNS', 'AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','GIDINET','HEDNS','HETZNER_V2','HUAWEICLOUD','INWX','JOKER','MIKROTIK','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP','UNIFI']" + PROVIDERS: "['ALIDNS', 'AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','GIDINET','HEDNS','HETZNER_V2','HUAWEICLOUD','INWX','JOKER','MIKROTIK','MYTHICBEASTS', 'NAMEDOTCOM','NS1','OPENWRT','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP','UNIFI']" ENV_CONTEXT: ${{ toJson(env) }} VARS_CONTEXT: ${{ toJson(vars) }} SECRETS_CONTEXT: ${{ toJson(secrets) }} @@ -109,6 +109,7 @@ jobs: MYTHICBEASTS_DOMAIN: ${{ vars.MYTHICBEASTS_DOMAIN }} NAMEDOTCOM_DOMAIN: ${{ vars.NAMEDOTCOM_DOMAIN }} NS1_DOMAIN: ${{ vars.NS1_DOMAIN }} + OPENWRT_DOMAIN: ${{ vars.OPENWRT_DOMAIN }} POWERDNS_DOMAIN: ${{ vars.POWERDNS_DOMAIN }} ROUTE53_DOMAIN: ${{ vars.ROUTE53_DOMAIN }} SAKURACLOUD_DOMAIN: ${{ vars.SAKURACLOUD_DOMAIN }} @@ -199,6 +200,10 @@ jobs: # NS1_TOKEN: ${{ secrets.NS1_TOKEN }} # + OPENWRT_USERNAME: ${{ secrets.OPENWRT_USERNAME }} + OPENWRT_PASSWORD: ${{ secrets.OPENWRT_PASSWORD }} + OPENWRT_HOST: ${{ secrets.OPENWRT_HOST }} + # POWERDNS_APIKEY: ${{ secrets.POWERDNS_APIKEY }} POWERDNS_APIURL: ${{ secrets.POWERDNS_APIURL }} POWERDNS_SERVERNAME: ${{ secrets.POWERDNS_SERVERNAME }} diff --git a/.goreleaser.yml b/.goreleaser.yml index fce02ca7cd..b90647766b 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|bind|bunny_dns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi_v5|gcloud|gcore|gidinet|hedns|hetzner|hetzner_v2|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|unifi|vercel|vultr).*:)+.*" + regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|bind|bunny_dns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi_v5|gcloud|gcore|gidinet|hedns|hetzner|hetzner_v2|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|openwrt|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|unifi|vercel|vultr).*:)+.*" order: 2 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/OWNERS b/OWNERS index 13ab5ad029..243e1f5b50 100644 --- a/OWNERS +++ b/OWNERS @@ -46,6 +46,7 @@ providers/netcup @kordianbruck providers/netlify @SphericalKat providers/ns1 @costasd # providers/opensrs NEEDS VOLUNTEER +providers/openwrt @huskyistaken providers/oracle @kallsyms providers/ovh @masterzen providers/packetframe @hamptonmoore diff --git a/README.md b/README.md index e580dc8715..3bdb3d912a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Currently supported DNS providers: - Netcup - Netlify - NS1 +- OpenWrt - Oracle Cloud - OVH - Packetframe diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index d12f5e864b..1a5eda2fa1 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -169,6 +169,7 @@ * [Netcup](provider/netcup.md) * [Netlify](provider/netlify.md) * [NS1](provider/ns1.md) +* [OpenWrt](provider/openwrt.md) * [OpenSRS](provider/opensrs.md) * [Oracle Cloud](provider/oracle.md) * [OVH](provider/ovh.md) diff --git a/documentation/provider/index.md b/documentation/provider/index.md index d25adf4d33..cfc4d28cfa 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -73,6 +73,7 @@ Jump to a table: | [`NETLIFY`](netlify.md) | ❌ | ✅ | ❌ | | [`NS1`](ns1.md) | ❌ | ✅ | ❌ | | [`OPENSRS`](opensrs.md) | ❌ | ❌ | ✅ | +| [`OPENWRT`](openwrt.md) | ❌ | ✅ | ❌ | | [`ORACLE`](oracle.md) | ❌ | ✅ | ❌ | | [`OVH`](ovh.md) | ❌ | ✅ | ✅ | | [`PACKETFRAME`](packetframe.md) | ❌ | ✅ | ❌ | @@ -140,6 +141,7 @@ Jump to a table: | [`NETLIFY`](netlify.md) | ✅ | ❌ | ❌ | ✅ | | [`NS1`](ns1.md) | ✅ | ✅ | ✅ | ✅ | | [`OPENSRS`](opensrs.md) | ❔ | ❔ | ❌ | ❔ | +| [`OPENWRT`](openwrt.md) | ❔ | ❔ | ❌ | ✅ | | [`ORACLE`](oracle.md) | ❔ | ✅ | ✅ | ✅ | | [`OVH`](ovh.md) | ❔ | ✅ | ❌ | ✅ | | [`PACKETFRAME`](packetframe.md) | ❔ | ❌ | ❌ | ❔ | @@ -202,6 +204,7 @@ Jump to a table: | [`NETCUP`](netcup.md) | ❔ | ❔ | ❌ | ❌ | ❔ | | [`NETLIFY`](netlify.md) | ✅ | ❔ | ❌ | ❌ | ❔ | | [`NS1`](ns1.md) | ✅ | ✅ | ❌ | ✅ | ❔ | +| [`OPENWRT`](openwrt.md) | ❌ | ❔ | ❔ | ❔ | ❔ | | [`ORACLE`](oracle.md) | ✅ | ❔ | ❔ | ✅ | ❔ | | [`OVH`](ovh.md) | ❌ | ❔ | ❔ | ❌ | ❔ | | [`PACKETFRAME`](packetframe.md) | ❔ | ❔ | ❔ | ✅ | ❔ | @@ -263,6 +266,7 @@ Jump to a table: | [`NETCUP`](netcup.md) | ❔ | ❔ | ✅ | ❔ | | [`NETLIFY`](netlify.md) | ❔ | ❌ | ✅ | ❔ | | [`NS1`](ns1.md) | ✅ | ✅ | ✅ | ✅ | +| [`OPENWRT`](openwrt.md) | ❔ | ❔ | ✅ | ❔ | | [`ORACLE`](oracle.md) | ❔ | ✅ | ✅ | ❔ | | [`OVH`](ovh.md) | ❔ | ❔ | ✅ | ❔ | | [`PACKETFRAME`](packetframe.md) | ❔ | ❔ | ✅ | ❔ | @@ -460,6 +464,7 @@ Providers in this category and their maintainers are: |[`NETLIFY`](netlify.md)|@SphericalKat| |[`NS1`](ns1.md)|@costasd| |[`OPENSRS`](opensrs.md)|@philhug| +|[`OPENWRT`](openwrt.md)|@huskyistaken| |[`ORACLE`](oracle.md)|@kallsyms| |[`OVH`](ovh.md)|@masterzen| |[`PACKETFRAME`](packetframe.md)|@hamptonmoore| diff --git a/documentation/provider/openwrt.md b/documentation/provider/openwrt.md new file mode 100644 index 0000000000..9925db44e1 --- /dev/null +++ b/documentation/provider/openwrt.md @@ -0,0 +1,54 @@ +This is the provider for [OpenWrt](https://openwrt.org/). + +## Important notes + +This provider only supports the following record types. + +* [A](../language-reference/domain-modifiers/A.md) +* [AAAA](../language-reference/domain-modifiers/AAAA.md) +* [CNAME](../language-reference/domain-modifiers/CNAME.md) +* [MX](../language-reference/domain-modifiers/MX.md) +* [SRV](../language-reference/domain-modifiers/SRV.md) + +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `OPENWRT`. + +Required fields include: + +* `username` and `password`: Authentication information +* `host`: The hostname/address of OpenWrt instance + +Example: + +{% code title="creds.json" %} +```json +{ + "openwrt": { + "TYPE": "OPENWRT", + "username": "root", + "password": "your-password", + "host": "http://192.168.1.1" + } +} +``` +{% endcode %} + +## Usage + +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var REG_NONE = NewRegistrar("none"); +var DSP_OPENWRT = NewDnsProvider("openwrt"); + +D("example.com", REG_NONE, DnsProvider(DSP_OPENWRT), + A("foo", "1.2.3.4"), + AAAA("another", "2003::1"), + CNAME("myalias", "www.example.com."), + MX("@", 5, "mail"), + SRV("_sip._tcp", 10, 60, 5060, "pbx.example.com."), +); +``` +{% endcode %} diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index b20a260d29..ef6d0fcdc2 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -290,6 +290,13 @@ "api_token": "$NS1_TOKEN", "domain": "$NS1_DOMAIN" }, + "OPENWRT": { + "TYPE": "OPENWRT", + "domain": "$OPENWRT_DOMAIN", + "host": "$OPENWRT_HOST", + "username": "$OPENWRT_USERNAME", + "password": "$OPENWRT_PASSWORD" + }, "ORACLE": { "TYPE": "ORACLE", "compartment": "$ORACLE_COMPARTMENT", diff --git a/pkg/providers/_all/all.go b/pkg/providers/_all/all.go index 621613c877..b964c0a0a2 100644 --- a/pkg/providers/_all/all.go +++ b/pkg/providers/_all/all.go @@ -50,6 +50,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v4/providers/netcup" _ "github.com/StackExchange/dnscontrol/v4/providers/netlify" _ "github.com/StackExchange/dnscontrol/v4/providers/ns1" + _ "github.com/StackExchange/dnscontrol/v4/providers/openwrt" _ "github.com/StackExchange/dnscontrol/v4/providers/opensrs" _ "github.com/StackExchange/dnscontrol/v4/providers/oracle" _ "github.com/StackExchange/dnscontrol/v4/providers/ovh" diff --git a/providers/openwrt/api.go b/providers/openwrt/api.go new file mode 100644 index 0000000000..1d86e4ec9d --- /dev/null +++ b/providers/openwrt/api.go @@ -0,0 +1,274 @@ +package openwrt + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/StackExchange/dnscontrol/v4/pkg/printer" +) + +var idCounter uint = 0 + +type rpcRequest struct { + ID uint `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +func getAuthorization(username string, password string, host string) (string, error) { + idCounter += 1 + reqBody, err := json.Marshal(rpcRequest{ + ID: idCounter, + Method: "login", + Params: []any{ + username, + password, + }, + }) + if err != nil { + return "", err + } + + client := &http.Client{} + req, _ := http.NewRequest( + http.MethodPost, + host+"/cgi-bin/luci/rpc/auth", + bytes.NewBuffer(reqBody), + ) + + retryCount := 0 + +retry: + resp, err := client.Do(req) + if err != nil { + return "", err + } + + bodyString, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable { + retryCount++ + if retryCount == 5 { + return string(bodyString), errors.New("rate limit exceeded") + } + printer.Warnf("rate limiting.. waiting for %d second(s)\n", retryCount*10) + time.Sleep(time.Second * time.Duration(retryCount*10)) + goto retry + } + + if resp.StatusCode != http.StatusOK { + return "", errors.New(string(bodyString)) + } + + var responseStruct struct { + ID int `json:"id"` + Result string `json:"result"` + Error error `json:"error"` + } + err = json.Unmarshal(bodyString, &responseStruct) + if err != nil { + return "", err + } + return responseStruct.Result, responseStruct.Error +} + +func (c *openwrtProvider) getRecords(domain string) ([]rewriteEntity, error) { + resp, err := c.uciGetAll() + if err != nil { + return nil, fmt.Errorf("failed to fetch records from openwrt: %w", err) + } + + records := make([]rewriteEntity, 0) + for _, record := range resp { + var recDomain string + switch record.Type { + case "domain": + recDomain = record.Name + case "cname": + recDomain = record.Cname + case "mxhost": + recDomain = record.Domain + case "srvhost": + recDomain = record.Srv + default: + continue + } + recDomain = strings.TrimRight(recDomain, ".") + + if !strings.HasSuffix(recDomain, "."+domain) && recDomain != domain { + continue + } + + records = append(records, record) + } + + return records, nil +} + +func (c *openwrtProvider) uciApply() ([]any, error) { + resp, err := c.uciCall("apply", []any{}) + if err != nil { + return nil, err + } + + var response struct { + ID int `json:"id"` + Result []any `json:"result"` + Error error `json:"error"` + } + err = json.Unmarshal(resp, &response) + if err != nil { + return nil, err + } + + return response.Result, response.Error +} + +func (c *openwrtProvider) uciSection(sectionType string, values rewriteEntity) (bool, error) { + resp, err := c.uciCall("section", []any{"dhcp", sectionType, nil, values}) + if err != nil { + return false, err + } + + var response struct { + ID int `json:"id"` + Result bool `json:"result"` + Error error `json:"error"` + } + err = json.Unmarshal(resp, &response) + if err != nil { + return false, err + } + + if !response.Result { + return false, errors.New("failed to create record") + } + + return response.Result, response.Error +} + +func (c *openwrtProvider) uciDelete(section string) (bool, error) { + resp, err := c.uciCall("delete", []any{"dhcp", section}) + if err != nil { + return false, err + } + + var response struct { + ID int `json:"id"` + Result bool `json:"result"` + Error error `json:"error"` + } + err = json.Unmarshal(resp, &response) + if err != nil { + return false, err + } + + if !response.Result { + return false, errors.New("failed to delete record") + } + + return response.Result, response.Error +} + +func (c *openwrtProvider) uciTset(section string, values rewriteEntity) (bool, error) { + resp, err := c.uciCall("tset", []any{"dhcp", section, values}) + if err != nil { + return false, err + } + + var response struct { + ID int `json:"id"` + Result bool `json:"result"` + Error error `json:"error"` + } + err = json.Unmarshal(resp, &response) + if err != nil { + return false, err + } + + if !response.Result { + return false, errors.New("failed to modify record") + } + + return response.Result, response.Error +} + +func (c *openwrtProvider) uciGetAll() (map[string]rewriteEntity, error) { + resp, err := c.uciCall("get_all", []any{"dhcp"}) + if err != nil { + return nil, err + } + + var response struct { + ID int `json:"id"` + Result map[string]rewriteEntity `json:"result"` + Error error `json:"error"` + } + err = json.Unmarshal(resp, &response) + if err != nil { + return nil, err + } + + return response.Result, response.Error +} + +func (c *openwrtProvider) uciCall(method string, params []any) ([]byte, error) { + client := &http.Client{} + + idCounter += 1 + requestBody, err := json.Marshal(rpcRequest{ + ID: idCounter, + Method: method, + Params: params, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + http.MethodGet, + c.host+"/cgi-bin/luci/rpc/uci?auth="+c.auth, + bytes.NewReader(requestBody), + ) + if err != nil { + return nil, err + } + + retryCount := 0 + +retry: + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + bodyString, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable { + retryCount++ + if retryCount == 5 { + return bodyString, errors.New("rate limit exceeded") + } + printer.Warnf("rate limiting.. waiting for %d second(s)\n", retryCount*10) + time.Sleep(time.Second * time.Duration(retryCount*10)) + goto retry + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.New(string(bodyString)) + } + + return bodyString, nil +} diff --git a/providers/openwrt/auditrecords.go b/providers/openwrt/auditrecords.go new file mode 100644 index 0000000000..a1f150cf5e --- /dev/null +++ b/providers/openwrt/auditrecords.go @@ -0,0 +1,50 @@ +package openwrt + +import ( + "fmt" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/rejectif" +) + +var supportedRTypes = map[string]struct{}{ + "A": {}, + "AAAA": {}, + "CNAME": {}, + "MX": {}, + "SRV": {}, +} + +// AuditRecords returns a list of errors corresponding to the records +// that aren't supported by this provider. If all records are +// supported, an empty list is returned. +func AuditRecords(records []*models.RecordConfig) []error { + a := rejectif.Auditor{} + + // MX records cannot have null/empty target + a.Add("MX", rejectif.MxNull) + + // SRV records cannot have null target + a.Add("SRV", rejectif.SrvHasNullTarget) + + // Start with auditor errors + var errors []error + errors = append(errors, a.Audit(records)...) + + // Check for unsupported record types + for _, r := range records { + if _, ok := supportedRTypes[r.Type]; !ok { + errors = append(errors, fmt.Errorf("record type %q is not supported by OpenWrt", r.Type)) + } + + // OpenWrt doesn't support wildcard CNAMEs + if r.Type == "CNAME" && r.GetLabel() == "*" { + errors = append(errors, fmt.Errorf("OpenWrt does not support wildcard CNAME records")) + } + } + + if len(errors) == 0 { + return nil + } + return errors +} diff --git a/providers/openwrt/openwrtProvider.go b/providers/openwrt/openwrtProvider.go new file mode 100644 index 0000000000..3f71804544 --- /dev/null +++ b/providers/openwrt/openwrtProvider.go @@ -0,0 +1,309 @@ +package openwrt + +import ( + "encoding/json" + "errors" + "fmt" + "net/netip" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/diff2" + "github.com/StackExchange/dnscontrol/v4/pkg/printer" + "github.com/StackExchange/dnscontrol/v4/pkg/providers" +) + +type openwrtProvider struct { + auth string + host string +} + +type rewriteEntity struct { + Section string `json:".name,omitempty"` + Type string `json:".type,omitempty"` + + // A + Name string `json:"name,omitempty"` + IP string `json:"ip,omitempty"` + + // CNAME + Cname string `json:"cname,omitempty"` + Target string `json:"target,omitempty"` + + // MX + Domain string `json:"domain,omitempty"` + Relay string `json:"relay,omitempty"` + Pref string `json:"pref,omitempty"` + + // SRV + Srv string `json:"srv,omitempty"` + Priority string `json:"class,omitempty"` + Weight string `json:"weight,omitempty"` + Port string `json:"port,omitempty"` + // Target string `json:"target,omitempty"` +} + +func newDsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + return newOpenwrt(conf, metadata) +} + +// newOpenwrt creates the provider. +func newOpenwrt(conf map[string]string, _ json.RawMessage) (*openwrtProvider, error) { + if conf["username"] == "" { + return nil, errors.New("missing openwrt username") + } + if conf["password"] == "" { + return nil, errors.New("missing openwrt password") + } + if conf["host"] == "" { + return nil, errors.New("missing openwrt host") + } + + host := conf["host"] + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + host = "http://" + host + } + + auth, err := getAuthorization(conf["username"], conf["password"], host) + if err != nil { + return nil, fmt.Errorf("could not login: %w", err) + } + + return &openwrtProvider{auth: auth, host: host}, nil +} + +var features = providers.DocumentationNotes{ + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +func init() { + const providerName = "OPENWRT" + const providerMaintainer = "@huskyistaken" + fns := providers.DspFuncs{ + Initializer: newDsp, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) +} + +// GetNameservers returns the nameservers for a domain. +func (c *openwrtProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + return []*models.Nameserver{}, nil +} + +// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. +func (c *openwrtProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { + // TTLs don't matter in OPENWRT and + // we use the default value of 300 + for _, record := range dc.Records { + record.TTL = 300 + } + + var corrections []*models.Correction + + changes, actualChangeCount, err := diff2.ByRecord(existingRecords, dc, + func(rec *models.RecordConfig) string { return "" }, + ) + if err != nil { + return nil, 0, err + } + for _, change := range changes { + var corr *models.Correction + switch change.Type { + case diff2.REPORT: + printer.Warnf("diff2 report message\n") + corr = &models.Correction{Msg: change.MsgsJoined} + + case diff2.CREATE: + var recordType string + switch change.New[0].Type { + case "A", "AAAA": + recordType = "domain" + case "CNAME": + recordType = "cname" + case "SRV": + recordType = "srvhost" + case "MX": + recordType = "mxhost" + } + re, err := toRewriteEntry(change.New[0]) + if err != nil { + return nil, 0, err + } + + corr = &models.Correction{ + Msg: change.Msgs[0], + F: func() error { + _, err := c.uciSection(recordType, re) + return err + }, + } + + case diff2.DELETE: + section := change.Old[0].Original.(rewriteEntity).Section + corr = &models.Correction{ + Msg: change.Msgs[0], + F: func() error { + fmt.Println(section) + _, err := c.uciDelete(section) + return err + }, + } + + case diff2.CHANGE: + section := change.Old[0].Original.(rewriteEntity).Section + re, err := toRewriteEntry(change.New[0]) + if err != nil { + return nil, 0, err + } + corr = &models.Correction{ + Msg: change.Msgs[0], + F: func() error { + _, err := c.uciTset(section, re) + return err + }, + } + + default: + panic(fmt.Sprintf("unhandled change.Type %s", change.Type)) + } + + corrections = append(corrections, corr) + } + + // Apply changes last, changes cannot be applied incrementally + // because doing so shifts the section names, making deleting + // records unreliable + if actualChangeCount > 0 { + corrections = append(corrections, &models.Correction{ + Msg: "Applying changes", + F: func() error { + _, err := c.uciApply() + return err + }, + }) + } + + return corrections, actualChangeCount, nil +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (c *openwrtProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records, error) { + domain := dc.Name + + records, err := c.getRecords(domain) + if err != nil { + return nil, err + } + + existingRecords := make([]*models.RecordConfig, 0) + for _, r := range records { + rc, err := toRc(domain, r) + if err != nil { + return nil, err + } + existingRecords = append(existingRecords, rc) + } + + return existingRecords, nil +} + +func toRc(domain string, r rewriteEntity) (*models.RecordConfig, error) { + rc := &models.RecordConfig{ + TTL: 300, + Original: r, + } + var recDomain string + + switch r.Type { + case "domain": + recDomain = r.Name + addr, err := netip.ParseAddr(r.IP) + if err != nil { + return nil, err + } + + rc.SetTargetIP(addr) + switch { + case addr.Is4(): + rc.Type = "A" + case addr.Is6(): + rc.Type = "AAAA" + } + + case "cname": + recDomain = r.Cname + rc.Type = "CNAME" + rc.SetTarget(r.Target) + + case "mxhost": + recDomain = r.Domain + rc.Type = "MX" + pref, err := strconv.ParseUint(r.Pref, 10, 16) + if err != nil { + return nil, err + } + rc.SetTargetMX(uint16(pref), r.Relay) + + case "srvhost": + recDomain = r.Srv + rc.Type = "SRV" + priority, err := strconv.ParseUint(r.Priority, 10, 16) + if err != nil { + return nil, err + } + weight, err := strconv.ParseUint(r.Weight, 10, 16) + if err != nil { + return nil, err + } + port, err := strconv.ParseUint(r.Port, 10, 16) + if err != nil { + return nil, err + } + rc.SetTargetSRV(uint16(priority), uint16(weight), uint16(port), r.Target) + + default: + return nil, fmt.Errorf("unhandled record type: %s", r.Type) + } + + rc.SetLabelFromFQDN(recDomain, domain) + + return rc, nil +} + +func toRewriteEntry(rc *models.RecordConfig) (rewriteEntity, error) { + var newRecordEntry rewriteEntity + + // omits .type and .name + switch rc.Type { + case "A", "AAAA": + newRecordEntry.Name = rc.NameFQDN + newRecordEntry.IP = rc.GetTargetIP().String() + + case "CNAME": + newRecordEntry.Cname = rc.NameFQDN + newRecordEntry.Target = rc.GetTargetField() + + case "SRV": + newRecordEntry.Srv = rc.NameFQDN + newRecordEntry.Priority = string(strconv.Itoa(int(rc.SrvPriority))) + newRecordEntry.Weight = strconv.Itoa(int(rc.SrvWeight)) + newRecordEntry.Port = strconv.Itoa(int(rc.SrvPort)) + newRecordEntry.Target = rc.GetTargetField() + + case "MX": + newRecordEntry.Domain = rc.NameFQDN + newRecordEntry.Pref = strconv.Itoa(int(rc.MxPreference)) + newRecordEntry.Relay = rc.GetTargetField() + + default: + return rewriteEntity{}, fmt.Errorf("unhandled record type: %s", rc.Type) + } + + return newRecordEntry, nil +}