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

migrate code for github

This commit is contained in:
Craig Peterson
2016-08-22 18:31:50 -06:00
commit ef0bbf53af
359 changed files with 157476 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
package activedir
import (
"encoding/json"
"flag"
"fmt"
"runtime"
"github.com/StackExchange/dnscontrol/providers"
)
var flagFakePowerShell = flag.Bool("fakeps", false, "ACTIVEDIR: Do not run PowerShell. Open adzonedump.*.json files for input, and write to -psout any PS1 commands that make changes.")
var flagPsFuture = flag.String("psout", "dns_update_commands.ps1", "ACTIVEDIR: Where to write PS1 commands for future execution.")
var flagPsLog = flag.String("pslog", "powershell.log", "ACTIVEDIR: filename of PS1 command log.")
// This is the struct that matches either (or both) of the Registrar and/or DNSProvider interfaces:
type adProvider struct {
adServer string
}
// Register with the dnscontrol system.
// This establishes the name (all caps), and the function to call to initialize it.
func init() {
providers.RegisterDomainServiceProviderType("ACTIVEDIRECTORY_PS", newDNS)
}
func newDNS(config map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
if runtime.GOOS == "windows" || *flagFakePowerShell {
srv := config["ADServer"]
if srv == "" {
return nil, fmt.Errorf("ADServer required for Active Directory provider")
}
return &adProvider{adServer: srv}, nil
}
fmt.Printf("WARNING: PowerShell not available. ActiveDirectory will not be updated.\n")
return providers.None{}, nil
}

Binary file not shown.

View File

@@ -0,0 +1,78 @@
### ActiveDirectory
This provider updates a DNS Zone in an ActiveDirectory Integrated Zone.
When run on Windows, AD is updated directly. The code generates
PowerShell commands, executes them, and checks the results.
It leaves behind a log file of the commands that were generated.
When run on non-Windows, AD isn't updated because we can't execute
PowerShell at this time. Instead of reading the existing zone data
from AD, It learns what
records are in the zone by reading
`adzonedump.{ZONENAME}.json`, a file that must be created beforehand.
It does not actually update AD, it generates a file with PowerShell
commands that would do the updates, which you must execute afterwords.
If the `adzonedump.{ZONENAME}.json` does not exist, the zone is quietly skipped.
Not implemented:
* Delete records. This provider will not delete any records. It will only add
and change existing records. See "Note to future devs" below.
* Update TTLs. It ignores TTLs.
## required creds.json config
No "creds.json" configuration is expected.
## example dns config js:
```
var REG_NONE = NewRegistrar('none', 'NONE')
var DSP_ACTIVEDIRECTORY_DS = NewDSP("activedir", "ACTIVEDIRECTORY_PS");
D('ds.stackexchange.com', REG_NONE,
DSP_ACTIVEDIRECTORY_DS,
)
//records handled by another provider...
);
```
## Special Windows stuff
This provider needs to do 2 things:
* Get a list of zone records:
* powerShellDump: Runs a PS command that dumps the zone to JSON.
* readZoneDump: Opens a adzonedump.$DOMAINNAME.json file and reads JSON out of it. If the file does not exist, this is considered an error and processing stops.
* Update records:
* powerShellExec: Execute PS commands that do the update.
* powerShellRecord: Record the PS command that can be run later to do the updates. This file is -psout=dns_update_commands.ps1
So what happens when? Well, that's complex. We want both Windows and Linux to be able to use -fakewindows
for either debugging or (on Windows) actual use. However only Windows permits -fakewinows=false and actually executes
the PS code. Here's which algorithm is used for each case:
* If -fakewindows is used on any system: readZoneDump and powerShellRecord is used.
* On Windows (without -fakewindows): powerShellDump and powerShellExec is used.
* On Linux (wihtout -fakewindows): the provider loads as "NONE" and nothing happens.
## Note to future devs
### Why doesn't this provider delete records?
Because at this time Stack doesn't fully control AD zones
using dnscontrol. It only needs to add/change records.
What should we do when it does need to delete them?
Currently NO_PURGE is a no-op. I would change it to update
domain metadata to flag that deletes should be enabled/disabled.
Then generate the deletes only if this flag exists. To be paranoid,
the func that does the deleting could check this flag to make sure
that it really should be deleting something.

View File

@@ -0,0 +1,293 @@
package activedir
import (
"encoding/json"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/TomOnTime/utfutil"
"github.com/miekg/dns/dnsutil"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers/diff"
)
const zoneDumpFilenamePrefix = "adzonedump"
type RecordConfigJson struct {
Name string `json:"hostname"`
Type string `json:"recordtype"`
Data string `json:"recorddata"`
TTL uint32 `json:"timetolive"`
}
// GetDomainCorrections gets existing records, diffs them against existing, and returns corrections.
func (c *adProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
// Read foundRecords:
foundRecords, err := c.getExistingRecords(dc.Name)
if err != nil {
return nil, fmt.Errorf("c.getExistingRecords(%v) failed: %v", dc.Name, err)
}
// Read expectedRecords:
//expectedRecords := make([]*models.RecordConfig, len(dc.Records))
expectedRecords := make([]diff.Record, len(dc.Records))
for i, r := range dc.Records {
if r.TTL == 0 {
r.TTL = models.DefaultTTL
}
expectedRecords[i] = r
}
// Convert to []diff.Records and compare:
foundDiffRecords := make([]diff.Record, 0, len(foundRecords))
for _, rec := range foundRecords {
foundDiffRecords = append(foundDiffRecords, rec)
}
_, create, _, mod := diff.IncrementalDiff(foundDiffRecords, expectedRecords)
// NOTE(tlim): This provider does not delete records. If
// you need to delete a record, either delete it manually
// or see providers/activedir/doc.md for implementation tips.
// Generate changes.
corrections := []*models.Correction{}
for _, d := range create {
corrections = append(corrections, c.createRec(dc.Name, d.Desired.(*models.RecordConfig))...)
}
for _, m := range mod {
corrections = append(corrections, c.modifyRec(dc.Name, m))
}
return corrections, nil
}
// zoneDumpFilename returns the filename to use to write or read
// an activedirectory zone dump for a particular domain.
func zoneDumpFilename(domainname string) string {
return zoneDumpFilenamePrefix + "." + domainname + ".json"
}
// readZoneDump reads a pre-existing zone dump from adzonedump.*.json.
func (c *adProvider) readZoneDump(domainname string) ([]byte, error) {
// File not found is considered an error.
dat, err := utfutil.ReadFile(zoneDumpFilename(domainname), utfutil.WINDOWS)
if err != nil {
fmt.Println("Powershell to generate zone dump:")
fmt.Println(c.generatePowerShellZoneDump(domainname))
}
return dat, err
}
// powerShellLogCommand logs to flagPsLog that a PowerShell command is going to be run.
func powerShellLogCommand(command string) error {
return logHelper(fmt.Sprintf("# %s\r\n%s\r\n", time.Now().UTC(), strings.TrimSpace(command)))
}
// powerShellLogOutput logs to flagPsLog that a PowerShell command is going to be run.
func powerShellLogOutput(s string) error {
return logHelper(fmt.Sprintf("OUTPUT: START\r\n%s\r\nOUTPUT: END\r\n", s))
}
// powerShellLogErr logs that a PowerShell command had an error.
func powerShellLogErr(e error) error {
err := logHelper(fmt.Sprintf("ERROR: %v\r\r", e)) //Log error to powershell.log
if err != nil {
return err //Bubble up error created in logHelper
}
return e //Bubble up original error
}
func logHelper(s string) error {
logfile, err := os.OpenFile(*flagPsLog, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0660)
if err != nil {
return fmt.Errorf("ERROR: Can not create/append to %#v: %v\n", *flagPsLog, err)
}
_, err = fmt.Fprintln(logfile, s)
if err != nil {
return fmt.Errorf("ERROR: Append to %#v failed: %v\n", *flagPsLog, err)
}
if logfile.Close() != nil {
return fmt.Errorf("ERROR: Closing %#v failed: %v\n", *flagPsLog, err)
}
return nil
}
// powerShellRecord records that a PowerShell command should be executed later.
func powerShellRecord(command string) error {
recordfile, err := os.OpenFile(*flagPsFuture, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0660)
if err != nil {
return fmt.Errorf("ERROR: Can not create/append to %#v: %v\n", *flagPsFuture, err)
}
_, err = recordfile.WriteString(command)
if err != nil {
return fmt.Errorf("ERROR: Append to %#v failed: %v\n", *flagPsFuture, err)
}
return recordfile.Close()
}
func (c *adProvider) getExistingRecords(domainname string) ([]*models.RecordConfig, error) {
//log.Printf("getExistingRecords(%s)\n", domainname)
// Get the JSON either from adzonedump or by running a PowerShell script.
data, err := c.getRecords(domainname)
if err != nil {
return nil, fmt.Errorf("getRecords failed on %#v: %v\n", domainname, err)
}
var recs []*RecordConfigJson
err = json.Unmarshal(data, &recs)
if err != nil {
return nil, fmt.Errorf("json.Unmarshal failed on %#v: %v\n", domainname, err)
}
result := make([]*models.RecordConfig, 0, len(recs))
for i := range recs {
t, err := recs[i].unpackRecord(domainname)
if err == nil {
result = append(result, t)
}
}
return result, nil
}
func (r *RecordConfigJson) unpackRecord(origin string) (*models.RecordConfig, error) {
rc := models.RecordConfig{}
rc.Name = strings.ToLower(r.Name)
rc.NameFQDN = dnsutil.AddOrigin(rc.Name, origin)
rc.Type = r.Type
rc.TTL = r.TTL
switch rc.Type {
case "A":
rc.Target = r.Data
case "CNAME":
rc.Target = strings.ToLower(r.Data)
case "AAAA", "MX", "NAPTR", "NS", "SOA", "SRV":
return nil, fmt.Errorf("Unimplemented: %v", r.Type)
default:
log.Fatalf("Unhandled models.RecordConfigJson type: %v (%v)\n", rc.Type, r)
}
return &rc, nil
}
// powerShellDump runs a PowerShell command to get a dump of all records in a DNS zone.
func (c *adProvider) generatePowerShellZoneDump(domainname string) string {
cmd_txt := `@("REPLACE_WITH_ZONE") | %{
Get-DnsServerResourceRecord -ComputerName REPLACE_WITH_COMPUTER_NAME -ZoneName $_ | select hostname,recordtype,@{n="timestamp";e={$_.timestamp.tostring()}},@{n="timetolive";e={$_.timetolive.totalseconds}},@{n="recorddata";e={($_.recorddata.ipv4address,$_.recorddata.ipv6address,$_.recorddata.HostNameAlias,"other_record" -ne $null)[0]-as [string]}} | ConvertTo-Json > REPLACE_WITH_FILENAMEPREFIX.REPLACE_WITH_ZONE.json
}`
cmd_txt = strings.Replace(cmd_txt, "REPLACE_WITH_ZONE", domainname, -1)
cmd_txt = strings.Replace(cmd_txt, "REPLACE_WITH_COMPUTER_NAME", c.adServer, -1)
cmd_txt = strings.Replace(cmd_txt, "REPLACE_WITH_FILENAMEPREFIX", zoneDumpFilenamePrefix, -1)
return cmd_txt
}
// generatePowerShellCreate generates PowerShell commands to ADD a record.
func (c *adProvider) generatePowerShellCreate(domainname string, rec *models.RecordConfig) string {
content := rec.Target
text := "\r\n" // Skip a line.
text += fmt.Sprintf("Add-DnsServerResourceRecord%s", rec.Type)
text += fmt.Sprintf(` -ComputerName "%s"`, c.adServer)
text += fmt.Sprintf(` -ZoneName "%s"`, domainname)
text += fmt.Sprintf(` -Name "%s"`, rec.Name)
switch rec.Type {
case "CNAME":
text += fmt.Sprintf(` -HostNameAlias "%s"`, content)
case "A":
text += fmt.Sprintf(` -IPv4Address "%s"`, content)
case "NS":
text = fmt.Sprintf("\r\n"+`echo "Skipping NS update (%v %v)"`+"\r\n", rec.Name, rec.Target)
default:
panic(fmt.Errorf("ERROR: generatePowerShellCreate() does not yet handle recType=%s recName=%#v content=%#v)\n", rec.Type, rec.Name, content))
}
text += "\r\n"
return text
}
// generatePowerShellModify generates PowerShell commands to MODIFY a record.
func (c *adProvider) generatePowerShellModify(domainname, recName, recType, oldContent, newContent string, oldTTL, newTTL uint32) string {
var queryField, queryContent string
switch recType {
case "A":
queryField = "IPv4address"
queryContent = `"` + oldContent + `"`
case "CNAME":
queryField = "HostNameAlias"
queryContent = `"` + oldContent + `"`
default:
panic(fmt.Errorf("ERROR: generatePowerShellModify() does not yet handle recType=%s recName=%#v content=(%#v, %#v)\n", recType, recName, oldContent, newContent))
}
text := "\r\n" // Skip a line.
text += fmt.Sprintf(`echo "MODIFY %s %s %s old=%s new=%s"`, recName, domainname, recType, oldContent, newContent)
text += "\r\n"
text += "$OldObj = Get-DnsServerResourceRecord"
text += fmt.Sprintf(` -ComputerName "%s"`, c.adServer)
text += fmt.Sprintf(` -ZoneName "%s"`, domainname)
text += fmt.Sprintf(` -Name "%s"`, recName)
text += fmt.Sprintf(` -RRType "%s"`, recType)
text += fmt.Sprintf(" | Where-Object {$_.RecordData.%s -eq %s -and $_.HostName -eq \"%s\"}", queryField, queryContent, recName)
text += "\r\n"
text += `if($OldObj.Length -ne $null){ throw "Error, multiple results for Get-DnsServerResourceRecord" }`
text += "\r\n"
text += "$NewObj = $OldObj.Clone()"
text += "\r\n"
if oldContent != newContent {
text += fmt.Sprintf(`$NewObj.RecordData.%s = "%s"`, queryField, newContent)
text += "\r\n"
}
if oldTTL != newTTL {
text += fmt.Sprintf(`$NewObj.TimeToLive = New-TimeSpan -Seconds %d`, newTTL)
text += "\r\n"
}
text += "Set-DnsServerResourceRecord"
text += fmt.Sprintf(` -ComputerName "%s"`, c.adServer)
text += fmt.Sprintf(` -ZoneName "%s"`, domainname)
text += fmt.Sprintf(` -NewInputObject $NewObj -OldInputObject $OldObj`)
text += "\r\n"
return text
}
func (c *adProvider) createRec(domainname string, rec *models.RecordConfig) []*models.Correction {
arr := []*models.Correction{
{
Msg: fmt.Sprintf("CREATE record: %s %s ttl(%d) %s", rec.Name, rec.Type, rec.TTL, rec.Target),
F: func() error {
return powerShellDoCommand(c.generatePowerShellCreate(domainname, rec))
}},
}
return arr
}
func (c *adProvider) modifyRec(domainname string, m diff.Correlation) *models.Correction {
old, rec := m.Existing.(*models.RecordConfig), m.Desired.(*models.RecordConfig)
oldContent := old.GetContent()
newContent := rec.GetContent()
return &models.Correction{
Msg: m.String(),
F: func() error {
return powerShellDoCommand(c.generatePowerShellModify(domainname, rec.Name, rec.Type, oldContent, newContent, old.TTL, rec.TTL))
},
}
}

