From 0bf851ec062763364ffaf2a7087b7e19b57f7d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=B8?= <37530178+riku22@users.noreply.github.com> Date: Fri, 10 Mar 2023 00:15:59 +0900 Subject: [PATCH] New provider: LuaDNS (#2127) --- OWNERS | 1 + README.md | 1 + documentation/SUMMARY.md | 1 + documentation/providers.md | 2 + documentation/providers/luadns.md | 42 +++++ integrationTest/providers.json | 5 + providers/_all/all.go | 1 + providers/luadns/api.go | 251 +++++++++++++++++++++++++++++ providers/luadns/auditrecords.go | 15 ++ providers/luadns/luadnsProvider.go | 212 ++++++++++++++++++++++++ 10 files changed, 531 insertions(+) create mode 100644 documentation/providers/luadns.md create mode 100644 providers/luadns/api.go create mode 100644 providers/luadns/auditrecords.go create mode 100644 providers/luadns/luadnsProvider.go diff --git a/OWNERS b/OWNERS index af894577b..1082e6c74 100644 --- a/OWNERS +++ b/OWNERS @@ -25,6 +25,7 @@ providers/internetbs @pragmaton providers/inwx @patschi providers/msdns @tlimoncelli providers/linode @koesie10 +providers/luadns @riku22 providers/namecheap @willpower232 # providers/namedotcom NEEDS VOLUNTEER providers/netcup @kordianbruck diff --git a/README.md b/README.md index 7de7de932..39b6e2aa5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Currently supported DNS providers: - Hurricane Electric DNS - INWX - Linode +- LuaDNS - Microsoft Windows Server DNS Server - NS1 - Name.com diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index 9d149cd97..a48e0c94e 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -111,6 +111,7 @@ * [Internet.bs](providers/internetbs.md) * [INWX](providers/inwx.md) * [Linode](providers/linode.md) + * [LuaDNS](providers/luadns.md) * [Microsoft DNS Server on Microsoft Windows Server](providers/msdns.md) * [Namecheap](providers/namecheap.md) * [Name.com](providers/namedotcom.md) diff --git a/documentation/providers.md b/documentation/providers.md index af5c51e5e..48a8b5ea2 100644 --- a/documentation/providers.md +++ b/documentation/providers.md @@ -40,6 +40,7 @@ If a feature is definitively not supported for whatever reason, we would also li | `INTERNETBS` | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ | | `INWX` | ❌ | ✅ | ✅ | ❌ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | | `LINODE` | ❌ | ✅ | ❌ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ | +| `LUADNS` | ✅ | ✅ | ❌ | ✅ | ❔ | ✅ | ✅ | ❔ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | | `MSDNS` | ✅ | ✅ | ❌ | ❌ | ❔ | ❌ | ✅ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ | | `NAMECHEAP` | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ❌ | ❔ | ❔ | ❌ | ❔ | ❌ | ❔ | ❌ | ❌ | ❌ | ✅ | | `NAMEDOTCOM` | ✅ | ✅ | ✅ | ✅ | ❔ | ❔ | ❌ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ✅ | ❌ | ✅ | ✅ | @@ -120,6 +121,7 @@ Providers in this category and their maintainers are: |`INTERNETBS`|@pragmaton| |`INWX`|@svenpeter42| |`LINODE`|@koesie10| +|`LUADNS`|@riku22| |`NAMECHEAP`|@willpower232| |`NETCUP`|@kordianbruck| |`NETLIFY`|@SphericalKat| diff --git a/documentation/providers/luadns.md b/documentation/providers/luadns.md new file mode 100644 index 000000000..345fc1d59 --- /dev/null +++ b/documentation/providers/luadns.md @@ -0,0 +1,42 @@ +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `LUADNS` +along with your [email and API key](https://www.luadns.com/api.html#authentication). + +Example: + +{% code title="creds.json" %} +```json +{ + "luadns": { + "TYPE": "LUADNS", + "email": "your-email", + "apikey": "your-api-key" + } +} +``` +{% endcode %} + +## Metadata +This provider does not recognize any special metadata fields unique to LuaDNS. + +## Usage +An example `dnsconfig.js` configuration: + +```javascript +var REG_NONE = NewRegistrar("none"); +var DSP_LUADNS = NewDnsProvider("luadns"); + +D("example.tld", REG_NONE, DnsProvider(DSP_LUADNS), + A("test", "1.2.3.4") +); +``` + +## Activation +[Create API key](https://api.luadns.com/api_keys). + +## Caveats +- LuaDNS cannot change the default nameserver TTL in `nameserver_ttl`, it is forced to fixed at 86400("1d"). +This is not the case if you are using vanity nameservers. +- This provider does not currently support the "FORWARD" and "REDIRECT" record types. +- The API is available on the LuaDNS free plan, but due to the limit of 30 records, some tests will fail when doing integration tests. diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 5888e61da..0d9496bf4 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -134,6 +134,11 @@ "domain": "$LINODE_DOMAIN", "token": "$LINODE_TOKEN" }, + "LUADNS": { + "domain": "$LUADNS_DOMAIN", + "email": "$LUADNS_EMAIL", + "apikey": "$LUADNS_APIKEY" + }, "MSDNS": { "domain": "$MSDNS_DOMAIN", "dnsserver": "$MSDNS_DNSSERVER", diff --git a/providers/_all/all.go b/providers/_all/all.go index 3479855bc..fab691db5 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -29,6 +29,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/internetbs" _ "github.com/StackExchange/dnscontrol/v3/providers/inwx" _ "github.com/StackExchange/dnscontrol/v3/providers/linode" + _ "github.com/StackExchange/dnscontrol/v3/providers/luadns" _ "github.com/StackExchange/dnscontrol/v3/providers/msdns" _ "github.com/StackExchange/dnscontrol/v3/providers/namecheap" _ "github.com/StackExchange/dnscontrol/v3/providers/namedotcom" diff --git a/providers/luadns/api.go b/providers/luadns/api.go new file mode 100644 index 000000000..3e8d4d677 --- /dev/null +++ b/providers/luadns/api.go @@ -0,0 +1,251 @@ +package luadns + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/StackExchange/dnscontrol/v3/models" +) + +// Api layer for LuaDNS + +const ( + apiURL = "https://api.luadns.com/v1" +) + +type luadnsProvider struct { + domainIndex map[string]uint32 + nameserversNames []string + creds struct { + email string + apikey string + } +} + +type errorResponse struct { + Status string `json:"status"` + RequestID string `json:"request_id"` + Message string `json:"message"` +} + +type userInfoResponse struct { + Email string `json:"email"` + Name string `json:"name"` + TTL uint32 `json:"ttl"` + NameServers []string `json:"name_servers"` +} + +type zoneRecord struct { + ID uint32 `json:"id"` + Name string `json:"name"` +} + +type zoneResponse []zoneRecord + +type domainRecord struct { + ID uint32 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + TTL uint32 `json:"ttl"` +} + +type recordResponse []domainRecord + +type requestParams map[string]string +type jsonRequestParams map[string]any + +func (l *luadnsProvider) fetchAvailableNameservers() error { + l.nameserversNames = nil + var bodyString, err = l.get("/users/me", "GET", requestParams{}) + if err != nil { + return fmt.Errorf("failed fetching available nameservers list from LuaDNS: %s", err) + } + var ui userInfoResponse + json.Unmarshal(bodyString, &ui) + l.nameserversNames = ui.NameServers + return nil +} + +func (l *luadnsProvider) fetchDomainList() error { + l.domainIndex = map[string]uint32{} + var bodyString, err = l.get("/zones", "GET", requestParams{}) + if err != nil { + return fmt.Errorf("failed fetching domain list from LuaDNS: %s", err) + } + var dr zoneResponse + json.Unmarshal(bodyString, &dr) + for _, domain := range dr { + l.domainIndex[domain.Name] = domain.ID + } + return nil +} + +func (l *luadnsProvider) getDomainID(name string) (uint32, error) { + if l.domainIndex == nil { + if err := l.fetchDomainList(); err != nil { + return 0, err + } + } + id, ok := l.domainIndex[name] + if !ok { + return 0, fmt.Errorf("'%s' not a zone in luadns account", name) + } + return id, nil +} + +func (l *luadnsProvider) createDomain(domain string) error { + params := jsonRequestParams{ + "name": domain, + } + if _, err := l.get("/zones", "POST", params); err != nil { + return fmt.Errorf("failed create domain (LuaDNS): %s", err) + } + return nil +} + +func (l *luadnsProvider) createRecord(domainID uint32, rec jsonRequestParams) error { + if _, err := l.get(fmt.Sprintf("/zones/%d/records", domainID), "POST", rec); err != nil { + return fmt.Errorf("failed create record (LuaDNS): %s", err) + } + return nil +} + +func (l *luadnsProvider) deleteRecord(domainID uint32, recordID uint32) error { + if _, err := l.get(fmt.Sprintf("/zones/%d/records/%d", domainID, recordID), "DELETE", requestParams{}); err != nil { + return fmt.Errorf("failed delete record (LuaDNS): %s", err) + } + return nil +} + +func (l *luadnsProvider) modifyRecord(domainID uint32, recordID uint32, rec jsonRequestParams) error { + if _, err := l.get(fmt.Sprintf("/zones/%d/records/%d", domainID, recordID), "PUT", rec); err != nil { + return fmt.Errorf("failed update (LuaDNS): %s", err) + } + return nil +} + +func (l *luadnsProvider) getRecords(domainID uint32) ([]domainRecord, error) { + var bodyString, err = l.get(fmt.Sprintf("/zones/%d/records", domainID), "GET", requestParams{}) + if err != nil { + return nil, fmt.Errorf("failed fetching record list from LuaDNS: %s", err) + } + var dr recordResponse + json.Unmarshal(bodyString, &dr) + var records []domainRecord + for _, rec := range dr { + if rec.Type == "SOA" { + continue + } + records = append(records, rec) + } + return records, nil +} + +func (l *luadnsProvider) get(endpoint string, method string, params any) ([]byte, error) { + client := &http.Client{} + var req, err = l.makeRequest(endpoint, method, params) + if err != nil { + return []byte{}, err + } + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(l.creds.email, l.creds.apikey) + // LuaDNS has a rate limit of 1200 request per 5 minute. + // So we do a very primitive rate limiting here - delay every request for 250ms - so max. 4 requests/second. + time.Sleep(250 * time.Millisecond) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode == 200 { + bodyString, _ := io.ReadAll(resp.Body) + return bodyString, nil + } + + bodyString, _ := io.ReadAll(resp.Body) + + var errResp errorResponse + err = json.Unmarshal(bodyString, &errResp) + if err != nil { + return bodyString, fmt.Errorf("LuaDNS API Error: %s URL:%s%s", string(bodyString), req.Host, req.URL.RequestURI()) + } + return bodyString, fmt.Errorf("LuaDNS API error: %s URL:%s%s", errResp.Message, req.Host, req.URL.RequestURI()) +} + +func (l *luadnsProvider) makeRequest(endpoint string, method string, params any) (*http.Request, error) { + switch v := params.(type) { + case requestParams: + req, err := http.NewRequest(method, apiURL+endpoint, nil) + if err != nil { + return nil, err + } + q := req.URL.Query() + for pName, pValue := range v { + q.Add(pName, pValue) + } + req.URL.RawQuery = q.Encode() + return req, nil + case jsonRequestParams: + requestJSON, err := json.Marshal(params) + if err != nil { + return nil, err + } + req, err := http.NewRequest(method, apiURL+endpoint, bytes.NewBuffer(requestJSON)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return req, nil + default: + return nil, fmt.Errorf("invalid request type") + } +} + +func nativeToRecord(domain string, r *domainRecord) *models.RecordConfig { + rc := &models.RecordConfig{ + Type: r.Type, + TTL: r.TTL, + Original: r, + } + rc.SetLabelFromFQDN(r.Name, domain) + switch rtype := rc.Type; rtype { + case "TXT": + rc.SetTargetTXT(r.Content) + default: + rc.PopulateFromString(rtype, r.Content, domain) + } + return rc +} + +func recordsToNative(rc *models.RecordConfig) jsonRequestParams { + r := jsonRequestParams{ + "name": fmt.Sprintf("%s.", rc.GetLabelFQDN()), + "type": rc.Type, + "ttl": rc.TTL, + } + switch rtype := rc.Type; rtype { + case "TXT": + r["content"] = rc.GetTargetTXTJoined() + default: + r["content"] = rc.GetTargetCombined() + } + return r +} + +func checkNS(dc *models.DomainConfig) { + newList := make([]*models.RecordConfig, 0, len(dc.Records)) + for _, rec := range dc.Records { + // LuaDNS does not support changing the TTL of the default nameservers, so forcefully change the TTL to 86400. + if rec.Type == "NS" && strings.HasSuffix(rec.GetTargetField(), ".luadns.net.") && rec.TTL != 86400 { + rec.TTL = 86400 + } + newList = append(newList, rec) + } + dc.Records = newList +} diff --git a/providers/luadns/auditrecords.go b/providers/luadns/auditrecords.go new file mode 100644 index 000000000..aec3059e1 --- /dev/null +++ b/providers/luadns/auditrecords.go @@ -0,0 +1,15 @@ +package luadns + +import ( + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/rejectif" +) + +// 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{} + a.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2023-03-03 + return a.Audit(records) +} diff --git a/providers/luadns/luadnsProvider.go b/providers/luadns/luadnsProvider.go new file mode 100644 index 000000000..7e604af9c --- /dev/null +++ b/providers/luadns/luadnsProvider.go @@ -0,0 +1,212 @@ +package luadns + +import ( + "encoding/json" + "fmt" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/pkg/diff2" + // "github.com/StackExchange/dnscontrol/v3/pkg/transform" + "github.com/StackExchange/dnscontrol/v3/providers" + // "github.com/miekg/dns/dnsutil" +) + +/* + +LuaDNS API DNS provider: + +Info required in `creds.json`: + - email + - apikey +*/ + +var features = providers.DocumentationNotes{ + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Can(), + providers.CanUseTLSA: providers.Can(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Can(), + providers.DocOfficiallySupported: providers.Can(), +} + +func init() { + fns := providers.DspFuncs{ + Initializer: NewLuaDNS, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType("LUADNS", fns, features) +} + +// NewLuaDNS creates the provider. +func NewLuaDNS(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + l := &luadnsProvider{} + l.creds.email, l.creds.apikey = m["email"], m["apikey"] + if l.creds.email == "" || l.creds.apikey == "" { + return nil, fmt.Errorf("missing LuaDNS email or apikey") + } + + // Get a domain to validate authentication + if err := l.fetchDomainList(); err != nil { + return nil, err + } + + return l, nil +} + +// GetNameservers returns the nameservers for a domain. +func (l *luadnsProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + if len(l.nameserversNames) == 0 { + l.fetchAvailableNameservers() + } + return models.ToNameserversStripTD(l.nameserversNames) +} + +// ListZones returns a list of the DNS zones. +func (l *luadnsProvider) ListZones() ([]string, error) { + if err := l.fetchDomainList(); err != nil { + return nil, err + } + zones := make([]string, 0, len(l.domainIndex)) + for d := range l.domainIndex { + zones = append(zones, d) + } + return zones, nil +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (l *luadnsProvider) GetZoneRecords(domain string) (models.Records, error) { + domainID, err := l.getDomainID(domain) + if err != nil { + return nil, err + } + records, err := l.getRecords(domainID) + if err != nil { + return nil, err + } + existingRecords := make([]*models.RecordConfig, len(records)) + for i := range records { + existingRecords[i] = nativeToRecord(domain, &records[i]) + } + return existingRecords, nil +} + +// GetDomainCorrections returns a list of corrections to update a domain. +func (l *luadnsProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + err := dc.Punycode() + if err != nil { + return nil, err + } + domainID, err := l.getDomainID(dc.Name) + if err != nil { + return nil, err + } + records, err := l.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + + checkNS(dc) + + // Normalize + models.PostProcessRecords(records) + + var corrections []*models.Correction + var corrs []*models.Correction + if !diff2.EnableDiff2 { + differ := diff.New(dc) + _, create, del, mod, err := differ.IncrementalDiff(records) + if err != nil { + return nil, err + } + + corrections := []*models.Correction{} + for _, d := range del { + corrs := l.makeDeleteCorrection(d.Existing, domainID, d.String()) + corrections = append(corrections, corrs...) + } + + for _, d := range create { + corrs := l.makeCreateCorrection(d.Desired, domainID, d.String()) + corrections = append(corrections, corrs...) + } + + for _, d := range mod { + corrs := l.makeChangeCorrection(d.Existing, d.Desired, domainID, d.String()) + corrections = append(corrections, corrs...) + } + + return corrections, nil + } + + changes, err := diff2.ByRecord(records, dc, nil) + if err != nil { + return nil, err + } + + for _, change := range changes { + msg := change.Msgs[0] + switch change.Type { + case diff2.REPORT: + corrs = []*models.Correction{{Msg: change.MsgsJoined}} + case diff2.CREATE: + corrs = l.makeCreateCorrection(change.New[0], domainID, msg) + case diff2.CHANGE: + corrs = l.makeChangeCorrection(change.Old[0], change.New[0], domainID, msg) + case diff2.DELETE: + corrs = l.makeDeleteCorrection(change.Old[0], domainID, msg) + default: + panic(fmt.Sprintf("unhandled inst.Type %s", change.Type)) + } + corrections = append(corrections, corrs...) + } + return corrections, nil +} + +func (l *luadnsProvider) makeCreateCorrection(newrec *models.RecordConfig, domainID uint32, msg string) []*models.Correction { + req := recordsToNative(newrec) + return []*models.Correction{{ + Msg: msg, + F: func() error { + return l.createRecord(domainID, req) + }, + }} +} + +func (l *luadnsProvider) makeChangeCorrection(oldrec *models.RecordConfig, newrec *models.RecordConfig, domainID uint32, msg string) []*models.Correction { + recordID := oldrec.Original.(*domainRecord).ID + req := recordsToNative(newrec) + return []*models.Correction{{ + Msg: fmt.Sprintf("%s, LuaDNS ID: %d", msg, recordID), + F: func() error { + return l.modifyRecord(domainID, recordID, req) + }, + }} +} + +func (l *luadnsProvider) makeDeleteCorrection(deleterec *models.RecordConfig, domainID uint32, msg string) []*models.Correction { + recordID := deleterec.Original.(*domainRecord).ID + return []*models.Correction{{ + Msg: fmt.Sprintf("%s, LuaDNS ID: %d", msg, recordID), + F: func() error { + return l.deleteRecord(domainID, recordID) + }, + }} +} + +// EnsureZoneExists creates a zone if it does not exist +func (l *luadnsProvider) EnsureZoneExists(domain string) error { + if l.domainIndex == nil { + if err := l.fetchDomainList(); err != nil { + return err + } + } + if _, ok := l.domainIndex[domain]; ok { + return nil + } + return l.createDomain(domain) +}