diff --git a/OWNERS b/OWNERS index 6aca8ddda..d32a6e794 100644 --- a/OWNERS +++ b/OWNERS @@ -21,6 +21,7 @@ providers/namecheap @captncraig # providers/namedotcom providers/netcup @kordianbruck providers/ns1 @captncraig +providers/oracle @kallsyms # providers/route53 # providers/softlayer providers/vultr @pgaskin diff --git a/README.md b/README.md index 8b4bea958..d3f5008c8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Currently supported DNS providers: - OVH - OctoDNS - OpenSRS + - Oracle Cloud - PowerDNS - SoftLayer - Vultr diff --git a/docs/_providers/oracle.md b/docs/_providers/oracle.md new file mode 100644 index 000000000..f1464e366 --- /dev/null +++ b/docs/_providers/oracle.md @@ -0,0 +1,43 @@ +--- +name: Oracle Cloud +title: Oracle Cloud Provider +layout: default +jsId: ORACLE +--- +# Oracle Cloud Provider + +## Configuration + +Create an API key through the Oracle Cloud portal, and provide the user OCID, tenancy OCID, key fingerprint, region, and the contents of the private key. +The OCID of the compartment DNS resources should be put in can also optionally be provided. + +{% highlight json %} +{ + "oracle": { + "user_ocid": "$ORACLE_USER_OCID", + "tenancy_ocid": "$ORACLE_TENANCY_OCID", + "fingerprint": "$ORACLE_FINGERPRINT", + "region": "$ORACLE_REGION", + "private_key": "$ORACLE_PRIVATE_KEY", + "compartment": "$ORACLE_COMPARTMENT" + }, +} +{% endhighlight %} + +## Metadata +This provider does not recognize any special metadata fields unique to Oracle Cloud. + +## Usage +Example Javascript: + +{% highlight js %} +var REG_NONE = NewRegistrar('none', 'NONE') +var ORACLE = NewDnsProvider("oracle", "ORACLE"); + +D("example.tld", REG_NONE, DnsProvider(ORACLE), + NAMESERVER_TTL(86400), + + A("test","1.2.3.4") +); +{% endhighlight %} + diff --git a/docs/provider-list.md b/docs/provider-list.md index d53676814..7d3253458 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -91,6 +91,7 @@ Maintainers of contributed providers: * `NS1` @captncraig * `OCTODNS` @TomOnTime * `OPENSRS` @pierre-emmanuelJ +* `ORACLE` @kallsyms * `OVH` @masterzen * `POWERDNS` @jpbede * `SOFTLAYER`@jamielennox diff --git a/go.mod b/go.mod index 91bd70695..d366f6965 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/mjibson/esc v0.2.0 github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 github.com/nrdcg/goinwx v0.8.1 + github.com/oracle/oci-go-sdk/v32 v32.0.0 github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014 github.com/philhug/opensrs-go v0.0.0-20171126225031-9dfa7433020d github.com/pierrec/lz4 v2.6.0+incompatible // indirect diff --git a/go.sum b/go.sum index 49e700855..067a9d18a 100644 --- a/go.sum +++ b/go.sum @@ -369,6 +369,10 @@ github.com/nrdcg/goinwx v0.8.1 h1:20EQ/JaGFnSKwiDH2JzjIpicffl3cPk6imJBDqVBVtU= github.com/nrdcg/goinwx v0.8.1/go.mod h1:tILVc10gieBp/5PMvbcYeXM6pVQ+c9jxDZnpaR1UW7c= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/oracle/oci-go-sdk v1.8.0 h1:4SO45bKV0I3/Mn1os3ANDZmV0eSE5z5CLdSUIkxtyzs= +github.com/oracle/oci-go-sdk v24.3.0+incompatible h1:x4mcfb4agelf1O4/1/auGlZ1lr97jXRSSN5MxTgG/zU= +github.com/oracle/oci-go-sdk/v32 v32.0.0 h1:SSbzrQO3WRcPJEZ8+b3SFPYsPtkFM96clqrp03lrwbU= +github.com/oracle/oci-go-sdk/v32 v32.0.0/go.mod h1:aZc4jC59IuNP3cr5y1nj555QvwojMX2nMJaBiozuuEs= github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014 h1:37VE5TYj2m/FLA9SNr4z0+A0JefvTmR60Zwf8XSEV7c= github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014/go.mod h1:joRatxRJaZBsY3JAOEMcoOp05CnZzsx4scTxi95DHyQ= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index cf2f8cf44..64c99aaa2 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -677,7 +677,7 @@ func makeTests(t *testing.T) []*TestGroup { // Netcup: NS records not currently supported. tc("NS for subdomain", ns("xyz", "ns2.foo.com.")), tc("Dual NS for subdomain", ns("xyz", "ns2.foo.com."), ns("xyz", "ns1.foo.com.")), - tc("NS Record pointing to @", ns("foo", "**current-domain**")), + tc("NS Record pointing to @", a("@", "1.2.3.4"), ns("foo", "**current-domain**")), ), testgroup("IGNORE_NAME function", diff --git a/integrationTest/providers.json b/integrationTest/providers.json index bbd438d04..f811fdf85 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -126,6 +126,15 @@ "directory": "config", "domain": "example.com" }, + "ORACLE": { + "user_ocid": "$ORACLE_USER_OCID", + "tenancy_ocid": "$ORACLE_TENANCY_OCID", + "fingerprint": "$ORACLE_FINGERPRINT", + "region": "$ORACLE_REGION", + "private_key": "$ORACLE_PRIVATE_KEY", + "compartment": "$ORACLE_COMPARTMENT", + "domain": "$ORACLE_DOMAIN" + }, "OVH": { "app-key": "$OVH_APP_KEY", "app-secret-key": "$OVH_APP_SECRET_KEY", diff --git a/providers/_all/all.go b/providers/_all/all.go index 14b14f6bc..87caab169 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -30,6 +30,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/ns1" _ "github.com/StackExchange/dnscontrol/v3/providers/octodns" _ "github.com/StackExchange/dnscontrol/v3/providers/opensrs" + _ "github.com/StackExchange/dnscontrol/v3/providers/oracle" _ "github.com/StackExchange/dnscontrol/v3/providers/ovh" _ "github.com/StackExchange/dnscontrol/v3/providers/powerdns" _ "github.com/StackExchange/dnscontrol/v3/providers/route53" diff --git a/providers/oracle/oracleProvider.go b/providers/oracle/oracleProvider.go new file mode 100644 index 000000000..579c01c02 --- /dev/null +++ b/providers/oracle/oracleProvider.go @@ -0,0 +1,361 @@ +package oracle + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/oracle/oci-go-sdk/v32/dns" + + "github.com/oracle/oci-go-sdk/v32/common" + "github.com/oracle/oci-go-sdk/v32/example/helpers" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/pkg/printer" + "github.com/StackExchange/dnscontrol/v3/providers" +) + +var features = providers.DocumentationNotes{ + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), + + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDS: providers.Cannot(), // should be supported, but getting 500s in tests + providers.CanUseNAPTR: providers.Can(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Can(), + providers.CanUseTLSA: providers.Can(), + providers.CanUseTXTMulti: providers.Can(), +} + +func init() { + providers.RegisterDomainServiceProviderType("ORACLE", New, features) +} + +type oracleProvider struct { + client dns.DnsClient + compartment string +} + +// New creates a new provider for Oracle Cloud DNS +func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + client, err := dns.NewDnsClientWithConfigurationProvider(common.NewRawConfigurationProvider( + settings["tenancy_ocid"], + settings["user_ocid"], + settings["region"], + settings["fingerprint"], + settings["private_key"], + nil, + )) + if err != nil { + return nil, err + } + + return &oracleProvider{ + client: client, + compartment: settings["compartment"], + }, nil +} + +// ListZones lists the zones on this account. +func (o *oracleProvider) ListZones() ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + listResp, err := o.client.ListZones(ctx, dns.ListZonesRequest{ + CompartmentId: &o.compartment, + }) + if err != nil { + return nil, err + } + + zones := make([]string, len(listResp.Items)) + for i, zone := range listResp.Items { + zones[i] = *zone.Name + } + return zones, nil +} + +// EnsureDomainExists creates the domain if it does not exist. +func (o *oracleProvider) EnsureDomainExists(domain string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + getResp, err := o.client.GetZone(ctx, dns.GetZoneRequest{ + ZoneNameOrId: &domain, + CompartmentId: &o.compartment, + }) + if err == nil { + return nil + } + if err != nil && getResp.RawResponse.StatusCode != 404 { + return err + } + + _, err = o.client.CreateZone(ctx, dns.CreateZoneRequest{ + CreateZoneDetails: dns.CreateZoneDetails{ + CompartmentId: &o.compartment, + Name: &domain, + ZoneType: dns.CreateZoneDetailsZoneTypePrimary, + }, + }) + if err != nil { + return err + } + + // poll until the zone is ready + pollUntilAvailable := func(r common.OCIOperationResponse) bool { + if converted, ok := r.Response.(dns.GetZoneResponse); ok { + return converted.LifecycleState != dns.ZoneLifecycleStateActive + } + return true + } + _, err = o.client.GetZone(ctx, dns.GetZoneRequest{ + ZoneNameOrId: &domain, + CompartmentId: &o.compartment, + RequestMetadata: helpers.GetRequestMetadataWithCustomizedRetryPolicy(pollUntilAvailable), + }) + + return err +} + +func (o *oracleProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + getResp, err := o.client.GetZone(ctx, dns.GetZoneRequest{ + ZoneNameOrId: &domain, + CompartmentId: &o.compartment, + }) + if err != nil { + return nil, err + } + + nss := make([]string, len(getResp.Zone.Nameservers)) + for i, ns := range getResp.Zone.Nameservers { + nss[i] = *ns.Hostname + } + + return models.ToNameservers(nss) +} + +func (o *oracleProvider) GetZoneRecords(domain string) (models.Records, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + records := models.Records{} + + request := dns.GetZoneRecordsRequest{ + ZoneNameOrId: &domain, + CompartmentId: &o.compartment, + } + + for { + getResp, err := o.client.GetZoneRecords(ctx, request) + if err != nil { + return nil, err + } + + for _, record := range getResp.Items { + // Hide SOAs + if *record.Rtype == "SOA" { + continue + } + + rc := &models.RecordConfig{ + Type: *record.Rtype, + TTL: uint32(*record.Ttl), + Original: record, + } + rc.SetLabelFromFQDN(*record.Domain, domain) + + switch rc.Type { + case "ALIAS": + err = rc.SetTarget(*record.Rdata) + default: + err = rc.PopulateFromString(*record.Rtype, *record.Rdata, domain) + } + + if err != nil { + return nil, err + } + + records = append(records, rc) + } + + if getResp.OpcNextPage == nil { + break + } + + request.Page = getResp.OpcNextPage + } + + return records, nil +} + +func (o *oracleProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc, err := dc.Copy() + if err != nil { + return nil, err + } + + err = dc.Punycode() + if err != nil { + return nil, err + } + domain := dc.Name + + existingRecords, err := o.GetZoneRecords(domain) + if err != nil { + return nil, err + } + + // Normalize + models.PostProcessRecords(existingRecords) + + filteredNewRecords := models.Records{} + + // Ensure we don't emit changes for attempted modification of built-in apex NSs + for _, rec := range dc.Records { + if rec.Type != "NS" { + filteredNewRecords = append(filteredNewRecords, rec) + continue + } + + recNS := rec.GetTargetField() + if rec.GetLabel() == "@" && strings.HasSuffix(recNS, "dns.oraclecloud.com.") { + printer.Warnf("Oracle Cloud does not allow changes to built-in apex NS records. Ignoring change to %s...\n", recNS) + continue + } + + if rec.TTL != 86400 { + printer.Warnf("Oracle Cloud forces TTL=86400 for NS records. Ignoring configured TTL of %d for %s\n", rec.TTL, recNS) + rec.TTL = 86400 + } + filteredNewRecords = append(filteredNewRecords, rec) + } + + differ := diff.New(dc) + _, create, dels, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + + /* + Oracle's API doesn't have a way to update an existing record. + You can either update an existing RRSet, Domain (FQDN), or Zone in which you have to supply + the entire desired state, or you can patch specifying ADD/REMOVE actions. + Oracle's API is also increadibly slow, so updating individual RRSets is unbearably slow + for any size zone. + */ + + corrections := []*models.Correction{} + + if len(create) > 0 { + createRecords := models.Records{} + desc := "" + for _, d := range create { + createRecords = append(createRecords, d.Desired) + desc += d.String() + "\n" + } + desc = desc[:len(desc)-1] + + corrections = append(corrections, &models.Correction{ + Msg: desc, + F: func() error { + return o.patch(createRecords, nil, domain) + }, + }) + } + + if len(dels) > 0 { + deleteRecords := models.Records{} + desc := "" + for _, d := range dels { + deleteRecords = append(deleteRecords, d.Existing) + desc += d.String() + "\n" + } + desc = desc[:len(desc)-1] + + corrections = append(corrections, &models.Correction{ + Msg: desc, + F: func() error { + return o.patch(nil, deleteRecords, domain) + }, + }) + } + + if len(modify) > 0 { + createRecords := models.Records{} + deleteRecords := models.Records{} + desc := "" + for _, d := range modify { + createRecords = append(createRecords, d.Desired) + deleteRecords = append(deleteRecords, d.Existing) + desc += d.String() + "\n" + } + desc = desc[:len(desc)-1] + + corrections = append(corrections, &models.Correction{ + Msg: desc, + F: func() error { + return o.patch(createRecords, deleteRecords, domain) + }, + }) + } + + return corrections, nil +} + +func (o *oracleProvider) patch(createRecords, deleteRecords models.Records, domain string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + patchReq := dns.PatchZoneRecordsRequest{ + ZoneNameOrId: &domain, + CompartmentId: &o.compartment, + } + + ops := make([]dns.RecordOperation, 0, len(createRecords)+len(deleteRecords)) + + for _, rec := range deleteRecords { + ops = append(ops, convertToRecordOperation(rec, dns.RecordOperationOperationRemove)) + } + for _, rec := range createRecords { + ops = append(ops, convertToRecordOperation(rec, dns.RecordOperationOperationAdd)) + } + + for batchStart := 0; batchStart < len(ops); batchStart += 100 { + batchEnd := batchStart + 100 + if batchEnd > len(ops) { + batchEnd = len(ops) + } + patchReq.Items = ops[batchStart:batchEnd] + _, err := o.client.PatchZoneRecords(ctx, patchReq) + if err != nil { + return err + } + } + + return nil +} + +func convertToRecordOperation(rec *models.RecordConfig, op dns.RecordOperationOperationEnum) dns.RecordOperation { + fqdn := rec.GetLabelFQDN() + rtype := rec.Type + rdata := rec.GetTargetCombined() + ttl := int(rec.TTL) + return dns.RecordOperation{ + Domain: &fqdn, + Rtype: &rtype, + Rdata: &rdata, + Ttl: &ttl, + Operation: op, + } +}