Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New provider to support complicated multi-dns-provider setups #605

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions providers/dns/dns_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/xenolf/lego/providers/dns/lightsail"
"github.com/xenolf/lego/providers/dns/linode"
"github.com/xenolf/lego/providers/dns/linodev4"
"github.com/xenolf/lego/providers/dns/multi"
"github.com/xenolf/lego/providers/dns/mydnsjp"
"github.com/xenolf/lego/providers/dns/namecheap"
"github.com/xenolf/lego/providers/dns/namedotcom"
Expand Down Expand Up @@ -120,6 +121,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
return linodev4.NewDNSProvider()
case "manual":
return acme.NewDNSProviderManual()
case "multi":
return multi.NewDNSProvider()
case "mydnsjp":
return mydnsjp.NewDNSProvider()
case "namecheap":
Expand Down Expand Up @@ -162,3 +165,7 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
return nil, fmt.Errorf("unrecognised DNS provider: %s", name)
}
}

func init() {
multi.NewDNSChallengeProviderByName = NewDNSChallengeProviderByName
}
57 changes: 57 additions & 0 deletions providers/dns/multi/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package multi

import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
)

// ProviderConfig is the configuration for a multiple provider setup. This is expected to be given in json format via
// MULTI_CONFIG environment variable, or in a file location specified by MULTI_CONFIG_FILE.
type ProviderConfig struct {
// Domain names to list of provider names
Domains map[string][]string
// Provider Name -> Key/Value pairs for environment
Providers map[string]map[string]string
}

// providerNamesForDomain chooses the most appropriate domain from the config and returns its' list of dns providers
// looks for most specific match to least specific, one dot at a time. Finally folling back to "default" domain.
func (m *ProviderConfig) providerNamesForDomain(domain string) ([]string, error) {
parts := strings.Split(domain, ".")
var names []string
for i := 0; i < len(parts); i++ {
partial := strings.Join(parts[i:], ".")
if names = m.Domains[partial]; names != nil {
break
}
}
if names == nil {
names = m.Domains["default"]
}
if names == nil {
return nil, fmt.Errorf("Couldn't find any suitable dns provider for domain %s", domain)
}
return names, nil
}

func getConfig() (*ProviderConfig, error) {
var rawJSON []byte
var err error
if cfg := os.Getenv("MULTI_CONFIG"); cfg != "" {
rawJSON = []byte(cfg)
} else if path := os.Getenv("MULTI_CONFIG_FILE"); path != "" {
if rawJSON, err = ioutil.ReadFile(path); err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("'multi' provider requires json config in MULTI_CONFIG or MULTI_CONFIG_FILE")
}
cfg := &ProviderConfig{}
if err = json.Unmarshal(rawJSON, cfg); err != nil {
return nil, err
}
return cfg, nil
}
204 changes: 204 additions & 0 deletions providers/dns/multi/multi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Package multi implements a dynamic challenge provider that can select different dns providers for different domains,
// and even multiple distinct dns providers and accounts for each individual domain. This can be useful if:
//
// - Multiple dns providers are used for active-active redundant dns service
//
// - You need a single certificate issued for different domains, each using different dns services
//
// Configuration is given by selecting DNS provider type "multi", and by giving further per-domain information via a json object:
//
// {
// "Providers": {
// "cloudflare": {
// "CLOUDFLARE_EMAIL": "myacct@example.com",
// "CLOUDFLARE_API_KEY": "123qwerty..."
// },
// "digitalocean":{
// "DO_AUTH_TOKEN": "456uiop..."
// }
// }
// "Domains": {
// "example.com": ["digitalocean"],
// "example.org": ["cloudflare"],
// "example.net": ["digitalocean, cloudflare"]
// }
// }
//
// In the above json, each "Provider" is a named provider instance along with the associated credentials. The credentials will be set as environment
// variables as appropriate when the provider is instantiated for the first time.
//
// If the provider name is the same as a registered provider type (like "cloudflare"), the type will be inferred. If it is not the same (perhaps in cases where multiple
// different accounts are involved), you may specify it with the `type` field on the provider object.
//
// Domains are then linked to one or more of the named providers by name. Challenges will be filled on every provider specified for the domain. When looking for a domain
// configuration, config domains will be checked from most specific to least specific by each dot. For example, to fill a challenge for `foo.example.com`,
// a configured domain for `foo.example.com` will be looked for, failing that it will look for `example.com` and `com` in that order. If there is still no match and a
// domain with the name `default` is found, that will be used. Otherwise an error will be returned.
//
// The json configuration for domains can be specified directly via environment variable (`MULTI_CONFIG`), or from a file referenced by `MULTI_CONFIG_FILE`.
package multi

