github.com/teknogeek/dnscontrol/v2@v2.10.1-0.20200227202244-ae299b55ba42/providers/digitalocean/digitaloceanProvider.go (about) 1 package digitalocean 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "net/http" 8 9 "github.com/StackExchange/dnscontrol/v2/models" 10 "github.com/StackExchange/dnscontrol/v2/providers" 11 "github.com/StackExchange/dnscontrol/v2/providers/diff" 12 "github.com/miekg/dns/dnsutil" 13 14 "github.com/digitalocean/godo" 15 "golang.org/x/oauth2" 16 ) 17 18 /* 19 20 DigitalOcean API DNS provider: 21 22 Info required in `creds.json`: 23 - token 24 25 */ 26 27 // DoApi is the handle for operations. 28 type DoApi struct { 29 client *godo.Client 30 } 31 32 var defaultNameServerNames = []string{ 33 "ns1.digitalocean.com", 34 "ns2.digitalocean.com", 35 "ns3.digitalocean.com", 36 } 37 38 // NewDo creates a DO-specific DNS provider. 39 func NewDo(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { 40 if m["token"] == "" { 41 return nil, fmt.Errorf("no DigitalOcean token provided") 42 } 43 44 ctx := context.Background() 45 oauthClient := oauth2.NewClient( 46 ctx, 47 oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m["token"]}), 48 ) 49 client := godo.NewClient(oauthClient) 50 51 api := &DoApi{client: client} 52 53 // Get a domain to validate the token 54 _, resp, err := api.client.Domains.List(ctx, &godo.ListOptions{PerPage: 1}) 55 if err != nil { 56 return nil, err 57 } 58 if resp.StatusCode != http.StatusOK { 59 return nil, fmt.Errorf("token for digitalocean is not valid") 60 } 61 62 return api, nil 63 } 64 65 var features = providers.DocumentationNotes{ 66 providers.DocCreateDomains: providers.Can(), 67 providers.DocOfficiallySupported: providers.Cannot(), 68 providers.CanUseSRV: providers.Can(), 69 // Digitalocean support CAA records, except 70 // ";" value with issue/issuewild records: 71 // https://www.digitalocean.com/docs/networking/dns/how-to/create-caa-records/ 72 providers.CanUseCAA: providers.Can(), 73 providers.CanGetZones: providers.Can(), 74 } 75 76 func init() { 77 providers.RegisterDomainServiceProviderType("DIGITALOCEAN", NewDo, features) 78 } 79 80 // EnsureDomainExists returns an error if domain doesn't exist. 81 func (api *DoApi) EnsureDomainExists(domain string) error { 82 ctx := context.Background() 83 _, resp, err := api.client.Domains.Get(ctx, domain) 84 if resp.StatusCode == http.StatusNotFound { 85 _, _, err := api.client.Domains.Create(ctx, &godo.DomainCreateRequest{ 86 Name: domain, 87 IPAddress: "", 88 }) 89 return err 90 } 91 return err 92 } 93 94 // GetNameservers returns the nameservers for domain. 95 func (api *DoApi) GetNameservers(domain string) ([]*models.Nameserver, error) { 96 return models.StringsToNameservers(defaultNameServerNames), nil 97 } 98 99 // GetZoneRecords gets the records of a zone and returns them in RecordConfig format. 100 func (api *DoApi) GetZoneRecords(domain string) (models.Records, error) { 101 records, err := getRecords(api, domain) 102 if err != nil { 103 return nil, err 104 } 105 106 var existingRecords []*models.RecordConfig 107 for i := range records { 108 r := toRc(domain, &records[i]) 109 if r.Type == "SOA" { 110 continue 111 } 112 existingRecords = append(existingRecords, r) 113 } 114 115 return existingRecords, nil 116 } 117 118 // GetDomainCorrections returns a list of corretions for the domain. 119 func (api *DoApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { 120 ctx := context.Background() 121 dc.Punycode() 122 123 existingRecords, err := api.GetZoneRecords(dc.Name) 124 if err != nil { 125 return nil, err 126 } 127 128 // Normalize 129 models.PostProcessRecords(existingRecords) 130 131 differ := diff.New(dc) 132 _, create, delete, modify := differ.IncrementalDiff(existingRecords) 133 134 var corrections = []*models.Correction{} 135 136 // Deletes first so changing type works etc. 137 for _, m := range delete { 138 id := m.Existing.Original.(*godo.DomainRecord).ID 139 corr := &models.Correction{ 140 Msg: fmt.Sprintf("%s, DO ID: %d", m.String(), id), 141 F: func() error { 142 _, err := api.client.Domains.DeleteRecord(ctx, dc.Name, id) 143 return err 144 }, 145 } 146 corrections = append(corrections, corr) 147 } 148 for _, m := range create { 149 req := toReq(dc, m.Desired) 150 corr := &models.Correction{ 151 Msg: m.String(), 152 F: func() error { 153 _, _, err := api.client.Domains.CreateRecord(ctx, dc.Name, req) 154 return err 155 }, 156 } 157 corrections = append(corrections, corr) 158 } 159 for _, m := range modify { 160 id := m.Existing.Original.(*godo.DomainRecord).ID 161 req := toReq(dc, m.Desired) 162 corr := &models.Correction{ 163 Msg: fmt.Sprintf("%s, DO ID: %d", m.String(), id), 164 F: func() error { 165 _, _, err := api.client.Domains.EditRecord(ctx, dc.Name, id, req) 166 return err 167 }, 168 } 169 corrections = append(corrections, corr) 170 } 171 172 return corrections, nil 173 } 174 175 func getRecords(api *DoApi, name string) ([]godo.DomainRecord, error) { 176 ctx := context.Background() 177 178 records := []godo.DomainRecord{} 179 opt := &godo.ListOptions{} 180 for { 181 result, resp, err := api.client.Domains.Records(ctx, name, opt) 182 if err != nil { 183 return nil, err 184 } 185 186 for _, d := range result { 187 records = append(records, d) 188 } 189 190 if resp.Links == nil || resp.Links.IsLastPage() { 191 break 192 } 193 194 page, err := resp.Links.CurrentPage() 195 if err != nil { 196 return nil, err 197 } 198 199 opt.Page = page + 1 200 } 201 202 return records, nil 203 } 204 205 func toRc(domain string, r *godo.DomainRecord) *models.RecordConfig { 206 // This handles "@" etc. 207 name := dnsutil.AddOrigin(r.Name, domain) 208 209 target := r.Data 210 // Make target FQDN (#rtype_variations) 211 if r.Type == "CNAME" || r.Type == "MX" || r.Type == "NS" || r.Type == "SRV" { 212 // If target is the domainname, e.g. cname foo.example.com -> example.com, 213 // DO returns "@" on read even if fqdn was written. 214 if target == "@" { 215 target = domain 216 } else if target == "." { 217 target = "" 218 } 219 target = target + "." 220 } 221 222 t := &models.RecordConfig{ 223 Type: r.Type, 224 TTL: uint32(r.TTL), 225 MxPreference: uint16(r.Priority), 226 SrvPriority: uint16(r.Priority), 227 SrvWeight: uint16(r.Weight), 228 SrvPort: uint16(r.Port), 229 Original: r, 230 CaaTag: r.Tag, 231 CaaFlag: uint8(r.Flags), 232 } 233 t.SetLabelFromFQDN(name, domain) 234 t.SetTarget(target) 235 switch rtype := r.Type; rtype { 236 case "TXT": 237 t.SetTargetTXTString(target) 238 default: 239 // nothing additional required 240 } 241 return t 242 } 243 244 func toReq(dc *models.DomainConfig, rc *models.RecordConfig) *godo.DomainRecordEditRequest { 245 name := rc.GetLabel() // DO wants the short name or "@" for apex. 246 target := rc.GetTargetField() // DO uses the target field only for a single value 247 priority := 0 // DO uses the same property for MX and SRV priority 248 249 switch rc.Type { // #rtype_variations 250 case "MX": 251 priority = int(rc.MxPreference) 252 case "SRV": 253 priority = int(rc.SrvPriority) 254 case "TXT": 255 // TXT records are the one place where DO combines many items into one field. 256 target = rc.GetTargetCombined() 257 case "CAA": 258 // DO API requires that value ends in dot 259 // But the value returned from API doesn't contain this, 260 // so no need to strip the dot when reading value from API. 261 target = target + "." 262 default: 263 // no action required 264 } 265 266 return &godo.DomainRecordEditRequest{ 267 Type: rc.Type, 268 Name: name, 269 Data: target, 270 TTL: int(rc.TTL), 271 Priority: priority, 272 Port: int(rc.SrvPort), 273 Weight: int(rc.SrvWeight), 274 Tag: rc.CaaTag, 275 Flags: int(rc.CaaFlag), 276 } 277 }