diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 520cf6bcc..530b36ae7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,6 +94,7 @@ jobs: CLOUDFLAREAPI_KEY: ${{ secrets.CLOUDFLAREAPI_KEY }} CLOUDFLAREAPI_TOKEN: ${{ secrets.CLOUDFLAREAPI_TOKEN }} CLOUDFLAREAPI_USER: ${{ secrets.CLOUDFLAREAPI_USER }} + CLOUDFLAREAPI_ACCOUNTID: ${{ secrets.CLOUDFLAREAPI_ACCOUNTID }} # CLOUDNS_DOMAIN: ${{ secrets.CLOUDNS_DOMAIN }} CLOUDNS_AUTH_ID: ${{ secrets.CLOUDNS_AUTH_ID }} diff --git a/docs/_functions/domain/CF_WORKER_ROUTE.md b/docs/_functions/domain/CF_WORKER_ROUTE.md new file mode 100644 index 000000000..5edd42da3 --- /dev/null +++ b/docs/_functions/domain/CF_WORKER_ROUTE.md @@ -0,0 +1,29 @@ +--- +name: CF_WORKER_ROUTE +parameters: + - pattern + - script +--- + +`CF_WORKER_ROUTE` uses the [Cloudflare Workers](https://developers.cloudflare.com/workers/) +API to manage [worker routes](https://developers.cloudflare.com/workers/platform/routes) +for a given domain. + +If _any_ `CF_WORKER_ROUTE` function is used then `dnscontrol` will manage _all_ +Worker Routes for the domain. To be clear: this means it will delete existing routes that +were created outside of DNSControl. + +WARNING: This interface is not extensively tested. Take precautions such as making +backups and manually verifying `dnscontrol preview` output before running +`dnscontrol push`. + +This example assigns the patterns `api.foo.com/*` and `foo.com/api/*` to a `my-worker` script: + +{% include startExample.html %} +{% highlight js %} +D("foo.com", .... , + CF_WORKER_ROUTE("api.foo.com/*", "my-worker"), + CF_WORKER_ROUTE("foo.com/api/*", "my-worker"), +); +{%endhighlight%} +{% include endExample.html %} diff --git a/docs/_providers/cloudflare.md b/docs/_providers/cloudflare.md index e5a35e9ab..738a73656 100644 --- a/docs/_providers/cloudflare.md +++ b/docs/_providers/cloudflare.md @@ -11,32 +11,58 @@ jsId: CLOUDFLAREAPI * When using `SPF()` or the `SPF_BUILDER()` the records are converted to RecordType `TXT` as Cloudflare API fails otherwise. See more [here](https://github.com/StackExchange/dnscontrol/issues/446). ## Configuration -In the credentials file you must provide a [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens): + +The Cloudflare API supports two different authentication methods. + +The recommended (newer) method is to +provide a [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens). + +This method is enabled by setting the "apitoken" value in `creds.json`: {% highlight json %} { "cloudflare": { - "apitoken": "your-cloudflare-api-token" + "apitoken": "your-cloudflare-api-token", + "accountid": "your-cloudflare-account-id" } } {% endhighlight %} -Make sure the token has at least the right read zones and edit DNS records (i.e. `Zone → Zone → Read` and `Zone → DNS → Edit`; to modify Page Rules additionally requires `Zone → Page Rules → Edit`); -checkout [Cloudflare's documentation](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys) for instructions on how to generate and configure permissions on API tokens. +See [Cloudflare's documentation](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys) for instructions on how to generate and configure permissions on API tokens. +A token can be granded rights (authorization to do certain tasks) at a very granular level. DNSControl requires the token to have the following rights: -Or you can provide your Cloudflare API username and access key instead (but it isn't recommended because those credentials give DNSControl access to the complete Cloudflare API): +* Read zones (`Zone → Zone → Read`) +* Edit DNS records (`Zone → DNS → Edit`) +* Edit Page Rules (`Zone → Page Rules → Edit`) (Only required if `manage_redirects` is true for any dommain.) +* If Cloudflare Workers are being managed: (if `manage_workers`: set to `true` or `CF_WORKER_ROUTE()` is in use.) + * Edit Worker Scripts (`Account → Workers Scripts → Edit`) + * Edit Worker Scripts (`Zone → Workers Routes → Edit`) +* FYI: [An example permissions configuration](https://user-images.githubusercontent.com/210250/136301050-1fd430bf-21b6-428b-aa54-f6009964031d.png) + +The other (older, not recommended) method is to +provide your Cloudflare API username and access key. +This key is available under "My Settings". +This method is not recommended because these credentials give DNSControl access to the entire Cloudflare API. + +This method is enabled by setting the "apikey" and "apiuser" values in `creds.json`: {% highlight json %} { "cloudflare": { "apikey": "your-cloudflare-api-key", - "apiuser": "your-cloudflare-email-address" + "apiuser": "your-cloudflare-email-address", + "accountid": "your-cloudflare-account-id" } } {% endhighlight %} -If your Cloudflare account has access to multiple Cloudflare accounts, you can specify which Cloudflare account should be used when adding new domains: +You can not mix apikey/apiuser and apitoken. If all three values are set, you will receive an error. + +You should also set the "accountid" value. This is optional but may become required some day therefore we recommend setting it. +The Account ID is used to disambiguate when API key has access to multiple Cloudflare accounts. For example, when creating domains this key is used to determine which account to place the new domain. It is also required when using Workers. + +The "accountid" is found in the Cloudflare portal ("Account ID") on the DNS page. Set it in `creds.json`: {% highlight json %} { @@ -47,6 +73,8 @@ If your Cloudflare account has access to multiple Cloudflare accounts, you can s } {% endhighlight %} +Older `creds.json` files that do not have accountid set may work for now, but not in the future. + ## Metadata Record level metadata available: * `cloudflare_proxy` ("on", "off", or "full") @@ -58,6 +86,7 @@ Domain level metadata available: Provider level metadata available: * `ip_conversions` * `manage_redirects`: set to `true` to manage page-rule based redirects + * `manage_workers`: set to `true` to manage cloud workers (`CF_WORKER_ROUTE`) What does on/off/full mean? @@ -101,7 +130,7 @@ The following example shows how to set meta variables with and without aliases: D('example.tld', REG_NONE, DnsProvider(CLOUDFLARE), A('www1','1.2.3.11', CF_PROXY_ON), // turn proxy ON. A('www2','1.2.3.12', CF_PROXY_OFF), // default is OFF, this is a no-op. - A('www3','1.2.3.13', {'cloudflare_proxy': 'on'}) // why would anyone do this? + A('www3','1.2.3.13', {'cloudflare_proxy': 'on'}) // Old format. ); {% endhighlight %} @@ -132,8 +161,6 @@ D('example2.tld', REG_NONE, DnsProvider(CLOUDFLARE), ); {%endhighlight%} -## Activation -DNSControl depends on a Cloudflare Global API Key that's available under "My Settings". ## New domains If a domain does not exist in your Cloudflare account, DNSControl @@ -169,3 +196,43 @@ Notice a few details: 2. The IP address in those A records may be mostly irrelevant, as cloudflare should handle all requests (assuming some page rule matches). 3. Ordering matters for priority. CF_REDIRECT records will be added in the order they appear in your js. So put catch-alls at the bottom. 4. if _any_ `CF_REDIRECT` or `CF_TEMP_REDIRECT` functions are used then `dnscontrol` will manage _all_ "Forwarding URL" type Page Rules for the domain. Page Rule types other than "Forwarding URL” will be left alone. + +## Worker routes +The Cloudflare provider can manage Worker Routes for your domains. Simply use the `CF_WORKER_ROUTE` function passing the route pattern and the worker name: + +{% highlight js %} + +var CLOUDFLARE = NewDnsProvider('cloudflare','CLOUDFLAREAPI'); + +D("foo.com", REG_NONE, DnsProvider(CLOUDFLARE), + { manage_workers: true}, // Enable editing workers. + + // Assign the patterns `api.foo.com/*` and `foo.com/api/*` to `my-worker` script. + CF_WORKER_ROUTE("api.foo.com/*", "my-worker"), + CF_WORKER_ROUTE("foo.com/api/*", "my-worker"), +); + +{%endhighlight%} + +The API key you use must be enabled to edit workers. In the portal, edit the API key, +under "Permissions" add "Account", "Workers Scripts", "Edit". Without this permission you may see errors that mention "failed fetching worker route list from cloudflare: bad status code from cloudflare: 403 not 200" + + +Please notice that if _any_ `CF_WORKER_ROUTE` function is used then `dnscontrol` will manage _all_ +Worker Routes for the domain. To be clear: this means it will delete existing routes that +were created outside of DNSControl. + +## Integration testing + +The integration tests assume that Cloudflare Workers are enabled and the credentials used +have the required permissions listed above. The flag `-cfworkers=false` will disable tests related to Workers. +This flag is intended for use with legacy domains where the integration test credentials do not +have access to read/edit Workers. This flag will eventually go away. + +{% highlight bash %} + +go test -v -verbose -provider CLOUDFLAREAPI -cfworkers=false + +{%endhighlight%} + +When `-cfworkers=false` is set, tests related to Workers are skipped. The Account ID is not required. diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index bd90f2247..c71d91188 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/StackExchange/dnscontrol/v3/pkg/normalize" "github.com/StackExchange/dnscontrol/v3/providers" _ "github.com/StackExchange/dnscontrol/v3/providers/_all" + "github.com/StackExchange/dnscontrol/v3/providers/cloudflare" "github.com/StackExchange/dnscontrol/v3/providers/config" "github.com/miekg/dns/dnsutil" ) @@ -24,6 +25,7 @@ var startIdx = flag.Int("start", 0, "Test number to begin with") var endIdx = flag.Int("end", 0, "Test index to stop after") var verbose = flag.Bool("verbose", false, "Print corrections as you run them") var printElapsed = flag.Bool("elapsed", false, "Print elapsed time for each testgroup") +var enableCFWorkers = flag.Bool("cfworkers", true, "Set false to disable CF worker tests") func init() { testing.Init() @@ -52,7 +54,11 @@ func getProvider(t *testing.T) (providers.DNSServiceProvider, string, map[int]bo // use this feature. Maybe because we didn't have the capabilities // feature at the time? if name == "CLOUDFLAREAPI" { - metadata = []byte(`{ "manage_redirects": true }`) + if *enableCFWorkers { + metadata = []byte(`{ "manage_redirects": true, "manage_workers": true }`) + } else { + metadata = []byte(`{ "manage_redirects": true }`) + } } provider, err := providers.CreateDNSProvider(name, cfg, metadata) @@ -69,6 +75,13 @@ func getProvider(t *testing.T) (providers.DNSServiceProvider, string, map[int]bo } } + if name == "CLOUDFLAREAPI" && *enableCFWorkers { + // Cloudflare only. Will do nothing if provider != *cloudflareProvider. + if err := cloudflare.PrepareCloudflareTestWorkers(provider); err != nil { + t.Fatal(err) + } + } + return provider, cfg["domain"], fails, cfg } @@ -118,6 +131,13 @@ func testPermitted(t *testing.T, p string, f TestGroup) error { // TODO(tlim): Have a separate validation pass so that such mistakes // are more visible? + // If there are any trueflags, make sure they are all true. + for _, c := range f.trueflags { + if !c { + return fmt.Errorf("excluded by alltrue(%v)", f.trueflags) + } + } + // If there are any required capabilities, make sure they all exist. if len(f.required) != 0 { for _, c := range f.required { @@ -326,11 +346,12 @@ func TestDualProviders(t *testing.T) { } type TestGroup struct { - Desc string - required []providers.Capability - only []string - not []string - tests []*TestCase + Desc string + required []providers.Capability + only []string + not []string + trueflags []bool + tests []*TestCase } type TestCase struct { @@ -399,6 +420,12 @@ func cfProxyCNAME(name, target, status string) *models.RecordConfig { return r } +func cfWorkerRoute(pattern, target string) *models.RecordConfig { + t := fmt.Sprintf("%s,%s", pattern, target) + r := makeRec("@", t, "CF_WORKER_ROUTE") + return r +} + func ns(name, target string) *models.RecordConfig { return makeRec(name, target, "NS") } @@ -553,6 +580,12 @@ func testgroup(desc string, items ...interface{}) *TestGroup { os.Exit(1) } group.only = append(group.only, v.names...) + case alltrueFilter: + if len(group.tests) != 0 { + fmt.Printf("ERROR: alltrue() must be before all tc(): %v\n", desc) + os.Exit(1) + } + group.trueflags = append(group.trueflags, v.flags...) case *TestCase: group.tests = append(group.tests, v) default: @@ -615,6 +648,14 @@ func only(n ...string) onlyFilter { return onlyFilter{names: n} } +type alltrueFilter struct { + flags []bool +} + +func alltrue(f ...bool) alltrueFilter { + return alltrueFilter{flags: f} +} + // func makeTests(t *testing.T) []*TestGroup { @@ -635,6 +676,8 @@ func makeTests(t *testing.T) []*TestGroup { // only("ROUTE53", "GANDI_V5") // Only apply to all providers except ROUTE53 + GANDI_V5: // not("ROUTE53", "GANDI_V5"), + // Only run this test if all these bool flags are true: + // alltrue(*enableCFWorkers, *anotherFlag, myBoolValue) // NOTE: You can't mix not() and only() // reset(not("ROUTE53"), only("GCLOUD")), // ERROR! // NOTE: All requires()/not()/only() must appear before any tc(). @@ -1384,6 +1427,34 @@ func makeTests(t *testing.T) []*TestGroup { tc("proxycnamechange", cfProxyCNAME("anewproxy", "example.com.", "off")), clear(), ), + + testgroup("CF_WORKER_ROUTE", + only("CLOUDFLAREAPI"), + alltrue(*enableCFWorkers), + // TODO(fdcastel): Add worker scripts via api call before test execution + tc("simple", cfWorkerRoute("cnn.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_cnn")), + tc("changeScript", cfWorkerRoute("cnn.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_msnbc")), + tc("changePattern", cfWorkerRoute("cable.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_msnbc")), + clear(), + tc("createMultiple", + cfWorkerRoute("cnn.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_cnn"), + cfWorkerRoute("msnbc.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_msnbc"), + ), + tc("addOne", + cfWorkerRoute("msnbc.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_msnbc"), + cfWorkerRoute("cnn.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_cnn"), + cfWorkerRoute("api.**current-domain-no-trailing**/cnn/*", "dnscontrol_integrationtest_cnn"), + ), + tc("changeOne", + cfWorkerRoute("msn.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_msnbc"), + cfWorkerRoute("cnn.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_cnn"), + cfWorkerRoute("api.**current-domain-no-trailing**/cnn/*", "dnscontrol_integrationtest_cnn"), + ), + tc("deleteOne", + cfWorkerRoute("msn.**current-domain-no-trailing**/*", "dnscontrol_integrationtest_msnbc"), + cfWorkerRoute("api.**current-domain-no-trailing**/cnn/*", "dnscontrol_integrationtest_cnn"), + ), + ), } return tests diff --git a/integrationTest/providers.json b/integrationTest/providers.json index cd9145337..e94cd0e4c 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -25,7 +25,8 @@ "apikey": "$CLOUDFLAREAPI_KEY", "apitoken": "$CLOUDFLAREAPI_TOKEN", "apiuser": "$CLOUDFLAREAPI_USER", - "domain": "$CLOUDFLAREAPI_DOMAIN" + "domain": "$CLOUDFLAREAPI_DOMAIN", + "accountid": "$CLOUDFLAREAPI_ACCOUNTID" }, "CLOUDFLAREAPI_OLD": { "apikey": "$CF_KEY", diff --git a/models/domain.go b/models/domain.go index 5d342b948..8ef22b21d 100644 --- a/models/domain.go +++ b/models/domain.go @@ -84,7 +84,7 @@ func (dc *DomainConfig) Punycode() error { return err } rec.SetTarget(t) - case "CF_REDIRECT", "CF_TEMP_REDIRECT": + case "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE": rec.SetTarget(rec.GetTargetField()) case "A", "AAAA", "CAA", "DS", "NAPTR", "SOA", "SSHFP", "TXT", "TLSA", "AZURE_ALIAS": // Nothing to do. diff --git a/models/record.go b/models/record.go index 5dc25cae2..3dc3a3526 100644 --- a/models/record.go +++ b/models/record.go @@ -34,12 +34,14 @@ import ( // ALIAS // CF_REDIRECT // CF_TEMP_REDIRECT +// CF_WORKER_ROUTE // FRAME // IMPORT_TRANSFORM // NAMESERVER // NO_PURGE // NS1_URLFWD // PAGE_RULE +// WORKER_ROUTE // PURGE // URL // URL301 @@ -512,7 +514,7 @@ func downcase(recs []*RecordConfig) { case "ANAME", "CNAME", "DS", "MX", "NS", "PTR", "NAPTR", "SRV", "TLSA", "AKAMAICDN": // These record types have a target that is case insensitive, so we downcase it. r.target = strings.ToLower(r.target) - case "A", "AAAA", "ALIAS", "CAA", "IMPORT_TRANSFORM", "TXT", "SSHFP", "CF_REDIRECT", "CF_TEMP_REDIRECT": + case "A", "AAAA", "ALIAS", "CAA", "IMPORT_TRANSFORM", "TXT", "SSHFP", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE": // These record types have a target that is case sensitive, or is an IP address. We leave them alone. // Do nothing. case "SOA": diff --git a/pkg/js/helpers.js b/pkg/js/helpers.js index 6af989d8c..2268350f4 100644 --- a/pkg/js/helpers.js +++ b/pkg/js/helpers.js @@ -722,7 +722,8 @@ function recordBuilder(type, opts) { // Handle D_EXTEND() with subdomains. if (d.subdomain && record.type != 'CF_REDIRECT' && - record.type != 'CF_TEMP_REDIRECT') { + record.type != 'CF_TEMP_REDIRECT' && + record.type != 'CF_WORKER_ROUTE') { fqdn = [d.subdomain, d.name].join(".") record.subdomain = d.subdomain; @@ -856,6 +857,18 @@ var CF_TEMP_REDIRECT = recordBuilder('CF_TEMP_REDIRECT', { }, }); +var CF_WORKER_ROUTE = recordBuilder('CF_WORKER_ROUTE', { + args: [ + ['pattern', _validateCloudflareRedirect], + ['script', _validateCloudflareRedirect], + ], + transform: function(record, args, modifiers) { + record.name = '@'; + record.target = args.pattern + ',' + args.script; + }, +}); + + var URL = recordBuilder('URL'); var URL301 = recordBuilder('URL301'); var FRAME = recordBuilder('FRAME'); diff --git a/pkg/js/js_test.go b/pkg/js/js_test.go index 1845d6e84..9fcab8ef1 100644 --- a/pkg/js/js_test.go +++ b/pkg/js/js_test.go @@ -132,6 +132,7 @@ func TestErrors(t *testing.T) { {"MX reversed", `D("foo.com","reg",MX("@","test.", 5))`}, {"CF_REDIRECT With comma", `D("foo.com","reg",CF_REDIRECT("foo.com,","baaa"))`}, {"CF_TEMP_REDIRECT With comma", `D("foo.com","reg",CF_TEMP_REDIRECT("foo.com","baa,a"))`}, + {"CF_WORKER_ROUTE With comma", `D("foo.com","reg",CF_WORKER_ROUTE("foo.com","baa,a"))`}, {"Bad cidr", `D(reverse("foo.com"), "reg")`}, {"Dup domains", `D("example.org", "reg"); D("example.org", "reg")`}, {"Bad NAMESERVER", `D("example.com","reg", NAMESERVER("@","ns1.foo.com."))`}, diff --git a/pkg/js/parse_tests/036-dextendcf.js b/pkg/js/parse_tests/036-dextendcf.js index f3785f105..844404b96 100644 --- a/pkg/js/parse_tests/036-dextendcf.js +++ b/pkg/js/parse_tests/036-dextendcf.js @@ -5,6 +5,8 @@ D("foo.com", REG, DnsProvider(CF)); D_EXTEND("sub.foo.com", A("test1.foo.com","10.2.3.1"), A("test2.foo.com","10.2.3.2"), + A("test3.foo.com","10.2.3.3"), CF_REDIRECT("test1.foo.com","https://goo.com/$1"), - CF_TEMP_REDIRECT("test2.foo.com","https://goo.com/$1") + CF_TEMP_REDIRECT("test2.foo.com","https://goo.com/$1"), + CF_WORKER_ROUTE("test3.foo.com","test-worker") ); diff --git a/pkg/js/parse_tests/036-dextendcf.json b/pkg/js/parse_tests/036-dextendcf.json index 4abf1af5c..1275fab9f 100644 --- a/pkg/js/parse_tests/036-dextendcf.json +++ b/pkg/js/parse_tests/036-dextendcf.json @@ -24,6 +24,12 @@ "target": "10.2.3.2", "type": "A" }, + { + "name": "test3.foo.com.sub", + "subdomain": "sub", + "target": "10.2.3.3", + "type": "A" + }, { "name": "@", "target": "test1.foo.com,https://goo.com/$1", @@ -33,6 +39,11 @@ "name": "@", "target": "test2.foo.com,https://goo.com/$1", "type": "CF_TEMP_REDIRECT" + }, + { + "name": "@", + "target": "test3.foo.com,test-worker", + "type": "CF_WORKER_ROUTE" } ], "registrar": "Third-Party" diff --git a/pkg/js/parse_tests/036-dextendcf/foo.com.zone b/pkg/js/parse_tests/036-dextendcf/foo.com.zone index dbea109bb..e8a1ba02c 100644 --- a/pkg/js/parse_tests/036-dextendcf/foo.com.zone +++ b/pkg/js/parse_tests/036-dextendcf/foo.com.zone @@ -1,5 +1,7 @@ $TTL 300 ;@ IN CF_REDIRECT test1.foo.com,https://goo.com/$1 ;@ IN CF_TEMP_REDIRECT test2.foo.com,https://goo.com/$1 +;@ IN CF_WORKER_ROUTE test3.foo.com,test-worker test1.foo.com.sub IN A 10.2.3.1 test2.foo.com.sub IN A 10.2.3.2 +test3.foo.com.sub IN A 10.2.3.3 diff --git a/pkg/js/parse_tests/040-cfWorkerRoute.js b/pkg/js/parse_tests/040-cfWorkerRoute.js new file mode 100644 index 000000000..e8b3d31ef --- /dev/null +++ b/pkg/js/parse_tests/040-cfWorkerRoute.js @@ -0,0 +1,3 @@ +D("foo.com","none", + CF_WORKER_ROUTE("test.foo.com","test-worker") +); \ No newline at end of file diff --git a/pkg/js/parse_tests/040-cfWorkerRoute.json b/pkg/js/parse_tests/040-cfWorkerRoute.json new file mode 100644 index 000000000..df2a13826 --- /dev/null +++ b/pkg/js/parse_tests/040-cfWorkerRoute.json @@ -0,0 +1,18 @@ +{ + "registrars": [], + "dns_providers": [], + "domains": [ + { + "name": "foo.com", + "registrar": "none", + "dnsProviders": {}, + "records": [ + { + "type": "CF_WORKER_ROUTE", + "name": "@", + "target": "test.foo.com,test-worker" + } + ] + } + ] +} diff --git a/pkg/js/parse_tests/040-cfWorkerRoute/foo.com.zone b/pkg/js/parse_tests/040-cfWorkerRoute/foo.com.zone new file mode 100644 index 000000000..2dca479d5 --- /dev/null +++ b/pkg/js/parse_tests/040-cfWorkerRoute/foo.com.zone @@ -0,0 +1,2 @@ +$TTL 300 +;@ IN CF_WORKER_ROUTE test.foo.com,test-worker diff --git a/providers/cloudflare/cloudflareProvider.go b/providers/cloudflare/cloudflareProvider.go index 3964d3661..e1e4851e9 100644 --- a/providers/cloudflare/cloudflareProvider.go +++ b/providers/cloudflare/cloudflareProvider.go @@ -58,6 +58,7 @@ func init() { providers.RegisterDomainServiceProviderType("CLOUDFLAREAPI", fns, features) providers.RegisterCustomRecordType("CF_REDIRECT", "CLOUDFLAREAPI", "") providers.RegisterCustomRecordType("CF_TEMP_REDIRECT", "CLOUDFLAREAPI", "") + providers.RegisterCustomRecordType("CF_WORKER_ROUTE", "CLOUDFLAREAPI", "") } // cloudflareProvider is the handle for API calls. @@ -67,6 +68,7 @@ type cloudflareProvider struct { ipConversions []transform.IPConversion ignoredLabels []string manageRedirects bool + manageWorkers bool cfClient *cloudflare.API } @@ -185,6 +187,14 @@ func (c *cloudflareProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*m records = append(records, prs...) } + if c.manageWorkers { + wrs, err := c.getWorkerRoutes(id, dc.Name) + if err != nil { + return nil, err + } + records = append(records, wrs...) + } + for _, rec := range dc.Records { if rec.Type == "ALIAS" { rec.Type = "CNAME" @@ -220,6 +230,11 @@ func (c *cloudflareProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*m Msg: d.String(), F: func() error { return c.deletePageRule(ex.Original.(cloudflare.PageRule).ID, id) }, }) + } else if ex.Type == "WORKER_ROUTE" { + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { return c.deleteWorkerRoute(ex.Original.(cloudflare.WorkerRoute).ID, id) }, + }) } else { corr := c.deleteRec(ex.Original.(cloudflare.DNSRecord), id) // DS records must always have a corresponding NS record. @@ -238,6 +253,11 @@ func (c *cloudflareProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*m Msg: d.String(), F: func() error { return c.createPageRule(id, des.GetTargetField()) }, }) + } else if des.Type == "WORKER_ROUTE" { + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { return c.createWorkerRoute(id, des.GetTargetField()) }, + }) } else { corr := c.createRec(des, id) // DS records must always have a corresponding NS record. @@ -258,6 +278,13 @@ func (c *cloudflareProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*m Msg: d.String(), F: func() error { return c.updatePageRule(ex.Original.(cloudflare.PageRule).ID, id, rec.GetTargetField()) }, }) + } else if rec.Type == "WORKER_ROUTE" { + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { + return c.updateWorkerRoute(ex.Original.(cloudflare.WorkerRoute).ID, id, rec.GetTargetField()) + }, + }) } else { e := ex.Original.(cloudflare.DNSRecord) proxy := e.Proxiable && rec.Metadata[metaProxy] != "off" @@ -416,6 +443,16 @@ func (c *cloudflareProvider) preprocessConfig(dc *models.DomainConfig) error { rec.TTL = 1 rec.Type = "PAGE_RULE" } + + // CF_WORKER_ROUTE record types. Encode target as $PATTERN,$SCRIPT + if rec.Type == "CF_WORKER_ROUTE" { + parts := strings.Split(rec.GetTargetField(), ",") + if len(parts) != 2 { + return fmt.Errorf("invalid data specified for cloudflare worker record") + } + rec.TTL = 1 + rec.Type = "WORKER_ROUTE" + } } // look for ip conversions and transform records @@ -473,12 +510,14 @@ func newCloudflare(m map[string]string, metadata json.RawMessage) (providers.DNS IPConversions string `json:"ip_conversions"` IgnoredLabels []string `json:"ignored_labels"` ManageRedirects bool `json:"manage_redirects"` + ManageWorkers bool `json:"manage_workers"` }{} err := json.Unmarshal([]byte(metadata), parsedMeta) if err != nil { return nil, err } api.manageRedirects = parsedMeta.ManageRedirects + api.manageWorkers = parsedMeta.ManageWorkers // ignored_labels: api.ignoredLabels = append(api.ignoredLabels, parsedMeta.IgnoredLabels...) if len(api.ignoredLabels) > 0 { @@ -630,3 +669,20 @@ func (c *cloudflareProvider) EnsureDomainExists(domain string) error { fmt.Printf("Added zone for %s to Cloudflare account: %s\n", domain, id) return err } + +// PrepareCloudflareWorkers creates Cloudflare Workers required for CF_WORKER_ROUTE tests. +func PrepareCloudflareTestWorkers(prv providers.DNSServiceProvider) error { + cf, ok := prv.(*cloudflareProvider) + if ok { + err := cf.createTestWorker("dnscontrol_integrationtest_cnn") + if err != nil { + return err + } + + err = cf.createTestWorker("dnscontrol_integrationtest_msnbc") + if err != nil { + return err + } + } + return nil +} diff --git a/providers/cloudflare/rest.go b/providers/cloudflare/rest.go index 95f0b9a60..d25692dd1 100644 --- a/providers/cloudflare/rest.go +++ b/providers/cloudflare/rest.go @@ -282,6 +282,76 @@ func (c *cloudflareProvider) createPageRule(domainID string, target string) erro return err } +func (c *cloudflareProvider) getWorkerRoutes(id string, domain string) ([]*models.RecordConfig, error) { + res, err := c.cfClient.ListWorkerRoutes(context.Background(), id) + if err != nil { + return nil, fmt.Errorf("failed fetching worker route list cloudflare: %s", err) + } + + recs := []*models.RecordConfig{} + for _, pr := range res.Routes { + var thisPr = pr + r := &models.RecordConfig{ + Type: "WORKER_ROUTE", + Original: thisPr, + TTL: 1, + } + r.SetLabel("@", domain) + r.SetTarget(fmt.Sprintf("%s,%s", // $PATTERN,$SCRIPT + pr.Pattern, + pr.Script)) + recs = append(recs, r) + } + return recs, nil +} + +func (c *cloudflareProvider) deleteWorkerRoute(recordID, domainID string) error { + _, err := c.cfClient.DeleteWorkerRoute(context.Background(), domainID, recordID) + return err +} + +func (c *cloudflareProvider) updateWorkerRoute(recordID, domainID string, target string) error { + // Causing stack overflow (!?) + // return c.updateWorkerRoute(recordID, domainID, target) + + if err := c.deleteWorkerRoute(recordID, domainID); err != nil { + return err + } + return c.createWorkerRoute(domainID, target) +} + +func (c *cloudflareProvider) createWorkerRoute(domainID string, target string) error { + // $PATTERN,$SCRIPT + parts := strings.Split(target, ",") + if len(parts) != 2 { + return fmt.Errorf("unexpected target: '%s' (expected: 'PATTERN,SCRIPT')", target) + } + wr := cloudflare.WorkerRoute{ + Pattern: parts[0], + Script: parts[1], + } + + _, err := c.cfClient.CreateWorkerRoute(context.Background(), domainID, wr) + return err +} + +func (c *cloudflareProvider) createTestWorker(workerName string) error { + wrp := cloudflare.WorkerRequestParams{ + ZoneID: "", + ScriptName: workerName, + } + + script := ` + addEventListener("fetch", (event) => { + event.respondWith( + new Response("Ok.", { status: 200 }) + ); + });` + + _, err := c.cfClient.UploadWorker(context.Background(), &wrp, script) + return err +} + // go-staticcheck lies! type pageRuleConstraint struct { Operator string `json:"operator"`