github.com/teknogeek/dnscontrol/v2@v2.10.1-0.20200227202244-ae299b55ba42/providers/activedir/domains.go (about)

     1  package activedir
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"os"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/StackExchange/dnscontrol/v2/models"
    11  	"github.com/StackExchange/dnscontrol/v2/pkg/printer"
    12  	"github.com/StackExchange/dnscontrol/v2/providers/diff"
    13  	"github.com/TomOnTime/utfutil"
    14  )
    15  
    16  const zoneDumpFilenamePrefix = "adzonedump"
    17  
    18  // RecordConfigJson RecordConfig, reconfigured for JSON input/output.
    19  type RecordConfigJson struct {
    20  	Name string `json:"hostname"`
    21  	Type string `json:"recordtype"`
    22  	Data string `json:"recorddata"`
    23  	TTL  uint32 `json:"timetolive"`
    24  }
    25  
    26  func (c *adProvider) GetNameservers(string) ([]*models.Nameserver, error) {
    27  	// TODO: If using AD for publicly hosted zones, probably pull these from config.
    28  	return nil, nil
    29  }
    30  
    31  // list of types this provider supports.
    32  // until it is up to speed with all the built-in types.
    33  var supportedTypes = map[string]bool{
    34  	"A":     true,
    35  	"AAAA":  true,
    36  	"CNAME": true,
    37  	"NS":    true,
    38  }
    39  
    40  // GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
    41  func (c *adProvider) GetZoneRecords(domain string) (models.Records, error) {
    42  	foundRecords, err := c.getExistingRecords(domain)
    43  	if err != nil {
    44  		return nil, fmt.Errorf("c.getExistingRecords(%q) failed: %v", domain, err)
    45  	}
    46  	return foundRecords, nil
    47  }
    48  
    49  // GetDomainCorrections gets existing records, diffs them against existing, and returns corrections.
    50  func (c *adProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
    51  
    52  	dc.Filter(func(r *models.RecordConfig) bool {
    53  		if r.Type == "NS" && r.Name == "@" {
    54  			return false
    55  		}
    56  		if !supportedTypes[r.Type] {
    57  			printer.Warnf("Active Directory only manages certain record types. Won't consider %s %s\n", r.Type, r.GetLabelFQDN())
    58  			return false
    59  		}
    60  		return true
    61  	})
    62  
    63  	// Read foundRecords:
    64  	foundRecords, err := c.getExistingRecords(dc.Name)
    65  	if err != nil {
    66  		return nil, fmt.Errorf("c.getExistingRecords(%v) failed: %v", dc.Name, err)
    67  	}
    68  
    69  	// Normalize
    70  	models.PostProcessRecords(foundRecords)
    71  
    72  	differ := diff.New(dc)
    73  	_, creates, dels, modifications := differ.IncrementalDiff(foundRecords)
    74  	// NOTE(tlim): This provider does not delete records.  If
    75  	// you need to delete a record, either delete it manually
    76  	// or see providers/activedir/doc.md for implementation tips.
    77  
    78  	// Generate changes.
    79  	corrections := []*models.Correction{}
    80  	for _, del := range dels {
    81  		corrections = append(corrections, c.deleteRec(dc.Name, del))
    82  	}
    83  	for _, cre := range creates {
    84  		corrections = append(corrections, c.createRec(dc.Name, cre)...)
    85  	}
    86  	for _, m := range modifications {
    87  		corrections = append(corrections, c.modifyRec(dc.Name, m))
    88  	}
    89  	return corrections, nil
    90  
    91  }
    92  
    93  // zoneDumpFilename returns the filename to use to write or read
    94  // an activedirectory zone dump for a particular domain.
    95  func zoneDumpFilename(domainname string) string {
    96  	return zoneDumpFilenamePrefix + "." + domainname + ".json"
    97  }
    98  
    99  // readZoneDump reads a pre-existing zone dump from adzonedump.*.json.
   100  func (c *adProvider) readZoneDump(domainname string) ([]byte, error) {
   101  	// File not found is considered an error.
   102  	dat, err := utfutil.ReadFile(zoneDumpFilename(domainname), utfutil.WINDOWS)
   103  	if err != nil {
   104  		printer.Printf("Powershell to generate zone dump:\n")
   105  		printer.Printf("%v\n", c.generatePowerShellZoneDump(domainname))
   106  	}
   107  	return dat, err
   108  }
   109  
   110  // powerShellLogCommand logs to flagPsLog that a PowerShell command is going to be run.
   111  func (c *adProvider) logCommand(command string) error {
   112  	return c.logHelper(fmt.Sprintf("# %s\r\n%s\r\n", time.Now().UTC(), strings.TrimSpace(command)))
   113  }
   114  
   115  // powerShellLogOutput logs to flagPsLog that a PowerShell command is going to be run.
   116  func (c *adProvider) logOutput(s string) error {
   117  	return c.logHelper(fmt.Sprintf("OUTPUT: START\r\n%s\r\nOUTPUT: END\r\n", s))
   118  }
   119  
   120  // powerShellLogErr logs that a PowerShell command had an error.
   121  func (c *adProvider) logErr(e error) error {
   122  	err := c.logHelper(fmt.Sprintf("ERROR: %v\r\r", e)) // Log error to powershell.log
   123  	if err != nil {
   124  		return err // Bubble up error created in logHelper
   125  	}
   126  	return e // Bubble up original error
   127  }
   128  
   129  func (c *adProvider) logHelper(s string) error {
   130  	logfile, err := os.OpenFile(c.psLog, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0660)
   131  	if err != nil {
   132  		return fmt.Errorf("error: Can not create/append to %#v: %v", c.psLog, err)
   133  	}
   134  	_, err = fmt.Fprintln(logfile, s)
   135  	if err != nil {
   136  		return fmt.Errorf("Append to %#v failed: %v", c.psLog, err)
   137  	}
   138  	if logfile.Close() != nil {
   139  		return fmt.Errorf("Closing %#v failed: %v", c.psLog, err)
   140  	}
   141  	return nil
   142  }
   143  
   144  // powerShellRecord records that a PowerShell command should be executed later.
   145  func (c *adProvider) powerShellRecord(command string) error {
   146  	recordfile, err := os.OpenFile(c.psOut, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0660)
   147  	if err != nil {
   148  		return fmt.Errorf("can not create/append to %#v: %v", c.psOut, err)
   149  	}
   150  	_, err = recordfile.WriteString(command)
   151  	if err != nil {
   152  		return fmt.Errorf("append to %#v failed: %v", c.psOut, err)
   153  	}
   154  	return recordfile.Close()
   155  }
   156  
   157  func (c *adProvider) getExistingRecords(domainname string) ([]*models.RecordConfig, error) {
   158  	// Get the JSON either from adzonedump or by running a PowerShell script.
   159  	data, err := c.getRecords(domainname)
   160  	if err != nil {
   161  		return nil, fmt.Errorf("getRecords failed on %#v: %v", domainname, err)
   162  	}
   163  
   164  	var recs []*RecordConfigJson
   165  	jdata := string(data)
   166  	// when there is only a single record, AD powershell does not
   167  	// wrap it in an array as our types expect. This makes sure it is always an array.
   168  	if strings.HasPrefix(strings.TrimSpace(jdata), "{") {
   169  		jdata = "[" + jdata + "]"
   170  		data = []byte(jdata)
   171  	}
   172  	err = json.Unmarshal(data, &recs)
   173  	if err != nil {
   174  		return nil, fmt.Errorf("json.Unmarshal failed on %#v: %v", domainname, err)
   175  	}
   176  
   177  	result := make([]*models.RecordConfig, 0, len(recs))
   178  	unsupportedCounts := map[string]int{}
   179  	for _, rec := range recs {
   180  		t, supportedType := rec.unpackRecord(domainname)
   181  		if !supportedType {
   182  			unsupportedCounts[rec.Type]++
   183  		}
   184  		if t != nil {
   185  			result = append(result, t)
   186  		}
   187  	}
   188  	for t, count := range unsupportedCounts {
   189  		printer.Warnf("%d records of type %s found in AD zone. These will be ignored.\n", count, t)
   190  	}
   191  
   192  	return result, nil
   193  }
   194  
   195  func (r *RecordConfigJson) unpackRecord(origin string) (rc *models.RecordConfig, supported bool) {
   196  	rc = &models.RecordConfig{
   197  		Type: r.Type,
   198  		TTL:  r.TTL,
   199  	}
   200  	rc.SetLabel(r.Name, origin)
   201  	switch rtype := rc.Type; rtype { // #rtype_variations
   202  	case "A", "AAAA":
   203  		rc.SetTarget(r.Data)
   204  	case "CNAME":
   205  		rc.SetTarget(strings.ToLower(r.Data))
   206  	case "NS":
   207  		// skip root NS
   208  		if rc.Name == "@" {
   209  			return nil, true
   210  		}
   211  		rc.SetTarget(strings.ToLower(r.Data))
   212  	case "SOA":
   213  		return nil, true
   214  	default:
   215  		return nil, false
   216  	}
   217  	return rc, true
   218  }
   219  
   220  // powerShellDump runs a PowerShell command to get a dump of all records in a DNS zone.
   221  func (c *adProvider) generatePowerShellZoneDump(domainname string) string {
   222  	cmdTxt := `@("REPLACE_WITH_ZONE") | %{
   223  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
   224  }`
   225  	cmdTxt = strings.Replace(cmdTxt, "REPLACE_WITH_ZONE", domainname, -1)
   226  	cmdTxt = strings.Replace(cmdTxt, "REPLACE_WITH_COMPUTER_NAME", c.adServer, -1)
   227  	cmdTxt = strings.Replace(cmdTxt, "REPLACE_WITH_FILENAMEPREFIX", zoneDumpFilenamePrefix, -1)
   228  	return cmdTxt
   229  }
   230  
   231  // generatePowerShellCreate generates PowerShell commands to ADD a record.
   232  func (c *adProvider) generatePowerShellCreate(domainname string, rec *models.RecordConfig) string {
   233  	content := rec.GetTargetField()
   234  	text := "\r\n" // Skip a line.
   235  	funcSuffix := rec.Type
   236  	if rec.Type == "NS" {
   237  		funcSuffix = ""
   238  	}
   239  	text += fmt.Sprintf("Add-DnsServerResourceRecord%s", funcSuffix)
   240  	text += fmt.Sprintf(` -ComputerName "%s"`, c.adServer)
   241  	text += fmt.Sprintf(` -ZoneName "%s"`, domainname)
   242  	text += fmt.Sprintf(` -Name "%s"`, rec.GetLabel())
   243  	text += fmt.Sprintf(` -TimeToLive $(New-TimeSpan -Seconds %d)`, rec.TTL)
   244  	switch rec.Type { // #rtype_variations
   245  	case "CNAME":
   246  		text += fmt.Sprintf(` -HostNameAlias "%s"`, content)
   247  	case "A":
   248  		text += fmt.Sprintf(` -IPv4Address "%s"`, content)
   249  	case "NS":
   250  		text += fmt.Sprintf(` -NS -NameServer "%s"`, content)
   251  	default:
   252  		panic(fmt.Errorf("generatePowerShellCreate() does not yet handle recType=%s recName=%#v content=%#v)",
   253  			rec.Type, rec.GetLabel(), content))
   254  		// We panic so that we quickly find any switch statements
   255  		// that have not been updated for a new RR type.
   256  	}
   257  	text += "\r\n"
   258  
   259  	return text
   260  }
   261  
   262  // generatePowerShellModify generates PowerShell commands to MODIFY a record.
   263  func (c *adProvider) generatePowerShellModify(domainname, recName, recType, oldContent, newContent string, oldTTL, newTTL uint32) string {
   264  
   265  	var queryField, queryContent string
   266  	queryContent = `"` + oldContent + `"`
   267  
   268  	switch recType { // #rtype_variations
   269  	case "A":
   270  		queryField = "IPv4address"
   271  	case "CNAME":
   272  		queryField = "HostNameAlias"
   273  	case "NS":
   274  		queryField = "NameServer"
   275  	default:
   276  		panic(fmt.Errorf("generatePowerShellModify() does not yet handle recType=%s recName=%#v content=(%#v, %#v)", recType, recName, oldContent, newContent))
   277  		// We panic so that we quickly find any switch statements
   278  		// that have not been updated for a new RR type.
   279  	}
   280  
   281  	text := "\r\n" // Skip a line.
   282  	text += fmt.Sprintf(`echo "MODIFY %s %s %s old=%s new=%s"`, recName, domainname, recType, oldContent, newContent)
   283  	text += "\r\n"
   284  
   285  	text += "$OldObj = Get-DnsServerResourceRecord"
   286  	text += fmt.Sprintf(` -ComputerName "%s"`, c.adServer)
   287  	text += fmt.Sprintf(` -ZoneName "%s"`, domainname)
   288  	text += fmt.Sprintf(` -Name "%s"`, recName)
   289  	text += fmt.Sprintf(` -RRType "%s"`, recType)
   290  	text += fmt.Sprintf(" |  Where-Object {$_.RecordData.%s -eq %s -and $_.HostName -eq \"%s\"}", queryField, queryContent, recName)
   291  	text += "\r\n"
   292  	text += `if($OldObj.Length -ne $null){ throw "Error, multiple results for Get-DnsServerResourceRecord" }`
   293  	text += "\r\n"
   294  
   295  	text += "$NewObj = $OldObj.Clone()"
   296  	text += "\r\n"
   297  
   298  	if oldContent != newContent {
   299  		text += fmt.Sprintf(`$NewObj.RecordData.%s = "%s"`, queryField, newContent)
   300  		text += "\r\n"
   301  	}
   302  
   303  	if oldTTL != newTTL {
   304  		text += fmt.Sprintf(`$NewObj.TimeToLive = New-TimeSpan -Seconds %d`, newTTL)
   305  		text += "\r\n"
   306  	}
   307  
   308  	text += "Set-DnsServerResourceRecord"
   309  	text += fmt.Sprintf(` -ComputerName "%s"`, c.adServer)
   310  	text += fmt.Sprintf(` -ZoneName "%s"`, domainname)
   311  	text += fmt.Sprintf(` -NewInputObject $NewObj -OldInputObject $OldObj`)
   312  	text += "\r\n"
   313  
   314  	return text
   315  }
   316  
   317  func (c *adProvider) generatePowerShellDelete(domainname, recName, recType, content string) string {
   318  	text := fmt.Sprintf(`echo "DELETE %s %s %s"`, recType, recName, content)
   319  	text += "\r\n"
   320  	text += `Remove-DnsServerResourceRecord -Force -ComputerName "%s" -ZoneName "%s" -Name "%s" -RRType "%s" -RecordData "%s"`
   321  	text += "\r\n"
   322  	return fmt.Sprintf(text, c.adServer, domainname, recName, recType, content)
   323  }
   324  
   325  func (c *adProvider) createRec(domainname string, cre diff.Correlation) []*models.Correction {
   326  	rec := cre.Desired
   327  	arr := []*models.Correction{
   328  		{
   329  			Msg: cre.String(),
   330  			F: func() error {
   331  				return c.powerShellDoCommand(c.generatePowerShellCreate(domainname, rec), true)
   332  			}},
   333  	}
   334  	return arr
   335  }
   336  
   337  func (c *adProvider) modifyRec(domainname string, m diff.Correlation) *models.Correction {
   338  	old, rec := m.Existing, m.Desired
   339  	return &models.Correction{
   340  		Msg: m.String(),
   341  		F: func() error {
   342  			return c.powerShellDoCommand(c.generatePowerShellModify(domainname, rec.GetLabel(), rec.Type, old.GetTargetField(), rec.GetTargetField(), old.TTL, rec.TTL), true)
   343  		},
   344  	}
   345  }
   346  
   347  func (c *adProvider) deleteRec(domainname string, cor diff.Correlation) *models.Correction {
   348  	rec := cor.Existing
   349  	return &models.Correction{
   350  		Msg: cor.String(),
   351  		F: func() error {
   352  			return c.powerShellDoCommand(c.generatePowerShellDelete(domainname, rec.GetLabel(), rec.Type, rec.GetTargetField()), true)
   353  		},
   354  	}
   355  }