import (
"fmt"
"os"
"time"

"github.com/xenolf/lego/acme"
)

// NewDNSChallengeProviderByName is defined here to avoid recursive imports, this must be injected by the dns package so that
// the delegated dns providers may be dynamically instantiated
var NewDNSChallengeProviderByName func(string) (acme.ChallengeProvider, error)

// DNSProvider implements a dns provider that selects which other providers to use for each domain individually.
type DNSProvider struct {
config *ProviderConfig
providers map[string]acme.ChallengeProvider
}

// NewDNSProvider creates a new multiple-provider meta-provider. It will look for a json configuration in "MULTI_CONFIG", or on disk from "MULTI_CONFIG_FILE"
func NewDNSProvider() (*DNSProvider, error) {
config, err := getConfig()
if err != nil {
return nil, err
}
return &DNSProvider{
providers: map[string]acme.ChallengeProvider{},
config: config,
}, nil
}

// AggregateProvider is simply a list of dns providers. All Challenges are filled by all members of the aggregate.
type AggregateProvider []acme.ChallengeProvider

// Present creates the txt record in all child dns providers
func (a AggregateProvider) Present(domain, token, keyAuth string) error {
for _, p := range a {
if err := p.Present(domain, token, keyAuth); err != nil {
return err
}
}
return nil
}

// CleanUp removes the txt record from all dns providers
func (a AggregateProvider) CleanUp(domain, token, keyAuth string) error {
for _, p := range a {
if err := p.CleanUp(domain, token, keyAuth); err != nil {
return err
}
}
return nil
}

// AggregateProviderTimeout is simply a list of dns providers. This type will be chosen when any of the 'subproviders' implement Timeout control.
// All Challenges are filled by all members of the aggregate.
// Timeout returned will be the maximum time of any child provider.
type AggregateProviderTimeout struct {
AggregateProvider
}

// Timeout gives the largest timeout values from any child provider that supports timeouts.
func (a AggregateProviderTimeout) Timeout() (timeout, interval time.Duration) {
for _, p := range a.AggregateProvider {
if to, ok := p.(acme.ChallengeProviderTimeout); ok {
t, i := to.Timeout()
if t > timeout {
timeout = t
}
if i > interval {
interval = i
}
}
}
return
}

func (d *DNSProvider) getProviderForDomain(domain string) (acme.ChallengeProvider, error) {
names, err := d.config.providerNamesForDomain(domain)
if err != nil {
return nil, err
}
var agg AggregateProvider
anyTimeouts := false
for _, n := range names {
p, err := d.providerByName(n)
if err != nil {
return nil, err
}
if _, ok := p.(acme.ChallengeProviderTimeout); ok {
anyTimeouts = true
}
agg = append(agg, p)
}
// don't wrap provider in aggregate if there is only one
if len(agg) == 1 {
return agg[0], nil
}
if anyTimeouts {
return AggregateProviderTimeout{agg}, nil
}
return agg, nil
}

func (d *DNSProvider) providerByName(name string) (acme.ChallengeProvider, error) {
if p, ok := d.providers[name]; ok {
return p, nil
}
if params, ok := d.config.Providers[name]; ok {
return d.buildProvider(name, params)
}
return nil, fmt.Errorf("Couldn't find appropriate config for dns provider named '%s'", name)
}

func (d *DNSProvider) buildProvider(name string, params map[string]string) (acme.ChallengeProvider, error) {
pType := name
origEnv := map[string]string{}

// copy parameters into environment, keeping track of previous values
for k, v := range params {
if k == "type" {
pType = v
continue
}
if oldVal, ok := os.LookupEnv(k); ok {
origEnv[k] = oldVal
}
os.Setenv(k, v)
}
// restore previous values
defer func() {
for k := range params {
if k == "type" {
continue
}
if oldVal, ok := origEnv[k]; ok {
os.Setenv(k, oldVal)
} else {
os.Unsetenv(k)
}
}
}()
prv, err := NewDNSChallengeProviderByName(pType)
if err != nil {
return nil, err
}
d.providers[name] = prv
return prv, nil
}

func (d *DNSProvider) Present(domain, token, keyAuth string) error {
provider, err := d.getProviderForDomain(domain)
if err != nil {
return err
}
return provider.Present(domain, token, keyAuth)
}

func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
provider, err := d.getProviderForDomain(domain)
if err != nil {
return err
}
return provider.CleanUp(domain, token, keyAuth)
}