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 @@
|
+
+
+ |
@@ -300,6 +304,9 @@
|
|
+
+
+ |
@@ -426,6 +433,9 @@
|
|
+
+
+ |
@@ -517,6 +527,9 @@
|
|
+
+
+ |
|
@@ -587,6 +600,9 @@
|
|
+
+
+ |
|
@@ -695,6 +711,9 @@
|
|
+
+
+ |
|
@@ -805,6 +824,9 @@
|
|
+
+
+ |
|
|
@@ -879,6 +901,9 @@
|
|
+
+
+ |
|
@@ -934,6 +959,7 @@
| |
|
|
+ |
@@ -1041,6 +1067,9 @@
|
|
+
+
+ |
|
@@ -1131,6 +1160,9 @@
|
+
+
+ |
|
@@ -1227,6 +1259,9 @@
|
|
+
+
+ |
|
@@ -1328,6 +1363,7 @@
| |
|
|
+ |
@@ -1375,6 +1411,9 @@
|
|
|
+
+
+ |
|
|
|
@@ -1451,6 +1490,9 @@
|
+
+
+ |
|
@@ -1605,6 +1647,9 @@
|
|
+
+
+ |
|
|
|
@@ -1731,6 +1776,9 @@
|
+
+
+ |
|
@@ -1860,6 +1908,9 @@
|
+
+
+ |
@@ -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
+}