Add ACME DNS-01 provider for Akamai FastDNS
This commit is contained in:
parent
07cfb982aa
commit
f681f5a6b1
@ -142,6 +142,22 @@ cloudflare:
|
||||
key: api-key
|
||||
```
|
||||
|
||||
##### Akamai FastDNS
|
||||
|
||||
```yaml
|
||||
akamai:
|
||||
serviceConsumerDomain: akab-tho6xie2aiteip8p-poith5aej0ughaba.luna.akamaiapis.net
|
||||
clientTokenSecretRef:
|
||||
name: akamai-dns
|
||||
key: clientToken
|
||||
clientSecretSecretRef:
|
||||
name: akamai-dns
|
||||
key: clientSecret
|
||||
accessTokenSecretRef:
|
||||
name: akamai-dns
|
||||
key: accessToken
|
||||
```
|
||||
|
||||
## CA Configuration
|
||||
|
||||
CA Issuers issue certificates signed from a X509 signing keypair, stored in a
|
||||
|
||||
@ -111,12 +111,22 @@ type ACMEIssuerDNS01Config struct {
|
||||
type ACMEIssuerDNS01Provider struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
Akamai *ACMEIssuerDNS01ProviderAkamai `json:"akamai,omitempty"`
|
||||
CloudDNS *ACMEIssuerDNS01ProviderCloudDNS `json:"clouddns,omitempty"`
|
||||
Cloudflare *ACMEIssuerDNS01ProviderCloudflare `json:"cloudflare,omitempty"`
|
||||
Route53 *ACMEIssuerDNS01ProviderRoute53 `json:"route53,omitempty"`
|
||||
AzureDNS *ACMEIssuerDNS01ProviderAzureDNS `json:"azuredns,omitempty"`
|
||||
}
|
||||
|
||||
// ACMEIssuerDNS01ProviderAkamai is a structure containing the DNS
|
||||
// configuration for Akamai DNS—Zone Record Management API
|
||||
type ACMEIssuerDNS01ProviderAkamai struct {
|
||||
ServiceConsumerDomain string `json:"serviceConsumerDomain"`
|
||||
ClientToken SecretKeySelector `json:"clientTokenSecretRef"`
|
||||
ClientSecret SecretKeySelector `json:"clientSecretSecretRef"`
|
||||
AccessToken SecretKeySelector `json:"accessTokenSecretRef"`
|
||||
}
|
||||
|
||||
// ACMEIssuerDNS01ProviderCloudDNS is a structure containing the DNS
|
||||
// configuration for Google Cloud DNS
|
||||
type ACMEIssuerDNS01ProviderCloudDNS struct {
|
||||
|
||||
@ -204,6 +204,15 @@ func (in *ACMEIssuerDNS01Config) DeepCopy() *ACMEIssuerDNS01Config {
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ACMEIssuerDNS01Provider) DeepCopyInto(out *ACMEIssuerDNS01Provider) {
|
||||
*out = *in
|
||||
if in.Akamai != nil {
|
||||
in, out := &in.Akamai, &out.Akamai
|
||||
if *in == nil {
|
||||
*out = nil
|
||||
} else {
|
||||
*out = new(ACMEIssuerDNS01ProviderAkamai)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
if in.CloudDNS != nil {
|
||||
in, out := &in.CloudDNS, &out.CloudDNS
|
||||
if *in == nil {
|
||||
@ -253,6 +262,25 @@ func (in *ACMEIssuerDNS01Provider) DeepCopy() *ACMEIssuerDNS01Provider {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ACMEIssuerDNS01ProviderAkamai) DeepCopyInto(out *ACMEIssuerDNS01ProviderAkamai) {
|
||||
*out = *in
|
||||
out.ClientToken = in.ClientToken
|
||||
out.ClientSecret = in.ClientSecret
|
||||
out.AccessToken = in.AccessToken
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ACMEIssuerDNS01ProviderAkamai.
|
||||
func (in *ACMEIssuerDNS01ProviderAkamai) DeepCopy() *ACMEIssuerDNS01ProviderAkamai {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(ACMEIssuerDNS01ProviderAkamai)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ACMEIssuerDNS01ProviderAzureDNS) DeepCopyInto(out *ACMEIssuerDNS01ProviderAzureDNS) {
|
||||
*out = *in
|
||||
|
||||
272
pkg/issuer/acme/dns/akamai/akamai.go
Normal file
272
pkg/issuer/acme/dns/akamai/akamai.go
Normal file
@ -0,0 +1,272 @@
|
||||
// Package akamai implements a DNS provider for solving the DNS-01
|
||||
// challenge using Akamai FastDNS.
|
||||
// See https://developer.akamai.com/api/luna/config-dns/overview.html
|
||||
package akamai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
|
||||
"github.com/jetstack/cert-manager/pkg/issuer/acme/dns/util"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// DNSProvider is an implementation of the acme.ChallengeProvider interface
|
||||
type DNSProvider struct {
|
||||
// serviceConsumerDomain as issued by Akamai Luna Control Center.
|
||||
// The ServiceConsumerDomain is the base URL.
|
||||
serviceConsumerDomain string
|
||||
|
||||
auth *EdgeGridAuth
|
||||
|
||||
transport http.RoundTripper
|
||||
findHostedDomainByFqdn func(string) (string, error)
|
||||
}
|
||||
|
||||
// NewDNSProvider returns a DNSProvider instance configured for Akamai.
|
||||
func NewDNSProvider(serviceConsumerDomain, clientToken, clientSecret, accessToken string) (*DNSProvider, error) {
|
||||
return &DNSProvider{
|
||||
serviceConsumerDomain,
|
||||
NewEdgeGridAuth(clientToken, clientSecret, accessToken),
|
||||
http.DefaultTransport,
|
||||
findHostedDomainByFqdn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func findHostedDomainByFqdn(fqdn string) (string, error) {
|
||||
zone, err := util.FindZoneByFqdn(fqdn, util.RecursiveNameservers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return util.UnFqdn(zone), nil
|
||||
}
|
||||
|
||||
// Timeout returns the timeout and interval to use when checking for DNS
|
||||
// propagation. Adjusting here to cope with spikes in propagation times.
|
||||
func (a *DNSProvider) Timeout() (timeout, interval time.Duration) {
|
||||
return 5 * time.Minute, 5 * time.Second
|
||||
}
|
||||
|
||||
// Present creates a TXT record to fulfil the dns-01 challenge
|
||||
func (a *DNSProvider) Present(domain, token, keyAuth string) error {
|
||||
fqdn, value, ttl := util.DNS01Record(domain, keyAuth)
|
||||
return a.setTxtRecord(fqdn, &dns01Record{value, ttl})
|
||||
}
|
||||
|
||||
// CleanUp removes the TXT record matching the specified parameters
|
||||
func (a *DNSProvider) CleanUp(domain, token, keyAuth string) error {
|
||||
fqdn, _, _ := util.DNS01Record(domain, keyAuth)
|
||||
return a.setTxtRecord(fqdn, nil)
|
||||
}
|
||||
|
||||
type dns01Record struct {
|
||||
value string
|
||||
ttl int
|
||||
}
|
||||
|
||||
func (a *DNSProvider) setTxtRecord(fqdn string, dns01Record *dns01Record) error {
|
||||
hostedDomain, err := a.findHostedDomainByFqdn(fqdn)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to determine hosted domain for %q", fqdn)
|
||||
}
|
||||
|
||||
zoneData, err := a.loadZoneData(hostedDomain)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to load zone data for %q", hostedDomain)
|
||||
}
|
||||
|
||||
recordName, err := makeTxtRecordName(fqdn, hostedDomain)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to create TXT record name")
|
||||
}
|
||||
|
||||
if updated, err := zoneData.setTxtRecord(recordName, dns01Record); !updated || err != nil {
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to set TXT record in %q", hostedDomain)
|
||||
}
|
||||
|
||||
return errors.Errorf("no %q TXT record found in %q", recordName, hostedDomain)
|
||||
}
|
||||
|
||||
newSerial, err := zoneData.incSoaSerial()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to increment SOA serial for %q", hostedDomain)
|
||||
}
|
||||
|
||||
if err := a.saveZoneData(hostedDomain, zoneData); err != nil {
|
||||
return errors.Wrapf(err, "failed to save zone data for %q", hostedDomain)
|
||||
}
|
||||
|
||||
glog.V(4).Infof("Updated Akamai TXT record for %q on %q using SOA serial of %d", recordName, hostedDomain, newSerial)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeTxtRecordName(fqdn, hostedDomain string) (string, error) {
|
||||
if !strings.HasSuffix(fqdn, "."+hostedDomain+".") {
|
||||
return "", errors.Errorf("fqdn %q is not part of %q", fqdn, hostedDomain)
|
||||
}
|
||||
|
||||
return fqdn[0 : len(fqdn)-len(hostedDomain)-2], nil
|
||||
}
|
||||
|
||||
func (a *DNSProvider) urlForDomain(domain string) string {
|
||||
return fmt.Sprintf("https://%s/config-dns/v1/zones/%s", a.serviceConsumerDomain, domain)
|
||||
}
|
||||
|
||||
func (a *DNSProvider) loadZoneData(domain string) (zoneData, error) {
|
||||
url := a.urlForDomain(domain)
|
||||
req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create HTTP request")
|
||||
}
|
||||
|
||||
responsePayload, err := a.makeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var zoneData map[string]interface{}
|
||||
err = json.NewDecoder(bytes.NewReader(responsePayload)).Decode(&zoneData)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to decode Akamai OPEN API response")
|
||||
}
|
||||
|
||||
return zoneData, nil
|
||||
}
|
||||
|
||||
func (a *DNSProvider) saveZoneData(domain string, data zoneData) error {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to encode zone data")
|
||||
}
|
||||
|
||||
url := a.urlForDomain(domain)
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create HTTP request")
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if _, err := a.makeRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *DNSProvider) makeRequest(req *http.Request) ([]byte, error) {
|
||||
if err := a.auth.SignRequest(req); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to sign HTTP request")
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Transport: a.transport,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error querying Akamai OPEN API")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Akamai OPEN API returned %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
responsePayload, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read response payload")
|
||||
}
|
||||
|
||||
return responsePayload, nil
|
||||
}
|
||||
|
||||
type zoneData map[string]interface{}
|
||||
|
||||
func (z zoneData) setTxtRecord(name string, dns01Record *dns01Record) (bool, error) {
|
||||
zone, ok := z["zone"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false, errors.New("failed to retrieve zone from zone data")
|
||||
}
|
||||
|
||||
var txtRecords []interface{}
|
||||
if txtNode, ok := zone["txt"]; ok {
|
||||
if txtRecords, ok = txtNode.([]interface{}); !ok {
|
||||
return false, errors.New("failed to retrieve TXT records from zone data")
|
||||
}
|
||||
}
|
||||
|
||||
if dns01Record == nil {
|
||||
if txtRecords = deleteRecord(txtRecords, name); txtRecords == nil {
|
||||
return false, nil
|
||||
}
|
||||
} else {
|
||||
txtRecords = updateRecord(txtRecords, name, map[string]interface{}{
|
||||
"name": name,
|
||||
"ttl": dns01Record.ttl,
|
||||
"active": true,
|
||||
"target": dns01Record.value,
|
||||
})
|
||||
}
|
||||
|
||||
if len(txtRecords) < 1 {
|
||||
delete(zone, "txt")
|
||||
} else {
|
||||
zone["txt"] = txtRecords
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (z zoneData) incSoaSerial() (uint64, error) {
|
||||
soa, ok := z["zone"].(map[string]interface{})["soa"].(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, errors.New("failed to retrieve SOA record from zone data")
|
||||
}
|
||||
|
||||
serial, ok := soa["serial"].(float64)
|
||||
if !ok {
|
||||
return 0, errors.New("failed to retrieve SOA serial from zone data")
|
||||
}
|
||||
|
||||
newSerial := uint64(serial) + 1
|
||||
soa["serial"] = newSerial
|
||||
return newSerial, nil
|
||||
}
|
||||
|
||||
func deleteRecord(records []interface{}, name string) []interface{} {
|
||||
for pos := range records {
|
||||
if recordName, ok := records[pos].(map[string]interface{})["name"]; ok && recordName == name {
|
||||
return append(records[:pos], records[pos+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateRecord(records []interface{}, name string, record map[string]interface{}) []interface{} {
|
||||
for pos := range records {
|
||||
if records[pos].(map[string]interface{})["name"] == name {
|
||||
records[pos] = record
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
||||
return append(records, record)
|
||||
}
|
||||
163
pkg/issuer/acme/dns/akamai/akamai_test.go
Normal file
163
pkg/issuer/acme/dns/akamai/akamai_test.go
Normal file
@ -0,0 +1,163 @@
|
||||
package akamai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const sampleZoneData = `{
|
||||
"token": "a184671d5307a388180fbf7f11dbdf46",
|
||||
"zone": {
|
||||
"name": "example.com",
|
||||
"soa": {
|
||||
"contact": "hostmaster.akamai.com.",
|
||||
"expire": 604800,
|
||||
"minimum": 180,
|
||||
"originserver": "use4.akamai.com.",
|
||||
"refresh": 900,
|
||||
"retry": 300,
|
||||
"serial": 1271354824,
|
||||
"ttl": 900
|
||||
},
|
||||
"ns": [
|
||||
{
|
||||
"active": true,
|
||||
"name": "",
|
||||
"target": "use4.akam.net.",
|
||||
"ttl": 3600
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"name": "",
|
||||
"target": "use3.akam.net.",
|
||||
"ttl": 3600
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
const sampleZoneDataWithTxt = `{
|
||||
"token": "a184671d5307a388180fbf7f11dbdf46",
|
||||
"zone": {
|
||||
"name": "example.com",
|
||||
"soa": {
|
||||
"contact": "hostmaster.akamai.com.",
|
||||
"expire": 604800,
|
||||
"minimum": 180,
|
||||
"originserver": "use4.akamai.com.",
|
||||
"refresh": 900,
|
||||
"retry": 300,
|
||||
"serial": 1271354825,
|
||||
"ttl": 900
|
||||
},
|
||||
"ns": [
|
||||
{
|
||||
"active": true,
|
||||
"name": "",
|
||||
"target": "use4.akam.net.",
|
||||
"ttl": 3600
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"name": "",
|
||||
"target": "use3.akam.net.",
|
||||
"ttl": 3600
|
||||
}
|
||||
],
|
||||
"txt": [
|
||||
{
|
||||
"active": true,
|
||||
"name" :"_acme-challenge.test",
|
||||
"target": "dns01-key",
|
||||
"ttl": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
type httpResponder func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (r httpResponder) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return r(req)
|
||||
}
|
||||
|
||||
func TestPresent(t *testing.T) {
|
||||
akamai, err := NewDNSProvider("akamai.example.com", "token", "secret", "access-token")
|
||||
assert.NoError(t, err)
|
||||
|
||||
var response []byte
|
||||
mockTransport(t, akamai, "example.com", sampleZoneData, &response)
|
||||
|
||||
assert.NoError(t, akamai.Present("test.example.com", "dns01-token", "dns01-key"))
|
||||
|
||||
var expected, actual map[string]interface{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(sampleZoneDataWithTxt), &expected))
|
||||
assert.NoError(t, json.Unmarshal(response, &actual))
|
||||
assert.EqualValues(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestCleanUp(t *testing.T) {
|
||||
akamai, err := NewDNSProvider("akamai.example.com", "token", "secret", "access-token")
|
||||
assert.NoError(t, err)
|
||||
|
||||
var response []byte
|
||||
mockTransport(t, akamai, "example.com", sampleZoneDataWithTxt, &response)
|
||||
|
||||
assert.NoError(t, akamai.CleanUp("test.example.com", "dns01-token", "dns01-key"))
|
||||
|
||||
var expected, actual map[string]interface{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(sampleZoneData), &expected))
|
||||
expected["zone"].(map[string]interface{})["soa"].(map[string]interface{})["serial"] = 1271354826.
|
||||
assert.NoError(t, json.Unmarshal(response, &actual))
|
||||
assert.EqualValues(t, expected, actual)
|
||||
}
|
||||
|
||||
func mockTransport(t *testing.T, akamai *DNSProvider, domain, data string, response *[]byte) {
|
||||
akamai.transport = httpResponder(func(req *http.Request) (*http.Response, error) {
|
||||
defer req.Body.Close()
|
||||
|
||||
if req.URL.String() != "https://akamai.example.com/config-dns/v1/zones/"+domain {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if req.Method == http.MethodGet {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte(data))),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if req.Method == http.MethodPost {
|
||||
if req.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("usupported Content Type: %v", req.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
var err error
|
||||
*response, err = ioutil.ReadAll(req.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Fatalf("unexpected method: %v", req.Method)
|
||||
return nil, nil
|
||||
})
|
||||
akamai.findHostedDomainByFqdn = func(fqdn string) (string, error) {
|
||||
if !strings.HasSuffix(fqdn, domain+".") {
|
||||
t.Fatalf("unexpected fqdn: %s", fqdn)
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
}
|
||||
264
pkg/issuer/acme/dns/akamai/edgegridauth.go
Normal file
264
pkg/issuer/acme/dns/akamai/edgegridauth.go
Normal file
@ -0,0 +1,264 @@
|
||||
package akamai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EdgeGridAuth holds all values required to perform Akamai API Client Authentication.
|
||||
// See https://developer.akamai.com/introduction/Client_Auth.html.
|
||||
type EdgeGridAuth struct {
|
||||
ClientToken string
|
||||
ClientSecret string
|
||||
AccessToken string
|
||||
HeadersToSign []string
|
||||
MaxBody int
|
||||
|
||||
now func() time.Time
|
||||
createNonce func() (string, error)
|
||||
}
|
||||
|
||||
type signingData struct {
|
||||
timestamp string
|
||||
authHeader string
|
||||
dataToSign string
|
||||
}
|
||||
|
||||
// edgeGridAuthTimeFormat is used for timestamps in request signatures.
|
||||
const edgeGridAuthTimeFormat = "20060102T15:04:05-0700" // yyyyMMddTHH:mm:ss+0000
|
||||
|
||||
const NoMaxBody = -1
|
||||
|
||||
// NewEdgeGridAuth returns a new request signer for Akamai EdgeGrid
|
||||
func NewEdgeGridAuth(clientToken, clientSecret, accessToken string, headersToSign ...string) *EdgeGridAuth {
|
||||
return &EdgeGridAuth{
|
||||
ClientToken: clientToken,
|
||||
ClientSecret: clientSecret,
|
||||
AccessToken: accessToken,
|
||||
HeadersToSign: headersToSign,
|
||||
MaxBody: NoMaxBody,
|
||||
|
||||
now: time.Now,
|
||||
createNonce: createRandomNonce,
|
||||
}
|
||||
}
|
||||
|
||||
// SignRequest calculates the signature for Akamai Open API and adds it as the Authorization header.
|
||||
// The Authorization header starts with the signing algorithm moniker (name of the algorithm) used to sign the request.
|
||||
// The moniker below identifies EdgeGrid V1, hash message authentication code, SHA–256 as the hash standard.
|
||||
// This moniker is then followed by a space and an ordered list of name value pairs with each field separated by a semicolon.
|
||||
func (e *EdgeGridAuth) SignRequest(req *http.Request) error {
|
||||
signingData, err := e.signingData(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf(
|
||||
"%ssignature=%s",
|
||||
signingData.authHeader,
|
||||
e.calculateRequestSignature(signingData)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EdgeGridAuth) calculateRequestSignature(signingData *signingData) string {
|
||||
return computeSignature(
|
||||
signingData.dataToSign,
|
||||
e.signingKey(signingData.timestamp))
|
||||
}
|
||||
|
||||
func (e *EdgeGridAuth) signingData(req *http.Request) (*signingData, error) {
|
||||
nonce, err := e.createNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timestamp := e.now().UTC().Format(edgeGridAuthTimeFormat)
|
||||
authHeader := fmt.Sprintf("EG1-HMAC-SHA256 client_token=%s;access_token=%s;timestamp=%s;nonce=%s;",
|
||||
e.ClientToken,
|
||||
e.AccessToken,
|
||||
timestamp,
|
||||
nonce)
|
||||
|
||||
return &signingData{
|
||||
timestamp: timestamp,
|
||||
authHeader: authHeader,
|
||||
dataToSign: e.dataToSign(req, authHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// dataToSign includes the information from the HTTP request that is relevant to ensuring that the request is authentic.
|
||||
// This data set comprised of the request data combined with the authorization header value (excluding the signature field,
|
||||
// but including the ; right before the signature field).
|
||||
func (e *EdgeGridAuth) dataToSign(req *http.Request, authHeader string) string {
|
||||
var buffer bytes.Buffer
|
||||
|
||||
buffer.WriteString(req.Method)
|
||||
buffer.WriteRune('\t')
|
||||
buffer.WriteString(req.URL.Scheme)
|
||||
buffer.WriteRune('\t')
|
||||
buffer.WriteString(req.URL.Host)
|
||||
buffer.WriteRune('\t')
|
||||
buffer.WriteString(relativeURL(req.URL))
|
||||
buffer.WriteRune('\t')
|
||||
buffer.WriteString(e.canonicalizedHeaders(req))
|
||||
buffer.WriteRune('\t')
|
||||
buffer.WriteString(e.computeBodyHash(req))
|
||||
buffer.WriteRune('\t')
|
||||
buffer.WriteString(authHeader)
|
||||
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
// signingKey is derived from the client secret.
|
||||
// The signing key is computed as the base64 encoding of the SHA–256 HMAC of the timestamp string
|
||||
// (the field value included in the HTTP authorization header described above) with the client secret as the key.
|
||||
func (e *EdgeGridAuth) signingKey(timestamp string) string {
|
||||
return computeSignature(timestamp, e.ClientSecret)
|
||||
}
|
||||
|
||||
// realtiveURL is the part of the URL that starts from the root path and includes the query string, with the handling of following special cases:
|
||||
// If the path is null or empty, set it to / (forward-slash).
|
||||
// If the path does not start with /, add / to the beginning.
|
||||
func relativeURL(url *url.URL) string {
|
||||
relativeURL := url.Path
|
||||
if relativeURL == "" {
|
||||
return "/"
|
||||
}
|
||||
|
||||
if relativeURL[0] != '/' {
|
||||
relativeURL = "/" + relativeURL
|
||||
}
|
||||
|
||||
if url.RawQuery != "" {
|
||||
relativeURL += "?"
|
||||
relativeURL += url.RawQuery
|
||||
}
|
||||
|
||||
return relativeURL
|
||||
}
|
||||
|
||||
// computeBodyHash returns the base64-encoded SHA–256 hash of the POST body.
|
||||
// For any other request methods, this field is empty. But the tac separator (\t) must be included.
|
||||
// The size of the POST body must be less than or equal to the value specified by the service.
|
||||
// Any request that does not meet this criteria SHOULD be rejected during the signing process,
|
||||
// as the request will be rejected by EdgeGrid.
|
||||
func (e *EdgeGridAuth) computeBodyHash(req *http.Request) string {
|
||||
if req.Body != nil {
|
||||
bodyBytes, _ := ioutil.ReadAll(req.Body)
|
||||
req.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
if req.Method == http.MethodPost && len(bodyBytes) > 0 {
|
||||
dataToHash := bodyBytes
|
||||
if e.MaxBody != NoMaxBody && len(dataToHash) > e.MaxBody {
|
||||
dataToHash = dataToHash[0:e.MaxBody]
|
||||
}
|
||||
sha256Sum := sha256.Sum256(dataToHash)
|
||||
return base64.StdEncoding.EncodeToString(sha256Sum[:])
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// canonicalizedHeaders returns the request headers as a canonicalized string.
|
||||
//
|
||||
// The protocol does not support multiple request headers with the same header name.
|
||||
// Such requests SHOULD be rejected during the signing process. Otherwise, EdgeGrid
|
||||
// will not produce the intended results by rejecting such requests or removing all
|
||||
// (but one) duplicated headers.
|
||||
//
|
||||
// Header names are case-insensitive per rfc2616.
|
||||
//
|
||||
// For each entry in the list of headers designated by the service provider to include
|
||||
// in the signature in the specified order, the canonicalization of the request header
|
||||
// is done as follows:
|
||||
//
|
||||
// Get the first header value for the name.
|
||||
// Trim the leading and trailing white spaces.
|
||||
// Replace all repeated white spaces with a single space.
|
||||
// Concatenate the name:value pairs with the tab (\t) separator (name field is all in lower case).
|
||||
// Terminate the headers with another tab (\t) separator.
|
||||
//
|
||||
// NOTE: The canonicalized data is used for creating the signature only, as this step
|
||||
// might alter the header value. If a header in the list is not present in the request,
|
||||
// or the header value is empty, nothing for that header, neither the name nor the tab
|
||||
// separator, may be included.
|
||||
func (e *EdgeGridAuth) canonicalizedHeaders(req *http.Request) string {
|
||||
if len(e.HeadersToSign) < 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var headerNamesToSign []string
|
||||
for headerName := range req.Header {
|
||||
for _, sign := range e.HeadersToSign {
|
||||
if strings.EqualFold(sign, headerName) {
|
||||
headerNamesToSign = append(headerNamesToSign, headerName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(headerNamesToSign) < 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sort.Strings(headerNamesToSign)
|
||||
|
||||
var buffer bytes.Buffer
|
||||
for _, headerName := range headerNamesToSign {
|
||||
for _, c := range headerName {
|
||||
buffer.WriteRune(unicode.ToLower(c))
|
||||
}
|
||||
|
||||
buffer.WriteRune(':')
|
||||
|
||||
white := false
|
||||
empty := true
|
||||
for _, c := range req.Header.Get(headerName) {
|
||||
if unicode.IsSpace(c) {
|
||||
white = true
|
||||
} else {
|
||||
if white && !empty {
|
||||
buffer.WriteRune(' ')
|
||||
}
|
||||
buffer.WriteRune(unicode.ToLower(c))
|
||||
empty = false
|
||||
white = false
|
||||
}
|
||||
}
|
||||
|
||||
buffer.WriteRune('\t')
|
||||
}
|
||||
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
// calculateSignature is the base64-encoding of the SHA–256 HMAC of the data to sign with the signing key.
|
||||
func computeSignature(message string, secret string) string {
|
||||
key := []byte(secret)
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(message))
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func createRandomNonce() (string, error) {
|
||||
bytes := make([]byte, 18)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
81
pkg/issuer/acme/dns/akamai/edgegridauth_test.go
Normal file
81
pkg/issuer/acme/dns/akamai/edgegridauth_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package akamai
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDataToSign(t *testing.T) {
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
"https://akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net/diagnostic-tools/v1/locations",
|
||||
http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
auth := NewEdgeGridAuth("ClientToken", "ClientSecret", "AccessToken")
|
||||
auth.now = func() time.Time {
|
||||
return time.Unix(1396461906, 0) // 20140402T18:05:06Z
|
||||
}
|
||||
auth.createNonce = func() (string, error) {
|
||||
return "185f94eb-537c-4c01-b8cc-2fa5a06aee7f", nil
|
||||
}
|
||||
|
||||
data, err := auth.signingData(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expected := "GET" +
|
||||
"\thttps" +
|
||||
"\takaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" +
|
||||
"\t/diagnostic-tools/v1/locations" +
|
||||
"\t" + // headers
|
||||
"\t" + // content hash
|
||||
"\tEG1-HMAC-SHA256 " +
|
||||
"client_token=ClientToken;" +
|
||||
"access_token=AccessToken;" +
|
||||
"timestamp=20140402T18:05:06+0000;" +
|
||||
"nonce=185f94eb-537c-4c01-b8cc-2fa5a06aee7f;"
|
||||
|
||||
assert.EqualValues(t, expected, data.dataToSign)
|
||||
}
|
||||
|
||||
func TestDataToSignWithHeaders(t *testing.T) {
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
"http://akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna-dev.akamaiapis.net/sample-api/v1/property/?fields=x&format=json&cpcode=1234",
|
||||
http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("x-a", "va")
|
||||
req.Header.Set("x-c", "\" xc \"")
|
||||
req.Header.Set("x-b", " w b")
|
||||
|
||||
auth := NewEdgeGridAuth(
|
||||
"ClientToken", "ClientSecret", "AccessToken",
|
||||
"x-c", "x-b", "x-a")
|
||||
auth.now = func() time.Time {
|
||||
return time.Unix(1376917283, 0) // 20130819T13:01:23Z
|
||||
}
|
||||
auth.createNonce = func() (string, error) {
|
||||
return "ac392096-8aa1-44fd-8c3b-f797d35a6736", nil
|
||||
}
|
||||
|
||||
data, err := auth.signingData(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expected := "GET" +
|
||||
"\thttp" +
|
||||
"\takaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna-dev.akamaiapis.net" +
|
||||
"\t/sample-api/v1/property/?fields=x&format=json&cpcode=1234" +
|
||||
"\tx-a:va\tx-b:w b\tx-c:\" xc \"\t" +
|
||||
"\t" + // content hash
|
||||
"\tEG1-HMAC-SHA256 " +
|
||||
"client_token=ClientToken;" +
|
||||
"access_token=AccessToken;" +
|
||||
"timestamp=20130819T13:01:23+0000;" +
|
||||
"nonce=ac392096-8aa1-44fd-8c3b-f797d35a6736;"
|
||||
|
||||
assert.EqualValues(t, expected, data.dataToSign)
|
||||
}
|
||||
@ -9,7 +9,11 @@ import (
|
||||
"k8s.io/client-go/kubernetes"
|
||||
corev1listers "k8s.io/client-go/listers/core/v1"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1"
|
||||
|
||||
"github.com/jetstack/cert-manager/pkg/issuer/acme/dns/akamai"
|
||||
"github.com/jetstack/cert-manager/pkg/issuer/acme/dns/azuredns"
|
||||
"github.com/jetstack/cert-manager/pkg/issuer/acme/dns/clouddns"
|
||||
"github.com/jetstack/cert-manager/pkg/issuer/acme/dns/cloudflare"
|
||||
@ -116,6 +120,30 @@ func (s *Solver) solverFor(crt *v1alpha1.Certificate, domain string) (solver, er
|
||||
|
||||
var impl solver
|
||||
switch {
|
||||
case providerConfig.Akamai != nil:
|
||||
clientToken, err := s.loadSecretData(&providerConfig.Akamai.ClientToken)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error getting akamai client token")
|
||||
}
|
||||
|
||||
clientSecret, err := s.loadSecretData(&providerConfig.Akamai.ClientSecret)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error getting akamai client secret")
|
||||
}
|
||||
|
||||
accessToken, err := s.loadSecretData(&providerConfig.Akamai.AccessToken)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error getting akamai access token")
|
||||
}
|
||||
|
||||
impl, err = akamai.NewDNSProvider(
|
||||
providerConfig.Akamai.ServiceConsumerDomain,
|
||||
string(clientToken),
|
||||
string(clientSecret),
|
||||
string(accessToken))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error instantiating akamai challenge solver")
|
||||
}
|
||||
case providerConfig.CloudDNS != nil:
|
||||
saSecret, err := s.secretLister.Secrets(s.resourceNamespace).Get(providerConfig.CloudDNS.ServiceAccount.Name)
|
||||
if err != nil {
|
||||
@ -189,3 +217,16 @@ func (s *Solver) solverFor(crt *v1alpha1.Certificate, domain string) (solver, er
|
||||
func NewSolver(issuer v1alpha1.GenericIssuer, client kubernetes.Interface, secretLister corev1listers.SecretLister, resourceNamespace string) *Solver {
|
||||
return &Solver{issuer, client, secretLister, resourceNamespace}
|
||||
}
|
||||
|
||||
func (s *Solver) loadSecretData(selector *v1alpha1.SecretKeySelector) ([]byte, error) {
|
||||
secret, err := s.secretLister.Secrets(s.resourceNamespace).Get(selector.Name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to load secret with name %q", selector.Name)
|
||||
}
|
||||
|
||||
if data, ok := secret.Data[selector.Key]; ok {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("no key %q in secret %q", selector.Key, selector.Name)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user