├── examples ├── list │ ├── .gitignore │ └── main.go ├── createTxt │ ├── .gitignore │ └── main.go ├── README.md ├── go.mod └── go.sum ├── libdnstest ├── .gitignore ├── .env.example ├── go.mod ├── go.sum ├── README.md └── route53_test.go ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── go.mod ├── LICENSE ├── quote.go ├── go.sum ├── BREAKING.md ├── README.md ├── provider.go ├── client_test.go ├── client.go └── .golangci.yml /examples/list/.gitignore: -------------------------------------------------------------------------------- 1 | list 2 | -------------------------------------------------------------------------------- /examples/createTxt/.gitignore: -------------------------------------------------------------------------------- 1 | createTxt 2 | -------------------------------------------------------------------------------- /libdnstest/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.env 3 | libdnstest.test 4 | -------------------------------------------------------------------------------- /libdnstest/.env.example: -------------------------------------------------------------------------------- 1 | # AWS Credentials (choose one method) 2 | 3 | # Method 1: Access Keys 4 | AWS_ACCESS_KEY_ID=your-access-key-here 5 | AWS_SECRET_ACCESS_KEY=your-secret-key-here 6 | #AWS_SESSION_TOKEN=optional-session-token 7 | 8 | # Method 2: Profile 9 | #AWS_PROFILE=your-profile-name 10 | 11 | # Optional: AWS Region (defaults to us-east-1) 12 | #AWS_REGION=us-east-1 13 | 14 | # Required: Test Zone (must include trailing dot) 15 | ROUTE53_TEST_ZONE=test.example.com. -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Route53 Provider Examples 2 | 3 | Set up AWS credentials using environment variables: 4 | ```bash 5 | export AWS_ACCESS_KEY_ID=your-access-key 6 | export AWS_SECRET_ACCESS_KEY=your-secret-key 7 | export AWS_REGION=us-east-1 # optional 8 | ``` 9 | 10 | Always use fully qualified zone names with a trailing dot (e.g., `example.com.`) 11 | 12 | ## Examples 13 | 14 | **List all records:** 15 | ```bash 16 | cd list && go run . example.com. 17 | ``` 18 | 19 | **Create TXT records:** 20 | ```bash 21 | cd createTxt && go run . example.com. 22 | ``` 23 | 24 | The provider returns typed structs like `libdns.Address`, `libdns.TXT`, etc., with specific fields for each record type. -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | labels: 8 | - "dependencies" 9 | commit-message: 10 | prefix: "chore" 11 | include: "scope" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | labels: 17 | - "dependencies" 18 | commit-message: 19 | prefix: "chore" 20 | include: "scope" 21 | - package-ecosystem: "docker" 22 | directory: "/" 23 | schedule: 24 | interval: "daily" 25 | labels: 26 | - "dependencies" 27 | commit-message: 28 | prefix: "chore" 29 | include: "scope" 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build and test 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v6 20 | with: 21 | go-version: 'stable' 22 | 23 | 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v8 26 | with: 27 | version: v2.5 28 | 29 | - name: Build 30 | run: go build -v ./... 31 | 32 | - name: Build examples 33 | run: cd examples && go build -v ./... 34 | 35 | - name: Test 36 | run: go test -v ./... 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/libdns/route53 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.39.1 7 | github.com/aws/aws-sdk-go-v2/config v1.31.10 8 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14 9 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 10 | github.com/libdns/libdns v1.1.1 11 | ) 12 | 13 | require ( 14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect 15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect 16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 18 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect 19 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect 23 | github.com/aws/smithy-go v1.23.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /examples/list/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/libdns/route53" 11 | ) 12 | 13 | func main() { 14 | if len(os.Args) < 2 { 15 | log.Fatalf("Usage: %s \nExample: %s example.com.", os.Args[0], os.Args[0]) 16 | } 17 | zone := os.Args[1] 18 | 19 | // Provider will use AWS credentials from environment or AWS config 20 | p := &route53.Provider{} 21 | 22 | // Get all records from the zone 23 | records, err := p.GetRecords(context.Background(), zone) 24 | if err != nil { 25 | log.Fatalf("Failed to get records: %v", err) 26 | } 27 | 28 | fmt.Printf("Records in zone %s:\n\n", zone) 29 | 30 | for _, record := range records { 31 | typeName := fmt.Sprintf("%T", record) 32 | if idx := strings.LastIndex(typeName, "."); idx != -1 { 33 | typeName = typeName[idx+1:] 34 | } 35 | 36 | rr := record.RR() 37 | fmt.Printf("%s: {Name:%s TTL:%s Type:%s Data:%s}\n", 38 | typeName, rr.Name, rr.TTL, rr.Type, rr.Data) 39 | } 40 | 41 | fmt.Printf("\nTotal: %d records\n", len(records)) 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Daniel Santos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/libdns/route53/examples 2 | 3 | go 1.25 4 | 5 | replace github.com/libdns/route53 => ../ 6 | 7 | require ( 8 | github.com/libdns/libdns v1.1.1 9 | github.com/libdns/route53 v0.0.0 10 | ) 11 | 12 | require ( 13 | github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect 14 | github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect 15 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect 16 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect 18 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect 19 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect 26 | github.com/aws/smithy-go v1.23.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /libdnstest/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/libdns/route53/libdnstest 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/libdns/libdns v1.1.1 7 | github.com/libdns/route53 v0.0.0 8 | ) 9 | 10 | require ( 11 | github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect 12 | github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect 13 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect 14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect 15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect 16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 18 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect 19 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect 24 | github.com/aws/smithy-go v1.23.0 // indirect 25 | ) 26 | 27 | replace github.com/libdns/route53 => ../ 28 | 29 | replace github.com/libdns/libdns => ../../libdns 30 | -------------------------------------------------------------------------------- /examples/createTxt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/libdns/libdns" 11 | "github.com/libdns/route53" 12 | ) 13 | 14 | func main() { 15 | if len(os.Args) < 2 { 16 | log.Fatalf("Usage: %s \nExample: %s example.com.", os.Args[0], os.Args[0]) 17 | } 18 | zone := os.Args[1] 19 | 20 | // Provider will use AWS credentials from environment or AWS config 21 | p := &route53.Provider{} 22 | ctx := context.Background() 23 | 24 | // Create multiple TXT records with the same name 25 | // Route53 will combine these into a single ResourceRecordSet 26 | records := []libdns.Record{ 27 | libdns.TXT{ 28 | Name: "example-txt", 29 | Text: `This string includes "quotation marks".`, 30 | TTL: 300 * time.Second, 31 | }, 32 | libdns.TXT{ 33 | Name: "example-txt", 34 | Text: `The last character in this string is an accented e: é`, 35 | TTL: 300 * time.Second, 36 | }, 37 | libdns.TXT{ 38 | Name: "example-txt", 39 | Text: "v=spf1 ip4:192.168.0.1/16 -all", 40 | TTL: 300 * time.Second, 41 | }, 42 | } 43 | 44 | // AppendRecords will add these records to the zone 45 | fmt.Printf("Creating TXT records in zone %s...\n", zone) 46 | created, err := p.AppendRecords(ctx, zone, records) 47 | if err != nil { 48 | log.Fatalf("Failed to create records: %v", err) 49 | } 50 | 51 | fmt.Printf("\nCreated %d TXT records:\n", len(created)) 52 | for _, record := range created { 53 | rr := record.RR() 54 | fmt.Printf("TXT: {Name:%s TTL:%s Type:%s Data:%s}\n", 55 | rr.Name, rr.TTL, rr.Type, rr.Data) 56 | } 57 | 58 | fmt.Printf("\nCleaning up...\n") 59 | deleted, err := p.DeleteRecords(ctx, zone, created) 60 | if err != nil { 61 | log.Fatalf("Failed to delete records: %v", err) 62 | } 63 | fmt.Printf("Deleted %d records\n", len(deleted)) 64 | } 65 | -------------------------------------------------------------------------------- /quote.go: -------------------------------------------------------------------------------- 1 | package route53 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func quote(s string) string { 10 | // Special characters in a TXT record value 11 | // 12 | // If your TXT record contains any of the following characters, you must specify the characters by using escape codes in the format \three-digit octal code: 13 | // Characters 000 to 040 octal (0 to 32 decimal, 0x00 to 0x20 hexadecimal) 14 | // Characters 177 to 377 octal (127 to 255 decimal, 0x7F to 0xFF hexadecimal) 15 | // ... 16 | // for example, if the value of your TXT record is "exämple.com", you specify "ex\344mple.com". 17 | // source https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat 18 | sb := strings.Builder{} 19 | for i := range len(s) { 20 | c := s[i] 21 | switch { 22 | case c < 32 || c >= 127: 23 | sb.WriteString(fmt.Sprintf("\\%03o", c)) 24 | case c == '"': 25 | sb.WriteString(`\"`) 26 | case c == '\\': 27 | sb.WriteString(`\\`) 28 | default: 29 | sb.WriteByte(c) 30 | } 31 | } 32 | s = sb.String() 33 | 34 | // quote strings 35 | s = `"` + s + `"` 36 | 37 | return s 38 | } 39 | 40 | func unquote(s string) string { 41 | // Unescape special characters 42 | var sb strings.Builder 43 | for i := 0; i < len(s); i++ { 44 | c := rune(s[i]) 45 | if c == '\\' && len(s) > i+1 { 46 | switch { 47 | case s[i+1] == '"': 48 | sb.WriteRune('"') 49 | i++ 50 | continue 51 | case s[i+1] == '\\': 52 | sb.WriteRune('\\') 53 | i++ 54 | continue 55 | case s[i+1] >= '0' && s[i+1] <= '7' && len(s) > i+3: 56 | octal, err := strconv.ParseInt(s[i+1:i+4], 8, 32) 57 | if err == nil { 58 | sb.WriteByte(byte(octal)) 59 | i += 3 60 | continue 61 | } 62 | } 63 | } 64 | sb.WriteRune(c) 65 | } 66 | 67 | return strings.Trim(sb.String(), `"`) 68 | } 69 | -------------------------------------------------------------------------------- /libdnstest/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= 2 | github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= 3 | github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= 4 | github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= 5 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= 6 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= 7 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= 8 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= 9 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= 12 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= 13 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 14 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 15 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= 16 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= 17 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= 18 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= 19 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 h1:jQzRC+0eI/l5mFXVoPTyyolrqyZtKIYaKHSuKJoIJKs= 20 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3/go.mod h1:1GNaojT/gG4Ru9tT39ton6kRZ3FvptJ/QRKBoqUOVX4= 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= 22 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= 23 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= 24 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= 25 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= 27 | github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= 28 | github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= 2 | github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= 3 | github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= 4 | github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= 5 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= 6 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= 7 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= 8 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= 9 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= 12 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= 13 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 14 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 15 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= 16 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= 17 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= 18 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= 19 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 h1:jQzRC+0eI/l5mFXVoPTyyolrqyZtKIYaKHSuKJoIJKs= 20 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3/go.mod h1:1GNaojT/gG4Ru9tT39ton6kRZ3FvptJ/QRKBoqUOVX4= 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= 22 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= 23 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= 24 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= 25 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= 27 | github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= 28 | github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= 29 | github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= 30 | github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= 31 | -------------------------------------------------------------------------------- /examples/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= 2 | github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= 3 | github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= 4 | github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= 5 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= 6 | github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= 7 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= 8 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= 9 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= 12 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= 13 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 14 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 15 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= 16 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= 17 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= 18 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= 19 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 h1:jQzRC+0eI/l5mFXVoPTyyolrqyZtKIYaKHSuKJoIJKs= 20 | github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3/go.mod h1:1GNaojT/gG4Ru9tT39ton6kRZ3FvptJ/QRKBoqUOVX4= 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= 22 | github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= 23 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= 24 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= 25 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= 27 | github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= 28 | github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= 29 | github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= 30 | github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= 31 | -------------------------------------------------------------------------------- /libdnstest/README.md: -------------------------------------------------------------------------------- 1 | # Provider-Specific Tests for Route53 2 | 3 | This directory contains provider-specific tests for the Route53 libdns provider using the official [libdnstest package](https://github.com/libdns/libdns/tree/master/libdnstest). These tests verify the provider implementation against the real AWS Route53 API, ensuring all libdns interface methods work correctly with actual DNS operations. 4 | 5 | ## Prerequisites 6 | 7 | 1. **AWS Account**: You need an AWS account with Route53 access 8 | 2. **Hosted Zone**: Create a dedicated test hosted zone in Route53 9 | 3. **IAM Permissions**: Your AWS credentials need the following permissions: 10 | - `route53:ListResourceRecordSets` 11 | - `route53:GetChange` 12 | - `route53:ChangeResourceRecordSets` 13 | - `route53:ListHostedZonesByName` 14 | - `route53:ListHostedZones` 15 | 16 | ## How To Run 17 | 18 | ### Method 1: Using AWS Access Keys 19 | 20 | 1. **Set Environment Variables**: 21 | ```bash 22 | export AWS_ACCESS_KEY_ID="your-access-key" 23 | export AWS_SECRET_ACCESS_KEY="your-secret-key" 24 | export AWS_REGION="us-east-1" # Optional, defaults to us-east-1 25 | export ROUTE53_TEST_ZONE="test.example.com." # Include trailing dot 26 | ``` 27 | 28 | ### Method 2: Using AWS Profile 29 | 30 | 1. **Configure AWS Profile** (if not already done): 31 | ```bash 32 | aws configure --profile myprofile 33 | ``` 34 | 35 | 2. **Set Environment Variables**: 36 | ```bash 37 | export AWS_PROFILE="myprofile" 38 | export ROUTE53_TEST_ZONE="test.example.com." # Include trailing dot 39 | ``` 40 | 41 | ### Method 3: Using .env File 42 | 43 | 1. **Copy and Configure .env**: 44 | ```bash 45 | cp .env.example .env 46 | # Edit .env with your credentials 47 | ``` 48 | 49 | 2. **Run Tests**: 50 | ```bash 51 | set -a && source .env && set +a && go test -v 52 | ``` 53 | 54 | ### Method 4: Using IAM Roles (EC2/ECS/Lambda) 55 | 56 | If running on AWS infrastructure with IAM roles attached, just set: 57 | ```bash 58 | export ROUTE53_TEST_ZONE="test.example.com." 59 | go test -v 60 | ``` 61 | 62 | ## What Gets Tested 63 | 64 | - **Core Operations**: GetRecords, AppendRecords, SetRecords, DeleteRecords 65 | - **Zone Listing**: ListZones (if provider implements it) 66 | - **All Record Types**: A, AAAA, CNAME, TXT, MX, SRV, CAA, NS, SVCB, HTTPS 67 | 68 | **Note**: These tests interact directly with the Route53 API and do not perform actual DNS queries. They verify that the provider correctly manages records through the AWS API. 69 | 70 | ## Important Warnings 71 | 72 | > [!WARNING] 73 | > **These tests create and delete real DNS records in your Route53 hosted zone!** 74 | > 75 | > - Use a **dedicated test zone** that doesn't host production DNS records 76 | > - All test records are prefixed with "test-" but bugs could cause data loss 77 | > - The tests attempt cleanup, but manual cleanup may be needed if tests fail 78 | > - AWS Route53 operations incur charges (though minimal for testing) 79 | 80 | ## Cost Considerations 81 | 82 | Route53 charges for: 83 | - Hosted zones (~$0.50/month per zone) 84 | - DNS queries (~$0.40 per million queries) - **Note: These tests do not perform DNS queries, only API operations** 85 | - Record operations are free 86 | 87 | For testing purposes, costs should be minimal since the tests only use the Route53 API to manage records, not actual DNS resolution. -------------------------------------------------------------------------------- /BREAKING.md: -------------------------------------------------------------------------------- 1 | # Breaking Changes 2 | 3 | ## Version 1.6 4 | 5 | ### libdns 1.0 Compatibility 6 | 7 | Version 1.6 requires **libdns v1.0** or later. The libdns v1.0 release introduced typed record structs that replace the generic `libdns.Record` type. This is a fundamental change to the libdns API. 8 | 9 | #### What Changed in libdns 1.0 10 | 11 | - **Typed Records**: Instead of using generic `libdns.Record` structs, libdns v1.0 introduced typed record implementations like `libdns.Address`, `libdns.TXT`, `libdns.SRV`, etc. 12 | - **Parse() Method**: The new `Record` interface includes a `Parse()` method that returns typed structs 13 | - **RR() Method**: All record types implement `RR()` to get the underlying resource record data 14 | 15 | #### Migration for libdns 1.0 16 | 17 | See the [libdns documentation](https://pkg.go.dev/github.com/libdns/libdns) for complete details on migrating to typed records. 18 | 19 | Example of the new API: 20 | ```go 21 | // Old (libdns <1.0) 22 | records := []libdns.Record{ 23 | { 24 | Type: "A", 25 | Name: "www", 26 | Value: "1.2.3.4", 27 | TTL: 300 * time.Second, 28 | }, 29 | } 30 | 31 | // New (libdns >=1.0) 32 | records := []libdns.Record{ 33 | libdns.Address{ 34 | Name: "www", 35 | Value: netip.MustParseAddr("1.2.3.4"), 36 | TTL: 300 * time.Second, 37 | }, 38 | } 39 | ``` 40 | 41 | ### Field Renames 42 | 43 | Two provider configuration fields have been renamed for clarity: 44 | 45 | #### 1. `MaxWaitDur` → `Route53MaxWait` 46 | 47 | **Old (pre-v1.6):** 48 | ```go 49 | provider := &route53.Provider{ 50 | MaxWaitDur: 60, // Was treated as seconds (multiplied by time.Second internally) 51 | } 52 | ``` 53 | 54 | **New (v1.6+):** 55 | ```go 56 | provider := &route53.Provider{ 57 | Route53MaxWait: 60 * time.Second, // Use proper time.Duration 58 | } 59 | ``` 60 | 61 | **Important:** In versions before v1.6, `MaxWaitDur` was silently multiplied by `time.Second` in the provider's init function. This was non-idiomatic Go and has been fixed. You must now provide a proper `time.Duration` value (like `60 * time.Second` or `2 * time.Minute`), as is standard in Go. 62 | 63 | **Failure to multiply by `time.Second` will result in a 60-nanosecond timeout instead of 60 seconds!** 64 | 65 | **Rationale:** The new name clearly indicates this is a Route53-specific timeout for AWS internal propagation, not general DNS propagation. 66 | 67 | #### 2. `WaitForPropagation` → `WaitForRoute53Sync` 68 | 69 | **Old (pre-v1.6):** 70 | ```go 71 | provider := &route53.Provider{ 72 | WaitForPropagation: true, 73 | } 74 | ``` 75 | 76 | **New (v1.6+):** 77 | ```go 78 | provider := &route53.Provider{ 79 | WaitForRoute53Sync: true, 80 | } 81 | ``` 82 | 83 | **Rationale:** The new name clearly indicates this waits for Route53's internal synchronization, not worldwide DNS propagation (which can take hours depending on TTL values). 84 | 85 | ### Removed Deprecated Fields 86 | 87 | Two deprecated fields have been removed in v1.6: 88 | 89 | - **`AWSProfile`** → Use `Profile` instead 90 | - **`Token`** → Use `SessionToken` instead 91 | 92 | These fields were deprecated several versions ago and have identical functionality to their replacements. 93 | 94 | ```go 95 | // Old (removed in v1.6) 96 | provider := &route53.Provider{ 97 | AWSProfile: "my-profile", 98 | Token: "my-session-token", 99 | } 100 | 101 | // New (v1.6+) 102 | provider := &route53.Provider{ 103 | Profile: "my-profile", 104 | SessionToken: "my-session-token", 105 | } 106 | ``` 107 | 108 | **JSON Configuration:** If using JSON config, update field names: `aws_profile` → `profile`, `token` → `session_token` 109 | 110 | ## Migration Checklist 111 | 112 | - [ ] Update to libdns v1.0+ (see libdns documentation for typed records) 113 | - [ ] Rename `MaxWaitDur` to `Route53MaxWait` in your code 114 | - [ ] Change from plain integer (e.g., `60`) to proper `time.Duration` (e.g., `60 * time.Second`) 115 | - [ ] Rename `WaitForPropagation` to `WaitForRoute53Sync` in your code 116 | - [ ] Replace `AWSProfile` with `Profile` (if using) 117 | - [ ] Replace `Token` with `SessionToken` (if using) 118 | - [ ] Update JSON/YAML configuration files with new field names 119 | - [ ] Test your code thoroughly after migration 120 | -------------------------------------------------------------------------------- /libdnstest/route53_test.go: -------------------------------------------------------------------------------- 1 | package route53_libdnstest_test 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | "os" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/libdns/libdns" 12 | "github.com/libdns/libdns/libdnstest" 13 | "github.com/libdns/route53" 14 | ) 15 | 16 | func TestRoute53Provider(t *testing.T) { 17 | // get credentials from environment 18 | accessKeyId := os.Getenv("AWS_ACCESS_KEY_ID") 19 | secretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY") 20 | sessionToken := os.Getenv("AWS_SESSION_TOKEN") 21 | region := os.Getenv("AWS_REGION") 22 | profile := os.Getenv("AWS_PROFILE") 23 | testZone := os.Getenv("ROUTE53_TEST_ZONE") 24 | 25 | // check required environment variables 26 | if testZone == "" { 27 | t.Skip("Skipping Route53 provider tests: ROUTE53_TEST_ZONE environment variable must be set") 28 | } 29 | 30 | if !strings.HasSuffix(testZone, ".") { 31 | t.Fatal("We expect the test zone to have trailing dot") 32 | } 33 | 34 | // create provider with available credentials 35 | provider := &route53.Provider{ 36 | Region: region, 37 | Profile: profile, 38 | AccessKeyId: accessKeyId, 39 | SecretAccessKey: secretAccessKey, 40 | SessionToken: sessionToken, 41 | } 42 | 43 | suite := libdnstest.NewTestSuite(libdnstest.WrapNoZoneLister(provider), testZone) 44 | suite.RunTests(t) 45 | } 46 | 47 | func TestSkipRoute53SyncOnDelete_Performance(t *testing.T) { 48 | // get credentials from environment 49 | accessKeyId := os.Getenv("AWS_ACCESS_KEY_ID") 50 | secretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY") 51 | sessionToken := os.Getenv("AWS_SESSION_TOKEN") 52 | region := os.Getenv("AWS_REGION") 53 | profile := os.Getenv("AWS_PROFILE") 54 | testZone := os.Getenv("ROUTE53_TEST_ZONE") 55 | 56 | // check required environment variables 57 | if testZone == "" { 58 | t.Skip("Skipping Route53 provider tests: ROUTE53_TEST_ZONE environment variable must be set") 59 | } 60 | 61 | if !strings.HasSuffix(testZone, ".") { 62 | t.Fatal("We expect the test zone to have trailing dot") 63 | } 64 | 65 | // create provider with waiting enabled but skip on delete operations 66 | provider := &route53.Provider{ 67 | Region: region, 68 | Profile: profile, 69 | AccessKeyId: accessKeyId, 70 | SecretAccessKey: secretAccessKey, 71 | SessionToken: sessionToken, 72 | WaitForRoute53Sync: true, 73 | SkipRoute53SyncOnDelete: true, 74 | } 75 | 76 | ctx := context.Background() 77 | testRecord := &libdns.Address{ 78 | Name: "test-append-record-r53-sync", 79 | TTL: 300 * time.Second, 80 | IP: netip.MustParseAddr("192.0.2.1"), 81 | } 82 | 83 | // clean up first - delete the record if it exists (fast because we skip sync on delete) 84 | t.Log("Cleaning up any existing test record...") 85 | _, _ = provider.DeleteRecords(ctx, testZone, []libdns.Record{testRecord}) 86 | 87 | // append the record - this should take longer because we wait for sync 88 | t.Log("Appending record with WaitForRoute53Sync=true (should be slow)...") 89 | appendStart := time.Now() 90 | _, err := provider.AppendRecords(ctx, testZone, []libdns.Record{testRecord}) 91 | appendDuration := time.Since(appendStart) 92 | if err != nil { 93 | t.Fatalf("Failed to append record: %v", err) 94 | } 95 | t.Logf("Append took %v", appendDuration) 96 | 97 | // delete the record - this should be fast because we skip sync on delete 98 | t.Log("Deleting record with SkipRoute53SyncOnDelete=true (should be fast)...") 99 | deleteStart := time.Now() 100 | _, err = provider.DeleteRecords(ctx, testZone, []libdns.Record{testRecord}) 101 | deleteDuration := time.Since(deleteStart) 102 | if err != nil { 103 | t.Fatalf("Failed to delete record: %v", err) 104 | } 105 | t.Logf("Delete took %v", deleteDuration) 106 | 107 | // verify that delete was significantly faster than append 108 | // append should wait for sync (typically 5-60 seconds) 109 | // delete should skip sync (typically <2 seconds) 110 | t.Logf("Performance comparison: append=%v, delete=%v, ratio=%.2fx", 111 | appendDuration, deleteDuration, float64(appendDuration)/float64(deleteDuration)) 112 | 113 | if deleteDuration >= appendDuration { 114 | t.Errorf("Delete operation took longer than append, expected delete to be faster due to SkipRoute53SyncOnDelete") 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Route53 for `libdns` 2 | ======================= 3 | 4 | [![godoc reference](https://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/libdns/route53) 5 | 6 | > [!WARNING] 7 | > **Breaking changes in v1.6:** Field names have changed. See [BREAKING.md](BREAKING.md) for migration guide. 8 | 9 | This package implements the [libdns interfaces](https://github.com/libdns/libdns) for AWS [Route53](https://aws.amazon.com/route53/). 10 | 11 | ## Example 12 | 13 | ```go 14 | package main 15 | 16 | import ( 17 | "context" 18 | "fmt" 19 | "time" 20 | 21 | "github.com/libdns/route53" 22 | ) 23 | 24 | func main() { 25 | // greate a new Route53 provider instance 26 | provider := &route53.Provider{ 27 | AccessKeyId: "YOUR_ACCESS_KEY_ID", 28 | SecretAccessKey: "YOUR_SECRET_ACCESS_KEY", 29 | Region: "us-east-1", 30 | } 31 | 32 | ctx := context.Background() 33 | zone := "example.com." 34 | 35 | // get all records for the zone 36 | records, err := provider.GetRecords(ctx, zone) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | for _, record := range records { 42 | fmt.Printf("%s %s %s %d\n", record.Name, record.Type, record.Value, record.TTL/time.Second) 43 | } 44 | } 45 | ``` 46 | 47 | ## Authenticating 48 | 49 | This package supports all the credential configuration methods described in the [AWS Developer Guide](https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/#specifying-credentials), such as `Environment Variables`, `Shared configuration files`, the `AWS Credentials file` located in `.aws/credentials`, and `Static Credentials`. You may also pass in static credentials directly (or via caddy's configuration). 50 | 51 | The following IAM policy is a minimal working example to give `libdns` permissions to manage DNS records: 52 | 53 | ```json 54 | { 55 | "Version": "2012-10-17", 56 | "Statement": [ 57 | { 58 | "Sid": "", 59 | "Effect": "Allow", 60 | "Action": [ 61 | "route53:ListResourceRecordSets", 62 | "route53:GetChange", 63 | "route53:ChangeResourceRecordSets" 64 | ], 65 | "Resource": [ 66 | "arn:aws:route53:::hostedzone/ZABCD1EFGHIL", 67 | "arn:aws:route53:::change/*" 68 | ] 69 | }, 70 | { 71 | "Sid": "", 72 | "Effect": "Allow", 73 | "Action": [ 74 | "route53:ListHostedZonesByName", 75 | "route53:ListHostedZones" 76 | ], 77 | "Resource": "*" 78 | } 79 | ] 80 | } 81 | ``` 82 | 83 | ### Running in Docker on EC2 with Instance Roles 84 | 85 | When running this provider in a Docker container on EC2 instances that use IAM instance roles, you need to ensure that the container can access the EC2 metadata service. By default, IMDSv2 (Instance Metadata Service Version 2) limits the hop count to 1, which prevents Docker containers from accessing the metadata service. 86 | 87 | Instances created through the AWS Console typically have a hop limit of 2 by default and won't have this issue. This configuration is usually needed for instances created programmatically or with older configurations. 88 | 89 | To enable Docker containers to use EC2 instance roles, configure the instance metadata options with an increased hop limit: 90 | 91 | ```bash 92 | aws ec2 modify-instance-metadata-options \ 93 | --instance-id \ 94 | --http-put-response-hop-limit 2 \ 95 | --http-endpoint enabled 96 | ``` 97 | 98 | Or when launching an instance: 99 | 100 | ```bash 101 | aws ec2 run-instances \ 102 | --metadata-options "HttpEndpoint=enabled,HttpPutResponseHopLimit=2" \ 103 | # ... other parameters 104 | ``` 105 | 106 | For more information, see the [AWS EC2 Instance Metadata Options documentation](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_InstanceMetadataOptionsRequest.html). 107 | 108 | ## Note on propagation-related fields 109 | 110 | When you update records in AWS Route53, changes first propagate internally across AWS's DNS servers before becoming visible to the public. This internal step usually finishes within seconds, but may take more in rare cases, and can be waited on when `WaitForRoute53Sync` is enabled. *It is different from normal DNS propagation, which depends on TTL and external caching.* 111 | 112 | See [Change Propagation to Route 53 DNS Servers](https://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeResourceRecordSets.html#API_ChangeResourceRecordSets_RequestSyntax:~:text=Change%20Propagation%20to%20Route%2053%20DNS%20Servers). 113 | 114 | ### Performance optimization for delete operations 115 | 116 | By default, when `WaitForRoute53Sync` is enabled, the provider waits for synchronization on all operations, including deletes. For bulk delete operations where immediate consistency is not required, you can skip the wait on deletes by setting `SkipRoute53SyncOnDelete` to `true`: 117 | 118 | ```go 119 | provider := &route53.Provider{ 120 | WaitForRoute53Sync: true, // Wait for sync on create/update 121 | SkipRoute53SyncOnDelete: true, // Skip wait on delete for better performance 122 | } 123 | ``` 124 | 125 | This can significantly speed up bulk delete operations while still maintaining consistency guarantees for create and update operations. 126 | 127 | ## Contributing 128 | 129 | Contributions are welcome! Please ensure that: 130 | 131 | 1. All code passes `golangci-lint` checks. Run the following before committing: 132 | ```bash 133 | golangci-lint run ./... 134 | ``` 135 | 136 | 2. All tests pass: 137 | ```bash 138 | go test ./... 139 | ``` 140 | 141 | 3. For integration tests, set up the required environment variables: 142 | ```bash 143 | export AWS_ACCESS_KEY_ID="your-key" 144 | export AWS_SECRET_ACCESS_KEY="your-secret" 145 | export ROUTE53_TEST_ZONE="test.example.com." 146 | cd libdnstest && go test -v 147 | ``` 148 | 149 | Please fix any linter issues before submitting a pull request. The project maintains strict code quality standards to ensure maintainability. 150 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | package route53 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | r53 "github.com/aws/aws-sdk-go-v2/service/route53" 8 | "github.com/libdns/libdns" 9 | ) 10 | 11 | // Provider implements the libdns interfaces for Route53. 12 | // 13 | // By default, the provider loads the AWS configuration from the environment. 14 | // To override these values, set the fields in the Provider struct. 15 | type Provider struct { 16 | client *r53.Client 17 | 18 | // Region is the AWS Region to use. If not set, it will use AWS_REGION 19 | // environment variable. 20 | Region string `json:"region,omitempty"` 21 | 22 | // AWSProfile is the AWS Profile to use. If not set, it will use 23 | // AWS_PROFILE environment variable. 24 | Profile string `json:"profile,omitempty"` 25 | 26 | // AccessKeyId is the AWS Access Key ID to use. If not set, it will use 27 | // AWS_ACCESS_KEY_ID 28 | AccessKeyId string `json:"access_key_id,omitempty"` //nolint:revive,staticcheck // established public API, cannot change 29 | 30 | // SecretAccessKey is the AWS Secret Access Key to use. If not set, it will use 31 | // AWS_SECRET_ACCESS_KEY environment variable. 32 | SecretAccessKey string `json:"secret_access_key,omitempty"` 33 | 34 | // SessionToken is the AWS Session Token to use. If not set, it will use 35 | // AWS_SESSION_TOKEN environment variable. 36 | SessionToken string `json:"session_token,omitempty"` 37 | 38 | // MaxRetries is the maximum number of retries to make when a request 39 | // fails. If not set, it will use 5 retries. 40 | MaxRetries int `json:"max_retries,omitempty"` 41 | 42 | // Route53MaxWait is the maximum amount of time to wait for a record 43 | // to be propagated within AWS infrastructure. Default is 1 minute. 44 | Route53MaxWait time.Duration `json:"route53_max_wait,omitempty"` 45 | 46 | // WaitForRoute53Sync if set to true, it will wait for the record to be 47 | // propagated within AWS infrastructure before returning. This is not related 48 | // to DNS propagation, that could take much longer. 49 | WaitForRoute53Sync bool `json:"wait_for_route53_sync,omitempty"` 50 | 51 | // SkipRoute53SyncOnDelete if set to true, it will skip waiting for Route53 52 | // synchronization when deleting records, even if WaitForRoute53Sync is true. 53 | // This can speed up bulk delete operations where waiting is not necessary. 54 | SkipRoute53SyncOnDelete bool `json:"skip_route53_sync_on_delete,omitempty"` 55 | 56 | // HostedZoneID is the ID of the hosted zone to use. If not set, it will 57 | // be discovered from the zone name. 58 | // 59 | // This option should contain only the ID; the "/hostedzone/" prefix 60 | // will be added automatically. 61 | HostedZoneID string `json:"hosted_zone_id,omitempty"` 62 | } 63 | 64 | // GetRecords lists all the records in the zone. 65 | func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { 66 | p.init(ctx) 67 | 68 | zoneID, err := p.getZoneID(ctx, zone) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | records, err := p.getRecords(ctx, zoneID, zone) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return records, nil 79 | } 80 | 81 | // AppendRecords adds records to the zone. It returns the records that were added. 82 | func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { 83 | p.init(ctx) 84 | 85 | zoneID, err := p.getZoneID(ctx, zone) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | // group records by name+type since Route53 treats them as a single ResourceRecordSet 91 | recordSets := p.groupRecordsByKey(records) 92 | 93 | var createdRecords []libdns.Record 94 | 95 | // process each record set 96 | for key, recordGroup := range recordSets { 97 | created, appendErr := p.appendRecordSet(ctx, zoneID, zone, key, recordGroup) 98 | if appendErr != nil { 99 | return nil, appendErr 100 | } 101 | createdRecords = append(createdRecords, created...) 102 | } 103 | 104 | return createdRecords, nil 105 | } 106 | 107 | // appendRecordSet appends records to a single ResourceRecordSet. 108 | func (p *Provider) appendRecordSet( 109 | ctx context.Context, 110 | zoneID, zone string, 111 | key recordSetKey, 112 | recordGroup []libdns.Record, 113 | ) ([]libdns.Record, error) { 114 | if len(recordGroup) == 0 { 115 | return nil, nil 116 | } 117 | 118 | // for single records, use the simple create 119 | if len(recordGroup) == 1 { 120 | newRecord, err := p.createRecord(ctx, zoneID, recordGroup[0], zone) 121 | if err != nil { 122 | return nil, err 123 | } 124 | return []libdns.Record{newRecord}, nil 125 | } 126 | 127 | // for multiple records, we need to append to existing set if it exists 128 | existingRecords, err := p.getRecords(ctx, zoneID, zone) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | // find existing records for this name+type 134 | var existingValues []libdns.Record 135 | absoluteName := libdns.AbsoluteName(key.name, zone) 136 | for _, existing := range existingRecords { 137 | existingRR := existing.RR() 138 | if existingRR.Name == absoluteName && existingRR.Type == key.recordType { 139 | existingValues = append(existingValues, existing) 140 | } 141 | } 142 | 143 | // combine existing records with new ones 144 | allRecords := make([]libdns.Record, 0, len(existingValues)+len(recordGroup)) 145 | allRecords = append(allRecords, existingValues...) 146 | allRecords = append(allRecords, recordGroup...) 147 | 148 | // use UPSERT to set all values at once 149 | err = p.setRecordSet(ctx, zoneID, zone, key.name, key.recordType, allRecords) 150 | if err != nil { 151 | return nil, err 152 | } 153 | 154 | // return only the new records that were added 155 | return recordGroup, nil 156 | } 157 | 158 | // recordSetKey uniquely identifies a Route53 ResourceRecordSet by name and type. 159 | type recordSetKey struct { 160 | name string 161 | recordType string 162 | } 163 | 164 | // DeleteRecords deletes the records from the zone. If a record does not have an ID, 165 | // it will be looked up. It returns the records that were deleted. 166 | func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { 167 | p.init(ctx) 168 | 169 | // mark this context as a delete operation 170 | ctx = context.WithValue(ctx, contextKeyIsDeleteOperation, true) 171 | 172 | zoneID, err := p.getZoneID(ctx, zone) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | existingRecords, err := p.getRecords(ctx, zoneID, zone) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | // group records by name+type 183 | toDelete := p.groupRecordsByKey(records) 184 | 185 | // index existing records for efficient lookup 186 | existingByKey := p.indexRecordsByKey(existingRecords) 187 | 188 | // process each record set 189 | var deletedRecords []libdns.Record 190 | for key, deleteGroup := range toDelete { 191 | deleted, deleteErr := p.processRecordSetDeletion(ctx, zoneID, zone, key, deleteGroup, existingByKey[key]) 192 | if deleteErr != nil { 193 | return nil, deleteErr 194 | } 195 | deletedRecords = append(deletedRecords, deleted...) 196 | } 197 | 198 | return deletedRecords, nil 199 | } 200 | 201 | // groupRecordsByKey groups records by their name and type. 202 | func (p *Provider) groupRecordsByKey(records []libdns.Record) map[recordSetKey][]libdns.Record { 203 | grouped := make(map[recordSetKey][]libdns.Record) 204 | for _, record := range records { 205 | rr := record.RR() 206 | key := recordSetKey{ 207 | name: rr.Name, 208 | recordType: rr.Type, 209 | } 210 | grouped[key] = append(grouped[key], record) 211 | } 212 | return grouped 213 | } 214 | 215 | // indexRecordsByKey creates an index of records by their name and type. 216 | func (p *Provider) indexRecordsByKey(records []libdns.Record) map[recordSetKey][]libdns.Record { 217 | indexed := make(map[recordSetKey][]libdns.Record) 218 | for _, record := range records { 219 | rr := record.RR() 220 | key := recordSetKey{ 221 | name: rr.Name, 222 | recordType: rr.Type, 223 | } 224 | indexed[key] = append(indexed[key], record) 225 | } 226 | return indexed 227 | } 228 | 229 | // processRecordSetDeletion handles the deletion of records from a single ResourceRecordSet. 230 | func (p *Provider) processRecordSetDeletion( 231 | ctx context.Context, 232 | zoneID, zone string, 233 | key recordSetKey, 234 | deleteGroup []libdns.Record, 235 | existingValues []libdns.Record, 236 | ) ([]libdns.Record, error) { 237 | if len(existingValues) == 0 { 238 | return nil, nil 239 | } 240 | 241 | // build set of values to delete 242 | deleteValues := make(map[string]bool) 243 | for _, rec := range deleteGroup { 244 | deleteValues[rec.RR().Data] = true 245 | } 246 | 247 | // determine which records to keep and which to delete 248 | var remainingValues, deletedRecords []libdns.Record 249 | for _, existing := range existingValues { 250 | if deleteValues[existing.RR().Data] { 251 | deletedRecords = append(deletedRecords, existing) 252 | } else { 253 | remainingValues = append(remainingValues, existing) 254 | } 255 | } 256 | 257 | // apply the appropriate operation 258 | if len(remainingValues) == 0 { 259 | // delete the entire record set 260 | err := p.deleteRecordSet(ctx, zoneID, zone, key.name, key.recordType, existingValues) 261 | if err != nil { 262 | return nil, err 263 | } 264 | } else { 265 | // update the record set with remaining values 266 | err := p.setRecordSet(ctx, zoneID, zone, key.name, key.recordType, remainingValues) 267 | if err != nil { 268 | return nil, err 269 | } 270 | } 271 | 272 | return deletedRecords, nil 273 | } 274 | 275 | // SetRecords sets the records in the zone, either by updating existing records 276 | // or creating new ones. It returns the updated records. 277 | func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { 278 | p.init(ctx) 279 | 280 | zoneID, err := p.getZoneID(ctx, zone) 281 | if err != nil { 282 | return nil, err 283 | } 284 | 285 | var updatedRecords []libdns.Record 286 | 287 | for _, record := range records { 288 | updatedRecord, updateErr := p.updateRecord(ctx, zoneID, record, zone) 289 | if updateErr != nil { 290 | return nil, updateErr 291 | } 292 | updatedRecords = append(updatedRecords, updatedRecord) 293 | } 294 | 295 | return updatedRecords, nil 296 | } 297 | 298 | // Interface guards. 299 | var ( 300 | _ libdns.RecordGetter = (*Provider)(nil) 301 | _ libdns.RecordAppender = (*Provider)(nil) 302 | _ libdns.RecordSetter = (*Provider)(nil) 303 | _ libdns.RecordDeleter = (*Provider)(nil) 304 | ) 305 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package route53 //nolint:testpackage // Testing internal functions 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/aws/aws-sdk-go-v2/service/route53/types" 10 | "github.com/libdns/libdns" 11 | ) 12 | 13 | func TestTXTMarshalling(t *testing.T) { 14 | cases := []struct { 15 | name string 16 | input string 17 | expected string 18 | }{ 19 | { 20 | name: "string with quotes", 21 | input: `This string includes "quotation marks".`, 22 | expected: `"This string includes \"quotation marks\"."`, 23 | }, 24 | { 25 | name: "string with backslashes", 26 | input: `This string includes \backslashes\`, 27 | expected: `"This string includes \\backslashes\\"`, 28 | }, 29 | { 30 | name: "string with special characters UTF-8", 31 | input: `The last character in this string is an accented e specified in octal format: é`, 32 | expected: `"The last character in this string is an accented e specified in octal format: \303\251"`, 33 | }, 34 | { 35 | name: "simple", 36 | input: "v=spf1 ip4:192.168.0.1/16 -all", 37 | expected: `"v=spf1 ip4:192.168.0.1/16 -all"`, 38 | }, 39 | { 40 | name: "control characters", 41 | input: "test\x00\x1f\x7f", 42 | expected: `"test\000\037\177"`, 43 | }, 44 | } 45 | 46 | for _, c := range cases { 47 | t.Run(c.name, func(t *testing.T) { 48 | actual := quote(c.input) 49 | if actual != c.expected { 50 | t.Errorf("expected %q, got %q", c.expected, actual) 51 | } 52 | }) 53 | } 54 | } 55 | 56 | func TestTXTUnmarhalling(t *testing.T) { 57 | cases := []struct { 58 | name string 59 | input string 60 | expected string 61 | }{ 62 | { 63 | name: "string with quotes", 64 | input: `"This string includes \"quotation marks\"."`, 65 | expected: `This string includes "quotation marks".`, 66 | }, 67 | { 68 | name: "string with backslashes", 69 | input: `"This string includes \\backslashes\\"`, 70 | expected: `This string includes \backslashes\`, 71 | }, 72 | { 73 | name: "string with special characters UTF-8", 74 | input: `"The last character in this string is an accented e specified in octal format: \303\251"`, 75 | expected: `The last character in this string is an accented e specified in octal format: é`, 76 | }, 77 | { 78 | name: "simple", 79 | input: `"v=spf1 ip4:192.168.0.1/16 -all"`, 80 | expected: "v=spf1 ip4:192.168.0.1/16 -all", 81 | }, 82 | { 83 | name: "control characters", 84 | input: `"test\000\037\177"`, 85 | expected: "test\x00\x1f\x7f", 86 | }, 87 | } 88 | 89 | for _, c := range cases { 90 | t.Run(c.name, func(t *testing.T) { 91 | actual := unquote(c.input) 92 | if actual != c.expected { 93 | t.Errorf("expected %q, got %q", c.expected, actual) 94 | } 95 | }) 96 | } 97 | } 98 | 99 | func TestParseRecordSet(t *testing.T) { //nolint:gocognit // test complexity is acceptable 100 | testZone := "example.com." 101 | cases := []struct { 102 | name string 103 | input types.ResourceRecordSet 104 | expected []libdns.RR 105 | }{ 106 | { 107 | name: "A record at zone apex", 108 | input: types.ResourceRecordSet{ 109 | Name: aws.String("example.com."), 110 | Type: types.RRTypeA, 111 | ResourceRecords: []types.ResourceRecord{ 112 | { 113 | Value: aws.String("127.0.0.1"), 114 | }, 115 | }, 116 | }, 117 | expected: []libdns.RR{ 118 | { 119 | Type: "A", 120 | Name: "@", 121 | Data: "127.0.0.1", 122 | }, 123 | }, 124 | }, 125 | { 126 | name: "CNAME record with wildcard", 127 | input: types.ResourceRecordSet{ 128 | Name: aws.String("*.example.com."), 129 | Type: types.RRTypeCname, 130 | ResourceRecords: []types.ResourceRecord{ 131 | { 132 | Value: aws.String("example.com"), 133 | }, 134 | }, 135 | }, 136 | expected: []libdns.RR{ 137 | { 138 | Type: "CNAME", 139 | Name: "*", 140 | Data: "example.com", 141 | }, 142 | }, 143 | }, 144 | { 145 | name: "TXT record", 146 | input: types.ResourceRecordSet{ 147 | Name: aws.String("test.example.com."), 148 | Type: types.RRTypeTxt, 149 | ResourceRecords: []types.ResourceRecord{ 150 | { 151 | Value: aws.String(`"This string includes \"quotation marks\"."`), 152 | }, 153 | { 154 | Value: aws.String(`"This string includes \\backslashes\\"`), 155 | }, 156 | { 157 | Value: aws.String( 158 | `"The last character in this string is an accented e specified in octal format: \303\251"`, 159 | ), 160 | }, 161 | { 162 | Value: aws.String(`"String 1" "String 2" "String 3"`), 163 | }, 164 | }, 165 | }, 166 | expected: []libdns.RR{ 167 | { 168 | Type: "TXT", 169 | Name: "test", 170 | Data: `This string includes "quotation marks".`, 171 | }, 172 | { 173 | Type: "TXT", 174 | Name: "test", 175 | Data: `This string includes \backslashes\`, 176 | }, 177 | { 178 | Type: "TXT", 179 | Name: "test", 180 | Data: `The last character in this string is an accented e specified in octal format: é`, 181 | }, 182 | { 183 | Type: "TXT", 184 | Name: "test", 185 | Data: `String 1String 2String 3`, 186 | }, 187 | }, 188 | }, 189 | { 190 | name: "TXT long record", 191 | input: types.ResourceRecordSet{ 192 | Name: aws.String("_testlong.example.com."), 193 | Type: types.RRTypeTxt, 194 | ResourceRecords: []types.ResourceRecord{ 195 | { 196 | Value: aws.String( 197 | `"3gImdrsMGi6MzHi2rMviVqvwJbv7tXDPk6JvUEI2Fnl7sRF1bUSjNIe4qnatzomDu368bV6Q45qItkF wwnYoGBXNu1uclGvlPIIcGQd6wqBPzTtv0P83brCXJ59RJNLnAif8a3EQuLy88GmblPq 42uJpHTeNYnDRLQt8WvhRCYySX6bx" "vJtK8TZJtVRFbCgUrziRgQVzLwV4fn2hitpnItt U3Ke9IE5 gcs1Obx9kG8wkQ9h4qIxKDLVsmYdhuw4kdLmM2Qm6jJ3ZlSIaQWFP2eNLq5NwZfgATZiGRhr"`, 198 | ), 199 | }, 200 | }, 201 | }, 202 | expected: []libdns.RR{ 203 | { 204 | Type: "TXT", 205 | Name: "_testlong", 206 | Data: "3gImdrsMGi6MzHi2rMviVqvwJbv7tXDPk6JvUEI2Fnl7sRF1bUSjNIe4qnatzomDu368bV6Q45qItkF wwnYoGBXNu1uclGvlPIIcGQd6wqBPzTtv0P83brCXJ59RJNLnAif8a3EQuLy88GmblPq 42uJpHTeNYnDRLQt8WvhRCYySX6bxvJtK8TZJtVRFbCgUrziRgQVzLwV4fn2hitpnItt U3Ke9IE5 gcs1Obx9kG8wkQ9h4qIxKDLVsmYdhuw4kdLmM2Qm6jJ3ZlSIaQWFP2eNLq5NwZfgATZiGRhr", 207 | }, 208 | }, 209 | }, 210 | } 211 | 212 | for _, c := range cases { 213 | t.Run(c.name, func(t *testing.T) { 214 | actual, err := parseRecordSet(c.input, testZone) 215 | if err != nil { 216 | t.Errorf("unexpected error: %v", err) 217 | } 218 | if len(actual) != len(c.expected) { 219 | t.Errorf("expected %d records, got %d", len(c.expected), len(actual)) 220 | } 221 | for i, record := range actual { 222 | if record.RR().Type != c.expected[i].Type { 223 | t.Errorf("expected type %s, got %s", c.expected[i].Type, record.RR().Type) 224 | } 225 | if record.RR().Name != c.expected[i].Name { 226 | t.Errorf("expected name %s, got %s", c.expected[i].Name, record.RR().Name) 227 | } 228 | if record.RR().Data != c.expected[i].Data { 229 | t.Errorf("expected value %s, got %s", c.expected[i].Data, record.RR().Data) 230 | } 231 | } 232 | }) 233 | } 234 | } 235 | 236 | func TestMarshalRecord(t *testing.T) { 237 | cases := []struct { 238 | name string 239 | input libdns.RR 240 | expected []types.ResourceRecord 241 | }{ 242 | { 243 | name: "A record", 244 | input: libdns.RR{ 245 | Type: "A", 246 | Name: "", 247 | Data: "127.0.0.1", 248 | }, 249 | expected: []types.ResourceRecord{ 250 | { 251 | Value: aws.String("127.0.0.1"), 252 | }, 253 | }, 254 | }, 255 | { 256 | name: "A record with name", 257 | input: libdns.RR{ 258 | Type: "A", 259 | Name: "test", 260 | Data: "127.0.0.1", 261 | }, 262 | expected: []types.ResourceRecord{ 263 | { 264 | Value: aws.String("127.0.0.1"), 265 | }, 266 | }, 267 | }, 268 | { 269 | name: "TXT record", 270 | input: libdns.RR{ 271 | Type: "TXT", 272 | Name: "", 273 | Data: "test", 274 | }, 275 | expected: []types.ResourceRecord{ 276 | { 277 | Value: aws.String(`"test"`), 278 | }, 279 | }, 280 | }, 281 | { 282 | name: "TXT record with name", 283 | input: libdns.RR{ 284 | Type: "TXT", 285 | Name: "test", 286 | Data: "test", 287 | }, 288 | expected: []types.ResourceRecord{ 289 | { 290 | Value: aws.String(`"test"`), 291 | }, 292 | }, 293 | }, 294 | { 295 | name: "TXT record with long value", 296 | input: libdns.RR{ 297 | Type: "TXT", 298 | Name: "test", 299 | Data: `3gImdrsMGi6MzHi2rMviVqvwJbv7tXDPk6JvUEI2Fnl7sRF1bUSjNIe4qnatzomDu368bV6Q45qItkF wwnYoGBXNu1uclGvlPIIcGQd6wqBPzTtv0P83brCXJ59RJNLnAif8a3EQuLy88GmblPq 42uJpHTeNYnDRLQt8WvhRCYySX6bxvJtK8TZJtVRFbCgUrziRgQVzLwV4fn2hitpnItt U3Ke9IE5 gcs1Obx9kG8wkQ9h4qIxKDLVsmYdhuw4kdLmM2Qm6jJ3ZlSIaQWFP2eNLq5NwZfgATZiGRhr`, 300 | }, 301 | expected: []types.ResourceRecord{ 302 | { 303 | Value: aws.String( 304 | `"3gImdrsMGi6MzHi2rMviVqvwJbv7tXDPk6JvUEI2Fnl7sRF1bUSjNIe4qnatzomDu368bV6Q45qItkF wwnYoGBXNu1uclGvlPIIcGQd6wqBPzTtv0P83brCXJ59RJNLnAif8a3EQuLy88GmblPq 42uJpHTeNYnDRLQt8WvhRCYySX6bxvJtK8TZJtVRFbCgUrziRgQVzLwV4fn2hitpnItt U3Ke9IE5 gcs1Obx9kG8wkQ9h4qIxKDLVsmYd" "huw4kdLmM2Qm6jJ3ZlSIaQWFP2eNLq5NwZfgATZiGRhr"`, 305 | ), 306 | }, 307 | }, 308 | }, 309 | { 310 | name: "TXT record with a special character", 311 | input: libdns.RR{ 312 | Type: "TXT", 313 | Name: "test", 314 | Data: `test é`, 315 | }, 316 | expected: []types.ResourceRecord{ 317 | { 318 | Value: aws.String(`"test \303\251"`), 319 | }, 320 | }, 321 | }, 322 | { 323 | name: "TXT record with quotes", 324 | input: libdns.RR{ 325 | Type: "TXT", 326 | Name: "test", 327 | Data: `"test"`, 328 | }, 329 | expected: []types.ResourceRecord{ 330 | { 331 | Value: aws.String(`"\"test\""`), 332 | }, 333 | }, 334 | }, 335 | { 336 | name: "TXT record with backslashes", 337 | input: libdns.RR{ 338 | Type: "TXT", 339 | Name: "test", 340 | Data: `\test\`, 341 | }, 342 | expected: []types.ResourceRecord{ 343 | { 344 | Value: aws.String(`"\\test\\"`), 345 | }, 346 | }, 347 | }, 348 | } 349 | 350 | for _, c := range cases { 351 | t.Run(c.name, func(t *testing.T) { 352 | actual := marshalRecord(c.input) 353 | if len(actual) != len(c.expected) { 354 | t.Errorf("expected %d records, got %d", len(c.expected), len(actual)) 355 | } 356 | for i, record := range actual { 357 | if *record.Value != *c.expected[i].Value { 358 | t.Errorf("expected value %s, got %s", *c.expected[i].Value, *record.Value) 359 | } 360 | } 361 | }) 362 | } 363 | } 364 | 365 | func TestRoute53MaxWait(t *testing.T) { 366 | cases := []struct { 367 | name string 368 | input time.Duration 369 | expected time.Duration 370 | }{ 371 | { 372 | name: "default", 373 | input: 0, 374 | expected: 60 * time.Second, 375 | }, 376 | { 377 | name: "custom", 378 | input: 120, 379 | expected: 120, 380 | }, 381 | } 382 | 383 | for _, c := range cases { 384 | t.Run(c.name, func(t *testing.T) { 385 | provider := Provider{Route53MaxWait: c.input} 386 | provider.init(context.TODO()) 387 | actual := provider.Route53MaxWait 388 | if actual != c.expected { 389 | t.Errorf("expected %d, got %d", c.expected, actual) 390 | } 391 | }) 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package route53 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "strings" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go-v2/aws" 12 | "github.com/aws/aws-sdk-go-v2/aws/retry" 13 | "github.com/aws/aws-sdk-go-v2/config" 14 | "github.com/aws/aws-sdk-go-v2/credentials" 15 | r53 "github.com/aws/aws-sdk-go-v2/service/route53" 16 | "github.com/aws/aws-sdk-go-v2/service/route53/types" 17 | "github.com/libdns/libdns" 18 | ) 19 | 20 | type contextKey int 21 | 22 | const ( 23 | contextKeyIsDeleteOperation contextKey = iota 24 | ) 25 | 26 | const ( 27 | // defaultTTL is the default TTL for DNS records in seconds. 28 | defaultTTL = 300 29 | // maxTXTValueLength is the maximum length of a single TXT record value. 30 | maxTXTValueLength = 255 31 | // maxRecordsPerPage is the maximum number of records Route53 returns per page. 32 | maxRecordsPerPage = 1000 33 | ) 34 | 35 | // changeRecordSet performs a specified action (UPSERT or DELETE) on a ResourceRecordSet. 36 | func (p *Provider) changeRecordSet( 37 | ctx context.Context, 38 | zoneID, zone, name, recordType string, 39 | records []libdns.Record, 40 | action types.ChangeAction, 41 | ) error { 42 | var resourceRecords []types.ResourceRecord 43 | for _, record := range records { 44 | rr := record.RR() 45 | resourceRecords = append(resourceRecords, marshalRecord(rr)...) 46 | } 47 | 48 | // use the TTL from the first record 49 | ttl := int64(defaultTTL) 50 | if len(records) > 0 { 51 | ttl = int64(records[0].RR().TTL.Seconds()) 52 | } 53 | 54 | input := &r53.ChangeResourceRecordSetsInput{ 55 | ChangeBatch: &types.ChangeBatch{ 56 | Changes: []types.Change{ 57 | { 58 | Action: action, 59 | ResourceRecordSet: &types.ResourceRecordSet{ 60 | Name: aws.String(libdns.AbsoluteName(name, zone)), 61 | ResourceRecords: resourceRecords, 62 | TTL: aws.Int64(ttl), 63 | Type: types.RRType(recordType), 64 | }, 65 | }, 66 | }, 67 | }, 68 | HostedZoneId: aws.String(zoneID), 69 | } 70 | 71 | return p.applyChange(ctx, input) 72 | } 73 | 74 | func (p *Provider) setRecordSet( 75 | ctx context.Context, 76 | zoneID, zone, name, recordType string, 77 | records []libdns.Record, 78 | ) error { 79 | // use UPSERT to replace the entire record set 80 | return p.changeRecordSet(ctx, zoneID, zone, name, recordType, records, types.ChangeActionUpsert) 81 | } 82 | 83 | func (p *Provider) deleteRecordSet( 84 | ctx context.Context, 85 | zoneID, zone, name, recordType string, 86 | records []libdns.Record, 87 | ) error { 88 | // use DELETE action to remove the entire record set 89 | return p.changeRecordSet(ctx, zoneID, zone, name, recordType, records, types.ChangeActionDelete) 90 | } 91 | 92 | func (p *Provider) init(ctx context.Context) { 93 | if p.client != nil { 94 | return 95 | } 96 | 97 | if p.MaxRetries == 0 { 98 | p.MaxRetries = 5 99 | } 100 | 101 | if p.Route53MaxWait == 0 { 102 | p.Route53MaxWait = time.Minute 103 | } 104 | 105 | opts := make([]func(*config.LoadOptions) error, 0) 106 | opts = append(opts, 107 | config.WithRetryer(func() aws.Retryer { 108 | return retry.AddWithMaxAttempts(retry.NewStandard(), p.MaxRetries) 109 | }), 110 | ) 111 | 112 | profile := p.Profile 113 | 114 | if profile != "" { 115 | opts = append(opts, config.WithSharedConfigProfile(profile)) 116 | } 117 | 118 | if p.Region != "" { 119 | opts = append(opts, config.WithRegion(p.Region)) 120 | } 121 | 122 | if p.AccessKeyId != "" && p.SecretAccessKey != "" { 123 | token := p.SessionToken 124 | 125 | opts = append( 126 | opts, 127 | config.WithCredentialsProvider( 128 | credentials.NewStaticCredentialsProvider(p.AccessKeyId, p.SecretAccessKey, token), 129 | ), 130 | ) 131 | } 132 | 133 | cfg, err := config.LoadDefaultConfig(ctx, opts...) 134 | if err != nil { 135 | log.Fatalf("route53: unable to load AWS SDK config, %v", err) 136 | } 137 | 138 | p.client = r53.NewFromConfig(cfg) 139 | } 140 | 141 | func chunkString(s string, chunkSize int) []string { 142 | var chunks []string 143 | for i := 0; i < len(s); i += chunkSize { 144 | end := i + chunkSize 145 | if end > len(s) { 146 | end = len(s) 147 | } 148 | chunks = append(chunks, s[i:end]) 149 | } 150 | return chunks 151 | } 152 | 153 | func parseRecordSet(set types.ResourceRecordSet, zone string) ([]libdns.Record, error) { 154 | records := make([]libdns.Record, 0) 155 | 156 | // Route53 returns TXT & SPF records with quotes around them. 157 | // https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat 158 | var ttl int64 159 | if set.TTL != nil { 160 | ttl = *set.TTL 161 | } 162 | 163 | rtype := string(set.Type) 164 | relativeName := libdns.RelativeName(*set.Name, zone) 165 | 166 | for _, record := range set.ResourceRecords { 167 | value := *record.Value 168 | switch rtype { 169 | case "TXT", "SPF": 170 | rows := strings.Split(value, "\n") 171 | for _, row := range rows { 172 | parts := strings.Split(row, `" "`) 173 | if len(parts) > 0 { 174 | parts[0] = strings.TrimPrefix(parts[0], `"`) 175 | parts[len(parts)-1] = strings.TrimSuffix(parts[len(parts)-1], `"`) 176 | } 177 | 178 | row = strings.Join(parts, "") 179 | row = unquote(row) 180 | 181 | rr := libdns.RR{ 182 | Name: relativeName, 183 | Data: row, 184 | Type: rtype, 185 | TTL: time.Duration(ttl) * time.Second, 186 | } 187 | parsedRecord, err := rr.Parse() 188 | if err != nil { 189 | return nil, fmt.Errorf("failed to parse %s record %s: %w", rtype, relativeName, err) 190 | } 191 | records = append(records, parsedRecord) 192 | } 193 | default: 194 | rr := libdns.RR{ 195 | Name: relativeName, 196 | Data: value, 197 | Type: rtype, 198 | TTL: time.Duration(ttl) * time.Second, 199 | } 200 | parsedRecord, err := rr.Parse() 201 | if err != nil { 202 | return nil, fmt.Errorf("failed to parse %s record %s: %w", rtype, relativeName, err) 203 | } 204 | records = append(records, parsedRecord) 205 | } 206 | } 207 | 208 | return records, nil 209 | } 210 | 211 | func marshalRecord(record libdns.RR) []types.ResourceRecord { 212 | resourceRecords := make([]types.ResourceRecord, 0) 213 | 214 | // Route53 requires TXT & SPF records to be quoted. 215 | // https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat 216 | switch record.Type { 217 | case "TXT", "SPF": 218 | strs := make([]string, 0) 219 | if len(record.Data) > maxTXTValueLength { 220 | strs = append(strs, chunkString(record.Data, maxTXTValueLength)...) 221 | } else { 222 | strs = append(strs, record.Data) 223 | } 224 | 225 | // Quote strings 226 | for i, str := range strs { 227 | strs[i] = quote(str) 228 | } 229 | 230 | // Finally join chunks with spaces 231 | resourceRecords = append(resourceRecords, types.ResourceRecord{ 232 | Value: aws.String(strings.Join(strs, " ")), 233 | }) 234 | default: 235 | resourceRecords = append(resourceRecords, types.ResourceRecord{ 236 | Value: aws.String(record.Data), 237 | }) 238 | } 239 | 240 | return resourceRecords 241 | } 242 | 243 | func (p *Provider) getRecords(ctx context.Context, zoneID string, zone string) ([]libdns.Record, error) { 244 | getRecordsInput := &r53.ListResourceRecordSetsInput{ 245 | HostedZoneId: aws.String(zoneID), 246 | MaxItems: aws.Int32(maxRecordsPerPage), 247 | } 248 | 249 | var records []libdns.Record 250 | 251 | for { 252 | getRecordResult, err := p.client.ListResourceRecordSets(ctx, getRecordsInput) 253 | if err != nil { 254 | var nshze *types.NoSuchHostedZone 255 | var iie *types.InvalidInput 256 | switch { 257 | case errors.As(err, &nshze): 258 | return records, fmt.Errorf("NoSuchHostedZone: %w", err) 259 | case errors.As(err, &iie): 260 | return records, fmt.Errorf("InvalidInput: %w", err) 261 | default: 262 | return records, err 263 | } 264 | } 265 | 266 | for _, s := range getRecordResult.ResourceRecordSets { 267 | parsedRecords, parseErr := parseRecordSet(s, zone) 268 | if parseErr != nil { 269 | return records, fmt.Errorf("failed to parse record set: %w", parseErr) 270 | } 271 | records = append(records, parsedRecords...) 272 | } 273 | 274 | if getRecordResult.IsTruncated { 275 | getRecordsInput.StartRecordName = getRecordResult.NextRecordName 276 | getRecordsInput.StartRecordType = getRecordResult.NextRecordType 277 | getRecordsInput.StartRecordIdentifier = getRecordResult.NextRecordIdentifier 278 | } else { 279 | break 280 | } 281 | } 282 | 283 | return records, nil 284 | } 285 | 286 | func (p *Provider) getZoneID(ctx context.Context, zoneName string) (string, error) { 287 | if p.HostedZoneID != "" { 288 | return "/hostedzone/" + p.HostedZoneID, nil 289 | } 290 | 291 | getZoneInput := &r53.ListHostedZonesByNameInput{ 292 | DNSName: aws.String(zoneName), 293 | MaxItems: aws.Int32(1), 294 | } 295 | 296 | getZoneResult, err := p.client.ListHostedZonesByName(ctx, getZoneInput) 297 | if err != nil { 298 | var idne *types.InvalidDomainName 299 | var iie *types.InvalidInput 300 | switch { 301 | case errors.As(err, &idne): 302 | return "", fmt.Errorf("InvalidDomainName: %w", err) 303 | case errors.As(err, &iie): 304 | return "", fmt.Errorf("InvalidInput: %w", err) 305 | default: 306 | return "", err 307 | } 308 | } 309 | 310 | matchingZones := []types.HostedZone{} 311 | 312 | if len(getZoneResult.HostedZones) > 0 { 313 | for z := range len(getZoneResult.HostedZones) { 314 | if *getZoneResult.HostedZones[z].Name == zoneName { 315 | matchingZones = append(matchingZones, getZoneResult.HostedZones[z]) 316 | } 317 | } 318 | } 319 | 320 | if len(matchingZones) == 1 { 321 | return *matchingZones[0].Id, nil 322 | } 323 | 324 | // If multiple zones matched the name 325 | if len(matchingZones) > 1 { 326 | // select the first public (i.e. ot-private) zone as a best guess. 327 | for _, zone := range matchingZones { 328 | if !zone.Config.PrivateZone { 329 | return *zone.Id, nil 330 | } 331 | } 332 | // All zone were private, give up and return. 333 | // Historically we always returned the first match without checking for public/private 334 | return *matchingZones[0].Id, nil 335 | } 336 | 337 | return "", fmt.Errorf("HostedZoneNotFound: No zones found for the domain %s", zoneName) 338 | } 339 | 340 | // changeRecord performs a CREATE or UPSERT operation on a single record. 341 | func (p *Provider) changeRecord( 342 | ctx context.Context, 343 | zoneID string, 344 | record libdns.Record, 345 | zone string, 346 | action types.ChangeAction, 347 | ) (libdns.Record, error) { 348 | resourceRecords := marshalRecord(record.RR()) 349 | changeInput := &r53.ChangeResourceRecordSetsInput{ 350 | ChangeBatch: &types.ChangeBatch{ 351 | Changes: []types.Change{ 352 | { 353 | Action: action, 354 | ResourceRecordSet: &types.ResourceRecordSet{ 355 | Name: aws.String(libdns.AbsoluteName(record.RR().Name, zone)), 356 | ResourceRecords: resourceRecords, 357 | TTL: aws.Int64(int64(record.RR().TTL.Seconds())), 358 | Type: types.RRType(record.RR().Type), 359 | }, 360 | }, 361 | }, 362 | }, 363 | HostedZoneId: aws.String(zoneID), 364 | } 365 | 366 | err := p.applyChange(ctx, changeInput) 367 | if err != nil { 368 | return record, err 369 | } 370 | 371 | return record, nil 372 | } 373 | 374 | func (p *Provider) createRecord( 375 | ctx context.Context, 376 | zoneID string, 377 | record libdns.Record, 378 | zone string, 379 | ) (libdns.Record, error) { 380 | return p.changeRecord(ctx, zoneID, record, zone, types.ChangeActionCreate) 381 | } 382 | 383 | func (p *Provider) updateRecord( 384 | ctx context.Context, 385 | zoneID string, 386 | record libdns.Record, 387 | zone string, 388 | ) (libdns.Record, error) { 389 | // route53's UPSERT replaces the entire ResourceRecordSet 390 | // for TXT records with the same name, we might want to preserve other values 391 | // but for libdns SetRecords, we should replace everything 392 | return p.changeRecord(ctx, zoneID, record, zone, types.ChangeActionUpsert) 393 | } 394 | 395 | func (p *Provider) applyChange(ctx context.Context, input *r53.ChangeResourceRecordSetsInput) error { 396 | changeResult, err := p.client.ChangeResourceRecordSets(ctx, input) 397 | if err != nil { 398 | return err 399 | } 400 | 401 | // Check if we should skip waiting for synchronization 402 | shouldWait := p.WaitForRoute53Sync 403 | if shouldWait && p.SkipRoute53SyncOnDelete { 404 | // Check if this is a delete operation 405 | if isDelete, ok := ctx.Value(contextKeyIsDeleteOperation).(bool); ok && isDelete { 406 | shouldWait = false 407 | } 408 | } 409 | 410 | // Wait for propagation if enabled and not skipped 411 | if shouldWait { 412 | changeInput := &r53.GetChangeInput{ 413 | Id: changeResult.ChangeInfo.Id, 414 | } 415 | 416 | // Wait for the RecordSetChange status to be "INSYNC" 417 | waiter := r53.NewResourceRecordSetsChangedWaiter(p.client) 418 | err = waiter.Wait(ctx, changeInput, p.Route53MaxWait) 419 | if err != nil { 420 | return err 421 | } 422 | } 423 | 424 | return nil 425 | } 426 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # This file is licensed under the terms of the MIT license https://opensource.org/license/mit 2 | # Copyright (c) 2021-2025 Marat Reymers 3 | 4 | # libdns/route53 golangci-lint config - based on golden config for golangci-lint v2.5.0 5 | # Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 6 | 7 | version: "2" 8 | 9 | issues: 10 | # Maximum count of issues with the same text. 11 | # Set to 0 to disable. 12 | # Default: 3 13 | max-same-issues: 50 14 | 15 | formatters: 16 | enable: 17 | - goimports # checks if the code and import statements are formatted according to the 'goimports' command 18 | - golines # checks if code is formatted, and fixes long lines 19 | 20 | ## you may want to enable 21 | #- gci # checks if code and import statements are formatted, with additional rules 22 | #- gofmt # checks if the code is formatted according to 'gofmt' command 23 | #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible 24 | #- swaggo # formats swaggo comments 25 | 26 | # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml 27 | settings: 28 | goimports: 29 | # A list of prefixes, which, if set, checks import paths 30 | # with the given prefixes are grouped after 3rd-party packages. 31 | # Default: [] 32 | local-prefixes: 33 | - github.com/my/project 34 | 35 | golines: 36 | # Target maximum line length. 37 | # Default: 100 38 | max-len: 120 39 | 40 | linters: 41 | enable: 42 | - asasalint # checks for pass []any as any in variadic func(...any) 43 | - asciicheck # checks that your code does not contain non-ASCII identifiers 44 | - bidichk # checks for dangerous unicode character sequences 45 | - bodyclose # checks whether HTTP response body is closed successfully 46 | - canonicalheader # checks whether net/http.Header uses canonical header 47 | - copyloopvar # detects places where loop variables are copied (Go 1.22+) 48 | - cyclop # checks function and package cyclomatic complexity 49 | - depguard # checks if package imports are in a list of acceptable packages 50 | - dupl # tool for code clone detection 51 | - durationcheck # checks for two durations multiplied together 52 | - embeddedstructfieldcheck # checks embedded types in structs 53 | - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases 54 | - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error 55 | - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 56 | - exhaustive # checks exhaustiveness of enum switch statements 57 | - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions 58 | - fatcontext # detects nested contexts in loops 59 | - forbidigo # forbids identifiers 60 | - funcorder # checks the order of functions, methods, and constructors 61 | - funlen # tool for detection of long functions 62 | - gocheckcompilerdirectives # validates go compiler directive comments (//go:) 63 | - gochecknoglobals # checks that no global variables exist 64 | - gochecknoinits # checks that no init functions are present in Go code 65 | - gochecksumtype # checks exhaustiveness on Go "sum types" 66 | - gocognit # computes and checks the cognitive complexity of functions 67 | - goconst # finds repeated strings that could be replaced by a constant 68 | - gocritic # provides diagnostics that check for bugs, performance and style issues 69 | - gocyclo # computes and checks the cyclomatic complexity of functions 70 | - godoclint # checks Golang's documentation practice 71 | - godot # checks if comments end in a period 72 | - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod 73 | - goprintffuncname # checks that printf-like functions are named with f at the end 74 | - gosec # inspects source code for security problems 75 | - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string 76 | - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution 77 | - ineffassign # detects when assignments to existing variables are not used 78 | - intrange # finds places where for loops could make use of an integer range 79 | - iotamixing # checks if iotas are being used in const blocks with other non-iota declarations 80 | - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) 81 | - makezero # finds slice declarations with non-zero initial length 82 | - mirror # reports wrong mirror patterns of bytes/strings usage 83 | - mnd # detects magic numbers 84 | - musttag # enforces field tags in (un)marshaled structs 85 | - nakedret # finds naked returns in functions greater than a specified function length 86 | - nestif # reports deeply nested if statements 87 | - nilerr # finds the code that returns nil even if it checks that the error is not nil 88 | - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) 89 | - nilnil # checks that there is no simultaneous return of nil error and an invalid value 90 | - noctx # finds sending http request without context.Context 91 | - nolintlint # reports ill-formed or insufficient nolint directives 92 | - nonamedreturns # reports all named returns 93 | - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL 94 | - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative 95 | - predeclared # finds code that shadows one of Go's predeclared identifiers 96 | - promlinter # checks Prometheus metrics naming via promlint 97 | - protogetter # reports direct reads from proto message fields when getters should be used 98 | - reassign # checks that package variables are not reassigned 99 | - recvcheck # checks for receiver type consistency 100 | - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint 101 | - rowserrcheck # checks whether Err of rows is checked successfully 102 | - sloglint # ensure consistent code style when using log/slog 103 | - spancheck # checks for mistakes with OpenTelemetry/Census spans 104 | - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed 105 | - staticcheck # is a go vet on steroids, applying a ton of static analysis checks 106 | - testableexamples # checks if examples are testable (have an expected output) 107 | - testifylint # checks usage of github.com/stretchr/testify 108 | - testpackage # makes you use a separate _test package 109 | - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes 110 | - unconvert # removes unnecessary type conversions 111 | - unparam # reports unused function parameters 112 | - unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection 113 | - unused # checks for unused constants, variables, functions and types 114 | - usestdlibvars # detects the possibility to use variables/constants from the Go standard library 115 | - usetesting # reports uses of functions with replacement inside the testing package 116 | - wastedassign # finds wasted assignment statements 117 | - whitespace # detects leading and trailing whitespace 118 | 119 | ## you may want to enable 120 | #- arangolint # opinionated best practices for arangodb client 121 | #- decorder # checks declaration order and count of types, constants, variables and functions 122 | #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized 123 | #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega 124 | #- godox # detects usage of FIXME, TODO and other keywords inside comments 125 | #- goheader # checks is file header matches to pattern 126 | #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters 127 | #- interfacebloat # checks the number of methods inside an interface 128 | #- ireturn # accept interfaces, return concrete types 129 | #- noinlineerr # disallows inline error handling `if err := ...; err != nil {` 130 | #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated 131 | #- tagalign # checks that struct tags are well aligned 132 | #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope 133 | #- wrapcheck # checks that errors returned from external packages are wrapped 134 | #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event 135 | 136 | ## disabled 137 | #- containedctx # detects struct contained context.Context field 138 | #- contextcheck # [too many false positives] checks the function whether use a non-inherited context 139 | #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) 140 | #- dupword # [useless without config] checks for duplicate words in the source code 141 | #- err113 # [too strict] checks the errors handling expressions 142 | #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted 143 | #- forcetypeassert # [replaced by errcheck] finds forced type assertions 144 | #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies 145 | #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase 146 | #- grouper # analyzes expression groups 147 | #- importas # enforces consistent import aliases 148 | #- lll # [replaced by golines] reports long lines 149 | #- maintidx # measures the maintainability index of each function 150 | #- misspell # [useless] finds commonly misspelled English words in comments 151 | #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity 152 | #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test 153 | #- tagliatelle # checks the struct tags 154 | #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers 155 | #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines 156 | #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines 157 | 158 | # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml 159 | settings: 160 | cyclop: 161 | # The maximal code complexity to report. 162 | # Default: 10 163 | max-complexity: 30 164 | # The maximal average package complexity. 165 | # If it's higher than 0.0 (float) the check is enabled. 166 | # Default: 0.0 167 | package-average: 10.0 168 | 169 | depguard: 170 | # Rules to apply. 171 | # 172 | # Variables: 173 | # - File Variables 174 | # Use an exclamation mark `!` to negate a variable. 175 | # Example: `!$test` matches any file that is not a go test file. 176 | # 177 | # `$all` - matches all go files 178 | # `$test` - matches all go test files 179 | # 180 | # - Package Variables 181 | # 182 | # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`) 183 | # 184 | # Default (applies if no custom rules are defined): Only allow $gostd in all files. 185 | rules: 186 | "deprecated": 187 | # List of file globs that will match this list of settings to compare against. 188 | # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed. 189 | # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`. 190 | # The placeholder '${config-path}' is substituted with a path relative to the configuration file. 191 | # Default: $all 192 | files: 193 | - "$all" 194 | # List of packages that are not allowed. 195 | # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). 196 | # Default: [] 197 | deny: 198 | - pkg: github.com/golang/protobuf 199 | desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules 200 | - pkg: github.com/satori/go.uuid 201 | desc: Use github.com/google/uuid instead, satori's package is not maintained 202 | - pkg: github.com/gofrs/uuid$ 203 | desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 204 | "non-test files": 205 | files: 206 | - "!$test" 207 | deny: 208 | - pkg: math/rand$ 209 | desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 210 | # "non-main files": 211 | # files: 212 | # - "!**/main.go" 213 | # deny: 214 | # - pkg: log$ 215 | # desc: Use log/slog instead, see https://go.dev/blog/slog 216 | 217 | embeddedstructfieldcheck: 218 | # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. 219 | # Default: false 220 | forbid-mutex: true 221 | 222 | errcheck: 223 | # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. 224 | # Such cases aren't reported by default. 225 | # Default: false 226 | check-type-assertions: true 227 | 228 | exhaustive: 229 | # Program elements to check for exhaustiveness. 230 | # Default: [ switch ] 231 | check: 232 | - switch 233 | - map 234 | 235 | exhaustruct: 236 | # List of regular expressions to match type names that should be excluded from processing. 237 | # Anonymous structs can be matched by '' alias. 238 | # Has precedence over `include`. 239 | # Each regular expression must match the full type name, including package path. 240 | # For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`, 241 | # but not `http\.Cookie`. 242 | # Default: [] 243 | exclude: 244 | # std libs 245 | - ^net/http.Client$ 246 | - ^net/http.Cookie$ 247 | - ^net/http.Request$ 248 | - ^net/http.Response$ 249 | - ^net/http.Server$ 250 | - ^net/http.Transport$ 251 | - ^net/url.URL$ 252 | - ^os/exec.Cmd$ 253 | - ^reflect.StructField$ 254 | # public libs 255 | - ^github.com/Shopify/sarama.Config$ 256 | - ^github.com/Shopify/sarama.ProducerMessage$ 257 | - ^github.com/mitchellh/mapstructure.DecoderConfig$ 258 | - ^github.com/prometheus/client_golang/.+Opts$ 259 | - ^github.com/spf13/cobra.Command$ 260 | - ^github.com/spf13/cobra.CompletionOptions$ 261 | - ^github.com/stretchr/testify/mock.Mock$ 262 | - ^github.com/testcontainers/testcontainers-go.+Request$ 263 | - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ 264 | - ^golang.org/x/tools/go/analysis.Analyzer$ 265 | - ^google.golang.org/protobuf/.+Options$ 266 | - ^gopkg.in/yaml.v3.Node$ 267 | # Allows empty structures in return statements. 268 | # Default: false 269 | allow-empty-returns: true 270 | 271 | funcorder: 272 | # Checks if the exported methods of a structure are placed before the non-exported ones. 273 | # Default: true 274 | struct-method: false 275 | 276 | funlen: 277 | # Checks the number of lines in a function. 278 | # If lower than 0, disable the check. 279 | # Default: 60 280 | lines: 100 281 | # Checks the number of statements in a function. 282 | # If lower than 0, disable the check. 283 | # Default: 40 284 | statements: 50 285 | 286 | gochecksumtype: 287 | # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. 288 | # Default: true 289 | default-signifies-exhaustive: false 290 | 291 | gocognit: 292 | # Minimal code complexity to report. 293 | # Default: 30 (but we recommend 10-20) 294 | min-complexity: 20 295 | 296 | gocritic: 297 | # Settings passed to gocritic. 298 | # The settings key is the name of a supported gocritic checker. 299 | # The list of supported checkers can be found at https://go-critic.com/overview. 300 | settings: 301 | captLocal: 302 | # Whether to restrict checker to params only. 303 | # Default: true 304 | paramsOnly: false 305 | underef: 306 | # Whether to skip (*x).method() calls where x is a pointer receiver. 307 | # Default: true 308 | skipRecvDeref: false 309 | 310 | godoclint: 311 | # List of rules to enable in addition to the default set. 312 | # Default: empty 313 | enable: 314 | # Assert no unused link in godocs. 315 | # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link 316 | - no-unused-link 317 | 318 | govet: 319 | # Enable all analyzers. 320 | # Default: false 321 | enable-all: true 322 | # Disable analyzers by name. 323 | # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. 324 | # Default: [] 325 | disable: 326 | - fieldalignment # too strict 327 | # Settings per analyzer. 328 | settings: 329 | shadow: 330 | # Whether to be strict about shadowing; can be noisy. 331 | # Default: false 332 | strict: true 333 | 334 | inamedparam: 335 | # Skips check for interface methods with only a single parameter. 336 | # Default: false 337 | skip-single-param: true 338 | 339 | mnd: 340 | # List of function patterns to exclude from analysis. 341 | # Values always ignored: `time.Date`, 342 | # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, 343 | # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. 344 | # Default: [] 345 | ignored-functions: 346 | - args.Error 347 | - flag.Arg 348 | - flag.Duration.* 349 | - flag.Float.* 350 | - flag.Int.* 351 | - flag.Uint.* 352 | - os.Chmod 353 | - os.Mkdir.* 354 | - os.OpenFile 355 | - os.WriteFile 356 | - prometheus.ExponentialBuckets.* 357 | - prometheus.LinearBuckets 358 | 359 | nakedret: 360 | # Make an issue if func has more lines of code than this setting, and it has naked returns. 361 | # Default: 30 362 | max-func-lines: 0 363 | 364 | nolintlint: 365 | # Exclude following linters from requiring an explanation. 366 | # Default: [] 367 | allow-no-explanation: [ funlen, gocognit, golines ] 368 | # Enable to require an explanation of nonzero length after each nolint directive. 369 | # Default: false 370 | require-explanation: true 371 | # Enable to require nolint directives to mention the specific linter being suppressed. 372 | # Default: false 373 | require-specific: true 374 | 375 | perfsprint: 376 | # Optimizes into strings concatenation. 377 | # Default: true 378 | strconcat: false 379 | 380 | reassign: 381 | # Patterns for global variable names that are checked for reassignment. 382 | # See https://github.com/curioswitch/go-reassign#usage 383 | # Default: ["EOF", "Err.*"] 384 | patterns: 385 | - ".*" 386 | 387 | rowserrcheck: 388 | # database/sql is always checked. 389 | # Default: [] 390 | packages: 391 | - github.com/jmoiron/sqlx 392 | 393 | sloglint: 394 | # Enforce not using global loggers. 395 | # Values: 396 | # - "": disabled 397 | # - "all": report all global loggers 398 | # - "default": report only the default slog logger 399 | # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global 400 | # Default: "" 401 | no-global: all 402 | # Enforce using methods that accept a context. 403 | # Values: 404 | # - "": disabled 405 | # - "all": report all contextless calls 406 | # - "scope": report only if a context exists in the scope of the outermost function 407 | # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only 408 | # Default: "" 409 | context: scope 410 | 411 | staticcheck: 412 | # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks 413 | # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] 414 | # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] 415 | checks: 416 | - all 417 | # Incorrect or missing package comment. 418 | # https://staticcheck.dev/docs/checks/#ST1000 419 | - -ST1000 420 | # Use consistent method receiver names. 421 | # https://staticcheck.dev/docs/checks/#ST1016 422 | - -ST1016 423 | # Omit embedded fields from selector expression. 424 | # https://staticcheck.dev/docs/checks/#QF1008 425 | - -QF1008 426 | 427 | usetesting: 428 | # Enable/disable `os.TempDir()` detections. 429 | # Default: false 430 | os-temp-dir: true 431 | 432 | exclusions: 433 | # Log a warning if an exclusion rule is unused. 434 | # Default: false 435 | warn-unused: true 436 | # Predefined exclusion rules. 437 | # Default: [] 438 | presets: 439 | - std-error-handling 440 | - common-false-positives 441 | # Excluding configuration per-path, per-linter, per-text and per-source. 442 | rules: 443 | - source: 'TODO' 444 | linters: [ godot ] 445 | - text: 'should have a package comment' 446 | linters: [ revive ] 447 | - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' 448 | linters: [ revive ] 449 | - text: 'package comment should be of the form ".+"' 450 | source: '// ?(nolint|TODO)' 451 | linters: [ revive ] 452 | - text: 'comment on exported \S+ \S+ should be of the form ".+"' 453 | source: '// ?(nolint|TODO)' 454 | linters: [ revive, staticcheck ] 455 | - path: '_test\.go' 456 | linters: 457 | - bodyclose 458 | - dupl 459 | - errcheck 460 | - funlen 461 | - goconst 462 | - gosec 463 | - noctx 464 | - wrapcheck --------------------------------------------------------------------------------