forked from TrueCloudLab/lego
Merge pull request #38 from janeczku/dns-providers
Modular dns-01 challenge providers
This commit is contained in:
commit
468e9a2ede
9 changed files with 861 additions and 11 deletions
|
@ -1,24 +1,26 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dnsTemplate = "_acme-challenge.%s. 300 IN TXT \"%s\""
|
||||
)
|
||||
// DNSProvider represents a service for creating dns records.
|
||||
type DNSProvider interface {
|
||||
// CreateTXT creates a TXT record
|
||||
CreateTXTRecord(fqdn, value string, ttl int) error
|
||||
RemoveTXTRecord(fqdn, value string, ttl int) error
|
||||
}
|
||||
|
||||
// dnsChallenge implements the dns-01 challenge according to ACME 7.5
|
||||
type dnsChallenge struct {
|
||||
jws *jws
|
||||
provider DNSProvider
|
||||
}
|
||||
|
||||
func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
|
||||
|
@ -36,11 +38,10 @@ func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
|
|||
// of the base64 encoding mentioned by the spec. Fix this if either the spec or boulder changes!
|
||||
keyAuthSha := hex.EncodeToString(keyAuthShaBytes[:sha256.Size])
|
||||
|
||||
dnsRecord := fmt.Sprintf(dnsTemplate, domain, keyAuthSha)
|
||||
logf("[DEBUG] acme: DNS Record: %s", dnsRecord)
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
_, _ = reader.ReadString('\n')
|
||||
fqdn := fmt.Sprintf("_acme-challenge.%s.", domain)
|
||||
if err = s.provider.CreateTXTRecord(fqdn, keyAuthSha, 120); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
|
||||
if err != nil {
|
||||
|
@ -83,5 +84,9 @@ Loop:
|
|||
resp, err = http.Get(chlng.URI)
|
||||
}
|
||||
|
||||
if err = s.provider.RemoveTXTRecord(fqdn, keyAuthSha, 120); err != nil {
|
||||
logf("[WARN] acme: Failed to cleanup DNS record. -> %v ", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
158
acme/dns_challenge_cloudflare.go
Normal file
158
acme/dns_challenge_cloudflare.go
Normal file
|
@ -0,0 +1,158 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/crackcomm/cloudflare"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// DNSProviderCloudFlare is an implementation of the DNSProvider interface
|
||||
type DNSProviderCloudFlare struct {
|
||||
client *cloudflare.Client
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewDNSProviderCloudFlare returns a DNSProviderCloudFlare instance with a configured cloudflare client.
|
||||
// Authentication is either done using the passed credentials or - when empty - using the environment
|
||||
// variables CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY.
|
||||
func NewDNSProviderCloudFlare(cloudflareEmail, cloudflareKey string) (*DNSProviderCloudFlare, error) {
|
||||
if cloudflareEmail == "" || cloudflareKey == "" {
|
||||
cloudflareEmail, cloudflareKey = envAuth()
|
||||
if cloudflareEmail == "" || cloudflareKey == "" {
|
||||
return nil, fmt.Errorf("CloudFlare credentials missing")
|
||||
}
|
||||
}
|
||||
|
||||
c := &DNSProviderCloudFlare{
|
||||
client: cloudflare.New(&cloudflare.Options{cloudflareEmail, cloudflareKey}),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// CreateTXTRecord creates a TXT record using the specified parameters
|
||||
func (c *DNSProviderCloudFlare) CreateTXTRecord(fqdn, value string, ttl int) error {
|
||||
zoneID, err := c.getHostedZoneID(fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := newTxtRecord(zoneID, fqdn, value, ttl)
|
||||
err = c.client.Records.Create(c.ctx, record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CloudFlare API call failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTXTRecord removes the TXT record matching the specified parameters
|
||||
func (c *DNSProviderCloudFlare) RemoveTXTRecord(fqdn, value string, ttl int) error {
|
||||
records, err := c.findTxtRecords(fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
err := c.client.Records.Delete(c.ctx, rec.ZoneID, rec.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CloudFlare API call has failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DNSProviderCloudFlare) findTxtRecords(fqdn string) ([]*cloudflare.Record, error) {
|
||||
zoneID, err := c.getHostedZoneID(fqdn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var records []*cloudflare.Record
|
||||
result, err := c.client.Records.List(c.ctx, zoneID)
|
||||
if err != nil {
|
||||
return records, fmt.Errorf("CloudFlare API call has failed: %v", err)
|
||||
}
|
||||
|
||||
name := unFqdn(fqdn)
|
||||
for _, rec := range result {
|
||||
if rec.Name == name && rec.Type == "TXT" {
|
||||
records = append(records, rec)
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (c *DNSProviderCloudFlare) getHostedZoneID(fqdn string) (string, error) {
|
||||
zones, err := c.client.Zones.List(c.ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CloudFlare API call failed: %v", err)
|
||||
}
|
||||
|
||||
var hostedZone cloudflare.Zone
|
||||
for _, zone := range zones {
|
||||
name := toFqdn(zone.Name)
|
||||
if strings.HasSuffix(fqdn, name) {
|
||||
if len(zone.Name) > len(hostedZone.Name) {
|
||||
hostedZone = *zone
|
||||
}
|
||||
}
|
||||
}
|
||||
if hostedZone.ID == "" {
|
||||
return "", fmt.Errorf("No matching CloudFlare zone found for domain %s", fqdn)
|
||||
}
|
||||
|
||||
return hostedZone.ID, nil
|
||||
}
|
||||
|
||||
func newTxtRecord(zoneID, fqdn, value string, ttl int) *cloudflare.Record {
|
||||
name := unFqdn(fqdn)
|
||||
return &cloudflare.Record{
|
||||
Type: "TXT",
|
||||
Name: name,
|
||||
Content: value,
|
||||
TTL: sanitizeTTL(ttl),
|
||||
ZoneID: zoneID,
|
||||
}
|
||||
}
|
||||
|
||||
func toFqdn(name string) string {
|
||||
n := len(name)
|
||||
if n == 0 || name[n-1] == '.' {
|
||||
return name
|
||||
}
|
||||
return name + "."
|
||||
}
|
||||
|
||||
func unFqdn(name string) string {
|
||||
n := len(name)
|
||||
if n != 0 && name[n-1] == '.' {
|
||||
return name[:n-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// TTL must be between 120 and 86400 seconds
|
||||
func sanitizeTTL(ttl int) int {
|
||||
if ttl < 120 {
|
||||
ttl = 120
|
||||
} else if ttl > 86400 {
|
||||
ttl = 86400
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func envAuth() (email, apiKey string) {
|
||||
email = os.Getenv("CLOUDFLARE_EMAIL")
|
||||
apiKey = os.Getenv("CLOUDFLARE_API_KEY")
|
||||
if len(email) == 0 || len(apiKey) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
return
|
||||
}
|
83
acme/dns_challenge_cloudflare_test.go
Normal file
83
acme/dns_challenge_cloudflare_test.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var (
|
||||
cflareLiveTest bool
|
||||
cflareEmail string
|
||||
cflareAPIKey string
|
||||
cflareDomain string
|
||||
)
|
||||
|
||||
func init() {
|
||||
cflareEmail = os.Getenv("CLOUDFLARE_EMAIL")
|
||||
cflareAPIKey = os.Getenv("CLOUDFLARE_API_KEY")
|
||||
cflareDomain = os.Getenv("CLOUDFLARE_DOMAIN")
|
||||
if len(cflareEmail) > 0 && len(cflareAPIKey) > 0 && len(cflareDomain) > 0 {
|
||||
cflareLiveTest = true
|
||||
}
|
||||
}
|
||||
|
||||
func restoreCloudFlareEnv() {
|
||||
os.Setenv("CLOUDFLARE_EMAIL", cflareEmail)
|
||||
os.Setenv("CLOUDFLARE_API_KEY", cflareAPIKey)
|
||||
}
|
||||
|
||||
func TestNewDNSProviderCloudFlareValid(t *testing.T) {
|
||||
os.Setenv("CLOUDFLARE_EMAIL", "")
|
||||
os.Setenv("CLOUDFLARE_API_KEY", "")
|
||||
_, err := NewDNSProviderCloudFlare("123", "123")
|
||||
assert.NoError(t, err)
|
||||
restoreCloudFlareEnv()
|
||||
}
|
||||
|
||||
func TestNewDNSProviderCloudFlareValidEnv(t *testing.T) {
|
||||
os.Setenv("CLOUDFLARE_EMAIL", "test@example.com")
|
||||
os.Setenv("CLOUDFLARE_API_KEY", "123")
|
||||
_, err := NewDNSProviderCloudFlare("", "")
|
||||
assert.NoError(t, err)
|
||||
restoreCloudFlareEnv()
|
||||
}
|
||||
|
||||
func TestNewDNSProviderCloudFlareMissingCredErr(t *testing.T) {
|
||||
os.Setenv("CLOUDFLARE_EMAIL", "")
|
||||
os.Setenv("CLOUDFLARE_API_KEY", "")
|
||||
_, err := NewDNSProviderCloudFlare("", "")
|
||||
assert.EqualError(t, err, "CloudFlare credentials missing")
|
||||
restoreCloudFlareEnv()
|
||||
}
|
||||
|
||||
func TestCloudFlareCreateTXTRecord(t *testing.T) {
|
||||
if !cflareLiveTest {
|
||||
t.Skip("skipping live test")
|
||||
}
|
||||
|
||||
provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fqdn := fmt.Sprintf("_acme-challenge.123.%s.", cflareDomain)
|
||||
err = provider.CreateTXTRecord(fqdn, "123d==", 120)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCloudFlareRemoveTXTRecord(t *testing.T) {
|
||||
if !cflareLiveTest {
|
||||
t.Skip("skipping live test")
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 1)
|
||||
|
||||
provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fqdn := fmt.Sprintf("_acme-challenge.123.%s.", cflareDomain)
|
||||
err = provider.RemoveTXTRecord(fqdn, "123d==", 120)
|
||||
assert.NoError(t, err)
|
||||
}
|
38
acme/dns_challenge_manual.go
Normal file
38
acme/dns_challenge_manual.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
dnsTemplate = "%s %d IN TXT \"%s\""
|
||||
)
|
||||
|
||||
// DNSProviderManual is an implementation of the DNSProvider interface
|
||||
type DNSProviderManual struct{}
|
||||
|
||||
// NewDNSProviderManual returns a DNSProviderManual instance.
|
||||
func NewDNSProviderManual() (*DNSProviderManual, error) {
|
||||
return &DNSProviderManual{}, nil
|
||||
}
|
||||
|
||||
// CreateTXTRecord prints instructions for manually creating the TXT record
|
||||
func (*DNSProviderManual) CreateTXTRecord(fqdn, value string, ttl int) error {
|
||||
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value)
|
||||
logf("[INFO] acme: Please create the following TXT record in your DNS zone:")
|
||||
logf("[INFO] acme: %s", dnsRecord)
|
||||
logf("[INFO] acme: Press 'Enter' when you are done")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
_, _ = reader.ReadString('\n')
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTXTRecord prints instructions for manually removing the TXT record
|
||||
func (*DNSProviderManual) RemoveTXTRecord(fqdn, value string, ttl int) error {
|
||||
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value)
|
||||
logf("[INFO] acme: You can now remove this TXT record from your DNS zone:")
|
||||
logf("[INFO] acme: %s", dnsRecord)
|
||||
return nil
|
||||
}
|
84
acme/dns_challenge_rfc2136.go
Normal file
84
acme/dns_challenge_rfc2136.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/miekg/dns"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DNSProviderRFC2136 is an implementation of the DNSProvider interface that
|
||||
// uses dynamic DNS updates (RFC 2136) to create TXT records on a nameserver.
|
||||
type DNSProviderRFC2136 struct {
|
||||
nameserver string
|
||||
zone string
|
||||
tsigKey string
|
||||
tsigSecret string
|
||||
}
|
||||
|
||||
// NewDNSProviderRFC2136 returns a new DNSProviderRFC2136 instance.
|
||||
// To disable TSIG authentication 'tsigKey' and 'tsigSecret' must be set to the empty string.
|
||||
// 'nameserver' must be a network address in the the form "host:port". 'zone' must be the fully
|
||||
// qualified name of the zone.
|
||||
func NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret string) (*DNSProviderRFC2136, error) {
|
||||
d := &DNSProviderRFC2136{
|
||||
nameserver: nameserver,
|
||||
zone: zone,
|
||||
}
|
||||
if len(tsigKey) > 0 && len(tsigSecret) > 0 {
|
||||
d.tsigKey = tsigKey
|
||||
d.tsigSecret = tsigSecret
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// CreateTXTRecord creates a TXT record using the specified parameters
|
||||
func (r *DNSProviderRFC2136) CreateTXTRecord(fqdn, value string, ttl int) error {
|
||||
return r.changeRecord("INSERT", fqdn, value, ttl)
|
||||
}
|
||||
|
||||
// RemoveTXTRecord removes the TXT record matching the specified parameters
|
||||
func (r *DNSProviderRFC2136) RemoveTXTRecord(fqdn, value string, ttl int) error {
|
||||
return r.changeRecord("REMOVE", fqdn, value, ttl)
|
||||
}
|
||||
|
||||
func (r *DNSProviderRFC2136) changeRecord(action, fqdn, value string, ttl int) error {
|
||||
// Create RR
|
||||
rr := new(dns.TXT)
|
||||
rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}
|
||||
rr.Txt = []string{value}
|
||||
rrs := make([]dns.RR, 1)
|
||||
rrs[0] = rr
|
||||
|
||||
// Create dynamic update packet
|
||||
m := new(dns.Msg)
|
||||
m.SetUpdate(dns.Fqdn(r.zone))
|
||||
switch action {
|
||||
case "INSERT":
|
||||
m.Insert(rrs)
|
||||
case "REMOVE":
|
||||
m.Remove(rrs)
|
||||
default:
|
||||
return fmt.Errorf("Unexpected action: %s", action)
|
||||
}
|
||||
|
||||
// Setup client
|
||||
c := new(dns.Client)
|
||||
c.SingleInflight = true
|
||||
// TSIG authentication / msg signing
|
||||
if len(r.tsigKey) > 0 && len(r.tsigSecret) > 0 {
|
||||
m.SetTsig(dns.Fqdn(r.tsigKey), dns.HmacMD5, 300, time.Now().Unix())
|
||||
c.TsigSecret = map[string]string{dns.Fqdn(r.tsigKey): r.tsigSecret}
|
||||
}
|
||||
|
||||
// Send the query
|
||||
reply, _, err := c.Exchange(m, r.nameserver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS update failed: %v", err)
|
||||
}
|
||||
if reply != nil && reply.Rcode != dns.RcodeSuccess {
|
||||
return fmt.Errorf("DNS update failed. Server replied: %s", dns.RcodeToString[reply.Rcode])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
227
acme/dns_challenge_rfc2136_test.go
Normal file
227
acme/dns_challenge_rfc2136_test.go
Normal file
|
@ -0,0 +1,227 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/miekg/dns"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
rfc2136TestValue = "so6ZGir4GaZqI11h9UccBB=="
|
||||
rfc2136TestFqdn = "_acme-challenge.123456789.www.example.com."
|
||||
rfc2136TestZone = "example.com."
|
||||
rfc2136TestTTL = 120
|
||||
rfc2136TestTsigKey = "example.com."
|
||||
rfc2136TestTsigSecret = "IwBTJx9wrDp4Y1RyC3H0gA=="
|
||||
)
|
||||
|
||||
var reqChan = make(chan *dns.Msg, 10)
|
||||
|
||||
func TestRFC2136CanaryLocalTestServer(t *testing.T) {
|
||||
dns.HandleFunc("example.com.", serverHandlerHello)
|
||||
defer dns.HandleRemove("example.com.")
|
||||
|
||||
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start test server: %v", err)
|
||||
}
|
||||
defer server.Shutdown()
|
||||
|
||||
c := new(dns.Client)
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("example.com.", dns.TypeTXT)
|
||||
r, _, err := c.Exchange(m, addrstr)
|
||||
if err != nil || len(r.Extra) == 0 {
|
||||
t.Fatalf("Failed to communicate with test server:", err)
|
||||
}
|
||||
txt := r.Extra[0].(*dns.TXT).Txt[0]
|
||||
if txt != "Hello world" {
|
||||
t.Error("Expected test server to return 'Hello world' but got: ", txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRFC2136ServerSuccess(t *testing.T) {
|
||||
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
|
||||
defer dns.HandleRemove(rfc2136TestZone)
|
||||
|
||||
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start test server: %v", err)
|
||||
}
|
||||
defer server.Shutdown()
|
||||
|
||||
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
|
||||
}
|
||||
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil {
|
||||
t.Errorf("Expected CreateTXTRecord() to return no error but the error was -> %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRFC2136ServerError(t *testing.T) {
|
||||
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnErr)
|
||||
defer dns.HandleRemove(rfc2136TestZone)
|
||||
|
||||
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start test server: %v", err)
|
||||
}
|
||||
defer server.Shutdown()
|
||||
|
||||
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
|
||||
}
|
||||
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err == nil {
|
||||
t.Errorf("Expected CreateTXTRecord() to return an error but it did not.")
|
||||
} else if !strings.Contains(err.Error(), "NOTZONE") {
|
||||
t.Errorf("Expected CreateTXTRecord() to return an error with the 'NOTZONE' rcode string but it did not.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRFC2136TsigClient(t *testing.T) {
|
||||
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
|
||||
defer dns.HandleRemove(rfc2136TestZone)
|
||||
|
||||
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start test server: %v", err)
|
||||
}
|
||||
defer server.Shutdown()
|
||||
|
||||
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, rfc2136TestTsigKey, rfc2136TestTsigSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
|
||||
}
|
||||
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil {
|
||||
t.Errorf("Expected CreateTXTRecord() to return no error but the error was -> %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRFC2136ValidUpdatePacket(t *testing.T) {
|
||||
dns.HandleFunc(rfc2136TestZone, serverHandlerPassBackRequest)
|
||||
defer dns.HandleRemove(rfc2136TestZone)
|
||||
|
||||
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start test server: %v", err)
|
||||
}
|
||||
defer server.Shutdown()
|
||||
|
||||
rr := new(dns.TXT)
|
||||
rr.Hdr = dns.RR_Header{
|
||||
Name: rfc2136TestFqdn,
|
||||
Rrtype: dns.TypeTXT,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: uint32(rfc2136TestTTL),
|
||||
}
|
||||
rr.Txt = []string{rfc2136TestValue}
|
||||
rrs := make([]dns.RR, 1)
|
||||
rrs[0] = rr
|
||||
m := new(dns.Msg)
|
||||
m.SetUpdate(dns.Fqdn(rfc2136TestZone))
|
||||
m.Insert(rrs)
|
||||
expectstr := m.String()
|
||||
expect, err := m.Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("Error packing expect msg: %v", err)
|
||||
}
|
||||
|
||||
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
|
||||
}
|
||||
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil {
|
||||
t.Errorf("Expected CreateTXTRecord() to return no error but the error was -> %v", err)
|
||||
}
|
||||
|
||||
rcvMsg := <-reqChan
|
||||
rcvMsg.Id = m.Id
|
||||
actual, err := rcvMsg.Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("Error packing actual msg: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(actual, expect) {
|
||||
tmp := new(dns.Msg)
|
||||
if err := tmp.Unpack(actual); err != nil {
|
||||
t.Fatalf("Error unpacking actual msg: %v", err)
|
||||
}
|
||||
t.Errorf("Expected msg:\n%s", expectstr)
|
||||
t.Errorf("Actual msg:\n%v", tmp)
|
||||
}
|
||||
}
|
||||
|
||||
func runLocalDNSTestServer(listenAddr string, tsig bool) (*dns.Server, string, error) {
|
||||
pc, err := net.ListenPacket("udp", listenAddr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
server := &dns.Server{PacketConn: pc, ReadTimeout: time.Hour, WriteTimeout: time.Hour}
|
||||
if tsig {
|
||||
server.TsigSecret = map[string]string{rfc2136TestTsigKey: rfc2136TestTsigSecret}
|
||||
}
|
||||
|
||||
waitLock := sync.Mutex{}
|
||||
waitLock.Lock()
|
||||
server.NotifyStartedFunc = waitLock.Unlock
|
||||
|
||||
go func() {
|
||||
server.ActivateAndServe()
|
||||
pc.Close()
|
||||
}()
|
||||
|
||||
waitLock.Lock()
|
||||
return server, pc.LocalAddr().String(), nil
|
||||
}
|
||||
|
||||
func serverHandlerHello(w dns.ResponseWriter, req *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
m.Extra = make([]dns.RR, 1)
|
||||
m.Extra[0] = &dns.TXT{
|
||||
Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0},
|
||||
Txt: []string{"Hello world"},
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
|
||||
if t := req.IsTsig(); t != nil {
|
||||
if w.TsigStatus() == nil {
|
||||
// Validated
|
||||
m.SetTsig(rfc2136TestZone, dns.HmacMD5, 300, time.Now().Unix())
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func serverHandlerReturnErr(w dns.ResponseWriter, req *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetRcode(req, dns.RcodeNotZone)
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
|
||||
if t := req.IsTsig(); t != nil {
|
||||
if w.TsigStatus() == nil {
|
||||
// Validated
|
||||
m.SetTsig(rfc2136TestZone, dns.HmacMD5, 300, time.Now().Unix())
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteMsg(m)
|
||||
reqChan <- req
|
||||
}
|
93
acme/dns_challenge_route53.go
Normal file
93
acme/dns_challenge_route53.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/mitchellh/goamz/aws"
|
||||
"github.com/mitchellh/goamz/route53"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DNSProviderRoute53 is an implementation of the DNSProvider interface
|
||||
type DNSProviderRoute53 struct {
|
||||
client *route53.Route53
|
||||
}
|
||||
|
||||
// NewDNSProviderRoute53 returns a DNSProviderRoute53 instance with a configured route53 client.
|
||||
// Authentication is either done using the passed credentials or - when empty -
|
||||
// using the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
|
||||
func NewDNSProviderRoute53(awsAccessKey, awsSecretKey, awsRegionName string) (*DNSProviderRoute53, error) {
|
||||
region, ok := aws.Regions[awsRegionName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Invalid AWS region name %s", awsRegionName)
|
||||
}
|
||||
|
||||
var auth aws.Auth
|
||||
// First try passed in credentials
|
||||
if awsAccessKey != "" && awsSecretKey != "" {
|
||||
auth = aws.Auth{awsAccessKey, awsSecretKey, ""}
|
||||
} else {
|
||||
// try getting credentials from environment
|
||||
envAuth, err := aws.EnvAuth()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS credentials missing")
|
||||
}
|
||||
auth = envAuth
|
||||
}
|
||||
|
||||
client := route53.New(auth, region)
|
||||
return &DNSProviderRoute53{client: client}, nil
|
||||
}
|
||||
|
||||
// CreateTXTRecord creates a TXT record using the specified parameters
|
||||
func (r *DNSProviderRoute53) CreateTXTRecord(fqdn, value string, ttl int) error {
|
||||
return r.changeRecord("UPSERT", fqdn, value, ttl)
|
||||
}
|
||||
|
||||
// RemoveTXTRecord removes the TXT record matching the specified parameters
|
||||
func (r *DNSProviderRoute53) RemoveTXTRecord(fqdn, value string, ttl int) error {
|
||||
return r.changeRecord("DELETE", fqdn, value, ttl)
|
||||
}
|
||||
|
||||
func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) error {
|
||||
hostedZoneID, err := r.getHostedZoneID(fqdn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordSet := newTXTRecordSet(fqdn, value, ttl)
|
||||
update := route53.Change{action, recordSet}
|
||||
changes := []route53.Change{update}
|
||||
req := route53.ChangeResourceRecordSetsRequest{Comment: "Created by Lego", Changes: changes}
|
||||
_, err = r.client.ChangeResourceRecordSets(hostedZoneID, &req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *DNSProviderRoute53) getHostedZoneID(fqdn string) (string, error) {
|
||||
zoneResp, err := r.client.ListHostedZones("", math.MaxInt64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var hostedZone route53.HostedZone
|
||||
for _, zone := range zoneResp.HostedZones {
|
||||
//if strings.HasSuffix(domain, strings.Trim(zone.Name, ".")) {
|
||||
if strings.HasSuffix(fqdn, zone.Name) {
|
||||
if len(zone.Name) > len(hostedZone.Name) {
|
||||
hostedZone = zone
|
||||
}
|
||||
}
|
||||
}
|
||||
if hostedZone.ID == "" {
|
||||
return "", fmt.Errorf("No Route53 zone found for domain %s", fqdn)
|
||||
}
|
||||
|
||||
return hostedZone.ID, nil
|
||||
}
|
||||
|
||||
func newTXTRecordSet(fqdn, value string, ttl int) route53.ResourceRecordSet {
|
||||
return route53.ResourceRecordSet{
|
||||
Name: fqdn,
|
||||
Type: "TXT",
|
||||
Records: []string{value},
|
||||
TTL: ttl,
|
||||
}
|
||||
}
|
126
acme/dns_challenge_route53_test.go
Normal file
126
acme/dns_challenge_route53_test.go
Normal file
|
@ -0,0 +1,126 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mitchellh/goamz/aws"
|
||||
"github.com/mitchellh/goamz/route53"
|
||||
"github.com/mitchellh/goamz/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var (
|
||||
route53Secret string
|
||||
route53Key string
|
||||
testServer *testutil.HTTPServer
|
||||
)
|
||||
|
||||
var ChangeResourceRecordSetsAnswer = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ChangeResourceRecordSetsResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
||||
<ChangeInfo>
|
||||
<Id>/change/asdf</Id>
|
||||
<Status>PENDING</Status>
|
||||
<SubmittedAt>2014</SubmittedAt>
|
||||
</ChangeInfo>
|
||||
</ChangeResourceRecordSetsResponse>`
|
||||
|
||||
var ListHostedZonesAnswer = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ListHostedZonesResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
||||
<HostedZones>
|
||||
<HostedZone>
|
||||
<Id>/hostedzone/Z2K123214213123</Id>
|
||||
<Name>example.com.</Name>
|
||||
<CallerReference>D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A</CallerReference>
|
||||
<Config>
|
||||
<Comment>Test comment</Comment>
|
||||
</Config>
|
||||
<ResourceRecordSetCount>10</ResourceRecordSetCount>
|
||||
</HostedZone>
|
||||
<HostedZone>
|
||||
<Id>/hostedzone/ZLT12321321124</Id>
|
||||
<Name>sub.example.com.</Name>
|
||||
<CallerReference>A970F076-FCB1-D959-B395-96474CC84EB8</CallerReference>
|
||||
<Config>
|
||||
<Comment>Test comment for subdomain host</Comment>
|
||||
</Config>
|
||||
<ResourceRecordSetCount>4</ResourceRecordSetCount>
|
||||
</HostedZone>
|
||||
</HostedZones>
|
||||
<IsTruncated>false</IsTruncated>
|
||||
<MaxItems>100</MaxItems>
|
||||
</ListHostedZonesResponse>`
|
||||
|
||||
var serverResponseMap = testutil.ResponseMap{
|
||||
"/2013-04-01/hostedzone/": testutil.Response{200, nil, ListHostedZonesAnswer},
|
||||
"/2013-04-01/hostedzone/Z2K123214213123/rrset": testutil.Response{200, nil, ChangeResourceRecordSetsAnswer},
|
||||
}
|
||||
|
||||
func init() {
|
||||
route53Key = os.Getenv("AWS_ACCESS_KEY_ID")
|
||||
route53Secret = os.Getenv("AWS_SECRET_ACCESS_KEY")
|
||||
testServer = testutil.NewHTTPServer()
|
||||
testServer.Start()
|
||||
}
|
||||
|
||||
func restoreRoute53Env() {
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", route53Key)
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", route53Secret)
|
||||
}
|
||||
|
||||
func makeRoute53TestServer() *testutil.HTTPServer {
|
||||
testServer.Flush()
|
||||
return testServer
|
||||
}
|
||||
|
||||
func makeRoute53Provider(server *testutil.HTTPServer) *DNSProviderRoute53 {
|
||||
auth := aws.Auth{"abc", "123", ""}
|
||||
client := route53.NewWithClient(auth, aws.Region{Route53Endpoint: server.URL}, testutil.DefaultClient)
|
||||
return &DNSProviderRoute53{client: client}
|
||||
}
|
||||
|
||||
func TestNewDNSProviderRoute53Valid(t *testing.T) {
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", "")
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "")
|
||||
_, err := NewDNSProviderRoute53("123", "123", "us-east-1")
|
||||
assert.NoError(t, err)
|
||||
restoreRoute53Env()
|
||||
}
|
||||
|
||||
func TestNewDNSProviderRoute53ValidEnv(t *testing.T) {
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", "123")
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "123")
|
||||
_, err := NewDNSProviderRoute53("", "", "us-east-1")
|
||||
assert.NoError(t, err)
|
||||
restoreRoute53Env()
|
||||
}
|
||||
|
||||
func TestNewDNSProviderRoute53MissingAuthErr(t *testing.T) {
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", "")
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "")
|
||||
_, err := NewDNSProviderRoute53("", "", "us-east-1")
|
||||
assert.EqualError(t, err, "AWS credentials missing")
|
||||
restoreRoute53Env()
|
||||
}
|
||||
|
||||
func TestNewDNSProviderRoute53InvalidRegionErr(t *testing.T) {
|
||||
_, err := NewDNSProviderRoute53("123", "123", "us-east-3")
|
||||
assert.EqualError(t, err, "Invalid AWS region name us-east-3")
|
||||
}
|
||||
|
||||
func TestRoute53CreateTXTRecord(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
testServer := makeRoute53TestServer()
|
||||
provider := makeRoute53Provider(testServer)
|
||||
testServer.ResponseMap(2, serverResponseMap)
|
||||
|
||||
err := provider.CreateTXTRecord("_acme-challenge.123.example.com.", "123456d==", 120)
|
||||
assert.NoError(err, "Expected CreateTXTRecord to return no error")
|
||||
|
||||
httpReqs := testServer.WaitRequests(2)
|
||||
httpReq := httpReqs[1]
|
||||
|
||||
assert.Equal("/2013-04-01/hostedzone/Z2K123214213123/rrset", httpReq.URL.Path,
|
||||
"Expected CreateTXTRecord to select the correct hostedzone")
|
||||
|
||||
}
|
36
acme/dns_challenge_test.go
Normal file
36
acme/dns_challenge_test.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rsa"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDNSValidServerResponse(t *testing.T) {
|
||||
privKey, _ := generatePrivateKey(rsakey, 512)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Replay-Nonce", "12345")
|
||||
w.Write([]byte("{\"type\":\"dns01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}"))
|
||||
}))
|
||||
|
||||
manualProvider, _ := NewDNSProviderManual()
|
||||
jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL}
|
||||
solver := &dnsChallenge{jws: jws, provider: manualProvider}
|
||||
clientChallenge := challenge{Type: "dns01", Status: "pending", URI: ts.URL, Token: "http8"}
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Second * 2)
|
||||
f := bufio.NewWriter(os.Stdout)
|
||||
defer f.Flush()
|
||||
f.WriteString("\n")
|
||||
}()
|
||||
|
||||
if err := solver.Solve(clientChallenge, "example.com"); err != nil {
|
||||
t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue