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:
200
providers/rwth/api.go
Normal file
200
providers/rwth/api.go
Normal 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)
|
||||
}
|
25
providers/rwth/auditrecords.go
Normal file
25
providers/rwth/auditrecords.go
Normal 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
55
providers/rwth/convert.go
Normal 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
91
providers/rwth/dns.go
Normal 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
|
||||
}
|
13
providers/rwth/listzones.go
Normal file
13
providers/rwth/listzones.go
Normal 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
|
||||
}
|
3
providers/rwth/registrar.go
Normal file
3
providers/rwth/registrar.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package rwth
|
||||
|
||||
// No registrar functionality
|
49
providers/rwth/rwthProvider.go
Normal file
49
providers/rwth/rwthProvider.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user