View File

@@ -0,0 +1,40 @@
package activedir
import (
"fmt"
"testing"
"github.com/StackExchange/dnscontrol/models"
)
func TestGetExistingRecords(t *testing.T) {
cf := &adProvider{}
*flagFakePowerShell = true
actual, err := cf.getExistingRecords("test2")
if err != nil {
t.Fatal(err)
}
expected := []*models.RecordConfig{
{Name: "@", NameFQDN: "test2", Type: "A", TTL: 600, Target: "10.166.2.11"},
//{Name: "_msdcs", NameFQDN: "_msdcs.test2", Type: "NS", TTL: 300, Target: "other_record"}, // Will be filtered.
{Name: "co-devsearch02", NameFQDN: "co-devsearch02.test2", Type: "A", TTL: 3600, Target: "10.8.2.64"},
{Name: "co-devservice01", NameFQDN: "co-devservice01.test2", Type: "A", TTL: 1200, Target: "10.8.2.48"}, // Downcased.
{Name: "yum", NameFQDN: "yum.test2", Type: "A", TTL: 3600, Target: "10.8.0.59"},
}
actualS := ""
for i, x := range actual {
actualS += fmt.Sprintf("%d %v\n", i, x)
}
expectedS := ""
for i, x := range expected {
expectedS += fmt.Sprintf("%d %v\n", i, x)
}
if actualS != expectedS {
t.Fatalf("got\n(%s)\nbut expected\n(%s)", actualS, expectedS)
}
}

View File

@@ -0,0 +1,17 @@
// +build !windows
package activedir
func (c *adProvider) getRecords(domainname string) ([]byte, error) {
if !*flagFakePowerShell {
panic("Can not happen: PowerShell on non-windows")
}
return c.readZoneDump(domainname)
}
func powerShellDoCommand(command string) error {
if !*flagFakePowerShell {
panic("Can not happen: PowerShell on non-windows")
}
return powerShellRecord(command)
}

View File

@@ -0,0 +1,95 @@
package activedir
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
func (c *adProvider) getRecords(domainname string) ([]byte, error) {
if !*flagFakePowerShell {
// If we are using PowerShell, make sure it is enabled
// and then run the PS1 command to generate the adzonedump file.
if !isPowerShellReady() {
fmt.Printf("\n\n\n")
fmt.Printf("***********************************************\n")
fmt.Printf("PowerShell DnsServer module not installed.\n")
fmt.Printf("See http://social.technet.microsoft.com/wiki/contents/articles/2202.remote-server-administration-tools-rsat-for-windows-client-and-windows-server-dsforum2wiki.aspx\n")
fmt.Printf("***********************************************\n")
fmt.Printf("\n\n\n")
return nil, fmt.Errorf("PowerShell module DnsServer not installed.")
}
_, err := powerShellExecCombined(c.generatePowerShellZoneDump(domainname))
if err != nil {
return []byte{}, err
}
}
// Return the contents of zone.*.json file instead.
return c.readZoneDump(domainname)
}
func isPowerShellReady() bool {
query, _ := powerShellExec(`(Get-Module -ListAvailable DnsServer) -ne $null`)
q, err := strconv.ParseBool(strings.TrimSpace(string(query)))
if err != nil {
return false
}
return q
}
func powerShellDoCommand(command string) error {
if *flagFakePowerShell {
// If fake, just record the command.
return powerShellRecord(command)
}
_, err := powerShellExec(command)
return err
}
func powerShellExec(command string) ([]byte, error) {
// log it.
err := powerShellLogCommand(command)
if err != nil {
return []byte{}, err
}
// Run it.
out, err := exec.Command("powershell", "-NoProfile", command).CombinedOutput()
if err != nil {
// If there was an error, log it.
powerShellLogErr(err)
}
// Return the result.
return out, err
}
// powerShellExecCombined runs a PS1 command and logs the output. This is useful when the output should be none or very small.
func powerShellExecCombined(command string) ([]byte, error) {
// log it.
err := powerShellLogCommand(command)
if err != nil {
return []byte{}, err
}
// Run it.
out, err := exec.Command("powershell", "-NoProfile", command).CombinedOutput()
if err != nil {
// If there was an error, log it.
powerShellLogErr(err)
return out, err
}
// Log output.
err = powerShellLogOutput(string(out))
if err != nil {
return []byte{}, err
}
// Return the result.
return out, err
}

Binary file not shown.

View File

