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