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 }