@@ -0,0 +1,304 @@
package bind
/*
bind -
Generate zonefiles suitiable for BIND.
The zonefiles are read and written to the directory -bind_dir
If the old zonefiles are readable, we read them to determine
if an update is actually needed. The old zonefile is also used
as the basis for generating the new SOA serial number.
If -bind_skeletin_src and -bind_skeletin_dst is defined, a
recursive file copy is performed from src to dst. This is
useful for copying named.ca and other static files.
*/
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/miekg/dns"
"github.com/miekg/dns/dnsutil"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers"
"github.com/StackExchange/dnscontrol/providers/diff"
)
type SoaInfo struct {
Ns string `json:"master"`
Mbox string `json:"mbox"`
Serial uint32 `json:"serial"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
Minttl uint32 `json:"minttl"`
}
func (s SoaInfo) String() string {
return fmt.Sprintf("%s %s %d %d %d %d %d", s.Ns, s.Mbox, s.Serial, s.Refresh, s.Retry, s.Expire, s.Minttl)
}
type Bind struct {
Default_ns []string `json:"default_ns"`
Default_Soa SoaInfo `json:"default_soa"`
}
var bindBaseDir = flag.String("bindtree", "zones", "BIND: Directory that stores BIND zonefiles.")
//var bindSkeletin = flag.String("bind_skeletin", "skeletin/master/var/named/chroot/var/named/master", "")
func rrToRecord(rr dns.RR, origin string, replace_serial uint32) (models.RecordConfig, uint32) {
// Convert's dns.RR into our native data type (models.RecordConfig).
// Records are translated directly with no changes.
// If it is an SOA for the apex domain and
// replace_serial != 0, change the serial to replace_serial.
// WARNING(tlim): This assumes SOAs do not have serial=0.
// If one is found, we replace it with serial=1.
var old_serial, new_serial uint32
header := rr.Header()
rc := models.RecordConfig{}
rc.Type = dns.TypeToString[header.Rrtype]
rc.NameFQDN = strings.ToLower(strings.TrimSuffix(header.Name, "."))
rc.Name = strings.ToLower(dnsutil.TrimDomainName(header.Name, origin))
rc.TTL = header.Ttl
if rc.TTL == models.DefaultTTL {
rc.TTL = 0
}
switch v := rr.(type) {
case *dns.A:
rc.Target = v.A.String()
case *dns.CNAME:
rc.Target = v.Target
case *dns.MX:
rc.Target = v.Mx
rc.Priority = v.Preference
case *dns.NS:
rc.Target = v.Ns
case *dns.SOA:
old_serial = v.Serial
if old_serial == 0 {
// For SOA records, we never return a 0 serial number.
old_serial = 1
}
new_serial = v.Serial
if rc.Name == "@" && replace_serial != 0 {
new_serial = replace_serial
}
rc.Target = fmt.Sprintf("%v %v %v %v %v %v %v",
v.Ns, v.Mbox, new_serial, v.Refresh, v.Retry, v.Expire, v.Minttl)
case *dns.TXT:
rc.Target = strings.Join(v.Txt, " ")
default:
log.Fatalf("Unimplemented zone record type=%s (%v)\n", rc.Type, rr)
}
return rc, old_serial
}
func makeDefaultSOA(info SoaInfo, origin string) *models.RecordConfig {
// Make a default SOA record in case one isn't found:
soa_rec := models.RecordConfig{
Type: "SOA",
Name: "@",
}
soa_rec.NameFQDN = dnsutil.AddOrigin(soa_rec.Name, origin)
if len(info.Ns) == 0 {
info.Ns = "DEFAULT_NOT_SET"
}
if len(info.Mbox) == 0 {
info.Mbox = "DEFAULT_NOT_SET"
}
if info.Serial == 0 {
info.Serial = 1
}
if info.Refresh == 0 {
info.Refresh = 3600
}
if info.Retry == 0 {
info.Retry = 600
}
if info.Expire == 0 {
info.Expire = 604800
}
if info.Minttl == 0 {
info.Minttl = 1440
}
soa_rec.Target = info.String()
return &soa_rec
}
func makeDefaultNS(origin string, names []string) []*models.RecordConfig {
var result []*models.RecordConfig
for _, n := range names {
rc := &models.RecordConfig{
Type: "NS",
Name: "@",
Target: n,
}
rc.NameFQDN = dnsutil.AddOrigin(rc.Name, origin)
result = append(result, rc)
}
return result
}
func (c *Bind) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
// Phase 1: Copy everything to []*models.RecordConfig:
// expectedRecords < dc.Records[i]
// foundRecords < zonefile
//
// Phase 2: Do any manipulations:
// add NS
// manipulate SOA
//
// Phase 3: Convert to []diff.Records and compare:
// expectedDiffRecords < expectedRecords
// foundDiffRecords < foundRecords
// diff.Inc...(foundDiffRecords, expectedDiffRecords )
// Default SOA record. If we see one in the zone, this will be replaced.
soa_rec := makeDefaultSOA(c.Default_Soa, dc.Name)
// Read expectedRecords:
expectedRecords := make([]*models.RecordConfig, 0, len(dc.Records))
for i := range dc.Records {
expectedRecords = append(expectedRecords, dc.Records[i])
}
// Read foundRecords:
foundRecords := make([]*models.RecordConfig, 0)
var old_serial, new_serial uint32
zonefile := filepath.Join(*bindBaseDir, strings.ToLower(dc.Name)+".zone")
found_fh, err := os.Open(zonefile)
zone_file_found := err == nil
if err != nil && !os.IsNotExist(os.ErrNotExist) {
// Don't whine if the file doesn't exist. However all other
// errors will be reported.
fmt.Printf("Could not read zonefile: %v\n", err)
} else {
for x := range dns.ParseZone(found_fh, dc.Name, zonefile) {
if x.Error != nil {
log.Println("Error in zonefile:", x.Error)
} else {
rec, serial := rrToRecord(x.RR, dc.Name, old_serial)
if serial != 0 && old_serial != 0 {
log.Fatalf("Multiple SOA records in zonefile: %v\n", zonefile)
}
if serial != 0 {
// This was an SOA record. Update the serial.
old_serial = serial
new_serial = generate_serial(old_serial)
// Regenerate with new serial:
*soa_rec, _ = rrToRecord(x.RR, dc.Name, new_serial)
rec = *soa_rec
}
foundRecords = append(foundRecords, &rec)
}
}
}
// Add NS records:
if len(c.Default_ns) != 0 && !dc.HasRecordTypeName("NS", "@") {
expectedRecords = append(expectedRecords, makeDefaultNS(dc.Name, c.Default_ns)...)
dc.Records = append(dc.Records, makeDefaultNS(dc.Name, c.Default_ns)...)
}
// Add SOA record:
if !dc.HasRecordTypeName("SOA", "@") {
expectedRecords = append(expectedRecords, soa_rec)
dc.Records = append(dc.Records, soa_rec)
}
// Convert to []diff.Records and compare:
foundDiffRecords := make([]diff.Record, len(foundRecords))
for i := range foundRecords {
foundDiffRecords[i] = foundRecords[i]
}
expectedDiffRecords := make([]diff.Record, len(expectedRecords))
for i := range expectedRecords {
expectedDiffRecords[i] = expectedRecords[i]
}
_, create, del, mod := diff.IncrementalDiff(foundDiffRecords, expectedDiffRecords)
// Print a list of changes. Generate an actual change that is the zone
changes := false
for _, i := range create {
changes = true
if zone_file_found {
fmt.Println(i)
}
}
for _, i := range del {
changes = true
if zone_file_found {
fmt.Println(i)
}
}
for _, i := range mod {
changes = true
if zone_file_found {
fmt.Println(i)
}
}
msg := fmt.Sprintf("GENERATE_ZONEFILE: %s", dc.Name)
if !zone_file_found {
msg = msg + fmt.Sprintf(" (%d records)", len(create))
}
corrections := []*models.Correction{}
if changes {
corrections = append(corrections,
&models.Correction{
Msg: msg,
F: func() error {
fmt.Printf("CREATING ZONEFILE: %v\n", zonefile)
zf, err := os.Create(zonefile)
if err != nil {
log.Fatalf("Could not create zonefile: %v", err)
}
zonefilerecords := make([]dns.RR, 0, len(dc.Records))
for _, r := range dc.Records {
zonefilerecords = append(zonefilerecords, r.RR())
}
err = WriteZoneFile(zf, zonefilerecords, dc.Name, models.DefaultTTL)
if err != nil {
log.Fatalf("WriteZoneFile error: %v\n", err)
}
err = zf.Close()
if err != nil {
log.Fatalf("Closing: %v", err)
}
return nil
},
})
}
return corrections, nil
}
func initBind(config map[string]string, providermeta json.RawMessage) (providers.DNSServiceProvider, error) {
// m -- the json blob from creds.json
// meta -- the json blob from NewReq('name', 'TYPE', meta)
api := &Bind{}
if len(providermeta) != 0 {
err := json.Unmarshal(providermeta, api)
if err != nil {
return nil, err
}
}
return api, nil
}
func init() {
providers.RegisterDomainServiceProviderType("BIND", initBind)
}

View File

@@ -0,0 +1,227 @@
// Generate zonefiles.
// This generates a zonefile that prioritizes beauty over efficiency.
package bind
import (
"bytes"
"fmt"
"io"
"log"
"sort"
"strings"
"github.com/miekg/dns"
"github.com/miekg/dns/dnsutil"
)
type zoneGenData struct {
Origin string
DefaultTtl uint32
Records []dns.RR
}
func (z *zoneGenData) Len() int { return len(z.Records) }
func (z *zoneGenData) Swap(i, j int) { z.Records[i], z.Records[j] = z.Records[j], z.Records[i] }
func (z *zoneGenData) Less(i, j int) bool {
//fmt.Printf("DEBUG: i=%#v j=%#v\n", i, j)
//fmt.Printf("DEBUG: z.Records=%#v\n", len(z.Records))
a, b := z.Records[i], z.Records[j]
//fmt.Printf("DEBUG: a=%#v b=%#v\n", a, b)
compA, compB := dnsutil.AddOrigin(a.Header().Name, z.Origin+"."), dnsutil.AddOrigin(b.Header().Name, z.Origin+".")
if compA != compB {
if compA == z.Origin+"." {
compA = "@"
}
if compB == z.Origin+"." {
compB = "@"
}
return zoneLabelLess(compA, compB)
}
rrtypeA, rrtypeB := a.Header().Rrtype, b.Header().Rrtype
if rrtypeA != rrtypeB {
return zoneRrtypeLess(rrtypeA, rrtypeB)
}
if rrtypeA == dns.TypeA {
ta2, tb2 := a.(*dns.A), b.(*dns.A)
ipa, ipb := ta2.A.To4(), tb2.A.To4()
if ipa == nil || ipb == nil {
log.Fatalf("should not happen: IPs are not 4 bytes: %#v %#v", ta2, tb2)
}
return bytes.Compare(ipa, ipb) == -1
}
if rrtypeA == dns.TypeMX {
ta2, tb2 := a.(*dns.MX), b.(*dns.MX)
pa, pb := ta2.Preference, tb2.Preference
return pa < pb
}
return a.String() < b.String()
}
// WriteZoneFile writes a beautifully formatted zone file.
func WriteZoneFile(w io.Writer, records []dns.RR, origin string, defaultTtl uint32) error {
// This function prioritizes beauty over efficiency.
// * The zone records are sorted by label, grouped by subzones to
// be easy to read and pleasant to the eye.
// * Within a label, SOA and NS records are listed first.
// * MX records are sorted numericly by preference value.
// * A records are sorted by IP address, not lexicographically.
// * Repeated labels are removed.
// * $TTL is used to eliminate clutter.
// * "@" is used instead of the apex domain name.
z := &zoneGenData{
Origin: origin,
DefaultTtl: defaultTtl,
}
z.Records = nil
for _, r := range records {
z.Records = append(z.Records, r)
}
return z.generateZoneFileHelper(w)
}
// generateZoneFileHelper creates a pretty zonefile.
func (z *zoneGenData) generateZoneFileHelper(w io.Writer) error {
nameShortPrevious := ""
sort.Sort(z)
fmt.Fprintln(w, "$TTL", z.DefaultTtl)
for i, rr := range z.Records {
line := rr.String()
if line[0] == ';' {
continue
}
hdr := rr.Header()
items := strings.SplitN(line, "\t", 5)
if len(items) < 5 {
log.Fatalf("Too few items in: %v", line)
}
// items[0]: name
nameFqdn := hdr.Name
nameShort := dnsutil.TrimDomainName(nameFqdn, z.Origin)
name := nameShort
if i > 0 && nameShort == nameShortPrevious {
name = ""
} else {
name = nameShort
}
nameShortPrevious = nameShort
// items[1]: ttl
ttl := ""
if hdr.Ttl != z.DefaultTtl && hdr.Ttl != 0 {
ttl = items[1]
}
// items[2]: class
if hdr.Class != dns.ClassINET {
log.Fatalf("Unimplemented class=%v", items[2])
}
// items[3]: type
typeStr := dns.TypeToString[hdr.Rrtype]
// items[4]: the remaining line
target := items[4]
//if typeStr == "TXT" {
// fmt.Printf("generateZoneFileHelper.go: target=%#v\n", target)
//}
fmt.Fprintln(w, formatLine([]int{10, 5, 2, 5, 0}, []string{name, ttl, "IN", typeStr, target}))
}
return nil
}
func formatLine(lengths []int, fields []string) string {
c := 0
result := ""
for i, length := range lengths {
item := fields[i]
for len(result) < c {
result += " "
}
if item != "" {
result += item + " "
}
c += length + 1
}
return strings.TrimRight(result, " ")
}
func zoneLabelLess(a, b string) bool {
// Compare two zone labels for the purpose of sorting the RRs in a Zone.
// If they are equal, we are done. All other code is simplified
// because we can assume a!=b.
if a == b {
return false
}
// Sort @ at the top, then *, then everything else lexigraphically.
// i.e. @ always is less. * is is less than everything but @.
if a == "@" {
return true
}
if b == "@" {
return false
}
if a == "*" {
return true
}
if b == "*" {
return false
}
// Split into elements and match up last elements to first. Compare the
// first non-equal elements.
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
ia := len(as) - 1
ib := len(bs) - 1
var min int
if ia < ib {
min = len(as) - 1
} else {
min = len(bs) - 1
}
// Skip the matching highest elements, then compare the next item.
for i, j := ia, ib; min >= 0; i, j, min = i-1, j-1, min-1 {
if as[i] != bs[j] {
return as[i] < bs[j]
}
}
// The min top elements were equal, so the shorter name is less.
return ia < ib
}
func zoneRrtypeLess(a, b uint16) bool {
// Compare two RR types for the purpose of sorting the RRs in a Zone.
// If they are equal, we are done. All other code is simplified
// because we can assume a!=b.
if a == b {
return false
}
// List SOAs, then NSs, then all others.
// i.e. SOA is always less. NS is less than everything but SOA.
if a == dns.TypeSOA {
return true
}
if b == dns.TypeSOA {
return false
}
if a == dns.TypeNS {
return true
}
if b == dns.TypeNS {
return false
}
return a < b
}

View File

@@ -0,0 +1,299 @@
package bind
import (
"bytes"
"fmt"
"log"
"math/rand"
"testing"
"github.com/miekg/dns"
"github.com/miekg/dns/dnsutil"
)
func parseAndRegen(t *testing.T, buf *bytes.Buffer, expected string) {
// Take a zonefile, parse it, then generate a zone. We should
// get back the same string.
// This is used after any WriteZoneFile test as an extra verification step.
// Parse the output:
var parsed []dns.RR
for x := range dns.ParseZone(buf, "bosun.org", "bosun.org.zone") {
if x.Error != nil {
log.Fatalf("Error in zonefile: %v", x.Error)
} else {
parsed = append(parsed, x.RR)
}
}
// Generate it back:
buf2 := &bytes.Buffer{}
WriteZoneFile(buf2, parsed, "bosun.org.", 300)
// Compare:
if buf2.String() != expected {
t.Fatalf("Regenerated zonefile does not match: got=(\n%v\n)\nexpected=(\n%v\n)\n", buf2.String(), expected)
}
}
// func WriteZoneFile
func TestWriteZoneFileSimple(t *testing.T) {
r1, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.153")
r2, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.154")
r3, _ := dns.NewRR("www.bosun.org. 300 IN CNAME bosun.org.")
buf := &bytes.Buffer{}
WriteZoneFile(buf, []dns.RR{r1, r2, r3}, "bosun.org.", 300)
expected := `$TTL 300
@ IN A 192.30.252.153
IN A 192.30.252.154
www IN CNAME bosun.org.
`
if buf.String() != expected {
t.Log(buf.String())
t.Log(expected)
t.Fatalf("Zone file does not match.")
}
parseAndRegen(t, buf, expected)
}
func TestWriteZoneFileMx(t *testing.T) {
//exhibits explicit ttls and long name
r1, _ := dns.NewRR(`bosun.org. 300 IN TXT "aaa"`)
r2, _ := dns.NewRR(`bosun.org. 300 IN TXT "bbb"`)
r2.(*dns.TXT).Txt[0] = `b"bb`
r3, _ := dns.NewRR("bosun.org. 300 IN MX 1 ASPMX.L.GOOGLE.COM.")
r4, _ := dns.NewRR("bosun.org. 300 IN MX 5 ALT1.ASPMX.L.GOOGLE.COM.")
r5, _ := dns.NewRR("bosun.org. 300 IN MX 10 ASPMX3.GOOGLEMAIL.COM.")
r6, _ := dns.NewRR("bosun.org. 300 IN A 198.252.206.16")
r7, _ := dns.NewRR("*.bosun.org. 600 IN A 198.252.206.16")
r8, _ := dns.NewRR(`_domainkey.bosun.org. 300 IN TXT "vvvv"`)
r9, _ := dns.NewRR(`google._domainkey.bosun.org. 300 IN TXT "\"foo\""`)
buf := &bytes.Buffer{}
WriteZoneFile(buf, []dns.RR{r1, r2, r3, r4, r5, r6, r7, r8, r9}, "bosun.org", 300)
if buf.String() != testdataZFMX {
t.Log(buf.String())
t.Log(testdataZFMX)
t.Fatalf("Zone file does not match.")
}
parseAndRegen(t, buf, testdataZFMX)
}
var testdataZFMX = `$TTL 300
@ IN A 198.252.206.16
IN MX 1 ASPMX.L.GOOGLE.COM.
IN MX 5 ALT1.ASPMX.L.GOOGLE.COM.
IN MX 10 ASPMX3.GOOGLEMAIL.COM.
IN TXT "aaa"
IN TXT "b\"bb"
* 600 IN A 198.252.206.16
_domainkey IN TXT "vvvv"
google._domainkey IN TXT "\"foo\""
`
func TestWriteZoneFileOrder(t *testing.T) {
var records []dns.RR
for i, td := range []string{
"@",
"@",
"@",
"stackoverflow.com.",
"*",
"foo",
"bar.foo",
"hip.foo",
"mup",
"a.mup",
"bzt.mup",
"aaa.bzt.mup",
"zzz.bzt.mup",
"nnn.mup",
"zt.mup",
"zap",
} {
name := dnsutil.AddOrigin(td, "stackoverflow.com.")
r, _ := dns.NewRR(fmt.Sprintf("%s 300 IN A 1.2.3.%d", name, i))
records = append(records, r)
}
records[0].Header().Name = "stackoverflow.com."
records[1].Header().Name = "@"
buf := &bytes.Buffer{}
WriteZoneFile(buf, records, "stackoverflow.com.", 300)
// Compare
if buf.String() != testdataOrder {
t.Log(buf.String())
t.Log(testdataOrder)
t.Fatalf("Zone file does not match.")
}
parseAndRegen(t, buf, testdataOrder)
// Now shuffle the list many times and make sure it still works:
for iteration := 5; iteration > 0; iteration-- {
// Randomize the list:
perm := rand.Perm(len(records))
for i, v := range perm {
records[i], records[v] = records[v], records[i]
//fmt.Println(i, v)
}
// Generate
buf := &bytes.Buffer{}
WriteZoneFile(buf, records, "stackoverflow.com.", 300)
// Compare
if buf.String() != testdataOrder {
t.Log(buf.String())
t.Log(testdataOrder)
t.Fatalf("Zone file does not match.")
}
parseAndRegen(t, buf, testdataOrder)
}
}
var testdataOrder = `$TTL 300
@ IN A 1.2.3.0
IN A 1.2.3.1
IN A 1.2.3.2
IN A 1.2.3.3
* IN A 1.2.3.4
foo IN A 1.2.3.5
bar.foo IN A 1.2.3.6
hip.foo IN A 1.2.3.7
mup IN A 1.2.3.8
a.mup IN A 1.2.3.9
bzt.mup IN A 1.2.3.10
aaa.bzt.mup IN A 1.2.3.11
zzz.bzt.mup IN A 1.2.3.12
nnn.mup IN A 1.2.3.13
zt.mup IN A 1.2.3.14
zap IN A 1.2.3.15
`
// func formatLine
func TestFormatLine(t *testing.T) {
tests := []struct {
lengths []int
fields []string
expected string
}{
{[]int{2, 2, 0}, []string{"a", "b", "c"}, "a b c"},
{[]int{2, 2, 0}, []string{"aaaaa", "b", "c"}, "aaaaa b c"},
}
for _, ts := range tests {
actual := formatLine(ts.lengths, ts.fields)
if actual != ts.expected {
t.Errorf("\"%s\" != \"%s\"", actual, ts.expected)
}
}
}
// func zoneLabelLess
func TestZoneLabelLess(t *testing.T) {
/*
The zone should sort in prefix traversal order:
@
*
foo
bar.foo
hip.foo
mup
a.mup
bzt.mup
aaa.bzt.mup
zzz.bzt.mup
nnn.mup
zt.mup
zap
*/
var tests = []struct {
e1, e2 string
expected bool
}{
{"@", "@", false},
{"@", "*", true},
{"@", "b", true},
{"*", "@", false},
{"*", "*", false},
{"*", "b", true},
{"foo", "foo", false},
{"foo", "bar", false},
{"bar", "foo", true},
{"a.mup", "mup", false},
{"mup", "a.mup", true},
{"a.mup", "a.mup", false},
{"a.mup", "bzt.mup", true},
{"a.mup", "aa.mup", true},
{"zt.mup", "aaa.bzt.mup", false},
{"aaa.bzt.mup", "mup", false},
{"nnn.mup", "aaa.bzt.mup", false},
{`www\.miek.nl`, `www.miek.nl`, false},
}
for _, test := range tests {
actual := zoneLabelLess(test.e1, test.e2)
if test.expected != actual {
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
}
actual = zoneLabelLess(test.e2, test.e1)
// The reverse should work too:
var expected bool
if test.e1 == test.e2 {
expected = false
} else {
expected = !test.expected
}
if expected != actual {
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
}
}
}
func TestZoneRrtypeLess(t *testing.T) {
/*
In zonefiles we want to list SOAs, then NSs, then all others.
*/
var tests = []struct {
e1, e2 uint16
expected bool
}{
{dns.TypeSOA, dns.TypeSOA, false},
{dns.TypeSOA, dns.TypeA, true},
{dns.TypeSOA, dns.TypeTXT, true},
{dns.TypeSOA, dns.TypeNS, true},
{dns.TypeNS, dns.TypeSOA, false},
{dns.TypeNS, dns.TypeA, true},
{dns.TypeNS, dns.TypeTXT, true},
{dns.TypeNS, dns.TypeNS, false},
{dns.TypeA, dns.TypeSOA, false},
{dns.TypeA, dns.TypeA, false},
{dns.TypeA, dns.TypeTXT, true},
{dns.TypeA, dns.TypeNS, false},
{dns.TypeMX, dns.TypeSOA, false},
{dns.TypeMX, dns.TypeA, false},
{dns.TypeMX, dns.TypeTXT, true},
{dns.TypeMX, dns.TypeNS, false},
}
for _, test := range tests {
actual := zoneRrtypeLess(test.e1, test.e2)
if test.expected != actual {
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
}
actual = zoneRrtypeLess(test.e2, test.e1)
// The reverse should work too:
var expected bool
if test.e1 == test.e2 {
expected = false
} else {
expected = !test.expected
}
if expected != actual {
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
}
}
}

71
providers/bind/serial.go Normal file
View File

@@ -0,0 +1,71 @@
package bind
import (
"log"
"strconv"
"strings"
"time"
)
var nowFunc func() time.Time = time.Now
// generate_serial takes an old SOA serial number and increments it.
func generate_serial(old_serial uint32) uint32 {
// Serial numbers are in the format yyyymmddvv
// where vv is a version count that starts at 01 each day.
// Multiple serial numbers generated on the same day increase vv.
// If the old serial number is not in this format, it gets replaced
// with the new format. However if that would mean a new serial number
// that is smaller than the old one, we punt and increment the old number.
// At no time will a serial number == 0 be returned.
original := old_serial
old_serialStr := strconv.FormatUint(uint64(old_serial), 10)
var new_serial uint32
// Make draft new serial number:
today := nowFunc().UTC()
todayStr := today.Format("20060102")
version := uint32(1)
todayNum, err := strconv.ParseUint(todayStr, 10, 32)
if err != nil {
log.Fatalf("new serial won't fit in 32 bits: %v", err)
}
draft := uint32(todayNum)*100 + version
method := "none" // Used only in debugging.
if old_serial > draft {
// If old_serial was really slow, upgrade to new yyyymmddvv standard:
method = "o>d"
new_serial = old_serial + 1
new_serial = old_serial + 1
} else if old_serial == draft {
// Edge case: increment old serial:
method = "o=d"
new_serial = draft + 1
} else if len(old_serialStr) != 10 {
// If old_serial is wrong number of digits, upgrade to yyyymmddvv standard:
method = "len!=10"
new_serial = draft
} else if strings.HasPrefix(old_serialStr, todayStr) {
// If old_serial just needs to be incremented:
method = "prefix"
new_serial = old_serial + 1
} else {
// First serial number to be requested today:
method = "default"
new_serial = draft
}
if new_serial == 0 {
// We never return 0 as the serial number.
new_serial = 1
}
if old_serial == new_serial {
log.Fatalf("%v: old_serial == new_serial (%v == %v) draft=%v method=%v", original, old_serial, new_serial, draft, method)
}
if old_serial > new_serial {
log.Fatalf("%v: old_serial > new_serial (%v > %v) draft=%v method=%v", original, old_serial, new_serial, draft, method)
}
return new_serial
}

View File

@@ -0,0 +1,51 @@
package bind
import (
"testing"
"time"
)
func Test_generate_serial_1(t *testing.T) {
d1, _ := time.Parse("20060102", "20150108")
d4, _ := time.Parse("20060102", "40150108")
d12, _ := time.Parse("20060102", "20151231")
var tests = []struct {
Given uint32
Today time.Time
Expected uint32
}{
{0, d1, 2015010801},
{123, d1, 2015010801},
{2015010800, d1, 2015010801},
{2015010801, d1, 2015010802},
{2015010802, d1, 2015010803},
{2015010898, d1, 2015010899},
{2015010899, d1, 2015010900},
{2015090401, d1, 2015090402},
{201509040, d1, 2015010801},
{20150904, d1, 2015010801},
{2015090, d1, 2015010801},
// Verify 32-bits is enough to carry us 200 years in the future:
{4015090401, d4, 4015090402},
// Verify Dec 31 edge-case:
{2015123099, d12, 2015123101},
{2015123100, d12, 2015123101},
{2015123101, d12, 2015123102},
{2015123102, d12, 2015123103},
{2015123198, d12, 2015123199},
{2015123199, d12, 2015123200},
{2015123200, d12, 2015123201},
{201512310, d12, 2015123101},
}
for i, tst := range tests {
expected := tst.Expected
nowFunc = func() time.Time {
return tst.Today
}
found := generate_serial(tst.Given)
if expected != found {
t.Fatalf("Test:%d/%v: Expected (%d) got (%d)\n", i, tst.Given, expected, found)
}
}
}

View File

@@ -0,0 +1,306 @@
package cloudflare
import (
"encoding/json"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/miekg/dns/dnsutil"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers"
"github.com/StackExchange/dnscontrol/providers/diff"
"github.com/StackExchange/dnscontrol/transform"
)
/*
Cloudflare APi DNS provider:
Info required in `creds.json`:
- apikey
- apiuser
Record level metadata availible:
- cloudflare_proxy ("true" or "false")
Domain level metadata availible:
- cloudflare_proxy_default ("true" or "false")
Provider level metadata availible:
- ip_conversions
- secret_ips
*/
type CloudflareApi struct {
ApiKey string `json:"apikey"`
ApiUser string `json:"apiuser"`
domainIndex map[string]string
nameservers map[string][]*models.Nameserver
ipConversions []transform.IpConversion
secretIPs []net.IP
ignoredLabels []string
}
func labelMatches(label string, matches []string) bool {
//log.Printf("DEBUG: labelMatches(%#v, %#v)\n", label, matches)
for _, tst := range matches {
if label == tst {
return true
}
}
return false
}
func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
if c.domainIndex == nil {
if err := c.fetchDomainList(); err != nil {
return nil, err
}
}
id, ok := c.domainIndex[dc.Name]
if !ok {
return nil, fmt.Errorf("%s not listed in zones for cloudflare account", dc.Name)
}
dc.Nameservers = c.nameservers[dc.Name]
if err := c.preprocessConfig(dc); err != nil {
return nil, err
}
records, err := c.getRecordsForDomain(id)
if err != nil {
return nil, err
}
//for _, rec := range records {
for i := len(records) - 1; i >= 0; i-- {
rec := records[i]
// Delete ignore labels
if labelMatches(dnsutil.TrimDomainName(rec.(*cfRecord).Name, dc.Name), c.ignoredLabels) {
fmt.Printf("ignored_label: %s\n", rec.(*cfRecord).Name)
records = append(records[:i], records[i+1:]...)
}
//normalize cname,mx,ns records with dots to be consistent with our config format.
t := rec.(*cfRecord).Type
if t == "CNAME" || t == "MX" || t == "NS" {
rec.(*cfRecord).Content = dnsutil.AddOrigin(rec.(*cfRecord).Content+".", dc.Name)
}
}
expectedRecords := make([]diff.Record, 0, len(dc.Records))
for _, rec := range dc.Records {
if labelMatches(rec.Name, c.ignoredLabels) {
log.Fatalf("FATAL: dnsconfig contains label that matches ignored_labels: %#v is in %v)\n", rec.Name, c.ignoredLabels)
// Since we log.Fatalf, we don't need to be clean here.
}
expectedRecords = append(expectedRecords, recordWrapper{rec})
}
_, create, del, mod := diff.IncrementalDiff(records, expectedRecords)
corrections := []*models.Correction{}
for _, d := range del {
corrections = append(corrections, c.deleteRec(d.Existing.(*cfRecord), id))
}
for _, d := range create {
corrections = append(corrections, c.createRec(d.Desired.(recordWrapper).RecordConfig, id)...)
}
for _, d := range mod {
e, rec := d.Existing.(*cfRecord), d.Desired.(recordWrapper)
proxy := e.Proxiable && rec.Metadata[metaProxy] != "off"
corrections = append(corrections, &models.Correction{
Msg: fmt.Sprintf("MODIFY record %s %s: (%s %s) => (%s %s)", rec.Name, rec.Type, e.Content, e.GetComparisionData(), rec.Target, rec.GetComparisionData()),
F: func() error { return c.modifyRecord(id, e.ID, proxy, rec.RecordConfig) },
})
}
return corrections, nil
}
const (
metaProxy = "cloudflare_proxy"
metaProxyDefault = metaProxy + "_default"
metaOriginalIP = "original_ip" // TODO(tlim): Unclear what this means.
metaIPConversions = "ip_conversions" // TODO(tlim): Rename to obscure_rules.
metaSecretIPs = "secret_ips" // TODO(tlim): Rename to obscured_cidrs.
)
func checkProxyVal(v string) (string, error) {
v = strings.ToLower(v)
if v != "on" && v != "off" && v != "full" {
return "", fmt.Errorf("Bad metadata value for cloudflare_proxy: '%s'. Use on/off/full", v)
}
return v, nil
}
func (c *CloudflareApi) preprocessConfig(dc *models.DomainConfig) error {
// Determine the default proxy setting.
var defProxy string
var err error
if defProxy = dc.Metadata[metaProxyDefault]; defProxy == "" {
defProxy = "off"
} else {
defProxy, err = checkProxyVal(defProxy)
if err != nil {
return err
}
}
// Normalize the proxy setting for each record.
// A and CNAMEs: Validate. If null, set to default.
// else: Make sure it wasn't set. Set to default.
for _, rec := range dc.Records {
if rec.Type != "A" && rec.Type != "CNAME" && rec.Type != "AAAA" {
if rec.Metadata[metaProxy] != "" {
return fmt.Errorf("cloudflare_proxy set on %v record: %#v cloudflare_proxy=%#v", rec.Type, rec.Name, rec.Metadata[metaProxy])
}
// Force it to off.
rec.Metadata[metaProxy] = "off"
} else {
if val := rec.Metadata[metaProxy]; val == "" {
rec.Metadata[metaProxy] = defProxy
} else {
val, err := checkProxyVal(val)
if err != nil {
return err
}
rec.Metadata[metaProxy] = val
}
}
}
// look for ip conversions and transform records
for _, rec := range dc.Records {
if rec.TTL == 0 {
rec.TTL = 1
}
if rec.Type != "A" {
continue
}
//only transform "full"
if rec.Metadata[metaProxy] != "full" {
continue
}
ip := net.ParseIP(rec.Target)
if ip == nil {
return fmt.Errorf("%s is not a valid ip address", rec.Target)
}
newIP, err := transform.TransformIP(ip, c.ipConversions)
if err != nil {
return err
}
rec.Metadata[metaOriginalIP] = rec.Target
rec.Target = newIP.String()
}
return nil
}
func newCloudflare(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
api := &CloudflareApi{}
api.ApiUser, api.ApiKey = m["apiuser"], m["apikey"]
// check api keys from creds json file
if api.ApiKey == "" || api.ApiUser == "" {
return nil, fmt.Errorf("Cloudflare apikey and apiuser must be provided.")
}
if len(metadata) > 0 {
parsedMeta := &struct {
IPConversions string `json:"ip_conversions"`
SecretIps []interface{} `json:"secret_ips"`
IgnoredLabels []string `json:"ignored_labels"`
}{}
err := json.Unmarshal([]byte(metadata), parsedMeta)
if err != nil {
return nil, err
}
// ignored_labels:
for _, l := range parsedMeta.IgnoredLabels {
api.ignoredLabels = append(api.ignoredLabels, l)
}
// parse provider level metadata
api.ipConversions, err = transform.DecodeTransformTable(parsedMeta.IPConversions)
if err != nil {
return nil, err
}
ips := []net.IP{}
for _, ipStr := range parsedMeta.SecretIps {
var ip net.IP
if ip, err = models.InterfaceToIP(ipStr); err != nil {
return nil, err
}
ips = append(ips, ip)
}
api.secretIPs = ips
}
return api, nil
}
func init() {
providers.RegisterDomainServiceProviderType("CLOUDFLAREAPI", newCloudflare)
}
// Used on the "existing" records.
type cfRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxiable bool `json:"proxiable"`
Proxied bool `json:"proxied"`
TTL int `json:"ttl"`
Locked bool `json:"locked"`
ZoneID string `json:"zone_id"`
ZoneName string `json:"zone_name"`
CreatedOn time.Time `json:"created_on"`
ModifiedOn time.Time `json:"modified_on"`
Data interface{} `json:"data"`
Priority int `json:"priority"`
}
func (c *cfRecord) GetName() string {
return c.Name
}
func (c *cfRecord) GetType() string {
return c.Type
}
func (c *cfRecord) GetContent() string {
return c.Content
}
func (c *cfRecord) GetComparisionData() string {
mxPrio := ""
if c.Type == "MX" {
mxPrio = fmt.Sprintf(" %d ", c.Priority)
}
proxy := ""
if c.Type == "A" || c.Type == "CNAME" || c.Type == "AAAA" {
proxy = fmt.Sprintf(" proxy=%v ", c.Proxied)
}
return fmt.Sprintf("%d%s%s", c.TTL, mxPrio, proxy)
}
// Used on the "expected" records.
type recordWrapper struct {
*models.RecordConfig
}
func (c recordWrapper) GetComparisionData() string {
mxPrio := ""
if c.Type == "MX" {
mxPrio = fmt.Sprintf(" %d ", c.Priority)
}
proxy := ""
if c.Type == "A" || c.Type == "AAAA" || c.Type == "CNAME" {
proxy = fmt.Sprintf(" proxy=%v ", c.Metadata[metaProxy] != "off")
}
ttl := c.TTL
if ttl == 0 {
ttl = 1
}
return fmt.Sprintf("%d%s%s", ttl, mxPrio, proxy)
}

View File

