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 }