-
-
Notifications
You must be signed in to change notification settings - Fork 1k
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
Add DNS Provider for ACME-DNS. #591
Conversation
This commit adds a new DNS provider for [acme-dns](https://github.com/joohoi/acme-dns) to allow Lego to set DNS-01 challenge response TXT with an ACME-DNS server automatically. ACME-DNS allows ceding minimal zone editing permissions to the ACME client and can be useful when the primary DNS provider for the zone does not allow scripting/API access but can set a CNAME to an ACME-DNS server. Lower level ACME-DNS API calls & account loading/storing is handled by the `github.com/cpu/goacmedns` library. The provider loads existing ACME-DNS accounts from the specified JSON file on disk. Any accounts the provider registers on behalf of the user will also be saved to this JSON file. When required, the provider handles registering accounts with the ACME-DNS server domains that do not already have an ACME-DNS account. This will halt issuance with an error prompting the user to set the one-time manual CNAME required to delegate the DNS-01 challenge record to the ACME-DNS server. Subsequent runs will use the account from disk and assume the CNAME is in-place.
I didn't vendor the goacmedns library correctly and CI is unhappy:
I'm running I'll fix ASAP. Reviews most welcome in the meantime! |
As of d9bdd50 this should be resolved |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did not know https://github.com/joohoi/acme-dns, it looks great.
providers/dns/acmedns/acmedns.go
Outdated
func NewDNSProvider() (*DNSProvider, error) { | ||
endpoint := os.Getenv(apiBaseEnvVar) | ||
storagePath := os.Getenv(storagePathEnvVar) | ||
return NewDNSProviderClient(endpoint, storagePath) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In order to be homogeneous with the other DNS providers, can you rewrite like the following example:
https://github.com/xenolf/lego/blob/c4bbb4b819c087d02066fe745d7ca5b60a480d36/providers/dns/cloudflare/cloudflare.go#L32-L39
But you can keep NewDNSProviderClient
function name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in e5fc7bf
// path can be made up because we mock the client and storage instead of | ||
// making real API calls and writing JSON to disk. | ||
dp, err := NewDNSProviderClient("foo", "bar") | ||
if err != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In order to be homogeneous with the other DNS providers, can you replace the use of the std-lib by Testify.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I took a crack at this in 48d0f5b Let me know if I'm still writing my tests too stdlib focused.
providers/dns/acmedns/acmedns.go
Outdated
// Errors other than goacmeDNS.ErrDomainNotFound are unexpected. | ||
if err != nil && err != goacmedns.ErrDomainNotFound { | ||
return err | ||
} else if err == goacmedns.ErrDomainNotFound { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The else
does not seem necessary.
if err != nil && err != goacmedns.ErrDomainNotFound {
return err
}
if err == goacmedns.ErrDomainNotFound {
// The account did not exist. Create a new one and return an error
// indicating the required one-time manual CNAME setup.
return d.register(domain, fqdn)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, fixed in f5e122f
This looks cool, thanks @cpu!. We actually use lego as a package, and don't use the filesystem at all. It would be sweet if there was an option to not be bound to the file system for storage of the account info. Optionally getting it from environment variables could work for us. |
@shupp Thanks for the feedback
I would be open to refactoring slightly so that you could provide your own Edit: the required change was very small so I included it in this branch: 0c01b0e |
@ldez Ready for another 🔍 on this PR when you have a chance. Thanks! |
@cpu cool. Yeah, a follow-up would be more appropriate. Thanks! |
For users that programmatically use Lego there may be demand for using the ACME-DNS provider with a custom goacmedns.Storage implementation that is more complex than the "read/save JSON from disk" default storage.
@shupp I don't have experience using Lego as a package, would cpu@f4b2b6b meet your needs? |
@cpu Yeah, that's perfect. Thanks! |
I think the CI failure for b43a443 is a flake unrelated to the branch:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some remarks
providers/dns/acmedns/acmedns.go
Outdated
// credentials using the provided acmedns.Storage implementation. This is useful | ||
// for programmatic usage of the ACME-DNS provider where fine-grained control | ||
// over data persistence is required. | ||
func NewDNSProviderClientStorage(apiBase string, storage goacmedns.Storage) (*DNSProvider, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you can keep only 2 NewDNSProvider
functions:
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(apiBaseEnvVar, storagePathEnvVar)
if err != nil {
return nil, fmt.Errorf("acme-dns: %v", err)
}
client := goacmedns.NewClient(values[apiBaseEnvVar])
storage := goacmedns.NewFileStorage(values[storagePathEnvVar], 0600)
return NewDNSProviderClient(client, storage)
}
func NewDNSProviderClient(client acmeDNSClient, storage goacmedns.Storage) (*DNSProvider, error) {
// ....
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WFM! Much cleaner :-) I was a little bit shy about changing NewDNSProvider
after you had reviewed it. Fixed in d9b0372
providers/dns/acmedns/acmedns.go
Outdated
// DNSProvider is an implementation of the acme.ChallengeProvider interface for | ||
// an ACME-DNS server. | ||
type DNSProvider struct { | ||
apiBase string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
apiBase
is not used, it can be remove
|
||
for _, tc := range testCases { | ||
t.Run(tc.Name, func(t *testing.T) { | ||
assert := assert.New(t) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can delete this line, see below
// path can be made up because we mock the client and storage instead of | ||
// making real API calls and writing JSON to disk. | ||
dp, err := NewDNSProviderClient("foo", "bar") | ||
assert.Nil(err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
require.NoError(t, err)
err = dp.register(egDomain, egFQDN) | ||
if tc.ExpectedError != nil { | ||
assert.NotNil(err) | ||
assert.Equal(tc.ExpectedError, err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can remove assert.NotNil(err)
and rewrite the equals:
assert.Equal(t, tc.ExpectedError, err)
// path can be made up because we mock the client and storage instead of | ||
// making real API calls and writing JSON to disk. | ||
dp, err := NewDNSProviderClient("foo", "bar") | ||
assert.Nil(err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
require.NoError(t, err)
err = dp.Present(egDomain, "foo", egKeyAuth) | ||
if tc.ExpectedError != nil { | ||
assert.NotNil(err) | ||
assert.Equal(err, tc.ExpectedError) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can remove assert.NotNil(err)
and rewrite the equals:
assert.Equal(t, tc.ExpectedError, err)
assert.NotNil(err) | ||
assert.Equal(err, tc.ExpectedError) | ||
} else { | ||
assert.Nil(err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
require.NoError(t, err)
} | ||
|
||
// Check that the success testcase set a record. | ||
assert.Equal(t, len(validUpdateClient.records), 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assert.Len(t, validUpdateClient.records, 1)
assert.Equal(t, len(validUpdateClient.records), 1) | ||
|
||
// Check that the success testcase set the right record for the right account. | ||
assert.Equal(t, len(validUpdateClient.records[egAccount]), 43) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assert.Len(t, validUpdateClient.records[egAccount], 43)
@ldez Ready for another 🔍 when you have a chance. Thanks for your patience with the |
@ldez Another question: Do you want me to rebase this branch before it's merged or do you folks prefer to squash merge? I'm happy to cleanup the commit history if you'd like. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍
Thanks @ldez! |
This PR adds a new "acme-dns" DNS provider for acme-dns to allow Lego to set DNS-01 challenge response TXT records with an ACME-DNS server automatically. ACME-DNS allows ceding minimal zone editing permissions to the ACME client and can be useful when the primary DNS provider for the zone does not allow scripting/API access but can set a CNAME to an ACME-DNS server.
The acme-dns provider accepts an ACME-DNS API server address in the
ACME_DNS_API_BASE
environment variable. The provider's lower level ACME-DNS API calls & account loading/storing is handled by thegithub.com/cpu/goacmedns
library. This is roughly modelled after the acme-dns-certbot-joohoi hook for Certbot and the https://github.com/joohoi/pyacmedns Python library.The acme-dns provider accepts a JSON storage file for ACME-DNS account information in the
ACME_DNS_STORAGE_PATH
environment variable. The provider loads existing ACME-DNS accounts/domains from this file at runtime. When required, the provider handles registering new accounts with the ACME-DNS server and saves the details to the storage path. This will halt issuance with an error prompting the user to set the one-time manual CNAME required to delegate the DNS-01 challenge record to the ACME-DNS server. Subsequent runs will use the previously registered account from disk and assume the CNAME is in-place. Library users of Lego can use theNewDNSProviderClientStorage
constructor to provide their owngoacmedns.Storage
implementation if more advanced account management is required.You can avoid having Lego fail on the first run by pre-registering the domains with ACME-DNS using the
goacmedns-register
command line utility and providing the-storage
path you will later use as theACME_DNS_STORAGE_PATH
value. This will let you perform the initial one-time CNAME delegation ahead of usinglego
.Usage example:
ACME-DNS is running at
http://10.0.0.8:4443
with a configuration forpki.threeletter.agency
. We're going to issue a certificate for*.legotest.xkeyscore.club
with this setup by delegating the_acme-challenge.legotest.xkeyscore.club
record to thepki.threeletter.agency
ACME-DNS server. Account information will be saved in/root/.lego-acme-dns-accounts.json
.Since for this first run
ACME_DNS_STORAGE_PATH
is empty (we did not pre-register the domain withgoacmedns-register
) and the ACME-DNS CNAME delegation hasn't been done yet it will fail with an error about setting up the initial CNAME delegation:After adding the
_acme-challenge.legotest.xkeyscore.club. CNAME a9b4649d-113f-4449-886b-28dd534cc599.pki.threeletter.agency.
record to thexkeyscore.club
DNS zone and making sure all of the secondary DNS servers have the record too we can run Lego again and it will issue the certificate successfully by updating the ACME-DNS TXT record:Woohoo! 🎉