@@ -0,0 +1,116 @@
package cloudflare
import (
"net"
"testing"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/transform"
)
func newDomainConfig() *models.DomainConfig {
return &models.DomainConfig{
Name: "test.com",
Records: []*models.RecordConfig{},
Metadata: map[string]string{},
}
}
func TestPreprocess_BoolValidation(t *testing.T) {
cf := &CloudflareApi{}
domain := newDomainConfig()
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{metaProxy: "on"}})
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{metaProxy: "fUll"}})
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{}})
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{metaProxy: "Off"}})
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{metaProxy: "off"}})
err := cf.preprocessConfig(domain)
if err != nil {
t.Fatal(err)
}
expected := []string{"on", "full", "off", "off", "off"}
// make sure only "on" or "off", and "full" are actually set
for i, rec := range domain.Records {
if rec.Metadata[metaProxy] != expected[i] {
t.Fatalf("At index %d: expect '%s' but found '%s'", i, expected[i], rec.Metadata[metaProxy])
}
}
}
func TestPreprocess_BoolValidation_Fails(t *testing.T) {
cf := &CloudflareApi{}
domain := newDomainConfig()
domain.Records = append(domain.Records, &models.RecordConfig{Metadata: map[string]string{metaProxy: "true"}})
err := cf.preprocessConfig(domain)
if err == nil {
t.Fatal("Expected validation error, but got none")
}
}
func TestPreprocess_DefaultProxy(t *testing.T) {
cf := &CloudflareApi{}
domain := newDomainConfig()
domain.Metadata[metaProxyDefault] = "full"
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{metaProxy: "on"}})
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{metaProxy: "off"}})
domain.Records = append(domain.Records, &models.RecordConfig{Type: "A", Target: "1.2.3.4", Metadata: map[string]string{}})
err := cf.preprocessConfig(domain)
if err != nil {
t.Fatal(err)
}
expected := []string{"on", "off", "full"}
for i, rec := range domain.Records {
if rec.Metadata[metaProxy] != expected[i] {
t.Fatalf("At index %d: expect '%s' but found '%s'", i, expected[i], rec.Metadata[metaProxy])
}
}
}
func TestPreprocess_DefaultProxy_Validation(t *testing.T) {
cf := &CloudflareApi{}
domain := newDomainConfig()
domain.Metadata[metaProxyDefault] = "true"
err := cf.preprocessConfig(domain)
if err == nil {
t.Fatal("Expected validation error, but got none")
}
}
func TestIpRewriting(t *testing.T) {
var tests = []struct {
Given, Expected string
Proxy string
}{
//outside of range
{"5.5.5.5", "5.5.5.5", "full"},
{"5.5.5.5", "5.5.5.5", "on"},
// inside range, but not proxied
{"1.2.3.4", "1.2.3.4", "on"},
//inside range and proxied
{"1.2.3.4", "255.255.255.4", "full"},
}
cf := &CloudflareApi{}
domain := newDomainConfig()
cf.ipConversions = []transform.IpConversion{{net.ParseIP("1.2.3.0"), net.ParseIP("1.2.3.40"), net.ParseIP("255.255.255.0"), nil}}
for _, tst := range tests {
rec := &models.RecordConfig{Type: "A", Target: tst.Given, Metadata: map[string]string{metaProxy: tst.Proxy}}
domain.Records = append(domain.Records, rec)
}
err := cf.preprocessConfig(domain)
if err != nil {
t.Fatal(err)
}
for i, tst := range tests {
rec := domain.Records[i]
if rec.Target != tst.Expected {
t.Fatalf("At index %d, expected target of %s, but found %s.", i, tst.Expected, rec.Target)
}
if tst.Proxy == "full" && tst.Given != tst.Expected && rec.Metadata[metaOriginalIP] != tst.Given {
t.Fatalf("At index %d, expected original_ip to be set", i)
}
}
}
func TestCnameValidation(t *testing.T) {
}

View File

