Skip to content

Commit

Permalink
cloudflare: handle restricted API tokens (#985)
Browse files Browse the repository at this point in the history
  • Loading branch information
dmke authored and ldez committed Oct 9, 2019
1 parent 415e534 commit 828b0f3
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 48 deletions.
6 changes: 4 additions & 2 deletions cmd/zz_gen_cmd_dnshelp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:`)
Expand Down
41 changes: 34 additions & 7 deletions docs/content/dns/zz_gen_cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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).
Expand All @@ -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.

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.

* `Zone:Read`
* `DNS:Edit`
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.



Expand Down
91 changes: 91 additions & 0 deletions providers/dns/cloudflare/client.go
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 26 additions & 20 deletions providers/dns/cloudflare/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"},
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
41 changes: 34 additions & 7 deletions providers/dns/cloudflare/cloudflare.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
* `Zone:Read`
* `DNS:Edit`
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"
Expand Down
Loading

0 comments on commit 828b0f3

Please sign in to comment.