From 7865e37c8ff5c8dd4acc2d80aa8dedacea1f5e6a Mon Sep 17 00:00:00 2001 From: MisterErwin Date: Thu, 4 Aug 2022 20:40:27 +0200 Subject: [PATCH] Add RWTH provider (#1629) * Add RWTH provider * fix Owners order * Reorganize RWTH Provider * Fix staticcheck and code style issues Co-authored-by: Tom Limoncelli --- OWNERS | 1 + README.md | 1 + docs/_includes/matrix.html | 54 +++++++++ docs/_providers/rwth.md | 43 +++++++ docs/provider-list.md | 1 + pkg/prettyzone/prettyzone.go | 4 +- providers/_all/all.go | 1 + providers/rwth/api.go | 200 +++++++++++++++++++++++++++++++++ providers/rwth/auditrecords.go | 25 +++++ providers/rwth/convert.go | 55 +++++++++ providers/rwth/dns.go | 91 +++++++++++++++ providers/rwth/listzones.go | 13 +++ providers/rwth/registrar.go | 3 + providers/rwth/rwthProvider.go | 49 ++++++++ 14 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 docs/_providers/rwth.md create mode 100644 providers/rwth/api.go create mode 100644 providers/rwth/auditrecords.go create mode 100644 providers/rwth/convert.go create mode 100644 providers/rwth/dns.go create mode 100644 providers/rwth/listzones.go create mode 100644 providers/rwth/registrar.go create mode 100644 providers/rwth/rwthProvider.go diff --git a/OWNERS b/OWNERS index d57483f0b..f6a47203e 100644 --- a/OWNERS +++ b/OWNERS @@ -32,6 +32,7 @@ providers/ns1 @costasd providers/opensrs @philhug providers/oracle @kallsyms providers/route53 @tresni +providers/rwth @mistererwin # providers/softlayer NEEDS VOLUNTEER providers/vultr @pgaskin providers/ovh @masterzen diff --git a/README.md b/README.md index e18c8d201..4fa08b78a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Currently supported DNS providers: - Oracle Cloud - Packetframe - PowerDNS + - RWTH DNS-Admin - SoftLayer - TransIP - Vultr diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 090640bf6..629512956 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -43,6 +43,7 @@
PACKETFRAME
POWERDNS
ROUTE53
+
RWTH
SOFTLAYER
TRANSIP
VULTR
@@ -174,6 +175,9 @@ + + + DNS Provider @@ -300,6 +304,9 @@ + + + Registrar @@ -426,6 +433,9 @@ + + + ALIAS @@ -517,6 +527,9 @@ + + + @@ -587,6 +600,9 @@ + + + @@ -695,6 +711,9 @@ + + + @@ -805,6 +824,9 @@ + + + @@ -879,6 +901,9 @@ + + + @@ -934,6 +959,7 @@ + SRV @@ -1041,6 +1067,9 @@ + + + @@ -1131,6 +1160,9 @@ + + + @@ -1227,6 +1259,9 @@ + + + @@ -1328,6 +1363,7 @@ + AZURE_ALIAS @@ -1375,6 +1411,9 @@ + + + @@ -1451,6 +1490,9 @@ + + + @@ -1605,6 +1647,9 @@ + + + @@ -1731,6 +1776,9 @@ + + + @@ -1860,6 +1908,9 @@ + + + get-zones @@ -1969,6 +2020,9 @@ + + + diff --git a/docs/_providers/rwth.md b/docs/_providers/rwth.md new file mode 100644 index 000000000..4cbc48153 --- /dev/null +++ b/docs/_providers/rwth.md @@ -0,0 +1,43 @@ +--- +name: RWTH +title: RWTH DNS-Admin Provider +layout: default +jsId: RWTH +--- +# RWTH DNS-Admin Provider + +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `RWTH` +along with your [API Token](https://noc-portal.rz.rwth-aachen.de/dns-admin/en/api_tokens). + +Example: + +```json +{ + "rwth": { + "TYPE": "RWTH", + "api_key": "bQGz0DOi0AkTzG...=" + } +} +``` + +## Metadata +This provider does not recognize any special metadata fields unique to it. + +## Usage +An example `dnsconfig.js` configuration: + +```js +var REG_NONE = NewRegistrar("none"); +var DSP_RWTH = NewDnsProvider("rwth"); + +D("example.rwth-aachen.de", REG_NONE, DnsProvider(DSP_RWTH), + A("test", "1.2.3.4") +); +``` + +## Caveats +The default TTL is not automatically fetched, as the API does not provide such an endpoint. + +The RWTH deploys zones every 15 minutes, so it might take some time for changes to take effect. diff --git a/docs/provider-list.md b/docs/provider-list.md index 15d92b724..f12f30acd 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -102,6 +102,7 @@ Providers in this category and their maintainers are: * `OVH` @masterzen * `PACKETFRAME` @hamptonmoore * `POWERDNS` @jpbede +* `RWTH` @MisterErwin * `ROUTE53` @tresni * `SOFTLAYER`@jamielennox * `TRANSIP` @blackshadev diff --git a/pkg/prettyzone/prettyzone.go b/pkg/prettyzone/prettyzone.go index 8ce5930e5..3fcc41399 100644 --- a/pkg/prettyzone/prettyzone.go +++ b/pkg/prettyzone/prettyzone.go @@ -148,12 +148,12 @@ func (z *ZoneGenData) generateZoneFileHelper(w io.Writer) error { } fmt.Fprintf(w, "%s%s%s\n", - prefix, formatLine([]int{10, 5, 2, 5, 0}, []string{name, ttl, "IN", typeStr, target}), comment) + prefix, FormatLine([]int{10, 5, 2, 5, 0}, []string{name, ttl, "IN", typeStr, target}), comment) } return nil } -func formatLine(lengths []int, fields []string) string { +func FormatLine(lengths []int, fields []string) string { c := 0 result := "" for i, length := range lengths { diff --git a/providers/_all/all.go b/providers/_all/all.go index 467003734..c6c2fb67f 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -41,6 +41,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/packetframe" _ "github.com/StackExchange/dnscontrol/v3/providers/powerdns" _ "github.com/StackExchange/dnscontrol/v3/providers/route53" + _ "github.com/StackExchange/dnscontrol/v3/providers/rwth" _ "github.com/StackExchange/dnscontrol/v3/providers/softlayer" _ "github.com/StackExchange/dnscontrol/v3/providers/transip" _ "github.com/StackExchange/dnscontrol/v3/providers/vultr" diff --git a/providers/rwth/api.go b/providers/rwth/api.go new file mode 100644 index 000000000..1f13bd56d --- /dev/null +++ b/providers/rwth/api.go @@ -0,0 +1,200 @@ +package rwth + +// The documentation is hosted at https://noc-portal.rz.rwth-aachen.de/dns-admin/en/api_tokens and +// https://blog.rwth-aachen.de/itc/2022/07/13/api-im-dns-admin/ + +import ( + "encoding/json" + "fmt" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/printer" + "github.com/miekg/dns" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + baseURL = "https://noc-portal.rz.rwth-aachen.de/dns-admin/api/v1" +) + +type RecordReply struct { + ID int `json:"id"` + ZoneID int `json:"zone_id"` + Type string `json:"type"` + Content string `json:"content"` + Status string `json:"status"` + UpdatedAt time.Time `json:"updated_at"` + Editable bool `json:"editable"` + rec dns.RR // Store miekg/dns +} + +type zone struct { + ID int `json:"id"` + ZoneName string `json:"zone_name"` + Status string `json:"status"` + UpdatedAt time.Time `json:"updated_at"` + LastDeploy time.Time `json:"last_deploy"` + Dnssec struct { + ZoneSigningKey struct { + CreatedAt time.Time `json:"created_at"` + } `json:"zone_signing_key"` + KeySigningKey struct { + CreatedAt time.Time `json:"created_at"` + } `json:"key_signing_key"` + } `json:"dnssec"` +} + +func checkIsLockedSystemAPIRecord(record RecordReply) error { + if record.Type == "soa_record" { + // The upload of a BIND zone file can change the SOA record. + // Implementing this edge case this is too complex for now. + return fmt.Errorf("SOA records are locked in RWTH zones. They are hence not available for updating") + } + return nil +} + +func checkIsLockedSystemRecord(record *models.RecordConfig) error { + if record.Type == "SOA" { + // The upload of a BIND zone file can change the SOA record. + // Implementing this edge case this is too complex for now. + return fmt.Errorf("SOA records are locked in RWTH zones. They are hence not available for updating") + } + return nil +} + +func (api *rwthProvider) createRecord(domain string, record *models.RecordConfig) error { + if err := checkIsLockedSystemRecord(record); err != nil { + return err + } + + req := url.Values{} + req.Set("record_content", api.printRecConfig(*record)) + return api.request("/create_record", "POST", req, nil) +} + +func (api *rwthProvider) destroyRecord(record RecordReply) error { + if err := checkIsLockedSystemAPIRecord(record); err != nil { + return err + } + req := url.Values{} + req.Set("record_id", strconv.Itoa(record.ID)) + return api.request("/destroy_record", "DELETE", req, nil) +} + +func (api *rwthProvider) updateRecord(id int, record models.RecordConfig) error { + if err := checkIsLockedSystemRecord(&record); err != nil { + return err + } + req := url.Values{} + req.Set("record_id", strconv.Itoa(id)) + req.Set("record_content", api.printRecConfig(record)) + return api.request("/update_record", "POST", req, nil) +} + +func (api *rwthProvider) getAllRecords(domain string) ([]models.RecordConfig, error) { + zone, err := api.getZone(domain) + if err != nil { + return nil, err + } + records := make([]models.RecordConfig, 0) + response := []RecordReply{} + request := url.Values{} + request.Set("zone_id", strconv.Itoa(zone.ID)) + if err := api.request("/list_records", "GET", request, &response); err != nil { + return nil, fmt.Errorf("failed fetching zone records for %q: %w", domain, err) + } + for _, apiRecord := range response { + if checkIsLockedSystemAPIRecord(apiRecord) != nil { + continue + } + dnsRec, err := NewRR(apiRecord.Content) // Parse content as DNS record + if err != nil { + return nil, err + } + + recConfig, err := models.RRtoRC(dnsRec, domain) // and make it a RC + if err != nil { + return nil, err + } + recConfig.Original = apiRecord // but keep our ApiRecord as the original + + records = append(records, recConfig) + } + return records, nil +} + +func (api *rwthProvider) getAllZones() error { + if api.zones != nil { + return nil + } + zones := map[string]zone{} + response := &[]zone{} + if err := api.request("/list_zones", "GET", url.Values{}, response); err != nil { + return fmt.Errorf("failed fetching zones: %w", err) + } + for _, zone := range *response { + zones[zone.ZoneName] = zone + } + api.zones = zones + return nil +} + +func (api *rwthProvider) getZone(name string) (*zone, error) { + if err := api.getAllZones(); err != nil { + return nil, err + } + zone, ok := api.zones[name] + if !ok { + return nil, fmt.Errorf("%q is not a zone in this RWTH account", name) + } + return &zone, nil +} + +// Deploy the zone +func (api *rwthProvider) deployZone(domain string) error { + zone, err := api.getZone(domain) + if err != nil { + return err + } + req := url.Values{} + req.Set("zone_id", strconv.Itoa(zone.ID)) + return api.request("/deploy_zone", "POST", req, nil) +} + +// Send a request +func (api *rwthProvider) request(endpoint string, method string, request url.Values, target interface{}) error { + requestBody := strings.NewReader(request.Encode()) + req, err := http.NewRequest(method, baseURL+endpoint, requestBody) + if err != nil { + return err + } + req.Header.Add("PRIVATE-TOKEN", api.apiToken) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + cleanupResponseBody := func() { + err := resp.Body.Close() + if err != nil { + printer.Printf("failed closing response body: %q\n", err) + } + } + + defer cleanupResponseBody() + if resp.StatusCode != http.StatusOK { + data, _ := ioutil.ReadAll(resp.Body) + printer.Printf(string(data)) + return fmt.Errorf("bad status code from RWTH: %d not 200", resp.StatusCode) + } + if target == nil { + return nil + } + decoder := json.NewDecoder(resp.Body) + return decoder.Decode(target) +} diff --git a/providers/rwth/auditrecords.go b/providers/rwth/auditrecords.go new file mode 100644 index 000000000..6e83f9466 --- /dev/null +++ b/providers/rwth/auditrecords.go @@ -0,0 +1,25 @@ +package rwth + +import ( + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/recordaudit" +) + +// AuditRecords returns an error if any records are not +// supportable by this provider. +func AuditRecords(records []*models.RecordConfig) error { + + if err := recordaudit.TxtNoMultipleStrings(records); err != nil { + return err + } + + if err := recordaudit.TxtNoTrailingSpace(records); err != nil { + return err + } + + if err := recordaudit.TxtNotEmpty(records); err != nil { + return err + } + + return nil +} diff --git a/providers/rwth/convert.go b/providers/rwth/convert.go new file mode 100644 index 000000000..9d35b5184 --- /dev/null +++ b/providers/rwth/convert.go @@ -0,0 +1,55 @@ +package rwth + +import ( + "fmt" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/prettyzone" + "github.com/miekg/dns" + "io" + "strings" +) + +// Print the generateZoneFileHelper +func (api *rwthProvider) printRecConfig(rr models.RecordConfig) string { + // Similar to prettyzone + // Fake types are commented out. + prefix := "" + _, ok := dns.StringToType[rr.Type] + if !ok { + prefix = ";" + } + + // ttl + ttl := "" + if rr.TTL != 172800 && rr.TTL != 0 { + ttl = fmt.Sprint(rr.TTL) + } + + // type + typeStr := rr.Type + + // the remaining line + target := rr.GetTargetCombined() + + // comment + comment := ";" + + return fmt.Sprintf("%s%s%s\n", + prefix, prettyzone.FormatLine([]int{10, 5, 2, 5, 0}, []string{rr.NameFQDN, ttl, "IN", typeStr, target}), comment) +} + +// NewRR returns custom dns.NewRR with RWTH default TTL +func NewRR(s string) (dns.RR, error) { + if len(s) > 0 && s[len(s)-1] != '\n' { // We need a closing newline + return ReadRR(strings.NewReader(s + "\n")) + } + return ReadRR(strings.NewReader(s)) +} + +func ReadRR(r io.Reader) (dns.RR, error) { + zp := dns.NewZoneParser(r, ".", "") + zp.SetDefaultTTL(172800) + zp.SetIncludeAllowed(true) + rr, _ := zp.Next() + return rr, zp.Err() +} diff --git a/providers/rwth/dns.go b/providers/rwth/dns.go new file mode 100644 index 000000000..fabaa9778 --- /dev/null +++ b/providers/rwth/dns.go @@ -0,0 +1,91 @@ +package rwth + +import ( + "fmt" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/pkg/txtutil" +) + +var RWTHDefaultNs = []string{"dns-1.dfn.de", "dns-2.dfn.de", "zs1.rz.rwth-aachen.de", "zs2.rz.rwth-aachen.de"} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (api *rwthProvider) GetZoneRecords(domain string) (models.Records, error) { + records, err := api.getAllRecords(domain) + if err != nil { + return nil, err + } + foundRecords := models.Records{} + for i := range records { + foundRecords = append(foundRecords, &records[i]) + } + return foundRecords, nil +} + +// GetNameservers returns the default nameservers for RWTH. +func (api *rwthProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + return models.ToNameservers(RWTHDefaultNs) +} + +func (api *rwthProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc, err := dc.Copy() + if err != nil { + return nil, err + } + + err = dc.Punycode() + if err != nil { + return nil, err + } + domain := dc.Name + + // Get existing records + existingRecords, err := api.GetZoneRecords(domain) + if err != nil { + return nil, err + } + // Normalize + models.PostProcessRecords(existingRecords) + txtutil.SplitSingleLongTxt(dc.Records) // Autosplit long TXT records + + differ := diff.New(dc) + _, create, del, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + + var corrections []*models.Correction + + for _, d := range create { + des := d.Desired + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { return api.createRecord(dc.Name, des) }, + }) + } + for _, d := range del { + existingRecord := d.Existing.Original.(RecordReply) + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { return api.destroyRecord(existingRecord) }, + }) + } + for _, d := range modify { + rec := d.Desired + existingID := d.Existing.Original.(RecordReply).ID + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { return api.updateRecord(existingID, *rec) }, + }) + } + + // And deploy if any corrections were applied + if len(corrections) > 0 { + corrections = append(corrections, &models.Correction{ + Msg: fmt.Sprintf("Deploy zone %s", domain), + F: func() error { return api.deployZone(domain) }, + }) + } + + return corrections, nil +} diff --git a/providers/rwth/listzones.go b/providers/rwth/listzones.go new file mode 100644 index 000000000..cbc4b59b6 --- /dev/null +++ b/providers/rwth/listzones.go @@ -0,0 +1,13 @@ +package rwth + +// ListZones lists the zones on this account. +func (api *rwthProvider) ListZones() ([]string, error) { + if err := api.getAllZones(); err != nil { + return nil, err + } + var zones []string + for i := range api.zones { + zones = append(zones, i) + } + return zones, nil +} diff --git a/providers/rwth/registrar.go b/providers/rwth/registrar.go new file mode 100644 index 000000000..fd8691757 --- /dev/null +++ b/providers/rwth/registrar.go @@ -0,0 +1,3 @@ +package rwth + +// No registrar functionality diff --git a/providers/rwth/rwthProvider.go b/providers/rwth/rwthProvider.go new file mode 100644 index 000000000..fe7c45ff3 --- /dev/null +++ b/providers/rwth/rwthProvider.go @@ -0,0 +1,49 @@ +package rwth + +import ( + "encoding/json" + "fmt" + "github.com/StackExchange/dnscontrol/v3/providers" +) + +type rwthProvider struct { + apiToken string + zones map[string]zone +} + +// features is used to let dnscontrol know which features are supported by the RWTH DNS Admin. +var features = providers.DocumentationNotes{ + providers.CanAutoDNSSEC: providers.Unimplemented("Supported by RWTH but not implemented yet."), + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseAzureAlias: providers.Cannot(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDS: providers.Unimplemented("DS records are only supported at the apex and require a different API call that hasn't been implemented yet."), + providers.CanUseNAPTR: providers.Cannot(), + providers.CanUsePTR: providers.Can("PTR records with empty targets are not supported"), + providers.CanUseSRV: providers.Can("SRV records with empty targets are not supported."), + providers.CanUseSSHFP: providers.Can(), + providers.CanUseTLSA: providers.Cannot(), + providers.DocCreateDomains: providers.Cannot(), + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +// init registers the registrar and the domain service provider with dnscontrol. +func init() { + fns := providers.DspFuncs{ + Initializer: New, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType("RWTH", fns, features) +} + +func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + if settings["api_token"] == "" { + return nil, fmt.Errorf("missing RWTH api_token") + } + + api := &rwthProvider{apiToken: settings["api_token"]} + + return api, nil +}