mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-12 18:37:54 +08:00
b6fd4dffd7
* Lint: Fix ST1005: error strings should not be capitalized * Cleanup: Fix a lot of staticcheck.io warnings
358 lines
12 KiB
Go
358 lines
12 KiB
Go
package activedir
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/StackExchange/dnscontrol/v3/models"
|
|
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
|
|
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
|
|
"github.com/TomOnTime/utfutil"
|
|
)
|
|
|
|
const zoneDumpFilenamePrefix = "adzonedump"
|
|
|
|
// RecordConfigJSON RecordConfig, reconfigured for JSON input/output.
|
|
type RecordConfigJSON struct {
|
|
Name string `json:"hostname"`
|
|
Type string `json:"recordtype"`
|
|
Data string `json:"recorddata"`
|
|
TTL uint32 `json:"timetolive"`
|
|
}
|
|
|
|
func (c *adProvider) GetNameservers(string) ([]*models.Nameserver, error) {
|
|
// TODO: If using AD for publicly hosted zones, probably pull these from config.
|
|
return nil, nil
|
|
}
|
|
|
|
// list of types this provider supports.
|
|
// until it is up to speed with all the built-in types.
|
|
var supportedTypes = map[string]bool{
|
|
"A": true,
|
|
"AAAA": true,
|
|
"CNAME": true,
|
|
"NS": true,
|
|
}
|
|
|
|
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
|
|
func (c *adProvider) GetZoneRecords(domain string) (models.Records, error) {
|
|
foundRecords, err := c.getExistingRecords(domain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("c.getExistingRecords(%q) failed: %v", domain, err)
|
|
}
|
|
return foundRecords, nil
|
|
}
|
|
|
|
// GetDomainCorrections gets existing records, diffs them against existing, and returns corrections.
|
|
func (c *adProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
|
|
|
|
dc.Filter(func(r *models.RecordConfig) bool {
|
|
if r.Type == "NS" && r.Name == "@" {
|
|
return false
|
|
}
|
|
if !supportedTypes[r.Type] {
|
|
printer.Warnf("Active Directory only manages certain record types. Won't consider %s %s\n", r.Type, r.GetLabelFQDN())
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Read foundRecords:
|
|
foundRecords, err := c.getExistingRecords(dc.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("c.getExistingRecords(%v) failed: %v", dc.Name, err)
|
|
}
|
|
|
|
// Normalize
|
|
models.PostProcessRecords(foundRecords)
|
|
|
|
differ := diff.New(dc)
|
|
_, creates, dels, modifications, err := differ.IncrementalDiff(foundRecords)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 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 _, del := range dels {
|
|
corrections = append(corrections, c.deleteRec(dc.Name, del))
|
|
}
|
|
for _, cre := range creates {
|
|
corrections = append(corrections, c.createRec(dc.Name, cre)...)
|
|
}
|
|
for _, m := range modifications {
|
|
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 {
|
|
printer.Printf("Powershell to generate zone dump:\n")
|
|
printer.Printf("%v\n", c.generatePowerShellZoneDump(domainname))
|
|
}
|
|
return dat, err
|
|
}
|
|
|
|
// powerShellLogCommand logs to flagPsLog that a PowerShell command is going to be run.
|
|
func (c *adProvider) logCommand(command string) error {
|
|
return c.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 (c *adProvider) logOutput(s string) error {
|
|
return c.logHelper(fmt.Sprintf("OUTPUT: START\r\n%s\r\nOUTPUT: END\r\n", s))
|
|
}
|
|
|
|
// powerShellLogErr logs that a PowerShell command had an error.
|
|
func (c *adProvider) logErr(e error) error {
|
|
err := c.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 (c *adProvider) logHelper(s string) error {
|
|
logfile, err := os.OpenFile(c.psLog, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0660)
|
|
if err != nil {
|
|
return fmt.Errorf("error: Can not create/append to %#v: %v", c.psLog, err)
|
|
}
|
|
_, err = fmt.Fprintln(logfile, s)
|
|
if err != nil {
|
|
return fmt.Errorf("append to %#v failed: %v", c.psLog, err)
|
|
}
|
|
if logfile.Close() != nil {
|
|
return fmt.Errorf("closing %#v failed: %v", c.psLog, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// powerShellRecord records that a PowerShell command should be executed later.
|
|
func (c *adProvider) powerShellRecord(command string) error {
|
|
recordfile, err := os.OpenFile(c.psOut, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0660)
|
|
if err != nil {
|
|
return fmt.Errorf("can not create/append to %#v: %v", c.psOut, err)
|
|
}
|
|
_, err = recordfile.WriteString(command)
|
|
if err != nil {
|
|
return fmt.Errorf("append to %#v failed: %v", c.psOut, err)
|
|
}
|
|
return recordfile.Close()
|
|
}
|
|
|
|
func (c *adProvider) getExistingRecords(domainname string) ([]*models.RecordConfig, error) {
|
|
// 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", domainname, err)
|
|
}
|
|
|
|
var recs []*RecordConfigJSON
|
|
jdata := string(data)
|
|
// when there is only a single record, AD powershell does not
|
|
// wrap it in an array as our types expect. This makes sure it is always an array.
|
|
if strings.HasPrefix(strings.TrimSpace(jdata), "{") {
|
|
jdata = "[" + jdata + "]"
|
|
data = []byte(jdata)
|
|
}
|
|
err = json.Unmarshal(data, &recs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("json.Unmarshal failed on %#v: %v", domainname, err)
|
|
}
|
|
|
|
result := make([]*models.RecordConfig, 0, len(recs))
|
|
unsupportedCounts := map[string]int{}
|
|
for _, rec := range recs {
|
|
t, supportedType := rec.unpackRecord(domainname)
|
|
if !supportedType {
|
|
unsupportedCounts[rec.Type]++
|
|
}
|
|
if t != nil {
|
|
result = append(result, t)
|
|
}
|
|
}
|
|
for t, count := range unsupportedCounts {
|
|
printer.Warnf("%d records of type %s found in AD zone. These will be ignored.\n", count, t)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (r *RecordConfigJSON) unpackRecord(origin string) (rc *models.RecordConfig, supported bool) {
|
|
rc = &models.RecordConfig{
|
|
Type: r.Type,
|
|
TTL: r.TTL,
|
|
}
|
|
rc.SetLabel(r.Name, origin)
|
|
switch rtype := rc.Type; rtype { // #rtype_variations
|
|
case "A", "AAAA":
|
|
rc.SetTarget(r.Data)
|
|
case "CNAME":
|
|
rc.SetTarget(strings.ToLower(r.Data))
|
|
case "NS":
|
|
// skip root NS
|
|
if rc.Name == "@" {
|
|
return nil, true
|
|
}
|
|
rc.SetTarget(strings.ToLower(r.Data))
|
|
case "SOA":
|
|
return nil, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
return rc, true
|
|
}
|
|
|
|
// powerShellDump runs a PowerShell command to get a dump of all records in a DNS zone.
|
|
func (c *adProvider) generatePowerShellZoneDump(domainname string) string {
|
|
cmdTxt := `@("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,$_.recorddata.NameServer,"unsupported_record_type" -ne $null)[0]-as [string]}} | ConvertTo-Json > REPLACE_WITH_FILENAMEPREFIX.REPLACE_WITH_ZONE.json
|
|
}`
|
|
cmdTxt = strings.Replace(cmdTxt, "REPLACE_WITH_ZONE", domainname, -1)
|
|
cmdTxt = strings.Replace(cmdTxt, "REPLACE_WITH_COMPUTER_NAME", c.adServer, -1)
|
|
cmdTxt = strings.Replace(cmdTxt, "REPLACE_WITH_FILENAMEPREFIX", zoneDumpFilenamePrefix, -1)
|
|
return cmdTxt
|
|
}
|
|
|
|
// generatePowerShellCreate generates PowerShell commands to ADD a record.
|
|
func (c *adProvider) generatePowerShellCreate(domainname string, rec *models.RecordConfig) string {
|
|
content := rec.GetTargetField()
|
|
text := "\r\n" // Skip a line.
|
|
funcSuffix := rec.Type
|
|
if rec.Type == "NS" {
|
|
funcSuffix = ""
|
|
}
|
|
text += fmt.Sprintf("Add-DnsServerResourceRecord%s", funcSuffix)
|
|
text += fmt.Sprintf(` -ComputerName "%s"`, c.adServer)
|
|
text += fmt.Sprintf(` -ZoneName "%s"`, domainname)
|
|
text += fmt.Sprintf(` -Name "%s"`, rec.GetLabel())
|
|
text += fmt.Sprintf(` -TimeToLive $(New-TimeSpan -Seconds %d)`, rec.TTL)
|
|
switch rec.Type { // #rtype_variations
|
|
case "CNAME":
|
|
text += fmt.Sprintf(` -HostNameAlias "%s"`, content)
|
|
case "A":
|
|
text += fmt.Sprintf(` -IPv4Address "%s"`, content)
|
|
case "NS":
|
|
text += fmt.Sprintf(` -NS -NameServer "%s"`, content)
|
|
default:
|
|
panic(fmt.Errorf("generatePowerShellCreate() does not yet handle recType=%s recName=%#v content=%#v)",
|
|
rec.Type, rec.GetLabel(), content))
|
|
// We panic so that we quickly find any switch statements
|
|
// that have not been updated for a new RR type.
|
|
}
|
|
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
|
|
queryContent = `"` + oldContent + `"`
|
|
|
|
switch recType { // #rtype_variations
|
|
case "A":
|
|
queryField = "IPv4address"
|
|
case "CNAME":
|
|
queryField = "HostNameAlias"
|
|
case "NS":
|
|
queryField = "NameServer"
|
|
default:
|
|
panic(fmt.Errorf("generatePowerShellModify() does not yet handle recType=%s recName=%#v content=(%#v, %#v)", recType, recName, oldContent, newContent))
|
|
// We panic so that we quickly find any switch statements
|
|
// that have not been updated for a new RR type.
|
|
}
|
|
|
|
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 += ` -NewInputObject $NewObj -OldInputObject $OldObj`
|
|
text += "\r\n"
|
|
|
|
return text
|
|
}
|
|
|
|
func (c *adProvider) generatePowerShellDelete(domainname, recName, recType, content string) string {
|
|
text := fmt.Sprintf(`echo "DELETE %s %s %s"`, recType, recName, content)
|
|
text += "\r\n"
|
|
text += `Remove-DnsServerResourceRecord -Force -ComputerName "%s" -ZoneName "%s" -Name "%s" -RRType "%s" -RecordData "%s"`
|
|
text += "\r\n"
|
|
return fmt.Sprintf(text, c.adServer, domainname, recName, recType, content)
|
|
}
|
|
|
|
func (c *adProvider) createRec(domainname string, cre diff.Correlation) []*models.Correction {
|
|
rec := cre.Desired
|
|
arr := []*models.Correction{
|
|
{
|
|
Msg: cre.String(),
|
|
F: func() error {
|
|
return c.powerShellDoCommand(c.generatePowerShellCreate(domainname, rec), true)
|
|
}},
|
|
}
|
|
return arr
|
|
}
|
|
|
|
func (c *adProvider) modifyRec(domainname string, m diff.Correlation) *models.Correction {
|
|
old, rec := m.Existing, m.Desired
|
|
return &models.Correction{
|
|
Msg: m.String(),
|
|
F: func() error {
|
|
return c.powerShellDoCommand(c.generatePowerShellModify(domainname, rec.GetLabel(), rec.Type, old.GetTargetField(), rec.GetTargetField(), old.TTL, rec.TTL), true)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *adProvider) deleteRec(domainname string, cor diff.Correlation) *models.Correction {
|
|
rec := cor.Existing
|
|
return &models.Correction{
|
|
Msg: cor.String(),
|
|
F: func() error {
|
|
return c.powerShellDoCommand(c.generatePowerShellDelete(domainname, rec.GetLabel(), rec.Type, rec.GetTargetField()), true)
|
|
},
|
|
}
|
|
}
|