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:
37
providers/activedir/activedirProvider.go
Normal file
37
providers/activedir/activedirProvider.go
Normal 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
|
||||
}
|
BIN
providers/activedir/adzonedump.test2.json
Executable file
BIN
providers/activedir/adzonedump.test2.json
Executable file
Binary file not shown.
78
providers/activedir/doc.md
Normal file
78
providers/activedir/doc.md
Normal 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.
|
293
providers/activedir/domains.go
Normal file
293
providers/activedir/domains.go
Normal 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))
|
||||
},
|
||||
}
|
||||
}
|
40
providers/activedir/domains_test.go
Normal file
40
providers/activedir/domains_test.go
Normal 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)
|
||||
}
|
||||
}
|
17
providers/activedir/getzones_other.go
Normal file
17
providers/activedir/getzones_other.go
Normal 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)
|
||||
}
|
95
providers/activedir/getzones_windows.go
Normal file
95
providers/activedir/getzones_windows.go
Normal 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
|
||||
}
|
BIN
providers/activedir/zone.testzone.json
Executable file
BIN
providers/activedir/zone.testzone.json
Executable file
Binary file not shown.
304
providers/bind/bindProvider.go
Normal file
304
providers/bind/bindProvider.go
Normal 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)
|
||||
}
|
227
providers/bind/prettyzone.go
Normal file
227
providers/bind/prettyzone.go
Normal 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
|
||||
}
|
299
providers/bind/prettyzone_test.go
Normal file
299
providers/bind/prettyzone_test.go
Normal 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
71
providers/bind/serial.go
Normal 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
|
||||
}
|
51
providers/bind/serial_test.go
Normal file
51
providers/bind/serial_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
306
providers/cloudflare/cloudflareProvider.go
Normal file
306
providers/cloudflare/cloudflareProvider.go
Normal 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)
|
||||
}
|
116
providers/cloudflare/preprocess_test.go
Normal file
116
providers/cloudflare/preprocess_test.go
Normal 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) {
|
||||
|
||||
}
|
251
providers/cloudflare/rest.go
Normal file
251
providers/cloudflare/rest.go
Normal 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"`
|
||||
}
|
50
providers/config/providerConfig.go
Normal file
50
providers/config/providerConfig.go
Normal 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
153
providers/diff/diff.go
Normal 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
112
providers/diff/diff_test.go
Normal 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
|
||||
}
|
184
providers/gandi/gandiProvider.go
Normal file
184
providers/gandi/gandiProvider.go
Normal 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
168
providers/gandi/protocol.go
Normal 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
|
||||
}
|
61
providers/namecheap/namecheap.go
Normal file
61
providers/namecheap/namecheap.go
Normal 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
|
||||
}
|
48
providers/namedotcom/namedotcom.md
Normal file
48
providers/namedotcom/namedotcom.md
Normal 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))
|
||||
)
|
||||
```
|
117
providers/namedotcom/namedotcomProvider.go
Normal file
117
providers/namedotcom/namedotcomProvider.go
Normal 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
|
||||
}
|
82
providers/namedotcom/nameservers.go
Normal file
82
providers/namedotcom/nameservers.go
Normal 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
|
||||
}
|
||||
}
|
150
providers/namedotcom/nameservers_test.go
Normal file
150
providers/namedotcom/nameservers_test.go
Normal 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"}}`)
|
168
providers/namedotcom/records.go
Normal file
168
providers/namedotcom/records.go
Normal 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
119
providers/providers.go
Normal 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
|
||||
})
|
||||
|
||||
}
|
269
providers/route53/route53Provider.go
Normal file
269
providers/route53/route53Provider.go
Normal 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
|
||||
}
|
24
providers/route53/route53Provider_test.go
Normal file
24
providers/route53/route53Provider_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user