From 97210fbb9cca4bd25f2455a88e51ae34ca85f843 Mon Sep 17 00:00:00 2001 From: Enrique Valenzuela Date: Sat, 9 Aug 2025 00:39:15 +0000 Subject: [PATCH] Move Signer to AWS Signer --- go.mod | 2 + go.sum | 8 + .../provider/providers/route53/provider.go | 210 ++++++------- internal/provider/providers/route53/signer.go | 129 ++++---- .../provider/providers/route53/signer_test.go | 287 +++++++++++++++--- 5 files changed, 397 insertions(+), 239 deletions(-) diff --git a/go.mod b/go.mod index 6e126b730..95f855630 100644 --- a/go.mod +++ b/go.mod @@ -21,10 +21,12 @@ require ( require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.15.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index ca50025fc..0786cd3ae 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,12 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/breml/rootcerts v0.2.19 h1:3D/qwAC1xoh82GmZ21mYzQ1NaLOICUVntIo+MRZYr4U= github.com/breml/rootcerts v0.2.19/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= @@ -24,6 +27,9 @@ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -47,6 +53,7 @@ github.com/qdm12/gotree v0.3.0 h1:Q9f4C571EFK7ZEsPkEL2oGZX7I+ZhVxhh1ZSydW+5yI= github.com/qdm12/gotree v0.3.0/go.mod h1:iz06uXmRR4Aq9v6tX7mosXStO/yGHxRA1hbyD0UVeYw= github.com/qdm12/log v0.1.0 h1:jYBd/xscHYpblzZAd2kjZp2YmuYHjAAfbTViJWxoPTw= github.com/qdm12/log v0.1.0/go.mod h1:Vchi5M8uBvHfPNIblN4mjXn/oSbiWguQIbsgF1zdQPI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -93,6 +100,7 @@ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFW google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= kernel.org/pub/linux/libs/security/libcap/cap v1.2.69 h1:N0m3tKYbkRMmDobh/47ngz+AWeV7PcfXMDi8xu3Vrag= diff --git a/internal/provider/providers/route53/provider.go b/internal/provider/providers/route53/provider.go index 533a82ec7..41a95c875 100644 --- a/internal/provider/providers/route53/provider.go +++ b/internal/provider/providers/route53/provider.go @@ -1,20 +1,19 @@ package route53 import ( - "bytes" "context" "encoding/json" - "encoding/xml" "fmt" "net/http" "net/netip" - "net/url" - "time" + + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" "github.com/qdm12/ddns-updater/internal/models" "github.com/qdm12/ddns-updater/internal/provider/constants" "github.com/qdm12/ddns-updater/internal/provider/errors" - "github.com/qdm12/ddns-updater/internal/provider/headers" "github.com/qdm12/ddns-updater/internal/provider/utils" "github.com/qdm12/ddns-updater/pkg/publicip/ipversion" ) @@ -26,47 +25,72 @@ type Provider struct { ipv6Suffix netip.Prefix zoneID string ttl uint32 - signer *signer + session *session.Session + accessKey string // For static credentials + secretKey string // For static credentials } -func New(data json.RawMessage, domain, owner string, - ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) ( - provider *Provider, err error, -) { - var providerSpecificSettings struct { - AccessKey string `json:"access_key"` - SecretKey string `json:"secret_key"` - ZoneID string `json:"zone_id"` - TTL *uint32 `json:"ttl,omitempty"` +func New(data json.RawMessage, domain, owner string, ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (*Provider, error) { + var settings struct { + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + AWSProfile string `json:"aws_profile"` + ZoneID string `json:"zone_id"` + TTL *uint32 `json:"ttl,omitempty"` } - err = json.Unmarshal(data, &providerSpecificSettings) - if err != nil { + if err := json.Unmarshal(data, &settings); err != nil { return nil, fmt.Errorf("decoding provider specific settings: %w", err) } - const defaultTTL = 300 - ttl := uint32(defaultTTL) - if providerSpecificSettings.TTL != nil { - ttl = *providerSpecificSettings.TTL + if err := validateSettings(domain, settings.AccessKey, settings.SecretKey, settings.AWSProfile, settings.ZoneID); err != nil { + return nil, fmt.Errorf("validating provider specific settings: %w", err) } - err = validateSettings(domain, providerSpecificSettings.AccessKey, - providerSpecificSettings.SecretKey, providerSpecificSettings.ZoneID) - if err != nil { - return nil, fmt.Errorf("validating provider specific settings: %w", err) + const defaultTTL = 300 + ttl := defaultTTL + if settings.TTL != nil { + ttl = int(*settings.TTL) } - // Global resources needs signature to us-east-1 globalRegion - // and update / insert operations to route53 are also in us-east-1. - const globalRegion = "us-east-1" - const route53Service = "route53" - const v4SignatureVersion = "aws4_request" - signer := &signer{ - accessKey: providerSpecificSettings.AccessKey, - secretkey: providerSpecificSettings.SecretKey, - region: globalRegion, - service: route53Service, - signatureVersion: v4SignatureVersion, + var sess *session.Session + var accessKey, secretKey string + + if settings.AWSProfile != "" { + fmt.Println("Using AWS profile:", settings.AWSProfile) + var err error + sess, err = session.NewSessionWithOptions(session.Options{ + Profile: settings.AWSProfile, + SharedConfigState: session.SharedConfigEnable, + }) + if err != nil { + return nil, fmt.Errorf("creating AWS session: %w", err) + } + + // Verify credentials + _, err = sess.Config.Credentials.Get() + if err == nil { + stsSvc := sts.New(sess) + identity, err := stsSvc.GetCallerIdentity(&sts.GetCallerIdentityInput{}) + if err == nil { + fmt.Println("STS Identity:", *identity.Arn) + } else { + fmt.Println("Could not verify identity with STS:", err) + } + } else { + return nil, fmt.Errorf("resolving credentials from profile: %w", err) + } + } else { + fmt.Println("Using access key and secret key") + // Store credentials for static credential creation + accessKey = settings.AccessKey + secretKey = settings.SecretKey + + // Create a basic session for non-profile usage + var err error + sess, err = session.NewSession() + if err != nil { + return nil, fmt.Errorf("creating AWS session: %w", err) + } } return &Provider{ @@ -74,18 +98,26 @@ func New(data json.RawMessage, domain, owner string, owner: owner, ipVersion: ipVersion, ipv6Suffix: ipv6Suffix, - signer: signer, - zoneID: providerSpecificSettings.ZoneID, - ttl: ttl, + zoneID: settings.ZoneID, + ttl: uint32(ttl), + session: sess, + accessKey: accessKey, + secretKey: secretKey, }, nil } -func validateSettings(domain, accessKey, secretKey, zoneID string) (err error) { - err = utils.CheckDomain(domain) - if err != nil { +func validateSettings(domain, accessKey, secretKey, awsProfile, zoneID string) error { + if err := utils.CheckDomain(domain); err != nil { return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err) } + if awsProfile != "" { + if zoneID == "" { + return fmt.Errorf("%w", errors.ErrZoneIdentifierNotSet) + } + return nil // AWS SDK is expected to resolve credentials + } + switch { case accessKey == "": return fmt.Errorf("%w", errors.ErrAccessKeyNotSet) @@ -101,29 +133,12 @@ func (p *Provider) String() string { return utils.ToString(p.domain, p.owner, constants.Route53, p.ipVersion) } -func (p *Provider) Domain() string { - return p.domain -} - -func (p *Provider) Owner() string { - return p.owner -} - -func (p *Provider) IPVersion() ipversion.IPVersion { - return p.ipVersion -} - -func (p *Provider) IPv6Suffix() netip.Prefix { - return p.ipv6Suffix -} - -func (p *Provider) Proxied() bool { - return false -} - -func (p *Provider) BuildDomainName() string { - return utils.BuildDomainName(p.owner, p.domain) -} +func (p *Provider) Domain() string { return p.domain } +func (p *Provider) Owner() string { return p.owner } +func (p *Provider) IPVersion() ipversion.IPVersion { return p.ipVersion } +func (p *Provider) IPv6Suffix() netip.Prefix { return p.ipv6Suffix } +func (p *Provider) Proxied() bool { return false } +func (p *Provider) BuildDomainName() string { return utils.BuildDomainName(p.owner, p.domain) } func (p *Provider) HTML() models.HTMLRow { return models.HTMLRow{ @@ -134,60 +149,17 @@ func (p *Provider) HTML() models.HTMLRow { } } -// See https://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeResourceRecordSets.html -func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) { - u := url.URL{ - Scheme: "https", - Host: route53Domain, - Path: "/2013-04-01/hostedzone/" + p.zoneID + "/rrset", - } - - changeRRSetRequest := newChangeRRSetRequest(p.BuildDomainName(), p.ttl, ip) - - // Note the AWS API does not accept JSON for this endpoint - buffer := bytes.NewBuffer(nil) - encoder := xml.NewEncoder(buffer) - err = encoder.Encode(changeRRSetRequest) - if err != nil { - return netip.Addr{}, fmt.Errorf("XML encoding change RRSet request: %w", err) - } - - request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer) - if err != nil { - return netip.Addr{}, fmt.Errorf("creating http request: %w", err) - } - - p.setHeaders(request, buffer.Bytes()) - - response, err := client.Do(request) - if err != nil { - return netip.Addr{}, err - } - defer response.Body.Close() - - xmlDecoder := xml.NewDecoder(response.Body) - if response.StatusCode == http.StatusOK { - return ip, nil - } - - var errorResponse errorResponse - err = xmlDecoder.Decode(&errorResponse) - if err != nil { - return netip.Addr{}, fmt.Errorf("XML decoding response body: %w", err) - } - return netip.Addr{}, fmt.Errorf("%w: %d: request %s %s/%s: %s", - errors.ErrHTTPStatusNotValid, response.StatusCode, - errorResponse.RequestID, errorResponse.Error.Type, - errorResponse.Error.Code, errorResponse.Error.Message) +func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (netip.Addr, error) { + signer := p.createSigner() + return updateRecord(ctx, client, signer, p.zoneID, p.BuildDomainName(), p.ttl, ip) } -func (p *Provider) setHeaders(request *http.Request, payload []byte) { - now := time.Now().UTC() - headers.SetUserAgent(request) - headers.SetContentType(request, "application/xml") - headers.SetAccept(request, "application/xml") - request.Header.Set("Date", now.Format(dateTimeFormat)) - request.Header.Set("Host", route53Domain) - signature := p.signer.sign(request.Method, request.URL.Path, payload, now) - request.Header.Set("Authorization", signature) +func (p *Provider) createSigner() *Route53Signer { + if p.accessKey != "" && p.secretKey != "" { + // Use static credentials + creds := credentials.NewStaticCredentials(p.accessKey, p.secretKey, "") + return NewRoute53Signer(creds) + } + // Use session credentials (for profile-based authentication) + return NewRoute53Signer(p.session.Config.Credentials) } diff --git a/internal/provider/providers/route53/signer.go b/internal/provider/providers/route53/signer.go index c272ff801..590114a7f 100644 --- a/internal/provider/providers/route53/signer.go +++ b/internal/provider/providers/route53/signer.go @@ -1,93 +1,78 @@ package route53 import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" + "bytes" + "context" + "encoding/xml" "fmt" - "strings" + "net/http" + "net/netip" + "net/url" "time" + + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/signer/v4" + + "github.com/qdm12/ddns-updater/internal/provider/errors" ) const ( route53Domain = "route53.amazonaws.com" - dateTimeFormat = "20060102T150405Z" - dateFormat = "20060102" + dateTimeFormat = time.RFC1123 ) -// signer implements the signature v4 header based to upsert route53 domains -// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html -type signer struct { - accessKey string - secretkey string - region string - service string - signatureVersion string +type Route53Signer struct { + signer *v4.Signer } -func (s *signer) sign(method, urlPath string, payload []byte, date time.Time) ( - headerValue string, -) { - credentialScope := fmt.Sprintf("%s/%s/%s/%s", date.Format(dateFormat), - s.region, s.service, s.signatureVersion) - credential := fmt.Sprintf("%s/%s", s.accessKey, credentialScope) - const signedHeaders = "content-type;host" - canonicalRequest := buildCanonicalRequest(method, urlPath, signedHeaders, payload) - stringToSign := buildStringToSign(date, canonicalRequest, credentialScope) - signingKey := s.buildPrivateKey(date) - signature := hmacSha256Sum([]byte(signingKey), []byte(stringToSign)) - signatureString := hex.EncodeToString(signature) - return fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s,SignedHeaders=%s,Signature=%s", - credential, signedHeaders, signatureString) +func NewRoute53Signer(creds *credentials.Credentials) *Route53Signer { + return &Route53Signer{ + signer: v4.NewSigner(creds), + } } -func buildCanonicalRequest(method, path, headers string, payload []byte) ( - canonicalRequest string, -) { - canonicalHeaders := "content-type:application/xml\nhost:" + route53Domain + "\n" - const canonicalQuery = "" // no query arg used - payloadHashDigest := hex.EncodeToString(sha256Sum(payload)) - canonicalRequest = strings.Join([]string{ - strings.ToUpper(method), - path, - canonicalQuery, - canonicalHeaders, - headers, - payloadHashDigest, - }, "\n") - return canonicalRequest -} +func updateRecord(ctx context.Context, client *http.Client, signer *Route53Signer, zoneID, domainName string, ttl uint32, ip netip.Addr) (netip.Addr, error) { + u := url.URL{ + Scheme: "https", + Host: route53Domain, + Path: "/2013-04-01/hostedzone/" + zoneID + "/rrset", + } -func buildStringToSign(date time.Time, - canonicalRequest, credentialScope string, -) string { - return "AWS4-HMAC-SHA256\n" + - date.Format(dateTimeFormat) + "\n" + - credentialScope + "\n" + - hex.EncodeToString(sha256Sum([]byte(canonicalRequest))) -} + changeRRSetRequest := newChangeRRSetRequest(domainName, ttl, ip) + buffer := new(bytes.Buffer) + encoder := xml.NewEncoder(buffer) + if err := encoder.Encode(changeRRSetRequest); err != nil { + return netip.Addr{}, fmt.Errorf("XML encoding change RRSet request: %w", err) + } -func (s *signer) buildPrivateKey(date time.Time) string { - signingKey := []byte("AWS4" + s.secretkey) - for _, value := range [][]byte{ - []byte(date.Format(dateFormat)), - []byte(s.region), - []byte(s.service), - []byte(s.signatureVersion), - } { - signingKey = hmacSha256Sum(signingKey, value) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer) + if err != nil { + return netip.Addr{}, fmt.Errorf("creating http request: %w", err) } - return string(signingKey) -} -func sha256Sum(d []byte) []byte { - hasher := sha256.New() - hasher.Write(d) - return hasher.Sum(nil) -} + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Accept", "application/xml") + _, err = signer.signer.Sign(req, bytes.NewReader(buffer.Bytes()), "route53", "us-east-1", time.Now().UTC()) + if err != nil { + return netip.Addr{}, fmt.Errorf("signing request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return netip.Addr{}, err + } + defer resp.Body.Close() -func hmacSha256Sum(key, value []byte) []byte { - hasher := hmac.New(sha256.New, key) - hasher.Write(value) - return hasher.Sum(nil) + if resp.StatusCode == http.StatusOK { + return ip, nil + } + + var errResp errorResponse + if err := xml.NewDecoder(resp.Body).Decode(&errResp); err != nil { + return netip.Addr{}, fmt.Errorf("XML decoding response body: %w", err) + } + return netip.Addr{}, fmt.Errorf("%w: %d: request %s %s/%s: %s", + errors.ErrHTTPStatusNotValid, resp.StatusCode, + errResp.RequestID, errResp.Error.Type, + errResp.Error.Code, errResp.Error.Message) } diff --git a/internal/provider/providers/route53/signer_test.go b/internal/provider/providers/route53/signer_test.go index 68b467ceb..1e030b9e5 100644 --- a/internal/provider/providers/route53/signer_test.go +++ b/internal/provider/providers/route53/signer_test.go @@ -1,78 +1,269 @@ package route53 import ( - "net/http" + "encoding/json" + "net/netip" "testing" - "time" + "github.com/qdm12/ddns-updater/pkg/publicip/ipversion" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func Test_signer_sign(t *testing.T) { +func TestNew_WithAccessKey(t *testing.T) { t.Parallel() - signer := &signer{ - accessKey: "AKIDEXAMPLE", - secretkey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - region: "us-east-1", - service: "route53", - signatureVersion: "aws4_request", + settings := map[string]interface{}{ + "access_key": "AKIDEXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "zone_id": "Z123456789", + "ttl": 300, } - const method = http.MethodPost - const urlPath = "/2013-04-01/hostedzone/Z148QEXAMPLE8V/rrset" - payload := []byte{1, 2, 3, 4, 5} - date := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + data, err := json.Marshal(settings) + require.NoError(t, err) - headerValue := signer.sign(method, urlPath, payload, date) + provider, err := New(data, "example.com", "test", ipversion.IP4, netip.Prefix{}) + require.NoError(t, err) + assert.NotNil(t, provider) - const expected = "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE" + - "/20210101/us-east-1/route53/aws4_request," + - "SignedHeaders=content-type;host," + - "Signature=441038f6dd576fcb8c6426efd92615b705d8bd14394809aca9daf059256c61be" - assert.Equal(t, expected, headerValue) + // Check basic fields + assert.Equal(t, "example.com", provider.domain) + assert.Equal(t, "test", provider.owner) + assert.Equal(t, "Z123456789", provider.zoneID) + assert.Equal(t, uint32(300), provider.ttl) + assert.Equal(t, ipversion.IP4, provider.ipVersion) + + // Check that static credentials are stored + assert.Equal(t, "AKIDEXAMPLE", provider.accessKey) + assert.Equal(t, "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", provider.secretKey) + assert.NotNil(t, provider.session) +} + +func TestNew_WithProfile(t *testing.T) { + t.Parallel() + + settings := map[string]interface{}{ + "aws_profile": "test-profile", + "zone_id": "Z123456789", + } + + data, err := json.Marshal(settings) + require.NoError(t, err) + + // This test will fail if the profile doesn't exist, but that's expected in CI + // We're just testing that the code path works correctly + _, err = New(data, "example.com", "test", ipversion.IP4, netip.Prefix{}) + // Should either succeed or fail with a specific AWS profile error + if err != nil { + // Expected in environments without the test profile + assert.Contains(t, err.Error(), "creating AWS session") + } +} + +func TestNew_WithValidProfile_MockScenario(t *testing.T) { + t.Parallel() + + // Test the validation logic for profile scenarios + // This tests the validateSettings function with profile parameters + + testCases := []struct { + name string + domain string + awsProfile string + zoneID string + expectError bool + errorType string + }{ + { + name: "valid profile with zone ID", + domain: "example.com", + awsProfile: "my-profile", + zoneID: "Z123456789", + expectError: false, + }, + { + name: "profile without zone ID should fail", + domain: "example.com", + awsProfile: "my-profile", + zoneID: "", + expectError: true, + errorType: "zone identifier is not set", + }, + { + name: "invalid domain with profile", + domain: "", + awsProfile: "my-profile", + zoneID: "Z123456789", + expectError: true, + errorType: "domain is not valid", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := validateSettings(tc.domain, "", "", tc.awsProfile, tc.zoneID) + if tc.expectError { + assert.Error(t, err) + if tc.errorType != "" { + assert.Contains(t, err.Error(), tc.errorType) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNew_ProfileVsAccessKey_Priority(t *testing.T) { + t.Parallel() + + // Test what happens when both profile and access keys are provided + // Profile should take priority + settings := map[string]interface{}{ + "aws_profile": "test-profile", + "access_key": "AKIDEXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "zone_id": "Z123456789", + } + + data, err := json.Marshal(settings) + require.NoError(t, err) + + provider, err := New(data, "example.com", "test", ipversion.IP4, netip.Prefix{}) + + // Should fail because profile doesn't exist, but importantly: + // - It should attempt to use the profile (not the access keys) + // - The accessKey and secretKey fields should be empty (not populated from settings) + if err != nil { + assert.Contains(t, err.Error(), "creating AWS session") + } else { + // If somehow the profile exists and succeeds + assert.Empty(t, provider.accessKey, "When profile is provided, accessKey should be empty") + assert.Empty(t, provider.secretKey, "When profile is provided, secretKey should be empty") + assert.NotNil(t, provider.session, "Session should be created when using profile") + } } -func Test_buildCanonicalRequest(t *testing.T) { +func TestNew_DefaultTTL(t *testing.T) { t.Parallel() - const method = http.MethodPost - const urlPath = "/2013-04-01/hostedzone/Z148QEXAMPLE8V/rrset" - const headers = "content-type;host" - payload := []byte{1, 2, 3, 4, 5} + settings := map[string]interface{}{ + "access_key": "AKIDEXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "zone_id": "Z123456789", + // No TTL specified + } + + data, err := json.Marshal(settings) + require.NoError(t, err) - canonicalRequest := buildCanonicalRequest(method, urlPath, headers, payload) + provider, err := New(data, "example.com", "test", ipversion.IP4, netip.Prefix{}) + require.NoError(t, err) - const expected = "POST\n/2013-04-01/hostedzone/Z148QEXAMPLE8V/rrset\n\n" + - "content-type:application/xml\nhost:route53.amazonaws.com\n\n" + - "content-type;host\n74f81fe167d99b4cb41d6d0ccda82278caee9f3e2f25d5e5a3936ff3dcec60d0" - assert.Equal(t, expected, canonicalRequest) + // Should use default TTL of 300 + assert.Equal(t, uint32(300), provider.ttl) } -func Test_buildStringToSign(t *testing.T) { +func TestValidateSettings_AccessKey(t *testing.T) { t.Parallel() - date := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) - const canonicalRequest = "canonical request" - const credentialScope = "20210101/us-east-1/route53/aws4_request" - stringToSign := buildStringToSign(date, canonicalRequest, credentialScope) - const expected = "AWS4-HMAC-SHA256\n20210101T000000Z\n" + - "20210101/us-east-1/route53/aws4_request\n" + - "6148e80fc369360885d29b93dbc72ca5f4107f6609fadc47a77b036ef241f2bf" - assert.Equal(t, expected, stringToSign) + tests := []struct { + name string + domain string + accessKey string + secretKey string + awsProfile string + zoneID string + expectError bool + }{ + { + name: "valid access key setup", + domain: "example.com", + accessKey: "AKIDEXAMPLE", + secretKey: "secret", + zoneID: "Z123456789", + expectError: false, + }, + { + name: "missing access key", + domain: "example.com", + secretKey: "secret", + zoneID: "Z123456789", + expectError: true, + }, + { + name: "missing secret key", + domain: "example.com", + accessKey: "AKIDEXAMPLE", + zoneID: "Z123456789", + expectError: true, + }, + { + name: "missing zone ID", + domain: "example.com", + accessKey: "AKIDEXAMPLE", + secretKey: "secret", + expectError: true, + }, + { + name: "valid profile setup", + domain: "example.com", + awsProfile: "test-profile", + zoneID: "Z123456789", + expectError: false, + }, + { + name: "profile missing zone ID", + domain: "example.com", + awsProfile: "test-profile", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSettings(tt.domain, tt.accessKey, tt.secretKey, tt.awsProfile, tt.zoneID) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } } -func Test_signer_buildPrivateKey(t *testing.T) { +func TestProvider_Methods(t *testing.T) { t.Parallel() - date := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) - signer := &signer{ - secretkey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - region: "us-east-1", - service: "route53", - signatureVersion: "aws4_request", + settings := map[string]interface{}{ + "access_key": "AKIDEXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "zone_id": "Z123456789", } - privateKey := signer.buildPrivateKey(date) - const expectedPrivateKey = "\xa4\x13\x97\x935\x9d\x0f\xa6\xe6܉]\xfb\x83p\x85iJp\xe0\xb5\xf0͕E\xb5Jp\xe6,\x9e\xf7" - assert.Equal(t, expectedPrivateKey, privateKey) + + data, err := json.Marshal(settings) + require.NoError(t, err) + + provider, err := New(data, "example.com", "test", ipversion.IP4, netip.Prefix{}) + require.NoError(t, err) + + // Test interface methods + assert.Equal(t, "example.com", provider.Domain()) + assert.Equal(t, "test", provider.Owner()) + assert.Equal(t, ipversion.IP4, provider.IPVersion()) + assert.False(t, provider.Proxied()) + assert.Equal(t, "test.example.com", provider.BuildDomainName()) + + // Test string representation + assert.Contains(t, provider.String(), "example.com") + assert.Contains(t, provider.String(), "test") + assert.Contains(t, provider.String(), "route53") + + // Test HTML representation + html := provider.HTML() + assert.Contains(t, html.Domain, "test.example.com") + assert.Equal(t, "test", html.Owner) + assert.Contains(t, html.Provider, "Amazon Route 53") + assert.Equal(t, "ipv4", html.IPVersion) }