1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

Add RWTH provider (#1629)

* Add RWTH provider

* fix Owners order

* Reorganize RWTH Provider

* Fix staticcheck and code style issues

Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
This commit is contained in:
MisterErwin
2022-08-04 20:40:27 +02:00
committed by GitHub
parent ba747fa5a9
commit 7865e37c8f
14 changed files with 539 additions and 2 deletions

200
providers/rwth/api.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

55
providers/rwth/convert.go Normal file
View File

@@ -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()
}

91
providers/rwth/dns.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
package rwth
// No registrar functionality

View File

@@ -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
}