@@ -0,0 +1,251 @@
package cloudflare
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers/diff"
)
const (
baseURL = "https://api.cloudflare.com/client/v4/"
zonesURL = baseURL + "zones/"
recordsURL = zonesURL + "%s/dns_records/"
singleRecordURL = recordsURL + "%s"
)
// get list of domains for account. Cache so the ids can be looked up from domain name
func (c *CloudflareApi) fetchDomainList() error {
c.domainIndex = map[string]string{}
c.nameservers = map[string][]*models.Nameserver{}
page := 1
for {
zr := &zoneResponse{}
url := fmt.Sprintf("%s?page=%d&per_page=50", zonesURL, page)
if err := c.get(url, zr); err != nil {
return fmt.Errorf("Error fetching domain list from cloudflare: %s", err)
}
if !zr.Success {
return fmt.Errorf("Error fetching domain list from cloudflare: %s", stringifyErrors(zr.Errors))
}
for _, zone := range zr.Result {
c.domainIndex[zone.Name] = zone.ID
for _, ns := range zone.Nameservers {
c.nameservers[zone.Name] = append(c.nameservers[zone.Name], &models.Nameserver{Name: ns})
}
}
ri := zr.ResultInfo
if len(zr.Result) == 0 || ri.Page*ri.PerPage >= ri.TotalCount {
break
}
page++
}
return nil
}
// get all records for a domain
func (c *CloudflareApi) getRecordsForDomain(id string) ([]diff.Record, error) {
url := fmt.Sprintf(recordsURL, id)
page := 1
records := []diff.Record{}
for {
reqURL := fmt.Sprintf("%s?page=%d&per_page=100", url, page)
var data recordsResponse
if err := c.get(reqURL, &data); err != nil {
return nil, fmt.Errorf("Error fetching record list from cloudflare: %s", err)
}
if !data.Success {
return nil, fmt.Errorf("Error fetching record list cloudflare: %s", stringifyErrors(data.Errors))
}
for _, rec := range data.Result {
records = append(records, rec)
}
ri := data.ResultInfo
if len(data.Result) == 0 || ri.Page*ri.PerPage >= ri.TotalCount {
break
}
page++
}
return records, nil
}
// create a correction to delete a record
func (c *CloudflareApi) deleteRec(rec *cfRecord, domainID string) *models.Correction {
return &models.Correction{
Msg: fmt.Sprintf("DELETE record: %s %s %d %s (id=%s)", rec.Name, rec.Type, rec.TTL, rec.Content, rec.ID),
F: func() error {
endpoint := fmt.Sprintf(singleRecordURL, domainID, rec.ID)
req, err := http.NewRequest("DELETE", endpoint, nil)
if err != nil {
return err
}
c.setHeaders(req)
_, err = handleActionResponse(http.DefaultClient.Do(req))
return err
},
}
}
func (c *CloudflareApi) createRec(rec *models.RecordConfig, domainID string) []*models.Correction {
type createRecord struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL uint32 `json:"ttl"`
Priority uint16 `json:"priority"`
}
var id string
content := rec.Target
if rec.Metadata[metaOriginalIP] != "" {
content = rec.Metadata[metaOriginalIP]
}
prio := ""
if rec.Type == "MX" {
prio = fmt.Sprintf(" %d ", rec.Priority)
}
arr := []*models.Correction{{
Msg: fmt.Sprintf("CREATE record: %s %s %d%s %s", rec.Name, rec.Type, rec.TTL, prio, content),
F: func() error {
cf := &createRecord{
Name: rec.Name,
Type: rec.Type,
TTL: rec.TTL,
Content: content,
Priority: rec.Priority,
}
endpoint := fmt.Sprintf(recordsURL, domainID)
buf := &bytes.Buffer{}
encoder := json.NewEncoder(buf)
if err := encoder.Encode(cf); err != nil {
return err
}
req, err := http.NewRequest("POST", endpoint, buf)
if err != nil {
return err
}
c.setHeaders(req)
id, err = handleActionResponse(http.DefaultClient.Do(req))
return err
},
}}
if rec.Metadata[metaProxy] != "off" {
arr = append(arr, &models.Correction{
Msg: fmt.Sprintf("ACTIVATE PROXY for new record %s %s %d %s", rec.Name, rec.Type, rec.TTL, rec.Target),
F: func() error { return c.modifyRecord(domainID, id, true, rec) },
})
}
return arr
}
func (c *CloudflareApi) modifyRecord(domainID, recID string, proxied bool, rec *models.RecordConfig) error {
if domainID == "" || recID == "" {
return fmt.Errorf("Cannot modify record if domain or record id are empty.")
}
type record struct {
ID string `json:"id"`
Proxied bool `json:"proxied"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
Priority uint16 `json:"priority"`
TTL uint32 `json:"ttl"`
}
r := record{recID, proxied, rec.Name, rec.Type, rec.Target, rec.Priority, rec.TTL}
endpoint := fmt.Sprintf(singleRecordURL, domainID, recID)
buf := &bytes.Buffer{}
encoder := json.NewEncoder(buf)
if err := encoder.Encode(r); err != nil {
return err
}
req, err := http.NewRequest("PUT", endpoint, buf)
if err != nil {
return err
}
c.setHeaders(req)
_, err = handleActionResponse(http.DefaultClient.Do(req))
return err
}
// common error handling for all action responses
func handleActionResponse(resp *http.Response, err error) (id string, e error) {
if err != nil {
return "", err
}
defer resp.Body.Close()
result := &basicResponse{}
decoder := json.NewDecoder(resp.Body)
if err = decoder.Decode(result); err != nil {
return "", fmt.Errorf("Unknown error. Status code: %d", resp.StatusCode)
}
if resp.StatusCode != 200 {
return "", fmt.Errorf(stringifyErrors(result.Errors))
}
return result.Result.ID, nil
}
func (c *CloudflareApi) setHeaders(req *http.Request) {
req.Header.Set("X-Auth-Key", c.ApiKey)
req.Header.Set("X-Auth-Email", c.ApiUser)
}
// generic get handler. makes request and unmarshalls response to given interface
func (c *CloudflareApi) get(endpoint string, target interface{}) error {
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
c.setHeaders(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("Bad status code from cloudflare: %d not 200.", resp.StatusCode)
}
decoder := json.NewDecoder(resp.Body)
return decoder.Decode(target)
}
func stringifyErrors(errors []interface{}) string {
dat, err := json.Marshal(errors)
if err != nil {
return "???"
}
return string(dat)
}
type recordsResponse struct {
basicResponse
Result []*cfRecord `json:"result"`
ResultInfo pagingInfo `json:"result_info"`
}
type basicResponse struct {
Success bool `json:"success"`
Errors []interface{} `json:"errors"`
Messages []interface{} `json:"messages"`
Result struct {
ID string `json:"id"`
} `json:"result"`
}
type zoneResponse struct {
basicResponse
Result []struct {
ID string `json:"id"`
Name string `json:"name"`
Nameservers []string `json:"name_servers"`
} `json:"result"`
ResultInfo pagingInfo `json:"result_info"`
}
type pagingInfo struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Count int `json:"count"`
TotalCount int `json:"total_count"`
}

View File

@@ -0,0 +1,50 @@
// Package config provides functions for reading and parsing the provider credentials json file.
// It cleans nonstandard json features (comments and trailing commas), as well as replaces environment variable placeholders with
// their environment variable equivalents. To reference an environment variable in your json file, simply use values in this format:
// "key"="$ENV_VAR_NAME"
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/DisposaBoy/JsonConfigReader"
"github.com/TomOnTime/utfutil"
)
// LoadProviderConfigs will open the specified file name, and parse its contents. It will replace environment variables it finds if any value matches $[A-Za-z_-0-9]+
func LoadProviderConfigs(fname string) (map[string]map[string]string, error) {
var results = map[string]map[string]string{}
dat, err := utfutil.ReadFile(fname, utfutil.POSIX)
if err != nil {
return nil, fmt.Errorf("While reading provider credentials file %v: %v", fname, err)
}
s := string(dat)
r := JsonConfigReader.New(strings.NewReader(s))
err = json.NewDecoder(r).Decode(&results)
if err != nil {
return nil, fmt.Errorf("While parsing provider credentials file %v: %v", fname, err)
}
if err = replaceEnvVars(results); err != nil {
return nil, err
}
return results, nil
}
func replaceEnvVars(m map[string]map[string]string) error {
for provider, keys := range m {
for k, v := range keys {
if strings.HasPrefix(v, "$") {
env := v[1:]
newVal := os.Getenv(env)
if newVal == "" {
return fmt.Errorf("Provider %s references environment variable %s, but has no value.", provider, env)
}
keys[k] = newVal
}
}
}
return nil
}

153
providers/diff/diff.go Normal file
View File

@@ -0,0 +1,153 @@
package diff
import (
"fmt"
"sort"
)
type Record interface {
GetName() string
GetType() string
GetContent() string
// Get relevant comparision data. Default implentation uses "ttl [mx priority]", but providers may insert
// provider specific metadata if needed.
GetComparisionData() string
}
type Correlation struct {
Existing Record
Desired Record
}
type Changeset []Correlation
func IncrementalDiff(existing []Record, desired []Record) (unchanged, create, toDelete, modify Changeset) {
unchanged = Changeset{}
create = Changeset{}
toDelete = Changeset{}
modify = Changeset{}
// log.Printf("ID existing records: (%d)\n", len(existing))
// for i, d := range existing {
// log.Printf("\t%d\t%v\n", i, d)
// }
// log.Printf("ID desired records: (%d)\n", len(desired))
// for i, d := range desired {
// log.Printf("\t%d\t%v\n", i, d)
// }
//sort existing and desired by name
type key struct {
name, rType string
}
existingByNameAndType := map[key][]Record{}
desiredByNameAndType := map[key][]Record{}
for _, e := range existing {
k := key{e.GetName(), e.GetType()}
existingByNameAndType[k] = append(existingByNameAndType[k], e)
}
for _, d := range desired {
k := key{d.GetName(), d.GetType()}
desiredByNameAndType[k] = append(desiredByNameAndType[k], d)
}
// Look through existing records. This will give us changes and deletions and some additions
for key, existingRecords := range existingByNameAndType {
desiredRecords := desiredByNameAndType[key]
//first look through records that are the same content on both sides. Those are either modifications or unchanged
for i := len(existingRecords) - 1; i >= 0; i-- {
ex := existingRecords[i]
for j, de := range desiredRecords {
if de.GetContent() == ex.GetContent() {
//they're either identical or should be a modification of each other
if de.GetComparisionData() == ex.GetComparisionData() {
unchanged = append(unchanged, Correlation{ex, de})
} else {
modify = append(modify, Correlation{ex, de})
}
// remove from both slices by index
existingRecords = existingRecords[:i+copy(existingRecords[i:], existingRecords[i+1:])]
desiredRecords = desiredRecords[:j+copy(desiredRecords[j:], desiredRecords[j+1:])]
break
}
}
}
desiredLookup := map[string]Record{}
existingLookup := map[string]Record{}
// build index based on normalized value/ttl
for _, ex := range existingRecords {
normalized := fmt.Sprintf("%s %s", ex.GetContent(), ex.GetComparisionData())
if existingLookup[normalized] != nil {
panic(fmt.Sprintf("DUPLICATE E_RECORD FOUND: %s %s", key, normalized))
}
existingLookup[normalized] = ex
}
for _, de := range desiredRecords {
normalized := fmt.Sprintf("%s %s", de.GetContent(), de.GetComparisionData())
if desiredLookup[normalized] != nil {
panic(fmt.Sprintf("DUPLICATE D_RECORD FOUND: %s %s", key, normalized))
}
desiredLookup[normalized] = de
}
// if a record is in both, it is unchanged
for norm, ex := range existingLookup {
if de, ok := desiredLookup[norm]; ok {
unchanged = append(unchanged, Correlation{ex, de})
delete(existingLookup, norm)
delete(desiredLookup, norm)
}
}
//sort records by normalized text. Keeps behaviour deterministic
existingStrings, desiredStrings := []string{}, []string{}
for norm := range existingLookup {
existingStrings = append(existingStrings, norm)
}
for norm := range desiredLookup {
desiredStrings = append(desiredStrings, norm)
}
sort.Strings(existingStrings)
sort.Strings(desiredStrings)
// Modifications. Take 1 from each side.
for len(desiredStrings) > 0 && len(existingStrings) > 0 {
modify = append(modify, Correlation{existingLookup[existingStrings[0]], desiredLookup[desiredStrings[0]]})
existingStrings = existingStrings[1:]
desiredStrings = desiredStrings[1:]
}
// If desired still has things they are additions
for _, norm := range desiredStrings {
rec := desiredLookup[norm]
create = append(create, Correlation{nil, rec})
}
// if found , but not desired, delete it
for _, norm := range existingStrings {
rec := existingLookup[norm]
toDelete = append(toDelete, Correlation{rec, nil})
}
// remove this set from the desired list to indicate we have processed it.
delete(desiredByNameAndType, key)
}
//any name/type sets not already processed are pure additions
for name := range existingByNameAndType {
delete(desiredByNameAndType, name)
}
for _, desiredList := range desiredByNameAndType {
for _, rec := range desiredList {
create = append(create, Correlation{nil, rec})
}
}
return
}
func (c Correlation) String() string {
if c.Existing == nil {
return fmt.Sprintf("CREATE %s %s %s %s", c.Desired.GetType(), c.Desired.GetName(), c.Desired.GetContent(), c.Desired.GetComparisionData())
}
if c.Desired == nil {
return fmt.Sprintf("DELETE %s %s %s %s", c.Existing.GetType(), c.Existing.GetName(), c.Existing.GetContent(), c.Existing.GetComparisionData())
}
return fmt.Sprintf("MODIFY %s %s: (%s %s) -> (%s %s)", c.Existing.GetType(), c.Existing.GetName(), c.Existing.GetContent(), c.Existing.GetComparisionData(), c.Desired.GetContent(), c.Desired.GetComparisionData())
}

112
providers/diff/diff_test.go Normal file
View File

@@ -0,0 +1,112 @@
package diff
import (
"fmt"
"strings"
"testing"
"github.com/miekg/dns/dnsutil"
)
type myRecord string //@ A 1 1.2.3.4
func (m myRecord) GetName() string {
name := strings.SplitN(string(m), " ", 4)[0]
return dnsutil.AddOrigin(name, "example.com")
}
func (m myRecord) GetType() string {
return strings.SplitN(string(m), " ", 4)[1]
}
func (m myRecord) GetContent() string {
return strings.SplitN(string(m), " ", 4)[3]
}
func (m myRecord) GetComparisionData() string {
return fmt.Sprint(strings.SplitN(string(m), " ", 4)[2])
}
func TestAdditionsOnly(t *testing.T) {
desired := []Record{
myRecord("@ A 1 1.2.3.4"),
}
existing := []Record{}
checkLengths(t, existing, desired, 0, 1, 0, 0)
}
func TestDeletionsOnly(t *testing.T) {
existing := []Record{
myRecord("@ A 1 1.2.3.4"),
}
desired := []Record{}
checkLengths(t, existing, desired, 0, 0, 1, 0)
}
func TestModification(t *testing.T) {
existing := []Record{
myRecord("www A 1 1.1.1.1"),
myRecord("@ A 1 1.2.3.4"),
}
desired := []Record{
myRecord("@ A 32 1.2.3.4"),
myRecord("www A 1 1.1.1.1"),
}
un, _, _, mod := checkLengths(t, existing, desired, 1, 0, 0, 1)
if t.Failed() {
return
}
if un[0].Desired != desired[1] || un[0].Existing != existing[0] {
t.Error("Expected unchanged records to be correlated")
}
if mod[0].Desired != desired[0] || mod[0].Existing != existing[1] {
t.Errorf("Expected modified records to be correlated")
}
}
func TestUnchangedWithAddition(t *testing.T) {
existing := []Record{
myRecord("www A 1 1.1.1.1"),
}
desired := []Record{
myRecord("www A 1 1.2.3.4"),
myRecord("www A 1 1.1.1.1"),
}
un, _, _, _ := checkLengths(t, existing, desired, 1, 1, 0, 0)
if un[0].Desired != desired[1] || un[0].Existing != existing[0] {
t.Errorf("Expected unchanged records to be correlated")
}
}
func TestOutOfOrderRecords(t *testing.T) {
existing := []Record{
myRecord("www A 1 1.1.1.1"),
myRecord("www A 1 2.2.2.2"),
myRecord("www A 1 3.3.3.3"),
}
desired := []Record{
myRecord("www A 1 1.1.1.1"),
myRecord("www A 1 2.2.2.2"),
myRecord("www A 1 2.2.2.3"),
myRecord("www A 10 3.3.3.3"),
}
_, _, _, mods := checkLengths(t, existing, desired, 2, 1, 0, 1)
if mods[0].Desired != desired[3] || mods[0].Existing != existing[2] {
t.Fatalf("Expected to match %s and %s, but matched %s and %s", existing[2], desired[3], mods[0].Existing, mods[0].Desired)
}
}
func checkLengths(t *testing.T, existing, desired []Record, unCount, createCount, delCount, modCount int) (un, cre, del, mod Changeset) {
un, cre, del, mod = IncrementalDiff(existing, desired)
if len(un) != unCount {
t.Errorf("Got %d unchanged records, but expected %d", len(un), unCount)
}
if len(cre) != createCount {
t.Errorf("Got %d records to create, but expected %d", len(cre), createCount)
}
if len(del) != delCount {
t.Errorf("Got %d records to delete, but expected %d", len(del), delCount)
}
if len(mod) != modCount {
t.Errorf("Got %d records to modify, but expected %d", len(mod), modCount)
}
return
}

View File

@@ -0,0 +1,184 @@
package gandi
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers"
"github.com/StackExchange/dnscontrol/providers/diff"
gandirecord "github.com/prasmussen/gandi-api/domain/zone/record"
)
/*
Gandi API DNS provider:
Info required in `creds.json`:
- apikey
*/
type GandiApi struct {
ApiKey string
domainIndex map[string]int64 // Map of domainname to index
nameservers map[string][]*models.Nameserver
ZoneId int64
}
type cfRecord struct {
gandirecord.RecordInfo
}
func (c *cfRecord) GetName() string {
return c.Name
}
func (c *cfRecord) GetType() string {
return c.Type
}
func (c *cfRecord) GetTtl() int64 {
return c.Ttl
}
func (c *cfRecord) GetValue() string {
return c.Value
}
func (c *cfRecord) GetContent() string {
switch c.Type {
case "MX":
parts := strings.SplitN(c.Value, " ", 2)
// TODO(tlim): This should check for more errors.
return strings.Join(parts[1:], " ")
default:
}
return c.Value
}
func (c *cfRecord) GetComparisionData() string {
if c.Type == "MX" {
parts := strings.SplitN(c.Value, " ", 2)
priority, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Sprintf("%s %#v", c.Ttl, parts[0])
}
return fmt.Sprintf("%d %d", c.Ttl, priority)
}
return fmt.Sprintf("%d", c.Ttl)
}
func (c *GandiApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
if c.domainIndex == nil {
if err := c.fetchDomainList(); err != nil {
return nil, err
}
}
_, ok := c.domainIndex[dc.Name]
if !ok {
return nil, fmt.Errorf("%s not listed in zones for gandi account", dc.Name)
}
domaininfo, err := c.fetchDomainInfo(dc.Name)
if err != nil {
return nil, err
}
for _, nsname := range domaininfo.Nameservers {
dc.Nameservers = append(dc.Nameservers, &models.Nameserver{Name: nsname})
}
foundRecords, err := c.getZoneRecords(domaininfo.ZoneId)
if err != nil {
return nil, err
}
// Convert to []diff.Records and compare:
foundDiffRecords := make([]diff.Record, len(foundRecords))
for i, rec := range foundRecords {
n := &cfRecord{}
n.Id = 0
n.Name = rec.Name
n.Ttl = int64(rec.Ttl)
n.Type = rec.Type
n.Value = rec.Value
foundDiffRecords[i] = n
}
expectedDiffRecords := make([]diff.Record, len(dc.Records))
expectedRecordSets := make([]gandirecord.RecordSet, len(dc.Records))
for i, rec := range dc.Records {
n := &cfRecord{}
n.Id = 0
n.Name = rec.Name
n.Ttl = int64(rec.TTL)
if n.Ttl == 0 {
n.Ttl = 3600
}
n.Type = rec.Type
switch n.Type {
case "MX":
n.Value = fmt.Sprintf("%d %s", rec.Priority, rec.Target)
case "TXT":
n.Value = "\"" + rec.Target + "\"" // FIXME(tlim): Should do proper quoting.
default:
n.Value = rec.Target
}
expectedDiffRecords[i] = n
expectedRecordSets[i] = gandirecord.RecordSet{}
expectedRecordSets[i]["type"] = n.Type
expectedRecordSets[i]["name"] = n.Name
expectedRecordSets[i]["value"] = n.Value
if n.Ttl != 0 {
expectedRecordSets[i]["ttl"] = n.Ttl
}
}
_, create, del, mod := diff.IncrementalDiff(foundDiffRecords, expectedDiffRecords)
// Print a list of changes. Generate an actual change that is the zone
changes := false
for _, i := range create {
changes = true
fmt.Println(i)
}
for _, i := range del {
changes = true
fmt.Println(i)
}
for _, i := range mod {
changes = true
fmt.Println(i)
}
msg := fmt.Sprintf("GENERATE_ZONE: %s (%d records)", dc.Name, len(expectedDiffRecords))
corrections := []*models.Correction{}
if changes {
corrections = append(corrections,
&models.Correction{
Msg: msg,
F: func() error {
fmt.Printf("CREATING ZONE: %v\n", dc.Name)
return c.createGandiZone(dc.Name, domaininfo.ZoneId, expectedRecordSets)
},
})
}
return corrections, nil
}
func newGandi(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
api := &GandiApi{}
api.ApiKey = m["apikey"]
if api.ApiKey == "" {
return nil, fmt.Errorf("Gandi apikey must be provided.")
}
return api, nil
}
func init() {
providers.RegisterDomainServiceProviderType("GANDI", newGandi)
}

168
providers/gandi/protocol.go Normal file
View File

@@ -0,0 +1,168 @@
package gandi
import (
"fmt"
"github.com/StackExchange/dnscontrol/providers/diff"
)
import (
gandiclient "github.com/prasmussen/gandi-api/client"
gandidomain "github.com/prasmussen/gandi-api/domain"
gandizone "github.com/prasmussen/gandi-api/domain/zone"
gandirecord "github.com/prasmussen/gandi-api/domain/zone/record"
gandiversion "github.com/prasmussen/gandi-api/domain/zone/version"
)
// fetchDomainList gets list of domains for account. Cache ids for easy lookup.
func (c *GandiApi) fetchDomainList() error {
c.domainIndex = map[string]int64{}
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
domain := gandidomain.New(gc)
domains, err := domain.List()
if err != nil {
// fmt.Println(err)
return err
}
for _, d := range domains {
c.domainIndex[d.Fqdn] = d.Id
}
return nil
}
// fetchDomainInfo gets information about a domain.
func (c *GandiApi) fetchDomainInfo(fqdn string) (*gandidomain.DomainInfo, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
domain := gandidomain.New(gc)
return domain.Info(fqdn)
}
// getRecordsForDomain returns a list of records for a zone.
func (c *GandiApi) getZoneRecords(zoneid int64) ([]*gandirecord.RecordInfo, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
record := gandirecord.New(gc)
return record.List(zoneid, 0)
}
// listZones retrieves the list of zones.
func (c *GandiApi) listZones() ([]*gandizone.ZoneInfoBase, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
zone := gandizone.New(gc)
return zone.List()
}
// setZone assigns a particular zone to a domain.
func (c *GandiApi) setZones(domainname string, zone_id int64) (*gandidomain.DomainInfo, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
zone := gandizone.New(gc)
return zone.Set(domainname, zone_id)
}
// getZoneInfo gets ZoneInfo about a zone.
func (c *GandiApi) getZoneInfo(zoneid int64) (*gandizone.ZoneInfo, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
zone := gandizone.New(gc)
return zone.Info(zoneid)
}
// createZone creates an entirely new zone.
func (c *GandiApi) createZone(name string) (*gandizone.ZoneInfo, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
zone := gandizone.New(gc)
return zone.Create(name)
}
// replaceZoneContents
func (c *GandiApi) replaceZoneContents(zone_id int64, version_id int64, records []diff.Record) error {
return fmt.Errorf("replaceZoneContents unimplemented")
}
func (c *GandiApi) getEditableZone(domainname string, zoneinfo *gandizone.ZoneInfo) (int64, error) {
var zone_id int64
if zoneinfo.Domains < 2 {
// If there is only on{ domain linked to this zone, use it.
zone_id = zoneinfo.Id
fmt.Printf("Using zone id=%d named %#v\n", zone_id, zoneinfo.Name)
return zone_id, nil
}
// We can't use the zone_id given to us. Let's make/find a new one.
zones, err := c.listZones()
if err != nil {
return 0, err
}
zonename := fmt.Sprintf("%s dnscontrol", domainname)
for _, z := range zones {
if z.Name == zonename {
zone_id = z.Id
fmt.Printf("Recycling zone id=%d named %#v\n", zone_id, z.Name)
return zone_id, nil
}
}
zoneinfo, err = c.createZone(zonename)
if err != nil {
return 0, err
}
zone_id = zoneinfo.Id
fmt.Printf("Created zone id=%d named %#v\n", zone_id, zoneinfo.Name)
return zone_id, nil
}
// makeEditableZone
func (c *GandiApi) makeEditableZone(zone_id int64) (int64, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
version := gandiversion.New(gc)
return version.New(zone_id, 0)
}
// setZoneRecords
func (c *GandiApi) setZoneRecords(zone_id, version_id int64, records []gandirecord.RecordSet) ([]*gandirecord.RecordInfo, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
record := gandirecord.New(gc)
return record.SetRecords(zone_id, version_id, records)
}
// activateVersion
func (c *GandiApi) activateVersion(zone_id, version_id int64) (bool, error) {
gc := gandiclient.New(c.ApiKey, gandiclient.Production)
version := gandiversion.New(gc)
return version.Set(zone_id, version_id)
}
func (c *GandiApi) createGandiZone(domainname string, zone_id int64, records []gandirecord.RecordSet) error {
// Get the zone_id of the zone we'll be updating.
zoneinfo, err := c.getZoneInfo(zone_id)
if err != nil {
return err
}
//fmt.Println("ZONEINFO:", zoneinfo)
zone_id, err = c.getEditableZone(domainname, zoneinfo)
if err != nil {
return err
}
// Get the version_id of the zone we're updating.
version_id, err := c.makeEditableZone(zone_id)
if err != nil {
return err
}
// Update the new version.
_, err = c.setZoneRecords(zone_id, version_id, records)
if err != nil {
return err
}
// Activate zone version
_, err = c.activateVersion(zone_id, version_id)
if err != nil {
return err
}
_, err = c.setZones(domainname, zone_id)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,61 @@
package namecheap
import (
"fmt"
"strings"
nc "github.com/billputer/go-namecheap"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers"
)
type Namecheap struct {
ApiKey string
ApiUser string
client *nc.Client
}
func init() {
providers.RegisterRegistrarType("NAMECHEAP", newReg)
}
func newReg(m map[string]string) (providers.Registrar, error) {
api := &Namecheap{}
api.ApiUser, api.ApiKey = m["apiuser"], m["apikey"]
if api.ApiKey == "" || api.ApiUser == "" {
return nil, fmt.Errorf("Namecheap apikey and apiuser must be provided.")
}
api.client = nc.NewClient(api.ApiUser, api.ApiKey, api.ApiUser)
return api, nil
}
func (n *Namecheap) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
info, err := n.client.DomainGetInfo(dc.Name)
if err != nil {
return nil, err
}
//todo: sort both
found := strings.Join(info.DNSDetails.Nameservers, ",")
desired := ""
for _, d := range dc.Nameservers {
if desired != "" {
desired += ","
}
desired += d.Name
}
if found != desired {
parts := strings.SplitN(dc.Name, ".", 2)
sld, tld := parts[0], parts[1]
return []*models.Correction{
{Msg: fmt.Sprintf("Change Nameservers from '%s' to '%s'", found, desired),
F: func() error {
_, err := n.client.DomainDNSSetCustom(sld, tld, desired)
if err != nil {
return err
}
return nil
}},
}, nil
}
return nil, nil
}

View File

@@ -0,0 +1,48 @@
## name.com Provider
### required config
In your providers config json file you must provide your name.com api username and access token:
```
"yourNameDotComProviderName":{
"apikey": "yourApiKeyFromName.com-klasjdkljasdlk235235235235",
"apiuser": "yourUsername"
}
```
In order to get api access you need to [apply for access](https://www.name.com/reseller/apply)
### example dns config js (registrar only):
```
var NAMECOM = NewRegistrar("myNameCom","NAMEDOTCOM");
var mynameServers = [
NAMESERVER("bill.ns.cloudflare.com"),
NAMESERVER("fred.ns.cloudflare.com")
];
D("example.tld",NAMECOM,myNameServers
//records handled by another provider...
);
```
### example config (registrar and records managed by namedotcom)
```
var NAMECOM = NewRegistrar("myNameCom","NAMEDOTCOM");
var NAMECOMDSP = NewDSP("myNameCom","NAMEDOTCOM")
D("exammple.tld", NAMECOM, NAMECOMDSP,
//ns[1-4].name.com used by default as nameservers
//override default ttl of 300s
DefaultTTL(3600),
A("test","1.2.3.4"),
//override ttl for one record only
CNAME("foo","some.otherdomain.tld.",TTL(100))
)
```

View File

@@ -0,0 +1,117 @@
//Package namedotcom implements a registrar that uses the name.com api to set name servers. It will self register it's providers when imported.
package namedotcom
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/StackExchange/dnscontrol/providers"
)
type nameDotCom struct {
APIUser string `json:"apiuser"`
APIKey string `json:"apikey"`
}
func newReg(conf map[string]string) (providers.Registrar, error) {
return newProvider(conf)
}
func newDsp(conf map[string]string, meta json.RawMessage) (providers.DNSServiceProvider, error) {
return newProvider(conf)
}
func newProvider(conf map[string]string) (*nameDotCom, error) {
api := &nameDotCom{}
api.APIUser, api.APIKey = conf["apiuser"], conf["apikey"]
if api.APIKey == "" || api.APIUser == "" {
return nil, fmt.Errorf("Name.com apikey and apiuser must be provided.")
}
return api, nil
}
func init() {
providers.RegisterRegistrarType("NAMEDOTCOM", newReg)
providers.RegisterDomainServiceProviderType("NAMEDOTCOM", newDsp)
}
///
//various http helpers for interacting with api
///
func (n *nameDotCom) addAuth(r *http.Request) {
r.Header.Add("Api-Username", n.APIUser)
r.Header.Add("Api-Token", n.APIKey)
}
type apiResult struct {
Result struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"result"`
}
func (r *apiResult) getErr() error {
if r == nil {
return nil
}
if r.Result.Code != 100 {
if r.Result.Message == "" {
return fmt.Errorf("Unknown error from name.com")
}
return fmt.Errorf(r.Result.Message)
}
return nil
}
var apiBase = "https://api.name.com/api"
//perform http GET and unmarshal response json into target struct
func (n *nameDotCom) get(url string, target interface{}) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
n.addAuth(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
return json.Unmarshal(data, target)
}
// perform http POST, json marshalling the given data into the body
func (n *nameDotCom) post(url string, data interface{}) (*apiResult, error) {
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
if err := enc.Encode(data); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", url, buf)
if err != nil {
return nil, err
}
n.addAuth(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
text, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
result := &apiResult{}
if err = json.Unmarshal(text, result); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -0,0 +1,82 @@
package namedotcom
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/StackExchange/dnscontrol/models"
)
func (n *nameDotCom) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
foundNameservers, err := n.getNameservers(dc.Name)
if err != nil {
return nil, err
}
if defaultNsRegexp.MatchString(foundNameservers) {
foundNameservers = "ns1.name.com,ns2.name.com,ns3.name.com,ns4.name.com"
}
expected := []string{}
for _, ns := range dc.Nameservers {
name := strings.TrimRight(ns.Name, ".")
expected = append(expected, name)
}
sort.Strings(expected)
expectedNameservers := strings.Join(expected, ",")
if foundNameservers != expectedNameservers {
return []*models.Correction{
{
Msg: fmt.Sprintf("Update nameservers %s -> %s", foundNameservers, expectedNameservers),
F: n.updateNameservers(expected, dc.Name),
},
}, nil
}
return nil, nil
}
//even if you provide them "ns1.name.com", they will set it to "ns1qrt.name.com". This will match that pattern to see if defaults are in use.
var defaultNsRegexp = regexp.MustCompile(`ns1[a-z]{0,3}\.name\.com,ns2[a-z]{0,3}\.name\.com,ns3[a-z]{0,3}\.name\.com,ns4[a-z]{0,3}\.name\.com`)
func apiGetDomain(domain string) string {
return fmt.Sprintf("%s/domain/get/%s", apiBase, domain)
}
func apiUpdateNS(domain string) string {
return fmt.Sprintf("%s/domain/update_nameservers/%s", apiBase, domain)
}
type getDomainResult struct {
*apiResult
DomainName string `json:"domain_name"`
Nameservers []string `json:"nameservers"`
}
// returns comma joined list of nameservers (in alphabetical order)
func (n *nameDotCom) getNameservers(domain string) (string, error) {
result := &getDomainResult{}
if err := n.get(apiGetDomain(domain), result); err != nil {
return "", err
}
if err := result.getErr(); err != nil {
return "", err
}
sort.Strings(result.Nameservers)
return strings.Join(result.Nameservers, ","), nil
}
func (n *nameDotCom) updateNameservers(ns []string, domain string) func() error {
return func() error {
dat := struct {
Nameservers []string `json:"nameservers"`
}{ns}
resp, err := n.post(apiUpdateNS(domain), dat)
if err != nil {
return err
}
if err = resp.getErr(); err != nil {
return err
}
return nil
}
}

View File

@@ -0,0 +1,150 @@
package namedotcom
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/StackExchange/dnscontrol/models"
)
var (
mux *http.ServeMux
client *nameDotCom
server *httptest.Server
)
func setup() {
mux = http.NewServeMux()
server = httptest.NewServer(mux)
client = &nameDotCom{
APIUser: "bob",
APIKey: "123",
}
apiBase = server.URL
}
func teardown() {
server.Close()
}
func TestGetNameservers(t *testing.T) {
for i, test := range []struct {
givenNs, expected string
}{
{"", ""},
{`"foo.ns.tld","bar.ns.tld"`, "bar.ns.tld,foo.ns.tld"},
{"ERR", "ERR"},
{"MSGERR", "ERR"},
} {
setup()
defer teardown()
mux.HandleFunc("/domain/get/example.tld", func(w http.ResponseWriter, r *http.Request) {
if test.givenNs == "ERR" {
http.Error(w, "UH OH", 500)
return
}
if test.givenNs == "MSGERR" {
w.Write(nameComError)
return
}
w.Write(domainResponse(test.givenNs))
})
found, err := client.getNameservers("example.tld")
if err != nil {
if test.expected == "ERR" {
continue
}
t.Errorf("Error on test %d: %s", i, err)
continue
}
if test.expected == "ERR" {
t.Errorf("Expected error on test %d, but was none", i)
continue
}
if found != test.expected {
t.Errorf("Test %d: Expected '%s', but found '%s'", i, test.expected, found)
}
}
}
func TestGetCorrections(t *testing.T) {
for i, test := range []struct {
givenNs string
expected int
}{
{"", 1},
{`"foo.ns.tld","bar.ns.tld"`, 0},
{`"bar.ns.tld","foo.ns.tld"`, 0},
{`"foo.ns.tld"`, 1},
{`"1.ns.aaa","2.ns.www"`, 1},
{"ERR", -1}, //-1 means we expect an error
{"MSGERR", -1},
} {
setup()
defer teardown()
mux.HandleFunc("/domain/get/example.tld", func(w http.ResponseWriter, r *http.Request) {
if test.givenNs == "ERR" {
http.Error(w, "UH OH", 500)
return
}
if test.givenNs == "MSGERR" {
w.Write(nameComError)
return
}
w.Write(domainResponse(test.givenNs))
})
dc := &models.DomainConfig{
Name: "example.tld",
Nameservers: []*models.Nameserver{
{Name: "foo.ns.tld"},
{Name: "bar.ns.tld"},
},
}
corrections, err := client.GetRegistrarCorrections(dc)
if err != nil {
if test.expected == -1 {
continue
}
t.Errorf("Error on test %d: %s", i, err)
continue
}
if test.expected == -1 {
t.Errorf("Expected error on test %d, but was none", i)
continue
}
if len(corrections) != test.expected {
t.Errorf("Test %d: Expected '%d', but found '%d'", i, test.expected, len(corrections))
}
}
}
func domainResponse(ns string) []byte {
return []byte(fmt.Sprintf(`{
"result": {
"code": 100,
"message": "Command Successful"
},
"domain_name": "example.tld",
"create_date": "2015-12-28 18:08:05",
"expire_date": "2016-12-28 23:59:59",
"locked": true,
"nameservers": [%s],
"contacts": [],
"addons": {
"whois_privacy": {
"price": "3.99"
},
"domain\/renew": {
"price": "10.99"
}
}
}`, ns))
}
var nameComError = []byte(`{"result":{"code":251,"message":"Authentication Error - Invalid Username Or Api Token"}}`)

