mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-11 18:08:57 +08:00
449 lines
14 KiB
Go
449 lines
14 KiB
Go
package loopia
|
|
|
|
/*
|
|
|
|
Loopia XML_RPC API V1 DNS provider:
|
|
|
|
Documentation: https://www.loopia.com/api/
|
|
Endpoint: https://api.loopia.se/RPCSERV
|
|
|
|
Settings from `creds.json`:
|
|
- username
|
|
- password
|
|
- debug
|
|
- rate_limit_per
|
|
|
|
*/
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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/printer"
|
|
"github.com/StackExchange/dnscontrol/v3/pkg/txtutil"
|
|
"github.com/StackExchange/dnscontrol/v3/providers"
|
|
"github.com/miekg/dns/dnsutil"
|
|
)
|
|
|
|
// Section 1: Register this provider in the system.
|
|
|
|
// init registers the provider to dnscontrol.
|
|
func init() {
|
|
fns := providers.DspFuncs{
|
|
Initializer: newDsp,
|
|
RecordAuditor: AuditRecords,
|
|
}
|
|
providers.RegisterDomainServiceProviderType("LOOPIA", fns, features)
|
|
providers.RegisterRegistrarType("LOOPIA", newReg)
|
|
}
|
|
|
|
// features declares which features and options are available.
|
|
var features = providers.DocumentationNotes{
|
|
providers.CanAutoDNSSEC: providers.Cannot(),
|
|
providers.CanGetZones: providers.Can(),
|
|
providers.CanUseAKAMAICDN: providers.Cannot(),
|
|
providers.CanUseAlias: providers.Cannot(),
|
|
providers.CanUseAzureAlias: providers.Cannot(),
|
|
providers.CanUseCAA: providers.Can(),
|
|
providers.CanUseDS: providers.Cannot("Only supports DS records at the apex, only for .se and .nu domains; done automatically at back-end."),
|
|
providers.CanUseDSForChildren: providers.Cannot(),
|
|
providers.CanUseLOC: providers.Can(),
|
|
providers.CanUseNAPTR: providers.Can(),
|
|
providers.CanUsePTR: providers.Cannot(),
|
|
providers.CanUseSOA: providers.Cannot("💩"),
|
|
providers.CanUseSRV: providers.Can(),
|
|
providers.CanUseSSHFP: providers.Can(),
|
|
providers.CanUseTLSA: providers.Can(),
|
|
providers.DocCreateDomains: providers.Cannot("Can only manage domains registered through their service"),
|
|
providers.DocDualHost: providers.Can(),
|
|
providers.DocOfficiallySupported: providers.Cannot(),
|
|
}
|
|
|
|
// Section 2: Define the API client.
|
|
|
|
// See client.go
|
|
|
|
// newDsp generates a DNS Service Provider client handle.
|
|
func newDsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
|
|
return newHelper(conf, metadata)
|
|
}
|
|
|
|
// newReg generates a Registrar Provider client handle.
|
|
func newReg(conf map[string]string) (providers.Registrar, error) {
|
|
return newHelper(conf, nil)
|
|
}
|
|
|
|
// newHelper generates a handle.
|
|
func newHelper(m map[string]string, metadata json.RawMessage) (*APIClient, error) {
|
|
if m["username"] == "" {
|
|
return nil, fmt.Errorf("missing Loopia API username")
|
|
}
|
|
if m["password"] == "" {
|
|
return nil, fmt.Errorf("missing Loopia API password")
|
|
}
|
|
|
|
const booleanStringWarn = " setting as a 'string': 't', 'true', 'True' etc"
|
|
var err error
|
|
|
|
modifyNameServers := false
|
|
if m["modify_name_servers"] != "" { // optional
|
|
modifyNameServers, err = strconv.ParseBool(m["modify_name_servers"])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creds.json requires the modify_name_servers" + booleanStringWarn)
|
|
}
|
|
}
|
|
|
|
fetchApexNSEntries := false
|
|
if m["fetch_apex_ns_entries"] != "" { // optional
|
|
fetchApexNSEntries, err = strconv.ParseBool(m["fetch_apex_ns_entries"])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creds.json requires the fetch_apex_ns_entries" + booleanStringWarn)
|
|
}
|
|
}
|
|
|
|
dbg := false
|
|
if m["debug"] != "" { //debug is optional
|
|
dbg, err = strconv.ParseBool(m["debug"])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creds.json requires the debug" + booleanStringWarn)
|
|
}
|
|
}
|
|
|
|
api := NewClient(m["username"], m["password"], strings.ToLower(m["region"]), modifyNameServers, fetchApexNSEntries, dbg)
|
|
|
|
quota := m["rate_limit_per"]
|
|
err = api.requestRateLimiter.setRateLimitPer(quota)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unexpected value for rate_limit_per: %w", err)
|
|
}
|
|
return api, nil
|
|
}
|
|
|
|
// Section 3: Domain Service Provider (DSP) related functions
|
|
|
|
// ListZones lists the zones on this account.
|
|
func (c *APIClient) ListZones() ([]string, error) {
|
|
|
|
listResp, err := c.getDomains()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
zones := make([]string, len(listResp))
|
|
if c.Debug {
|
|
fmt.Printf("DEBUG: DOMAIN LIST START\n")
|
|
}
|
|
for i, zone := range listResp {
|
|
for _, prop := range zone.Properties {
|
|
if prop.Name() == "domain" { // the zones name is stored in property 'domain'
|
|
if c.Debug {
|
|
fmt.Printf("DEBUG: DOMAIN LIST %d: %v\n", i, prop.String())
|
|
}
|
|
// zone := zone
|
|
zones[i] = prop.String()
|
|
}
|
|
}
|
|
}
|
|
if c.Debug {
|
|
fmt.Printf("DEBUG: DOMAIN LIST END\n")
|
|
}
|
|
return zones, nil
|
|
}
|
|
|
|
// NB(tal): To future-proof your code, all new providers should
|
|
// implement GetDomainCorrections exactly as you see here
|
|
// (byte-for-byte the same). In 3.0
|
|
// we plan on using just the individual calls to GetZoneRecords,
|
|
// PostProcessRecords, and so on.
|
|
//
|
|
// Currently every provider does things differently, which prevents
|
|
// us from doing things like using GetZoneRecords() of a provider
|
|
// to make convertzone work with all providers.
|
|
|
|
// GetDomainCorrections get the current and existing records,
|
|
// post-process them, and generate corrections.
|
|
func (c *APIClient) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
|
|
existing, err := c.GetZoneRecords(dc.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
models.PostProcessRecords(existing)
|
|
clean := PrepFoundRecords(existing)
|
|
PrepDesiredRecords(dc)
|
|
return c.GenerateZoneRecordsCorrections(dc, clean)
|
|
}
|
|
|
|
// GetZoneRecords gathers the DNS records and converts them to
|
|
// dnscontrol's format.
|
|
func (c *APIClient) GetZoneRecords(domain string) (models.Records, error) {
|
|
|
|
// Two approaches. One: get all SubDomains, and get their respective records
|
|
// simultaneously, or first get subdomains then fill each subdomain with its
|
|
// respective records on a subsequent pass.
|
|
|
|
//step 1: subdomains
|
|
// Get existing subdomains for a domain:
|
|
subdomains, err := c.GetSubDomains(domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if c.Debug {
|
|
fmt.Printf("Amount of subdomains: %d\n", len(subdomains))
|
|
}
|
|
|
|
// Convert them to DNScontrol's native format:
|
|
existingRecords := []*models.RecordConfig{}
|
|
for _, subdomain := range subdomains {
|
|
//here seems like a good place to get the records for a subdomain.
|
|
//fukn ballz tho: each subdomain requires one API call. 💩
|
|
if c.Debug {
|
|
fmt.Printf("%s\n", subdomain)
|
|
}
|
|
//step 2: records for subdomains
|
|
// Get subdomain records:
|
|
subdomainrecords, err := c.getDomainRecords(domain, subdomain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, subdRr := range subdomainrecords {
|
|
|
|
//Note: subdomain cannot be any of [.-_ ]
|
|
record, err := nativeToRecord(subdRr, domain, subdomain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
existingRecords = append(existingRecords, record)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if c.Debug {
|
|
fmt.Printf("length of existingRecords: %d\n", len(existingRecords))
|
|
}
|
|
|
|
return existingRecords, nil
|
|
}
|
|
|
|
// PrepFoundRecords munges any records to make them compatible with
|
|
// this provider. Usually this is a no-op.
|
|
func PrepFoundRecords(recs models.Records) models.Records {
|
|
// If there are records that need to be modified, removed, etc. we
|
|
// do it here. Usually this is a no-op.
|
|
return recs
|
|
}
|
|
|
|
// PrepDesiredRecords munges any records to best suit this provider.
|
|
func PrepDesiredRecords(dc *models.DomainConfig) {
|
|
// Sort through the dc.Records, eliminate any that can't be
|
|
// supported; modify any that need adjustments to work with the
|
|
// provider. We try to do minimal changes otherwise it gets
|
|
// confusing.
|
|
|
|
dc.Punycode()
|
|
|
|
recordsToKeep := make([]*models.RecordConfig, 0, len(dc.Records))
|
|
for _, rec := range dc.Records {
|
|
if rec.Type == "ALIAS" {
|
|
// Loopia does not support ALIAS.
|
|
// Therefore, we change this to a CNAME.
|
|
rec.Type = "CNAME"
|
|
}
|
|
if rec.TTL < 300 {
|
|
/* you can submit TTL lower than 300 but the dig results are normalized to 300 */
|
|
printer.Warnf("Loopia does not support TTL < 300. Setting %s from %d to 300\n", rec.GetLabelFQDN(), rec.TTL)
|
|
rec.TTL = 300
|
|
} else if rec.TTL > 2147483647 {
|
|
/* you can submit a TTL higher than 4294967296 but Loopia shortens it to 2147483647. 68 year timeout tho. */
|
|
printer.Warnf("Loopia does not support TTL > 68 years. Setting %s from %d to 2147483647\n", rec.GetLabelFQDN(), rec.TTL)
|
|
rec.TTL = 2147483647
|
|
}
|
|
// if rec.Type == "TXT" {
|
|
// rec.SetTarget("\"" + rec.GetTargetField() + "\"") // FIXME(systemcrash): Should do proper quoting.
|
|
// }
|
|
// if rec.Type == "NS" && rec.GetLabel() == "@" {
|
|
// if !strings.HasSuffix(rec.GetTargetField(), ".loopia.se.") {
|
|
// printer.Warnf("Loopia does not support changing apex NS records. Ignoring %s\n", rec.GetTargetField())
|
|
// }
|
|
// continue
|
|
// }
|
|
recordsToKeep = append(recordsToKeep, rec)
|
|
}
|
|
dc.Records = recordsToKeep
|
|
}
|
|
|
|
// gatherAffectedLabels takes the output of diff.ChangedGroups and
|
|
// regroups it by FQDN of the label, not by Key. It also returns
|
|
// a list of all the FQDNs.
|
|
func gatherAffectedLabels(groups map[models.RecordKey][]string) (labels map[string]bool, msgs map[string][]string) {
|
|
labels = map[string]bool{}
|
|
msgs = map[string][]string{}
|
|
for k, v := range groups {
|
|
labels[k.NameFQDN] = true
|
|
msgs[k.NameFQDN] = append(msgs[k.NameFQDN], v...)
|
|
}
|
|
return labels, msgs
|
|
}
|
|
|
|
// GenerateZoneRecordsCorrections takes the desired and existing records
|
|
// and produces a Correction list. The correction list is simply
|
|
// a list of functions to call to actually make the desired
|
|
// correction, and a message to output to the user when the change is
|
|
// made.
|
|
func (c *APIClient) GenerateZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, error) {
|
|
if c.Debug {
|
|
debugRecords("GenerateZoneRecordsCorrections input:\n", existingRecords)
|
|
}
|
|
|
|
// Normalize
|
|
txtutil.SplitSingleLongTxt(dc.Records) // Autosplit long TXT records
|
|
|
|
var corrections []*models.Correction
|
|
var keysToUpdate map[models.RecordKey][]string
|
|
var differ diff.Differ
|
|
if !diff2.EnableDiff2 {
|
|
differ = diff.New(dc)
|
|
} else {
|
|
differ = diff.NewCompat(dc)
|
|
}
|
|
_, create, del, modify, err := differ.IncrementalDiff(existingRecords)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keysToUpdate, err = differ.ChangedGroups(existingRecords)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, d := range create {
|
|
// fmt.Printf("a creation: subdomain: %+v, existingfqdn: %+v \n", d.Desired.Name, d.Desired.NameFQDN)
|
|
des := d.Desired
|
|
zrec := recordToNative(des)
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: d.String(),
|
|
F: func() error {
|
|
// return c.CreateRecordSimulate(dc.Name, des.Name, zrec)
|
|
return c.CreateRecord(dc.Name, des.Name, zrec)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Determine which subdomains become extinct. Delete them.
|
|
affectedLabels, msgsForLabel := gatherAffectedLabels(keysToUpdate)
|
|
_, desiredRecords := dc.Records.GroupedByFQDN()
|
|
|
|
for fqdn := range affectedLabels {
|
|
if len(desiredRecords[fqdn]) == 0 {
|
|
msgs := strings.Join(msgsForLabel[fqdn], "\n")
|
|
msgs = "records affected by deletion of subdomain " + fqdn + "\n" + msgs
|
|
subdomain := dnsutil.TrimDomainName(fqdn, dc.Name)
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: msgs,
|
|
F: func() error {
|
|
return c.DeleteSubdomain(dc.Name, subdomain)
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, d := range del {
|
|
skip := false
|
|
for fqdn := range affectedLabels {
|
|
if len(desiredRecords[fqdn]) == 0 {
|
|
subdomain := dnsutil.TrimDomainName(fqdn, dc.Name)
|
|
if d.Existing.NameFQDN == fqdn && d.Existing.Name == subdomain {
|
|
// fmt.Printf("fqdn extinct wtf: %s\n", fqdn)
|
|
//deletion is a member of fqdn. skip its deletion (otherwise extra API call and its error)
|
|
skip = true
|
|
}
|
|
}
|
|
}
|
|
if !skip {
|
|
// fmt.Printf("a deletion: subdomain: %+v, existingfqdn: %+v \n", d.Existing.Name, d.Existing.NameFQDN)
|
|
existingRecord := d.Existing.Original.(zRec)
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: d.String(),
|
|
F: func() error {
|
|
// return c.DeleteRecordSimulate(dc.Name, d.Existing.Name, existingRecord.RecordID)
|
|
return c.DeleteRecord(dc.Name, d.Existing.Name, existingRecord.RecordID)
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, d := range modify {
|
|
subdomain := d.Existing.Name
|
|
// fmt.Printf("a modification: subdomain: %+v, existingfqdn: %+v \n", d.Existing.Name, d.Existing.NameFQDN)
|
|
rec := d.Desired
|
|
existingID := d.Existing.Original.(zRec).RecordID
|
|
zrec := recordToNative(rec, existingID)
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: d.String(),
|
|
F: func() error {
|
|
//weird BUG: if we provide d.Desired.Name, instead of 'subdomain',
|
|
//all change records get assigned a single subdomain, common across all change records.
|
|
// return c.UpdateRecordSimulate(dc.Name, subdomain, zrec)
|
|
return c.UpdateRecord(dc.Name, subdomain, zrec)
|
|
},
|
|
})
|
|
}
|
|
|
|
return corrections, nil
|
|
}
|
|
|
|
// debugRecords prints a list of RecordConfig.
|
|
func debugRecords(note string, recs []*models.RecordConfig) {
|
|
printer.Debugf(note)
|
|
for k, v := range recs {
|
|
printer.Printf(" %v: %v %v %v %v\n", k, v.GetLabel(), v.Type, v.TTL, v.GetTargetCombined())
|
|
}
|
|
}
|
|
|
|
// Section 3: Registrar-related functions
|
|
|
|
// GetNameservers returns a list of nameservers for domain.
|
|
func (c *APIClient) GetNameservers(domain string) ([]*models.Nameserver, error) {
|
|
if c.ModifyNameServers {
|
|
return nil, nil
|
|
}
|
|
nameservers, err := c.GetDomainNS(domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return models.ToNameserversStripTD(nameservers)
|
|
}
|
|
|
|
// GetRegistrarCorrections returns a list of corrections for this registrar.
|
|
func (c *APIClient) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
|
|
|
|
existingNs, err := c.GetDomainNS(dc.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(existingNs)
|
|
existing := strings.Join(existingNs, ",")
|
|
|
|
desiredNs := models.NameserversToStrings(dc.Nameservers)
|
|
sort.Strings(desiredNs)
|
|
desired := strings.Join(desiredNs, ",")
|
|
|
|
if existing != desired {
|
|
return []*models.Correction{
|
|
{
|
|
Msg: fmt.Sprintf("Change Nameservers from '%s' to '%s'", existing, desired),
|
|
F: func() (err error) {
|
|
// err = c.UpdateNameServers(dc.Name, desiredNs)
|
|
return
|
|
}},
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
}
|