cloudflare: handle restricted API tokens (#985)

This commit is contained in:
Dominik Menke 2019-10-09 02:20:30 +02:00 committed by Ludovic Fernandez
parent 415e5343da
commit 828b0f3420
6 changed files with 319 additions and 48 deletions

View file

@ -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:`)

View file

@ -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.

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"

View file

@ -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",
},
}