View File

@@ -0,0 +1,168 @@
package namedotcom
import (
"fmt"
"github.com/miekg/dns/dnsutil"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers/diff"
)
var defaultNameservers = []*models.Nameserver{
{Name: "ns1.name.com"},
{Name: "ns2.name.com"},
{Name: "ns3.name.com"},
{Name: "ns4.name.com"},
}
func (n *nameDotCom) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
dc.Nameservers = defaultNameservers
records, err := n.getRecords(dc.Name)
if err != nil {
return nil, err
}
actual := make([]diff.Record, len(records))
for i := range records {
actual[i] = records[i]
}
desired := make([]diff.Record, len(dc.Records))
for i, rec := range dc.Records {
if rec.TTL == 0 {
rec.TTL = 300
}
desired[i] = rec
}
_, create, del, mod := diff.IncrementalDiff(actual, desired)
corrections := []*models.Correction{}
for _, d := range del {
rec := d.Existing.(*nameComRecord)
c := &models.Correction{Msg: d.String(), F: func() error { return n.deleteRecord(rec.RecordID, dc.Name) }}
corrections = append(corrections, c)
}
for _, cre := range create {
rec := cre.Desired.(*models.RecordConfig)
c := &models.Correction{Msg: cre.String(), F: func() error { return n.createRecord(rec, dc.Name) }}
corrections = append(corrections, c)
}
for _, chng := range mod {
old := chng.Existing.(*nameComRecord)
new := chng.Desired.(*models.RecordConfig)
c := &models.Correction{Msg: chng.String(), F: func() error {
err := n.deleteRecord(old.RecordID, dc.Name)
if err != nil {
return err
}
return n.createRecord(new, dc.Name)
}}
corrections = append(corrections, c)
}
return corrections, nil
}
func apiGetRecords(domain string) string {
return fmt.Sprintf("%s/dns/list/%s", apiBase, domain)
}
func apiCreateRecord(domain string) string {
return fmt.Sprintf("%s/dns/create/%s", apiBase, domain)
}
func apiDeleteRecord(domain string) string {
return fmt.Sprintf("%s/dns/delete/%s", apiBase, domain)
}
type nameComRecord struct {
RecordID string `json:"record_id"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL string `json:"ttl"`
Priority string `json:"priority"`
}
func (r *nameComRecord) GetName() string {
return r.Name
}
func (r *nameComRecord) GetType() string {
return r.Type
}
func (r *nameComRecord) GetContent() string {
return r.Content
}
func (r *nameComRecord) GetComparisionData() string {
mxPrio := ""
if r.Type == "MX" {
mxPrio = fmt.Sprintf(" %s ", r.Priority)
}
return fmt.Sprintf("%s%s", r.TTL, mxPrio)
}
type listRecordsResponse struct {
*apiResult
Records []*nameComRecord `json:"records"`
}
func (n *nameDotCom) getRecords(domain string) ([]*nameComRecord, error) {
result := &listRecordsResponse{}
err := n.get(apiGetRecords(domain), result)
if err != nil {
return nil, err
}
if err = result.getErr(); err != nil {
return nil, err
}
for _, rc := range result.Records {
if rc.Type == "CNAME" || rc.Type == "MX" || rc.Type == "NS" {
rc.Content = rc.Content + "."
}
}
return result.Records, nil
}
func (n *nameDotCom) createRecord(rc *models.RecordConfig, domain string) error {
target := rc.Target
if rc.Type == "CNAME" || rc.Type == "MX" || rc.Type == "NS" {
if target[len(target)-1] == '.' {
target = target[:len(target)-1]
} else {
return fmt.Errorf("Unexpected. CNAME/MX/NS target did not end with dot.\n")
}
}
dat := struct {
Hostname string `json:"hostname"`
Type string `json:"type"`
Content string `json:"content"`
TTL uint32 `json:"ttl,omitempty"`
Priority uint16 `json:"priority,omitempty"`
}{
Hostname: dnsutil.TrimDomainName(rc.NameFQDN, domain),
Type: rc.Type,
Content: target,
TTL: rc.TTL,
Priority: rc.Priority,
}
if dat.Hostname == "@" {
dat.Hostname = ""
}
resp, err := n.post(apiCreateRecord(domain), dat)
if err != nil {
return err
}
return resp.getErr()
}
func (n *nameDotCom) deleteRecord(id, domain string) error {
dat := struct {
ID string `json:"record_id"`
}{id}
resp, err := n.post(apiDeleteRecord(domain), dat)
if err != nil {
return err
}
return resp.getErr()
}

119
providers/providers.go Normal file
View File

@@ -0,0 +1,119 @@
package providers
import (
"encoding/json"
"fmt"
"log"
"github.com/StackExchange/dnscontrol/models"
)
//Registrar is an interface for a domain registrar. It can return a list of needed corrections to be applied in the future.
type Registrar interface {
GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error)
}
//DNSServiceProvider is able to generate a set of corrections that need to be made to correct records for a domain
type DNSServiceProvider interface {
GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error)
}
//RegistrarInitializer is a function to create a registrar. Function will be passed the unprocessed json payload from the configuration file for the given provider.
type RegistrarInitializer func(map[string]string) (Registrar, error)
var registrarTypes = map[string]RegistrarInitializer{}
//DspInitializer is a function to create a registrar. Function will be passed the unprocessed json payload from the configuration file for the given provider.
type DspInitializer func(map[string]string, json.RawMessage) (DNSServiceProvider, error)
var dspTypes = map[string]DspInitializer{}
//RegisterRegistrarType adds a registrar type to the registry by providing a suitable initialization function.
func RegisterRegistrarType(name string, init RegistrarInitializer) {
if _, ok := registrarTypes[name]; ok {
log.Fatalf("Cannot register registrar type %s multiple times", name)
}
registrarTypes[name] = init
}
//RegisterDomainServiceProviderType adds a dsp to the registry with the given initialization function.
func RegisterDomainServiceProviderType(name string, init DspInitializer) {
if _, ok := dspTypes[name]; ok {
log.Fatalf("Cannot register registrar type %s multiple times", name)
}
dspTypes[name] = init
}
func createRegistrar(rType string, config map[string]string) (Registrar, error) {
initer, ok := registrarTypes[rType]
if !ok {
return nil, fmt.Errorf("Registrar type %s not declared.", rType)
}
return initer(config)
}
func createDNSProvider(dType string, config map[string]string, meta json.RawMessage) (DNSServiceProvider, error) {
initer, ok := dspTypes[dType]
if !ok {
return nil, fmt.Errorf("DSP type %s not declared", dType)
}
return initer(config, meta)
}
//CreateRegistrars will load all registrars from the dns config, and create instances of the correct type using data from
//the provider config to load relevant keys and options.
func CreateRegistrars(d *models.DNSConfig, providerConfigs map[string]map[string]string) (map[string]Registrar, error) {
regs := map[string]Registrar{}
for _, reg := range d.Registrars {
rawMsg, ok := providerConfigs[reg.Name]
if !ok && reg.Type != "NONE" {
return nil, fmt.Errorf("Registrar %s not listed in -providers file.", reg.Name)
}
registrar, err := createRegistrar(reg.Type, rawMsg)
if err != nil {
return nil, err
}
regs[reg.Name] = registrar
}
return regs, nil
}
func CreateDsps(d *models.DNSConfig, providerConfigs map[string]map[string]string) (map[string]DNSServiceProvider, error) {
dsps := map[string]DNSServiceProvider{}
for _, dsp := range d.DNSProviders {
//log.Printf("dsp.Name=%#v\n", dsp.Name)
rawMsg, ok := providerConfigs[dsp.Name]
if !ok {
return nil, fmt.Errorf("DNSServiceProvider %s not listed in -providers file.", dsp.Name)
}
provider, err := createDNSProvider(dsp.Type, rawMsg, dsp.Metadata)
if err != nil {
log.Printf("createDNSProvider provider=%#v\n", provider)
log.Printf("createDNSProvider err=%#v\n", err)
return nil, err
}
dsps[dsp.Name] = provider
}
return dsps, nil
}
// None is a basivc provider type that does absolutely nothing. Can be useful as a placeholder for third parties or unimplemented providers.
type None struct{}
func (n None) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
return nil, nil
}
func (n None) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
return nil, nil
}
func init() {
RegisterRegistrarType("NONE", func(map[string]string) (Registrar, error) {
return None{}, nil
})
RegisterDomainServiceProviderType("NONE", func(map[string]string, json.RawMessage) (DNSServiceProvider, error) {
return None{}, nil
})
}

View File

@@ -0,0 +1,269 @@
package route53
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
r53 "github.com/aws/aws-sdk-go/service/route53"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers"
"github.com/StackExchange/dnscontrol/providers/diff"
)
type route53Provider struct {
client *r53.Route53
zones map[string]*r53.HostedZone
}
func newRoute53(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
keyId, secretKey := m["KeyId"], m["SecretKey"]
if keyId == "" || secretKey == "" {
return nil, fmt.Errorf("Route53 KeyId and SecretKey must be provided.")
}
sess := session.New(&aws.Config{
Region: aws.String("us-west-2"),
Credentials: credentials.NewStaticCredentials(keyId, secretKey, ""),
})
api := &route53Provider{client: r53.New(sess)}
return api, nil
}
func init() {
providers.RegisterDomainServiceProviderType("ROUTE53", newRoute53)
}
func sPtr(s string) *string {
return &s
}
func (r *route53Provider) getZones() error {
var nextMarker *string
r.zones = make(map[string]*r53.HostedZone)
for {
inp := &r53.ListHostedZonesInput{MaxItems: sPtr("1"), Marker: nextMarker}
out, err := r.client.ListHostedZones(inp)
if err != nil {
return err
}
for _, z := range out.HostedZones {
domain := strings.TrimSuffix(*z.Name, ".")
r.zones[domain] = z
}
if out.NextMarker != nil {
nextMarker = out.NextMarker
} else {
break
}
}
return nil
}
//map key for grouping records
type key struct {
Name, Type string
}
func getKey(r diff.Record) key {
return key{r.GetName(), r.GetType()}
}
func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
if r.zones == nil {
if err := r.getZones(); err != nil {
return nil, err
}
}
var corrections = []*models.Correction{}
zone, ok := r.zones[dc.Name]
// add zone if it doesn't exist
if !ok {
//add correction to add zone
corrections = append(corrections,
&models.Correction{
Msg: "Add zone to aws",
F: func() error {
in := &r53.CreateHostedZoneInput{
Name: &dc.Name,
CallerReference: sPtr(fmt.Sprint(time.Now().UnixNano())),
}
out, err := r.client.CreateHostedZone(in)
zone = out.HostedZone
return err
},
})
//fake zone
zone = &r53.HostedZone{
Id: sPtr(""),
}
}
records, err := r.fetchRecordSets(zone.Id)
if err != nil {
return nil, err
}
//convert to dnscontrol RecordConfig format
dc.Nameservers = nil
var existingRecords = []*models.RecordConfig{}
for _, set := range records {
for _, rec := range set.ResourceRecords {
if *set.Type == "SOA" {
continue
} else if *set.Type == "NS" && strings.TrimSuffix(*set.Name, ".") == dc.Name {
dc.Nameservers = append(dc.Nameservers, &models.Nameserver{Name: strings.TrimSuffix(*rec.Value, ".")})
continue
}
r := &models.RecordConfig{
NameFQDN: unescape(set.Name),
Type: *set.Type,
Target: *rec.Value,
TTL: uint32(*set.TTL),
}
existingRecords = append(existingRecords, r)
}
}
e, w := []diff.Record{}, []diff.Record{}
for _, ex := range existingRecords {
e = append(e, ex)
}
for _, want := range dc.Records {
if want.TTL == 0 {
want.TTL = 300
}
if want.Type == "MX" {
want.Target = fmt.Sprintf("%d %s", want.Priority, want.Target)
want.Priority = 0
} else if want.Type == "TXT" {
want.Target = fmt.Sprintf(`"%s"`, want.Target) //FIXME: better escaping/quoting
}
w = append(w, want)
}
//diff
changeDesc := ""
_, create, delete, modify := diff.IncrementalDiff(e, w)
namesToUpdate := map[key]bool{}
for _, c := range create {
namesToUpdate[getKey(c.Desired)] = true
changeDesc += fmt.Sprintln(c)
}
for _, d := range delete {
namesToUpdate[getKey(d.Existing)] = true
changeDesc += fmt.Sprintln(d)
}
for _, m := range modify {
namesToUpdate[getKey(m.Desired)] = true
changeDesc += fmt.Sprintln(m)
}
if len(namesToUpdate) == 0 {
return nil, nil
}
updates := map[key][]*models.RecordConfig{}
//for each name we need to update, collect relevant records from dc
for k := range namesToUpdate {
updates[k] = nil
for _, rc := range dc.Records {
if getKey(rc) == k {
updates[k] = append(updates[k], rc)
}
}
}
changes := []*r53.Change{}
for k, recs := range updates {
chg := &r53.Change{}
changes = append(changes, chg)
var rrset *r53.ResourceRecordSet
if len(recs) == 0 {
chg.Action = sPtr("DELETE")
// on delete just submit the original resource set we got from r53.
for _, r := range records {
if *r.Name == k.Name+"." && *r.Type == k.Type {
rrset = r
break
}
}
} else {
//on change or create, just build a new record set from our desired state
chg.Action = sPtr("UPSERT")
rrset = &r53.ResourceRecordSet{
Name: sPtr(k.Name),
Type: sPtr(k.Type),
ResourceRecords: []*r53.ResourceRecord{},
}
for _, r := range recs {
val := r.Target
rr := &r53.ResourceRecord{
Value: &val,
}
rrset.ResourceRecords = append(rrset.ResourceRecords, rr)
i := int64(r.TTL)
rrset.TTL = &i //TODO: make sure that ttls are consistent within a set
}
}
chg.ResourceRecordSet = rrset
}
changeReq := &r53.ChangeResourceRecordSetsInput{
ChangeBatch: &r53.ChangeBatch{Changes: changes},
}
corrections = append(corrections,
&models.Correction{
Msg: changeDesc,
F: func() error {
changeReq.HostedZoneId = zone.Id
_, err := r.client.ChangeResourceRecordSets(changeReq)
return err
},
})
return corrections, nil
}
func (r *route53Provider) fetchRecordSets(zoneID *string) ([]*r53.ResourceRecordSet, error) {
if zoneID == nil || *zoneID == "" {
return nil, nil
}
var next *string
var nextType *string
var records []*r53.ResourceRecordSet
for {
listInput := &r53.ListResourceRecordSetsInput{
HostedZoneId: zoneID,
StartRecordName: next,
StartRecordType: nextType,
MaxItems: sPtr("100"),
}
list, err := r.client.ListResourceRecordSets(listInput)
if err != nil {
return nil, err
}
records = append(records, list.ResourceRecordSets...)
if list.NextRecordName != nil {
next = list.NextRecordName
nextType = list.NextRecordType
} else {
break
}
}
return records, nil
}
//we have to process names from route53 to match what we expect and to remove their odd octal encoding
func unescape(s *string) string {
if s == nil {
return ""
}
name := strings.TrimSuffix(*s, ".")
name = strings.Replace(name, `\052`, "*", -1) //TODO: escape all octal sequences
return name
}

View File

@@ -0,0 +1,24 @@
package route53
import "testing"
func TestUnescape(t *testing.T) {
var tests = []struct {
experiment, expected string
}{
{"foo", "foo"},
{"foo.", "foo"},
{"foo..", "foo."},
{"foo...", "foo.."},
{`\052`, "*"},
{`\052.foo..`, "*.foo."},
// {`\053.foo`, "+.foo"}, // Not implemented yet.
}
for i, test := range tests {
actual := unescape(&test.experiment)
if test.expected != actual {
t.Errorf("%d: Expected %s, got %s", i, test.expected, actual)
}
}
}