diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index c249816d..89e29b57 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -220,10 +220,12 @@ func displayDNSHelp(name string) error { ew.writeln(`Credentials:`) ew.writeln(` - "CF_API_EMAIL": Account email`) ew.writeln(` - "CF_API_KEY": API key`) - ew.writeln(` - "CF_API_TOKEN": API token`) + ew.writeln(` - "CF_DNS_API_TOKEN": API token with DNS:Edit permission (since v3.1.0)`) + ew.writeln(` - "CF_ZONE_API_TOKEN": API token with Zone:Read permission (since v3.1.0)`) ew.writeln(` - "CLOUDFLARE_API_KEY": Alias to CF_API_KEY`) - ew.writeln(` - "CLOUDFLARE_API_TOKEN": Alias to CF_API_TOKEN`) + ew.writeln(` - "CLOUDFLARE_DNS_API_TOKEN": Alias to CF_DNS_API_TOKEN`) ew.writeln(` - "CLOUDFLARE_EMAIL": Alias to CF_API_EMAIL`) + ew.writeln(` - "CLOUDFLARE_ZONE_API_TOKEN": Alias to CF_ZONE_API_TOKEN`) ew.writeln() ew.writeln(`Additional Configuration:`) diff --git a/docs/content/dns/zz_gen_cloudflare.md b/docs/content/dns/zz_gen_cloudflare.md index 0805abcb..1bd8f139 100644 --- a/docs/content/dns/zz_gen_cloudflare.md +++ b/docs/content/dns/zz_gen_cloudflare.md @@ -27,7 +27,7 @@ lego --dns cloudflare --domains my.domain.com --email my@email.com run # or -CLOUDFLARE_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ +CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns cloudflare --domains my.domain.com --email my@email.com run ``` @@ -40,10 +40,12 @@ lego --dns cloudflare --domains my.domain.com --email my@email.com run |-----------------------|-------------| | `CF_API_EMAIL` | Account email | | `CF_API_KEY` | API key | -| `CF_API_TOKEN` | API token | +| `CF_DNS_API_TOKEN` | API token with DNS:Edit permission (since v3.1.0) | +| `CF_ZONE_API_TOKEN` | API token with Zone:Read permission (since v3.1.0) | | `CLOUDFLARE_API_KEY` | Alias to CF_API_KEY | -| `CLOUDFLARE_API_TOKEN` | Alias to CF_API_TOKEN | +| `CLOUDFLARE_DNS_API_TOKEN` | Alias to CF_DNS_API_TOKEN | | `CLOUDFLARE_EMAIL` | Alias to CF_API_EMAIL | +| `CLOUDFLARE_ZONE_API_TOKEN` | Alias to CF_ZONE_API_TOKEN | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here](/lego/dns/#configuration-and-credentials). @@ -63,18 +65,43 @@ More information [here](/lego/dns/#configuration-and-credentials). ## Description -You may use `CF_API_EMAIL` and `CF_API_KEY` to authenticate, or `CF_API_TOKEN`. +You may use `CF_API_EMAIL` and `CF_API_KEY` to authenticate, or `CF_DNS_API_TOKEN`, or `CF_DNS_API_TOKEN` and `CF_ZONE_API_TOKEN`. ### API keys If using API keys (`CF_API_EMAIL` and `CF_API_KEY`), the Global API Key needs to be used, not the Origin CA Key. +Please be aware, that this in principle allows Lego to read and change *everything* related to this account. + ### API tokens -If using [API tokens](https://api.cloudflare.com/#getting-started-endpoints) (`CF_API_TOKEN`), the following permissions are required: +With API tokens (`CF_DNS_API_TOKEN`, and optionally `CF_ZONE_API_TOKEN`), +very specific access can be granted to your resources at Cloudflare. +See this [Cloudflare announcement](https://blog.cloudflare.com/api-tokens-general-availability/) for details. -* `Zone:Read` -* `DNS:Edit` +The main resources Lego cares for are the DNS entries for your Zones. +It also need to resolve a domain name to an internal Zone ID in order to manipulate DNS entries. + +Hence, you should create an API token with the following permissions: + +* Zone / Zone / Read +* Zone / DNS / Edit + +You also need to scope the access to all your domains for this to work. +Then pass the API token as `CF_DNS_API_TOKEN` to Lego. + +**Alternatively,** if you prefer a more strict set of privileges, +you can split the access tokens: + +* Create one with *Zone / Zone / Read* permissions and scope it to all your zones. + This is needed to resolve domain names to Zone IDs and can be shared among multiple Lego installations. + Pass this API token as `CF_ZONE_API_TOKEN` to Lego. +* Create another API token with *Zone / DNS / Edit* permissions and set the scope to the domains you want to manage with a single Lego installation. + Pass this token as `CF_DNS_API_TOKEN` to Lego. +* Repeat the previous step for each host you want to run Lego on. + +This "paranoid" setup is mainly interesting for users who manage many zones/domains with a single Cloudflare account. +It follows the principle of least privilege and limits the possible damage, should one of the hosts become compromised. diff --git a/providers/dns/cloudflare/client.go b/providers/dns/cloudflare/client.go new file mode 100644 index 00000000..3a5315a7 --- /dev/null +++ b/providers/dns/cloudflare/client.go @@ -0,0 +1,91 @@ +package cloudflare + +import ( + "sync" + + "github.com/cloudflare/cloudflare-go" + "github.com/go-acme/lego/v3/challenge/dns01" +) + +type metaClient struct { + clientEdit *cloudflare.API // needs Zone/DNS/Edit permissions + clientRead *cloudflare.API // needs Zone/Zone/Read permissions + + zones map[string]string // caches calls to ZoneIDByName, see lookupZoneID() + zonesMu *sync.RWMutex +} + +func newClient(config *Config) (*metaClient, error) { + // with AuthKey/AuthEmail we can access all available APIs + if config.AuthToken == "" { + client, err := cloudflare.New(config.AuthKey, config.AuthEmail, cloudflare.HTTPClient(config.HTTPClient)) + if err != nil { + return nil, err + } + + return &metaClient{ + clientEdit: client, + clientRead: client, + zones: make(map[string]string), + zonesMu: &sync.RWMutex{}, + }, nil + } + + dns, err := cloudflare.NewWithAPIToken(config.AuthToken, cloudflare.HTTPClient(config.HTTPClient)) + if err != nil { + return nil, err + } + + if config.ZoneToken == "" || config.ZoneToken == config.AuthToken { + return &metaClient{ + clientEdit: dns, + clientRead: dns, + zones: make(map[string]string), + zonesMu: &sync.RWMutex{}, + }, nil + } + + zone, err := cloudflare.NewWithAPIToken(config.ZoneToken, cloudflare.HTTPClient(config.HTTPClient)) + if err != nil { + return nil, err + } + + return &metaClient{ + clientEdit: dns, + clientRead: zone, + zones: make(map[string]string), + zonesMu: &sync.RWMutex{}, + }, nil +} + +func (m *metaClient) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { + return m.clientEdit.CreateDNSRecord(zoneID, rr) +} + +func (m *metaClient) DNSRecords(zoneID string, rr cloudflare.DNSRecord) ([]cloudflare.DNSRecord, error) { + return m.clientEdit.DNSRecords(zoneID, rr) +} + +func (m *metaClient) DeleteDNSRecord(zoneID, recordID string) error { + return m.clientEdit.DeleteDNSRecord(zoneID, recordID) +} + +func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { + m.zonesMu.RLock() + id := m.zones[fdqn] + m.zonesMu.RUnlock() + + if id != "" { + return id, nil + } + + id, err := m.clientRead.ZoneIDByName(dns01.UnFqdn(fdqn)) + if err != nil { + return "", err + } + + m.zonesMu.Lock() + m.zones[fdqn] = id + m.zonesMu.Unlock() + return id, nil +} diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go index c840a522..8cc75b84 100644 --- a/providers/dns/cloudflare/cloudflare.go +++ b/providers/dns/cloudflare/cloudflare.go @@ -19,9 +19,12 @@ const ( // Config is used to configure the creation of the DNSProvider type Config struct { - AuthEmail string - AuthKey string - AuthToken string + AuthEmail string + AuthKey string + + AuthToken string + ZoneToken string + TTL int PropagationTimeout time.Duration PollingInterval time.Duration @@ -42,13 +45,22 @@ func NewDefaultConfig() *Config { // DNSProvider is an implementation of the challenge.Provider interface type DNSProvider struct { - client *cloudflare.API + client *metaClient config *Config } // NewDNSProvider returns a DNSProvider instance configured for Cloudflare. -// Credentials must be passed in the environment variables: -// CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY, CLOUDFLARE_API_TOKEN. +// Credentials must be passed in as environment variables: +// +// Either provide CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY, +// or a CLOUDFLARE_DNS_API_TOKEN. +// +// For a more paranoid setup, provide CLOUDFLARE_DNS_API_TOKEN and CLOUDFLARE_ZONE_API_TOKEN. +// +// The email and API key should be avoided, if possible. +// Instead setup a API token with both Zone:Read and DNS:Edit permission, and pass the CLOUDFLARE_DNS_API_TOKEN environment variable. +// You can split the Zone:Read and DNS:Edit permissions across multiple API tokens: +// in this case pass both CLOUDFLARE_ZONE_API_TOKEN and CLOUDFLARE_DNS_API_TOKEN accordingly. func NewDNSProvider() (*DNSProvider, error) { values, err := env.GetWithFallback( []string{"CLOUDFLARE_EMAIL", "CF_API_EMAIL"}, @@ -57,7 +69,8 @@ func NewDNSProvider() (*DNSProvider, error) { if err != nil { var errT error values, errT = env.GetWithFallback( - []string{"CLOUDFLARE_API_TOKEN", "CF_API_TOKEN"}, + []string{"CLOUDFLARE_DNS_API_TOKEN", "CF_DNS_API_TOKEN"}, + []string{"CLOUDFLARE_ZONE_API_TOKEN", "CF_ZONE_API_TOKEN", "CLOUDFLARE_DNS_API_TOKEN", "CF_DNS_API_TOKEN"}, ) if errT != nil { return nil, fmt.Errorf("cloudflare: %v or %v", err, errT) @@ -67,7 +80,8 @@ func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.AuthEmail = values["CLOUDFLARE_EMAIL"] config.AuthKey = values["CLOUDFLARE_API_KEY"] - config.AuthToken = values["CLOUDFLARE_API_TOKEN"] + config.AuthToken = values["CLOUDFLARE_DNS_API_TOKEN"] + config.ZoneToken = values["CLOUDFLARE_ZONE_API_TOKEN"] return NewDNSProviderConfig(config) } @@ -82,22 +96,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("cloudflare: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } - client, err := getClient(config) + client, err := newClient(config) if err != nil { - return nil, err + return nil, fmt.Errorf("cloudflare: %v", err) } return &DNSProvider{client: client, config: config}, nil } -func getClient(config *Config) (*cloudflare.API, error) { - if config.AuthToken == "" { - return cloudflare.New(config.AuthKey, config.AuthEmail, cloudflare.HTTPClient(config.HTTPClient)) - } - - return cloudflare.NewWithAPIToken(config.AuthToken, cloudflare.HTTPClient(config.HTTPClient)) -} - // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { @@ -113,7 +119,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("cloudflare: %v", err) } - zoneID, err := d.client.ZoneIDByName(dns01.UnFqdn(authZone)) + zoneID, err := d.client.ZoneIDByName(authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %v", authZone, err) } @@ -148,7 +154,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("cloudflare: %v", err) } - zoneID, err := d.client.ZoneIDByName(dns01.UnFqdn(authZone)) + zoneID, err := d.client.ZoneIDByName(authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %v", authZone, err) } diff --git a/providers/dns/cloudflare/cloudflare.toml b/providers/dns/cloudflare/cloudflare.toml index 0c1bc7f5..f29341ba 100644 --- a/providers/dns/cloudflare/cloudflare.toml +++ b/providers/dns/cloudflare/cloudflare.toml @@ -11,35 +11,62 @@ lego --dns cloudflare --domains my.domain.com --email my@email.com run # or -CLOUDFLARE_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ +CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns cloudflare --domains my.domain.com --email my@email.com run ''' Additional = ''' ## Description -You may use `CF_API_EMAIL` and `CF_API_KEY` to authenticate, or `CF_API_TOKEN`. +You may use `CF_API_EMAIL` and `CF_API_KEY` to authenticate, or `CF_DNS_API_TOKEN`, or `CF_DNS_API_TOKEN` and `CF_ZONE_API_TOKEN`. ### API keys If using API keys (`CF_API_EMAIL` and `CF_API_KEY`), the Global API Key needs to be used, not the Origin CA Key. +Please be aware, that this in principle allows Lego to read and change *everything* related to this account. + ### API tokens -If using [API tokens](https://api.cloudflare.com/#getting-started-endpoints) (`CF_API_TOKEN`), the following permissions are required: +With API tokens (`CF_DNS_API_TOKEN`, and optionally `CF_ZONE_API_TOKEN`), +very specific access can be granted to your resources at Cloudflare. +See this [Cloudflare announcement](https://blog.cloudflare.com/api-tokens-general-availability/) for details. -* `Zone:Read` -* `DNS:Edit` +The main resources Lego cares for are the DNS entries for your Zones. +It also need to resolve a domain name to an internal Zone ID in order to manipulate DNS entries. + +Hence, you should create an API token with the following permissions: + +* Zone / Zone / Read +* Zone / DNS / Edit + +You also need to scope the access to all your domains for this to work. +Then pass the API token as `CF_DNS_API_TOKEN` to Lego. + +**Alternatively,** if you prefer a more strict set of privileges, +you can split the access tokens: + +* Create one with *Zone / Zone / Read* permissions and scope it to all your zones. + This is needed to resolve domain names to Zone IDs and can be shared among multiple Lego installations. + Pass this API token as `CF_ZONE_API_TOKEN` to Lego. +* Create another API token with *Zone / DNS / Edit* permissions and set the scope to the domains you want to manage with a single Lego installation. + Pass this token as `CF_DNS_API_TOKEN` to Lego. +* Repeat the previous step for each host you want to run Lego on. + +This "paranoid" setup is mainly interesting for users who manage many zones/domains with a single Cloudflare account. +It follows the principle of least privilege and limits the possible damage, should one of the hosts become compromised. ''' [Configuration] [Configuration.Credentials] CF_API_EMAIL = "Account email" CF_API_KEY = "API key" - CF_API_TOKEN = "API token" + CF_DNS_API_TOKEN = "API token with DNS:Edit permission (since v3.1.0)" + CF_ZONE_API_TOKEN = "API token with Zone:Read permission (since v3.1.0)" CLOUDFLARE_EMAIL = "Alias to CF_API_EMAIL" CLOUDFLARE_API_KEY = "Alias to CF_API_KEY" - CLOUDFLARE_API_TOKEN = "Alias to CF_API_TOKEN" + CLOUDFLARE_DNS_API_TOKEN = "Alias to CF_DNS_API_TOKEN" + CLOUDFLARE_ZONE_API_TOKEN = "Alias to CF_ZONE_API_TOKEN" [Configuration.Additional] CLOUDFLARE_POLLING_INTERVAL = "Time between DNS propagation check" CLOUDFLARE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" diff --git a/providers/dns/cloudflare/cloudflare_test.go b/providers/dns/cloudflare/cloudflare_test.go index 7c040492..8617a1da 100644 --- a/providers/dns/cloudflare/cloudflare_test.go +++ b/providers/dns/cloudflare/cloudflare_test.go @@ -12,7 +12,8 @@ import ( var envTest = tester.NewEnvTest( "CLOUDFLARE_EMAIL", "CLOUDFLARE_API_KEY", - "CLOUDFLARE_API_TOKEN"). + "CLOUDFLARE_DNS_API_TOKEN", + "CLOUDFLARE_ZONE_API_TOKEN"). WithDomain("CLOUDFLARE_DOMAIN") func TestNewDNSProvider(t *testing.T) { @@ -31,17 +32,24 @@ func TestNewDNSProvider(t *testing.T) { { desc: "success API token", envVars: map[string]string{ - "CLOUDFLARE_API_TOKEN": "012345abcdef", + "CLOUDFLARE_DNS_API_TOKEN": "012345abcdef", + }, + }, + { + desc: "success separate API tokens", + envVars: map[string]string{ + "CLOUDFLARE_DNS_API_TOKEN": "012345abcdef", + "CLOUDFLARE_ZONE_API_TOKEN": "abcdef012345", }, }, { desc: "missing credentials", envVars: map[string]string{ - "CLOUDFLARE_EMAIL": "", - "CLOUDFLARE_API_KEY": "", - "CLOUDFLARE_API_TOKEN": "", + "CLOUDFLARE_EMAIL": "", + "CLOUDFLARE_API_KEY": "", + "CLOUDFLARE_DNS_API_TOKEN": "", }, - expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_API_TOKEN", + expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, { desc: "missing email", @@ -49,7 +57,7 @@ func TestNewDNSProvider(t *testing.T) { "CLOUDFLARE_EMAIL": "", "CLOUDFLARE_API_KEY": "key", }, - expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL or some credentials information are missing: CLOUDFLARE_API_TOKEN", + expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, { desc: "missing api key", @@ -57,7 +65,7 @@ func TestNewDNSProvider(t *testing.T) { "CLOUDFLARE_EMAIL": "awesome@possum.com", "CLOUDFLARE_API_KEY": "", }, - expected: "cloudflare: some credentials information are missing: CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_API_TOKEN", + expected: "cloudflare: some credentials information are missing: CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, } @@ -82,6 +90,116 @@ func TestNewDNSProvider(t *testing.T) { } } +func TestNewDNSProviderWithToken(t *testing.T) { + type expected struct { + dnsToken string + zoneToken string + sameClient bool + error string + } + + testCases := []struct { + desc string + + // test input + envVars map[string]string + + // expectations + expected expected + }{ + { + desc: "same client when zone token is missing", + envVars: map[string]string{ + "CLOUDFLARE_DNS_API_TOKEN": "123", + }, + expected: expected{ + dnsToken: "123", + zoneToken: "123", + sameClient: true, + }, + }, + { + desc: "same client when zone token equals dns token", + envVars: map[string]string{ + "CLOUDFLARE_DNS_API_TOKEN": "123", + "CLOUDFLARE_ZONE_API_TOKEN": "123", + }, + expected: expected{ + dnsToken: "123", + zoneToken: "123", + sameClient: true, + }, + }, + { + desc: "failure when only zone api given", + envVars: map[string]string{ + "CLOUDFLARE_ZONE_API_TOKEN": "123", + }, + expected: expected{ + error: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN", + }, + }, + { + desc: "different clients when zone and dns token differ", + envVars: map[string]string{ + "CLOUDFLARE_DNS_API_TOKEN": "123", + "CLOUDFLARE_ZONE_API_TOKEN": "abc", + }, + expected: expected{ + dnsToken: "123", + zoneToken: "abc", + sameClient: false, + }, + }, + { + desc: "aliases work as expected", // CLOUDFLARE_* takes precedence over CF_* + envVars: map[string]string{ + "CLOUDFLARE_DNS_API_TOKEN": "123", + "CF_DNS_API_TOKEN": "456", + "CLOUDFLARE_ZONE_API_TOKEN": "abc", + "CF_ZONE_API_TOKEN": "def", + }, + expected: expected{ + dnsToken: "123", + zoneToken: "abc", + sameClient: false, + }, + }, + } + + defer envTest.RestoreEnv() + localEnvTest := tester.NewEnvTest( + "CLOUDFLARE_DNS_API_TOKEN", "CF_DNS_API_TOKEN", + "CLOUDFLARE_ZONE_API_TOKEN", "CF_ZONE_API_TOKEN", + ).WithDomain("CLOUDFLARE_DOMAIN") + envTest.ClearEnv() + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer localEnvTest.RestoreEnv() + localEnvTest.ClearEnv() + localEnvTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected.error != "" { + require.EqualError(t, err, test.expected.error) + return + } + + require.NoError(t, err) + require.NotNil(t, p) + assert.Equal(t, test.expected.dnsToken, p.config.AuthToken) + assert.Equal(t, test.expected.zoneToken, p.config.ZoneToken) + if test.expected.sameClient { + assert.Equal(t, p.client.clientRead, p.client.clientEdit) + } else { + assert.NotEqual(t, p.client.clientRead, p.client.clientEdit) + } + }) + } +} + func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string @@ -107,22 +225,22 @@ func TestNewDNSProviderConfig(t *testing.T) { }, { desc: "missing credentials", - expected: "invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: key & email must not be empty", }, { desc: "missing email", authKey: "123", - expected: "invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: key & email must not be empty", }, { desc: "missing api key", authEmail: "test@example.com", - expected: "invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: key & email must not be empty", }, { desc: "missing api token, fallback to api key/email", authToken: "", - expected: "invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: key & email must not be empty", }, }