pax_global_header00006660000000000000000000000064151737225450014525gustar00rootroot0000000000000052 comment=5ac8350335bce703231a806030cff85d78c93dfa cli53-0.9.0/000077500000000000000000000000001517372254500124525ustar00rootroot00000000000000cli53-0.9.0/.github/000077500000000000000000000000001517372254500140125ustar00rootroot00000000000000cli53-0.9.0/.github/ISSUE_TEMPLATE.md000066400000000000000000000022471517372254500165240ustar00rootroot00000000000000### Issue type - Bug report - Feature idea - Documentation report ### cli53 version (cli53 --version) ### OS / Platform ### Steps to reproduce ### Expected behaviour ### Actual behaviour ``` ``` ### Have you checked if the documentation has the information you require? ### Could you contribute a fix or help testing with this issue? cli53-0.9.0/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000007401517372254500176140ustar00rootroot00000000000000Please when making a pull request: - make sure changes are up to date with main. - please use brief, descriptive commit messages. - check code has been go formatted with 'go fmt'. - ensure you've added an integration test (under internal/features) or a unit test. - check the tests pass (make test). cli53 has very good existing test coverage, so I'm unlikely to accept a pull request without a test, but please do ping me if you're struggling to add a test case and I'll help! cli53-0.9.0/.github/workflows/000077500000000000000000000000001517372254500160475ustar00rootroot00000000000000cli53-0.9.0/.github/workflows/release.yml000066400000000000000000000011701517372254500202110ustar00rootroot00000000000000name: Release on: push: tags: - "*.*.*" permissions: contents: write jobs: release: name: Release runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.24 - name: Make release uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser version: v1.15.2 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CGO_ENABLED: 0 cli53-0.9.0/.github/workflows/test.yml000066400000000000000000000015051517372254500175520ustar00rootroot00000000000000name: Test on: push jobs: build: name: Build runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.24 - name: Run unit tests run: make test-unit - name: Run test coverage run: | # gucumber only works from under GOPATH... export GOPATH=$(go env GOPATH) export PATH=$PATH:$GOPATH/bin export GOSRC=$GOPATH/src/github.com/$GITHUB_REPOSITORY mkdir -p $GOSRC cp -r $(pwd)/* $GOSRC cd $GOSRC make test-coverage env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} GO111MODULE: "on" cli53-0.9.0/.gitignore000066400000000000000000000001371517372254500144430ustar00rootroot00000000000000/cli53 .stfolder cli53.sublime-project release/ coverage.txt coverage/ /dist/ # GoLand .idea cli53-0.9.0/.goreleaser.yml000066400000000000000000000005471517372254500154110ustar00rootroot00000000000000builds: - binary: cli53 main: ./cmd/cli53 ldflags: -s -w -X=github.com/barnybug/cli53.version={{.Version}} goos: - linux - darwin - windows goarch: - amd64 - arm - arm64 archives: - name_template: "{{ .ProjectName }}-{{ if eq .Os \"darwin\" }}mac{{ else }}{{ .Os }}{{ end }}-{{ .Arch }}" format: binary cli53-0.9.0/CHANGELOG.md000066400000000000000000000062371517372254500142730ustar00rootroot00000000000000## 0.8.10 (2017-09-16) - Add support for multivalue answer routing #241 @lalinsky ## 0.8.9 (2017-08-24) - Add CCA support #235. ## 0.8.8 (2017-05-06) - Fix shortenName overzealously removes suffixes #221 ## 0.8.7 (2016-11-22) - Lowercase record names to make imports case-insensitive. Fixes #206 - Support stdin (-) for import. Fixes #209 - Paginate instances listing ## 0.8.6 (2016-10-25) - Improve --dry-run output. #204 - Fix for quoting in TXT records. #205 ## 0.8.5 (2016-09-16) - Beta: 'instances' command. #119 - Fix for short zone IDs. #197 ## 0.8.4 (2016-09-10) - Fix for listing > 100 zones. #196 ## 0.8.3 (2016-09-10) - Handle aliases with multiple types correctly. #195 ## 0.8.2 (2016-09-07) - Add --dry-run option to import. #178 - List -format functionality. #185 - Use ListHostedZonesByZone for more efficient lookup. #193 Note: the default output format for `cli53 list` has been changed. To produce the old output, use `cli53 list -format text`. ## 0.8.1 (2016-09-04) - Build correct version number into releases - Zone ID|name usage clarification ## 0.8.0 (2016-08-28) - Updated dependencies - go 1.7 build for releases ## 0.7.4 (2016-04-17) - Reusable delegation set support (new commands: dslist, dscreate, dsdelete, and create parameters --delegation-set-id) - Fix import of routed ALIAS records ## 0.7.3 (2016-04-10) - Make replace case-insensitive. Fixes #167 - Fix purge logic for NS records (thanks @floppym) - Add sha256 checksums to releases ## 0.7.2 (2016-03-31) - Add --subdivision-code. Thanks @bensie. ## 0.7.1 (2016-03-19) - Handle multiple values for SPF/TXT records correctly. Fixes #160. - Fix delete/replace of wildcard records. Fixes #150. ## 0.7.0 (2016-03-05) - Add support to rrcreate for creating multiple records - Correct MX example in docs. Closes #154 - Disable debug info from release builds => significantly smaller executables ## 0.6.9 (2016-02-18) - Warn and skip traffic policy records ## 0.6.8 (2016-01-17) - Handle importing aliases and alias target simultaneously correctly. Fixes #133 - Add VPC private zone create support. Fixes #122 - Leave alias target expanded for 'export --full'. Fixes #132 ## 0.6.7 (2015-12-27) - Fix quoting SPF record support. Fixes #138. (tag: 0.6.7) ## 0.6.6 (2015-12-12) - Fix comparison of wildcard records on 'import --replace'. Fixes #127 - Add more ALIAS examples. Issue #129 - Add docs on CNAME trailing dot. Fixes #124 ## 0.6.5 (2015-11-09) - Fix CNAMEs to origin. Fixes #123 ## 0.6.4 (2015-11-08) - Add --profile option to select credentials. Fixes #117. - Sort exported records by name, SOA, then other types. Fixes #121. - Add GO15VENDOREXPERIMENT=1 in 'Building from source'. Fixes #120. ## 0.6.3 (2015-10-24) - Add codecov. - Support for wildcard records - Parameter validation - Add --replace for rrcreate. - Allow zero weighted records. ## 0.6.2 (2015-10-14) - README additions. - Allow domain name with final period on command line. - Paginate when finding a zone. - Fix pagination bug with multiple records under same name. Fixes #112 ## 0.6.1 (2015-10-13) - Remove win64 build from upx. - Ensure throttled requests in tests are retried. - Fix goupx ## 0.6.0 (2015-10-13) - Go! cli53-0.9.0/Dockerfile000066400000000000000000000002161517372254500144430ustar00rootroot00000000000000FROM alpine:latest COPY cli53 /bin/cli53 RUN chmod +x /bin/cli53 && apk add --no-cache openssl ca-certificates ENTRYPOINT ["cli53"] CMD ["-v"]cli53-0.9.0/LICENSE000066400000000000000000000020401517372254500134530ustar00rootroot00000000000000Copyright (C) 2015 Barnaby Gray Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. cli53-0.9.0/Makefile000066400000000000000000000035701517372254500141170ustar00rootroot00000000000000export GO15VENDOREXPERIMENT=1 exe = ./cmd/cli53 .PHONY: all build install test coverage test-deps all: install test-deps: go get github.com/wadey/gocovmerge go install github.com/wadey/gocovmerge go get github.com/gucumber/gucumber/cmd/gucumber go install github.com/gucumber/gucumber/cmd/gucumber build: go build -v $(exe) install: go install $(exe) test-unit: go test test-integration: build @tmp_gopath=$$(mktemp -d /tmp/cli53-gucumber.XXXXXX); \ GOBIN=$$tmp_gopath/bin go install github.com/gucumber/gucumber/cmd/gucumber@v0.0.0-20180127021336-7d5c79e832a2; \ mkdir -p $$tmp_gopath/src/github.com/barnybug; \ ln -s $$(pwd) $$tmp_gopath/src/github.com/barnybug/cli53; \ cd $$tmp_gopath/src/github.com/barnybug/cli53 && \ GOPATH=$$tmp_gopath $$tmp_gopath/bin/gucumber; \ status=$$?; \ chmod -R +w $$tmp_gopath; \ rm -rf $$tmp_gopath; \ exit $$status # run unit and system tests, then recombine coverage output test-coverage: test-deps rm -rf coverage && mkdir coverage go test -covermode=count -coverprofile=coverage/unit.txt go test -c -covermode=count -coverpkg . -o ./cli53 ./cmd/cli53 @tmp_gopath=$$(mktemp -d /tmp/cli53-gucumber.XXXXXX); \ GOBIN=$$tmp_gopath/bin go install github.com/gucumber/gucumber/cmd/gucumber@v0.0.0-20180127021336-7d5c79e832a2; \ mkdir -p $$tmp_gopath/src/github.com/barnybug; \ ln -s $$(pwd) $$tmp_gopath/src/github.com/barnybug/cli53; \ cd $$tmp_gopath/src/github.com/barnybug/cli53 && \ COVERAGE=1 GOPATH=$$tmp_gopath $$tmp_gopath/bin/gucumber; \ status=$$?; \ chmod -R +w $$tmp_gopath; \ rm -rf $$tmp_gopath; \ exit $$status gocovmerge coverage/*.txt > coverage.txt test: test-unit test-integration docker-build: sudo docker run --rm -v `pwd`:/go/src/github.com/barnybug/cli53 -w /go/src/github.com/barnybug/cli53 golang:1.6-alpine sh -c 'apk add --no-cache make git && make build' sudo docker build -t barnybug/cli53 . rm -f cli53 cli53-0.9.0/README.md000066400000000000000000000175721517372254500137450ustar00rootroot00000000000000[![Build status](https://secure.travis-ci.org/barnybug/cli53.svg?branch=main)](https://secure.travis-ci.org/barnybug/cli53) [![codecov.io](http://codecov.io/github/barnybug/cli53/coverage.svg?branch=main)](http://codecov.io/github/barnybug/cli53?branch=main) [![Join the chat at https://gitter.im/barnybug/cli53](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/barnybug/cli53?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) # cli53 - Command line tool for Amazon Route 53 ## Introduction cli53 provides import and export from BIND format and simple command line management of Route 53 domains. Features: - import and export BIND format - create, delete and list hosted zones - create, delete and update individual records - create AWS extensions: failover, geolocation, latency, weighted and ALIAS records - create, delete and use reusable delegation sets ## Installation Installation is easy, just download the binary from the github releases page (builds are available for Linux, Mac and Windows): https://github.com/barnybug/cli53/releases/latest $ sudo mv cli53-my-platform /usr/local/bin/cli53 $ sudo chmod +x /usr/local/bin/cli53 Alternatively, on Mac you can install it using homebrew $ brew install cli53 To configure your Amazon credentials, either place them in a file `~/.aws/credentials`: [default] aws_access_key_id = AKID1234567890 aws_secret_access_key = MY-SECRET-KEY Or set the environment variables: `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. You can switch between different sets in the credentials file by passing `--profile` to any command, or setting the environment variable `AWS_PROFILE`. For example: cli53 list --profile my_profile You can also assume a specific role by passing `--role-arn` to any command. For example: cli53 list --role-arn arn:aws:iam::123456789012:role/myRole You can combine role with profile. For example: cli53 list --profile my_profile --role-arn arn:aws:iam::123456789012:role/myRole For more information, see: http://blogs.aws.amazon.com/security/post/Tx3D6U6WSFGOK2H/A-New-and-Standardized-Way-to-Manage-Credentials-in-the-AWS-SDKs Note: for Alpine on Docker, the pre-built binaries do not work, so either use Debian, or follow the instructions below for Building from source. ## Building from source To build yourself from source (you will need golang >= 1.21 installed): $ go install github.com/barnybug/cli53/cmd/cli53@latest This will produce a binary `cli53` in `$GOPATH/bin` (`~/go/bin` by default), after this follow the steps as above. ## Getting Started Create a hosted zone: $ cli53 create example.com --comment 'my first zone' Check what we've done: $ cli53 list List also supports other output formats (eg. json for scripting using [jq](https://stedolan.github.io/jq/)): $ cli53 list -format json | jq .[].Name Import a BIND zone file: $ cli53 import --file zonefile.txt example.com Replace with an imported zone, waiting for completion: $ cli53 import --file zonefile.txt --replace --wait example.com Also you can 'dry-run' import, to check what will happen: $ cli53 import --file zonefile.txt --replace --wait --dry-run example.com Upsert with an imported zone (replace existing and add new records, without deleting): $ cli53 import --file zonefile.txt --upsert example.com Validate a zone file syntax: $ cli53 validate --file zonefile.txt Create an A record pointed to 192.168.0.1 with TTL of 60 seconds: $ cli53 rrcreate example.com 'www 60 A 192.168.0.1' Update this A record to point to 192.168.0.2: $ cli53 rrcreate --replace example.com 'www 60 A 192.168.0.2' Delete the A record: $ cli53 rrdelete example.com www A Create an MX record: $ cli53 rrcreate example.com '@ MX 10 mail1.' '@ MX 20 mail2.' Create a round robin A record: $ cli53 rrcreate example.com '@ A 127.0.0.1' '@ A 127.0.0.2' For CNAME records, relative domains have no trailing dot, but absolute domains should: $ cli53 rrcreate example.com 'login CNAME www' $ cli53 rrcreate example.com 'mail CNAME ghs.googlehosted.com.' Export as a BIND zone file (for backup!): $ cli53 export example.com Export fully-qualified domain names (instead of just prefixes) to `stdout`, and send AWS debug logging to `stderr`: $ cli53 export --full --debug example.com > example.com.txt 2> example.com.err.log Create some weighted records: $ cli53 rrcreate --identifier server1 --weight 10 example.com 'www A 192.168.0.1' $ cli53 rrcreate --identifier server2 --weight 20 example.com 'www A 192.168.0.2' Create an alias to an ELB: $ cli53 rrcreate example.com 'www AWS ALIAS A dns-name.elb.amazonaws.com. ABCDEFABCDE false' Create an alias to an A record: $ cli53 rrcreate example.com 'www AWS ALIAS A server1 $self false' Create an alias to a CNAME: $ cli53 rrcreate example.com 'docs AWS ALIAS CNAME mail $self false' Create some geolocation records: $ cli53 rrcreate -i Africa --continent-code AF example.com 'geo 300 IN A 127.0.0.1' $ cli53 rrcreate -i California --country-code US --subdivision-code CA example.com 'geo 300 IN A 127.0.0.2' Create a primary/secondary pair of health checked records: $ cli53 rrcreate -i Primary --failover PRIMARY --health-check 2e668584-4352-4890-8ffe-6d3644702a1b example.com 'ha 300 IN A 127.0.0.1' $ cli53 rrcreate -i Secondary --failover SECONDARY example.com 'ha 300 IN A 127.0.0.2' Create a multivalue record with health checks: $ cli53 rrcreate -i One --multivalue --health-check 2e668584-4352-4890-8ffe-6d3644702a1b example.com 'ha 300 IN A 127.0.0.1' $ cli53 rrcreate -i Two --multivalue --health-check 7c90445d-ad67-47bd-9649-3ca0985e1f88 example.com 'ha 300 IN A 127.0.0.2' Create, list and then delete a reusable delegation set: $ cli53 dscreate $ cli53 dslist $ cli53 dsdelete NA24DEGBDGB32 Further documentation is available, e.g.: $ cli53 --help $ cli53 rrcreate --help ## Bug reports Please open a github issue including cli53 version number `cli53 --version` and the commands or a zone file to reproduce the issue. A good bug report is much appreciated! ## Pull requests Pull requests are gratefully received, though please do include a test case too. ## Where's python/pypi cli53? I've since rewritten the original python cli53. As people were still installing the old version I've taken it off pypi. If you must, you can still install the python cli53 by giving pip the github branch: $ pip install git+https://github.com/barnybug/cli53.git@python Please note I'll no longer be supporting this any more, so any bug reports will be flatly closed! ## Broken CNAME exports (GoDaddy) Some DNS providers export broken bind files, without the trailing '.' on CNAME records. This is a requirement for absolute records (i.e. ones outside of the qualifying domain). If you see CNAME records being imported to route53 with an extra mydomain.com on the end (e.g. ghs.google.com.mydomain.com), then you need to fix your zone file before importing: $ perl -pe 's/((CNAME|MX\s+\d+)\s+[-a-zA-Z0-9._]+)(?!.)$/$1./i' broken.txt > fixed.txt ## Private/public zones To manage zones that have both a private and a public zone, you must specify the zone ID instead the domain name, which is ambiguous. This is the 13 character ID after '/hostedzone/' you can see in the output to 'cli53 list'. eg: $ cli53 rrcreate ZZZZZZZZZZZZZ 'name A 127.0.0.1' ## Setting Endpoint URL Similar to the AWS CLI, the Route 53 endpoint can be set with the --endpoint-url flag. It can be a hostname or a fully qualified URL. This is particularly useful for testing. $ cli53 list --endpoint-url "http://localhost:4580" ## Caveats As Amazon limits operations to a maximum of 100 changes, if you perform a large operation that changes over 100 resource records it will be split. An operation that involves deletes, followed by updates such as an import with --replace will very briefly leave the domain inconsistent. You have been warned! ## Changelog See: [CHANGELOG](CHANGELOG.md) cli53-0.9.0/awsrr.go000066400000000000000000000072411517372254500141430ustar00rootroot00000000000000package cli53 import ( "errors" "fmt" "github.com/miekg/dns" ) const ClassAWS = 253 const TypeALIAS = 0x0F99 type ALIASRdata struct { Type string Target string ZoneId string EvaluateTargetHealth bool } func (rd *ALIASRdata) Copy(dest dns.PrivateRdata) error { d := dest.(*ALIASRdata) d.Type = rd.Type d.Target = rd.Target d.ZoneId = rd.ZoneId d.EvaluateTargetHealth = rd.EvaluateTargetHealth return nil } func (rd *ALIASRdata) Len() int { return 0 } func (rd *ALIASRdata) Parse(txt []string) error { if len(txt) != 4 { return errors.New("4 parts required for ALIAS: type target zoneid evaluateTargetHealth") } rd.Type = txt[0] rd.Target = txt[1] rd.ZoneId = txt[2] rd.EvaluateTargetHealth = (txt[3] == "true") return nil } func (rd *ALIASRdata) Pack(buf []byte) (int, error) { return 0, nil } func (rd *ALIASRdata) Unpack(buf []byte) (int, error) { return 0, nil } func (rr *ALIASRdata) String() string { return fmt.Sprintf("%s %s %s %v", rr.Type, rr.Target, rr.ZoneId, rr.EvaluateTargetHealth, ) } func NewALIASRdata() dns.PrivateRdata { return new(ALIASRdata) } func init() { dns.StringToClass["AWS"] = ClassAWS dns.ClassToString[ClassAWS] = "AWS" dns.PrivateHandle("ALIAS", TypeALIAS, NewALIASRdata) } type AWSRoute interface { String() string Parse(KeyValues) } type AWSRR struct { dns.RR Route AWSRoute HealthCheckId *string Identifier string } func (rr *AWSRR) String() string { var kvs KeyValues if rr.HealthCheckId != nil { kvs = append(kvs, "healthCheckId", *rr.HealthCheckId) } kvs = append(kvs, "identifier", rr.Identifier) return fmt.Sprintf("%s ; AWS %s %s", rr.RR, rr.Route, kvs, ) } type FailoverRoute struct { Failover string } func (f *FailoverRoute) String() string { return KeyValues{"routing", "FAILOVER", "failover", f.Failover}.String() } func (f *FailoverRoute) Parse(kvs KeyValues) { f.Failover = kvs.GetString("failover") } type GeoLocationRoute struct { CountryCode *string ContinentCode *string SubdivisionCode *string } func (f *GeoLocationRoute) String() string { args := KeyValues{"routing", "GEOLOCATION"} if f.CountryCode != nil { args = append(args, "countryCode", *f.CountryCode) } if f.ContinentCode != nil { args = append(args, "continentCode", *f.ContinentCode) } if f.SubdivisionCode != nil { args = append(args, "subdivisionCode", *f.SubdivisionCode) } return args.String() } func (f *GeoLocationRoute) Parse(kvs KeyValues) { f.CountryCode = kvs.GetOptString("countryCode") f.ContinentCode = kvs.GetOptString("continentCode") f.SubdivisionCode = kvs.GetOptString("subdivisonCode") } type LatencyRoute struct { Region string } func (f *LatencyRoute) String() string { return KeyValues{"routing", "LATENCY", "region", f.Region}.String() } func (f *LatencyRoute) Parse(kvs KeyValues) { f.Region = kvs.GetString("region") } type WeightedRoute struct { Weight int64 } func (f *WeightedRoute) String() string { return KeyValues{"routing", "WEIGHTED", "weight", f.Weight}.String() } func (f *WeightedRoute) Parse(kvs KeyValues) { f.Weight = int64(kvs.GetInt("weight")) } type MultiValueAnswerRoute struct { } func (f *MultiValueAnswerRoute) String() string { return KeyValues{"routing", "MULTIVALUE"}.String() } func (f *MultiValueAnswerRoute) Parse(kvs KeyValues) { } var RoutingTypes = map[string]func() AWSRoute{ "FAILOVER": func() AWSRoute { return &FailoverRoute{} }, "GEOLOCATION": func() AWSRoute { return &GeoLocationRoute{} }, "LATENCY": func() AWSRoute { return &LatencyRoute{} }, "WEIGHTED": func() AWSRoute { return &WeightedRoute{} }, "MULTIVALUE": func() AWSRoute { return &MultiValueAnswerRoute{} }, } cli53-0.9.0/bind.go000066400000000000000000000311361517372254500137210ustar00rootroot00000000000000package cli53 import ( "fmt" "io" "net" "os" "regexp" "strconv" "strings" "github.com/aws/aws-sdk-go-v2/aws" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/miekg/dns" ) func parseComment(rr dns.RR, comment string) dns.RR { if strings.HasPrefix(comment, "; AWS ") { kvs, err := ParseKeyValues(comment[6:]) if err == nil { routing := kvs.GetString("routing") if fn, ok := RoutingTypes[routing]; ok { route := fn() route.Parse(kvs) rr = &AWSRR{ rr, route, kvs.GetOptString("healthCheckId"), kvs.GetString("identifier"), } } else { fmt.Printf("Warning: parse AWS extension - routing=\"%s\" not understood\n", routing) } } else { fmt.Printf("Warning: parse AWS extension: %s", err) } } return rr } func parseBindFile(reader io.Reader, filename, origin string) []dns.RR { parser := dns.NewZoneParser(reader, origin, filename) records := []dns.RR{} for { rr, ok := parser.Next() if !ok { break } record := parseComment(rr, parser.Comment()) records = append(records, record) } if err := parser.Err(); err != nil { fatalIfErr(err) } return records } func quoteValues(vals []string) string { var qvals []string for _, val := range vals { qvals = append(qvals, `"`+val+`"`) } return strings.Join(qvals, " ") } // ConvertBindToRR will convert a DNS record into a route53 ResourceRecord. func ConvertBindToRR(record dns.RR) route53types.ResourceRecord { switch record := record.(type) { case *dns.A: return route53types.ResourceRecord{ Value: aws.String(record.A.String()), } case *dns.AAAA: return route53types.ResourceRecord{ Value: aws.String(record.AAAA.String()), } case *dns.CNAME: return route53types.ResourceRecord{ Value: aws.String(record.Target), } case *dns.MX: value := fmt.Sprintf("%d %s", record.Preference, record.Mx) return route53types.ResourceRecord{ Value: aws.String(value), } case *dns.NAPTR: var value string if record.Replacement == "." { value = fmt.Sprintf("%d %d \"%s\" \"%s\" \"%s\" .", record.Order, record.Preference, record.Flags, record.Service, record.Regexp) } else { value = fmt.Sprintf("%d %d \"%s\" \"%s\" \"\" \"%s\"", record.Order, record.Preference, record.Flags, record.Service, record.Replacement) } return route53types.ResourceRecord{ Value: aws.String(value), } case *dns.NS: return route53types.ResourceRecord{ Value: aws.String(record.Ns), } case *dns.PTR: return route53types.ResourceRecord{ Value: aws.String(record.Ptr), } case *dns.SOA: value := fmt.Sprintf("%s %s %d %d %d %d %d", record.Ns, record.Mbox, record.Serial, record.Refresh, record.Retry, record.Expire, record.Minttl) return route53types.ResourceRecord{ Value: aws.String(value), } case *dns.SPF: value := quoteValues(record.Txt) return route53types.ResourceRecord{ Value: aws.String(value), } case *dns.SRV: value := fmt.Sprintf("%d %d %d %s", record.Priority, record.Weight, record.Port, record.Target) return route53types.ResourceRecord{ Value: aws.String(value), } case *dns.TXT: value := quoteValues(record.Txt) return route53types.ResourceRecord{ Value: aws.String(value), } case *dns.CAA: value := fmt.Sprintf("%d %s \"%s\"", record.Flag, record.Tag, record.Value) return route53types.ResourceRecord{ Value: aws.String(value), } default: errorAndExit(fmt.Sprintf("Unsupported resource record: %s", record)) } return route53types.ResourceRecord{} } // ConvertAliasToRRSet will convert an alias to a ResourceRecordSet. func ConvertAliasToRRSet(alias *dns.PrivateRR) *route53types.ResourceRecordSet { // AWS ALIAS extension record hdr := alias.Header() rdata := alias.Data.(*ALIASRdata) return &route53types.ResourceRecordSet{ Type: route53types.RRType(rdata.Type), Name: aws.String(hdr.Name), AliasTarget: &route53types.AliasTarget{ DNSName: aws.String(rdata.Target), HostedZoneId: aws.String(rdata.ZoneId), EvaluateTargetHealth: rdata.EvaluateTargetHealth, }, } } // ConvertBindToRRSet will convert some DNS records into a route53 // ResourceRecordSet. The records should have been previously grouped // by matching name, type and (if applicable) identifier. func ConvertBindToRRSet(records []dns.RR) *route53types.ResourceRecordSet { if len(records) == 0 { return nil } hdr := records[0].Header() name := strings.ToLower(hdr.Name) rrset := &route53types.ResourceRecordSet{ Type: route53types.RRType(dns.TypeToString[hdr.Rrtype]), Name: aws.String(name), TTL: aws.Int64(int64(hdr.Ttl)), } for _, record := range records { if awsrr, ok := record.(*AWSRR); ok { switch route := awsrr.Route.(type) { case *FailoverRoute: rrset.Failover = route53types.ResourceRecordSetFailover(route.Failover) case *GeoLocationRoute: rrset.GeoLocation = &route53types.GeoLocation{ CountryCode: route.CountryCode, ContinentCode: route.ContinentCode, SubdivisionCode: route.SubdivisionCode, } case *LatencyRoute: rrset.Region = route53types.ResourceRecordSetRegion(route.Region) case *WeightedRoute: rrset.Weight = aws.Int64(route.Weight) case *MultiValueAnswerRoute: rrset.MultiValueAnswer = aws.Bool(true) } if awsrr.HealthCheckId != nil { rrset.HealthCheckId = awsrr.HealthCheckId } rrset.SetIdentifier = aws.String(awsrr.Identifier) record = awsrr.RR } if rr, ok := record.(*dns.PrivateRR); ok { // 'AWS ALIAS' records do not have ResourceRecords rdata := rr.Data.(*ALIASRdata) rrset.Type = route53types.RRType(rdata.Type) rrset.AliasTarget = &route53types.AliasTarget{ DNSName: aws.String(rdata.Target), HostedZoneId: aws.String(rdata.ZoneId), EvaluateTargetHealth: rdata.EvaluateTargetHealth, } rrset.TTL = nil } else { rr := ConvertBindToRR(record) rrset.ResourceRecords = append(rrset.ResourceRecords, rr) } } return rrset } func absolute(name string) string { // route53 always treats target names as absolute, even when they are // missing the ending period. if !strings.HasSuffix(name, ".") { return name + "." } return name } var reNaptr = regexp.MustCompile(`^([[:digit:]]+) ([[:digit:]]+) "([^"]*)" "([^"]*)" "([^"]*)" "?([^"]+)"?$`) // ConvertRRSetToBind will convert a ResourceRecordSet to an array of RR entries func ConvertRRSetToBind(rrset *route53types.ResourceRecordSet) []dns.RR { ret := []dns.RR{} // A record either has resource records or is an alias. // Optionally a routing policy can apply which will can be: // - failover // - geolocation // - latency // - weighted name := *rrset.Name // Only resource records without routing can be represented in vanilla bind. if rrset.AliasTarget != nil { alias := rrset.AliasTarget dnsrr := &dns.PrivateRR{ Hdr: dns.RR_Header{ Name: name, Rrtype: TypeALIAS, Class: ClassAWS, Ttl: 86400, }, Data: &ALIASRdata{ string(rrset.Type), *alias.DNSName, *alias.HostedZoneId, alias.EvaluateTargetHealth, }, } ret = append(ret, dnsrr) } else if rrset.TrafficPolicyInstanceId != nil { // Warn and skip traffic policy records fmt.Fprintf(os.Stderr, "Warning: Skipping traffic policy record %s\n", name) } else { switch rrset.Type { case "A": for _, rr := range rrset.ResourceRecords { dnsrr := &dns.A{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, A: net.ParseIP(*rr.Value), } ret = append(ret, dnsrr) } case "AAAA": for _, rr := range rrset.ResourceRecords { dnsrr := &dns.AAAA{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, AAAA: net.ParseIP(*rr.Value), } ret = append(ret, dnsrr) } case "CNAME": for _, rr := range rrset.ResourceRecords { dnsrr := &dns.CNAME{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Target: absolute(*rr.Value), } ret = append(ret, dnsrr) } case "MX": // parse value for _, rr := range rrset.ResourceRecords { var preference uint16 var value string fmt.Sscanf(*rr.Value, "%d %s", &preference, &value) dnsrr := &dns.MX{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Mx: absolute(value), Preference: preference, } ret = append(ret, dnsrr) } case "NAPTR": for _, rr := range rrset.ResourceRecords { // parse value naptr := reNaptr.FindStringSubmatch(*rr.Value) order, _ := strconv.Atoi(naptr[1]) preference, _ := strconv.Atoi(naptr[2]) dnsrr := &dns.NAPTR{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeNAPTR, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Order: uint16(order), Preference: uint16(preference), Flags: naptr[3], Service: naptr[4], Regexp: naptr[5], Replacement: naptr[6], } ret = append(ret, dnsrr) } case "NS": for _, rr := range rrset.ResourceRecords { dnsrr := &dns.NS{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Ns: *rr.Value, } ret = append(ret, dnsrr) } case "PTR": for _, rr := range rrset.ResourceRecords { dnsrr := &dns.PTR{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Ptr: *rr.Value, } ret = append(ret, dnsrr) } case "SOA": for _, rr := range rrset.ResourceRecords { // parse value var ns, mbox string var serial, refresh, retry, expire, minttl uint32 fmt.Sscanf(*rr.Value, "%s %s %d %d %d %d %d", &ns, &mbox, &serial, &refresh, &retry, &expire, &minttl) dnsrr := &dns.SOA{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Ns: ns, Mbox: mbox, Serial: serial, Refresh: refresh, Retry: retry, Expire: expire, Minttl: minttl, } ret = append(ret, dnsrr) } case "SPF": for _, rr := range rrset.ResourceRecords { txt := splitValues(*rr.Value) dnsrr := &dns.SPF{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeSPF, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Txt: txt, } ret = append(ret, dnsrr) } case "SRV": for _, rr := range rrset.ResourceRecords { // parse value var priority, weight, port uint16 var target string fmt.Sscanf(*rr.Value, "%d %d %d %s", &priority, &weight, &port, &target) dnsrr := &dns.SRV{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Priority: priority, Weight: weight, Port: port, Target: absolute(target), } ret = append(ret, dnsrr) } case "TXT": for _, rr := range rrset.ResourceRecords { txt := splitValues(*rr.Value) dnsrr := &dns.TXT{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Txt: txt, } ret = append(ret, dnsrr) } case "CAA": for _, rr := range rrset.ResourceRecords { fields := strings.SplitN(*rr.Value, " ", 3) flag, _ := strconv.ParseUint(fields[0], 10, 8) tag := fields[1] value := parseCharacterString(fields[2]) dnsrr := &dns.CAA{ Hdr: dns.RR_Header{ Name: name, Rrtype: dns.TypeCAA, Class: dns.ClassINET, Ttl: uint32(*rrset.TTL), }, Flag: uint8(flag), Tag: tag, Value: value, } ret = append(ret, dnsrr) } } } var route AWSRoute if rrset.Failover != "" { route = &FailoverRoute{string(rrset.Failover)} } else if rrset.Weight != nil { route = &WeightedRoute{*rrset.Weight} } else if rrset.Region != "" { route = &LatencyRoute{string(rrset.Region)} } else if rrset.GeoLocation != nil { route = &GeoLocationRoute{rrset.GeoLocation.CountryCode, rrset.GeoLocation.ContinentCode, rrset.GeoLocation.SubdivisionCode} } else if rrset.MultiValueAnswer != nil && *rrset.MultiValueAnswer { route = &MultiValueAnswerRoute{} } if route != nil { for i, rr := range ret { // convert any records with AWS extensions into an AWSRR record awsrr := &AWSRR{rr, route, rrset.HealthCheckId, *rrset.SetIdentifier} ret[i] = awsrr } } return ret } cli53-0.9.0/bind_test.go000066400000000000000000000302721517372254500147600ustar00rootroot00000000000000package cli53 import ( "net" "testing" ) import ( "github.com/aws/aws-sdk-go-v2/aws" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/miekg/dns" "github.com/stretchr/testify/assert" ) var commonA = &dns.A{ Hdr: dns.RR_Header{ Name: "a.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: uint32(300), }, A: net.ParseIP("127.0.0.1"), } var testConvertRRSetToBindTable = []struct { Input route53types.ResourceRecordSet Output []dns.RR }{ { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("A"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("127.0.0.1"), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.A{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: uint32(86400), }, A: net.ParseIP("127.0.0.1"), }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("AAAA"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("127.0.0.1"), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.AAAA{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: uint32(86400), }, AAAA: net.ParseIP("127.0.0.1"), }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("CNAME"), Name: aws.String("test.example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("www.example.com."), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.CNAME{ Hdr: dns.RR_Header{ Name: "test.example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: uint32(86400), }, Target: "www.example.com.", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("MX"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("5 mail.example.com."), }, }, TTL: aws.Int64(3600), }, Output: []dns.RR{ &dns.MX{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: uint32(3600), }, Preference: 5, Mx: "mail.example.com.", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("NS"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("ns1.example.com."), }, }, TTL: aws.Int64(3600), }, Output: []dns.RR{ &dns.NS{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: uint32(3600), }, Ns: "ns1.example.com.", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("PTR"), Name: aws.String("98."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("foo.example.com."), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.PTR{ Hdr: dns.RR_Header{ Name: "98.", Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: uint32(86400), }, Ptr: "foo.example.com.", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("SOA"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("ns-2018.awsdns-60.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"), }, }, TTL: aws.Int64(900), }, Output: []dns.RR{ &dns.SOA{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: uint32(900), }, Ns: "ns-2018.awsdns-60.co.uk.", Mbox: "awsdns-hostmaster.amazon.com.", Serial: 1, Refresh: 7200, Retry: 900, Expire: 1209600, Minttl: 86400, }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("SPF"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("\"~all\""), }, }, TTL: aws.Int64(900), }, Output: []dns.RR{ &dns.SPF{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeSPF, Class: dns.ClassINET, Ttl: uint32(900), }, Txt: []string{"~all"}, }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("SRV"), Name: aws.String("_sip._tcp.example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("0 5 5060 sipserver.example.com."), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.SRV{ Hdr: dns.RR_Header{ Name: "_sip._tcp.example.com.", Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: uint32(86400), }, Priority: 0, Weight: 5, Port: 5060, Target: "sipserver.example.com.", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("TXT"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("\"hello\""), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.TXT{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(86400), }, Txt: []string{"hello"}, }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("CAA"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("0 issue \"example.net\""), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.CAA{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeCAA, Class: dns.ClassINET, Ttl: uint32(86400), }, Flag: 0, Tag: "issue", Value: "example.net", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("CAA"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("128 issuewild \"example.net; key=value\""), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.CAA{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeCAA, Class: dns.ClassINET, Ttl: uint32(86400), }, Flag: 128, Tag: "issuewild", Value: "example.net; key=value", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("NAPTR"), Name: aws.String("example.com."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String(`100 10 "u" "sip+E2U" "!^.*$!sip:information@foo.se!i" .`), }, }, TTL: aws.Int64(86400), }, Output: []dns.RR{ &dns.NAPTR{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: dns.TypeNAPTR, Class: dns.ClassINET, Ttl: uint32(86400), }, Order: 100, Preference: 10, Flags: "u", Service: "sip+E2U", Regexp: "!^.*$!sip:information@foo.se!i", Replacement: ".", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("A"), Name: aws.String("example.com."), AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("target"), HostedZoneId: aws.String("zoneid"), EvaluateTargetHealth: false, }, }, Output: []dns.RR{ &dns.PrivateRR{ Hdr: dns.RR_Header{ Name: "example.com.", Rrtype: TypeALIAS, Class: ClassAWS, Ttl: uint32(86400), }, Data: &ALIASRdata{ Type: "A", Target: "target", ZoneId: "zoneid", EvaluateTargetHealth: false, }, }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("A"), Name: aws.String("a."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("127.0.0.1"), }, }, Failover: route53types.ResourceRecordSetFailover("PRIMARY"), HealthCheckId: aws.String("6bb57c41-879a-42d0-acdd-ed6472f08eb9"), SetIdentifier: aws.String("failover-Primary"), TTL: aws.Int64(300), }, Output: []dns.RR{ &AWSRR{ commonA, &FailoverRoute{"PRIMARY"}, aws.String("6bb57c41-879a-42d0-acdd-ed6472f08eb9"), "failover-Primary", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("A"), Name: aws.String("a."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("127.0.0.1"), }, }, GeoLocation: &route53types.GeoLocation{ ContinentCode: aws.String("AF"), }, SetIdentifier: aws.String("Africa"), TTL: aws.Int64(300), }, Output: []dns.RR{ &AWSRR{ commonA, &GeoLocationRoute{ContinentCode: aws.String("AF")}, nil, "Africa", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("A"), Name: aws.String("a."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("127.0.0.1"), }, }, Region: route53types.ResourceRecordSetRegion("us-west-1"), SetIdentifier: aws.String("USWest1"), TTL: aws.Int64(300), }, Output: []dns.RR{ &AWSRR{ commonA, &LatencyRoute{Region: "us-west-1"}, nil, "USWest1", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("A"), Name: aws.String("a."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("127.0.0.1"), }, }, Weight: aws.Int64(1), SetIdentifier: aws.String("One"), TTL: aws.Int64(300), }, Output: []dns.RR{ &AWSRR{ commonA, &WeightedRoute{Weight: 1}, nil, "One", }, }, }, { Input: route53types.ResourceRecordSet{ Type: route53types.RRType("A"), Name: aws.String("a."), ResourceRecords: []route53types.ResourceRecord{ route53types.ResourceRecord{ Value: aws.String("127.0.0.1"), }, }, MultiValueAnswer: aws.Bool(true), SetIdentifier: aws.String("One"), TTL: aws.Int64(300), }, Output: []dns.RR{ &AWSRR{ commonA, &MultiValueAnswerRoute{}, nil, "One", }, }, }, } func TestConvertRRSetToBind(t *testing.T) { for _, test := range testConvertRRSetToBindTable { result := ConvertRRSetToBind(&test.Input) if !assert.NotNil(t, result, "Record: %s", test.Output) { continue } if !assert.Equal(t, len(test.Output), len(result)) { continue } for n := range test.Output { expected := test.Output[n].String() if assert.NotNil(t, result[n]) { actual := result[n].String() assert.Equal(t, expected, actual) } } } } func TestConvertBindToRRSet(t *testing.T) { for _, test := range testConvertRRSetToBindTable { result := ConvertBindToRRSet(test.Output) if !assert.NotNil(t, result, "Record %s", test.Output) { continue } assert.Equal(t, test.Input, *result) } } func mustParseRR(s string) dns.RR { rr, err := dns.NewRR(s) if err != nil { panic(err) } return rr } var testParseCommentTable = []struct { Record dns.RR Comment string Output string }{ { Record: mustParseRR("test 3600 IN A 127.0.0.1"), Comment: "", Output: "test. 3600 IN A 127.0.0.1", }, // { // Record: mustParseRR("test 3600 IN A 127.0.0.1"), // Comment: `AWS routing="GEOLOCATION" countryCode="GB" identifier="UK"`, // Output: `test. 3600 IN A 127.0.0.1 ; AWS routing="GEOLOCATION" countryCode="GB" identifier="UK"`, // }, } func TestParseComment(t *testing.T) { for _, test := range testParseCommentTable { result := parseComment(test.Record, test.Comment) assert.Equal(t, test.Output, result.String()) } } cli53-0.9.0/cmd/000077500000000000000000000000001517372254500132155ustar00rootroot00000000000000cli53-0.9.0/cmd/cli53/000077500000000000000000000000001517372254500141345ustar00rootroot00000000000000cli53-0.9.0/cmd/cli53/main.go000066400000000000000000000002021517372254500154010ustar00rootroot00000000000000package main import ( "os" "github.com/barnybug/cli53" ) func main() { exitCode := cli53.Main(os.Args) os.Exit(exitCode) } cli53-0.9.0/cmd/cli53/main_test.go000066400000000000000000000004771517372254500164560ustar00rootroot00000000000000package main import ( "flag" "testing" "github.com/barnybug/cli53" ) // Test started when the test binary is started. Only calls main. func TestSystem(t *testing.T) { args := append([]string{"cli53"}, flag.Args()...) exitCode := cli53.Main(args) if exitCode != 0 { t.Errorf("exit code: %d\n", exitCode) } } cli53-0.9.0/commands.go000066400000000000000000000466001517372254500146100ustar00rootroot00000000000000package cli53 import ( "context" "fmt" "io" "os" "sort" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/miekg/dns" ) const ChangeBatchSize = 100 func createZone(ctx context.Context, name, comment, vpcId, vpcRegion, delegationSetId string) { callerReference := uniqueReference() req := route53.CreateHostedZoneInput{ CallerReference: &callerReference, Name: &name, HostedZoneConfig: &route53types.HostedZoneConfig{ Comment: &comment, }, } if vpcId != "" && vpcRegion != "" { req.VPC = &route53types.VPC{ VPCId: aws.String(vpcId), VPCRegion: route53types.VPCRegion(vpcRegion), } } if delegationSetId != "" { delegationSetId = strings.Replace(delegationSetId, "/delegationset/", "", 1) req.DelegationSetId = aws.String(delegationSetId) } resp, err := r53.CreateHostedZone(ctx, &req) fatalIfErr(err) fmt.Printf("Created zone: '%s' ID: '%s'\n", *resp.HostedZone.Name, *resp.HostedZone.Id) } func createReusableDelegationSet(ctx context.Context, zoneId string) { callerReference := uniqueReference() req := route53.CreateReusableDelegationSetInput{ CallerReference: &callerReference, } if zoneId != "" { req.HostedZoneId = &zoneId } resp, err := r53.CreateReusableDelegationSet(ctx, &req) fatalIfErr(err) ds := resp.DelegationSet fmt.Printf("Created reusable delegation set ID: '%s'\n", *ds.Id) for _, ns := range ds.NameServers { fmt.Printf("Nameserver: %s\n", ns) } } func listReusableDelegationSets(ctx context.Context) { req := route53.ListReusableDelegationSetsInput{} resp, err := r53.ListReusableDelegationSets(ctx, &req) fatalIfErr(err) fmt.Printf("Reusable delegation sets:\n") if len(resp.DelegationSets) == 0 { fmt.Println("none") return } for _, ds := range resp.DelegationSets { fmt.Printf("- ID: %s Nameservers: %s\n", *ds.Id, strings.Join(ds.NameServers, ", ")) } } func deleteReusableDelegationSet(ctx context.Context, id string) { if !strings.HasPrefix(id, "/delegationset/") { id = "/delegationset/" + id } req := route53.DeleteReusableDelegationSetInput{ Id: &id, } _, err := r53.DeleteReusableDelegationSet(ctx, &req) fatalIfErr(err) fmt.Printf("Deleted reusable delegation set\n") } func deleteRecordSets(ctx context.Context, zone *route53types.HostedZone, rrsets []*route53types.ResourceRecordSet, wait bool) (int, error) { // delete all non-default SOA/NS records changes := []route53types.Change{} for _, rrset := range rrsets { if !isAuthRecord(zone, rrset) { change := route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: rrset, } changes = append(changes, change) } } if len(changes) > 0 { req := route53.ChangeResourceRecordSetsInput{ HostedZoneId: zone.Id, ChangeBatch: &route53types.ChangeBatch{ Changes: changes, }, } resp, err := r53.ChangeResourceRecordSets(ctx, &req) if err != nil { return 0, err } if wait { waitForChange(ctx, resp.ChangeInfo) } } return len(changes), nil } func purgeZoneRecords(ctx context.Context, zone *route53types.HostedZone, wait bool) { total := 0 err := batchListAllRecordSets(ctx, r53, *zone.Id, func(rrsets []*route53types.ResourceRecordSet) { n, err := deleteRecordSets(ctx, zone, rrsets, wait) fatalIfErr(err) total += n }) fatalIfErr(err) fmt.Printf("%d record sets deleted\n", total) } func deleteZone(ctx context.Context, name string, purge bool) { zone := lookupZone(ctx, name) if purge { purgeZoneRecords(ctx, zone, false) } req := route53.DeleteHostedZoneInput{Id: zone.Id} _, err := r53.DeleteHostedZone(ctx, &req) fatalIfErr(err) fmt.Printf("Deleted zone: '%s' ID: '%s'\n", *zone.Name, *zone.Id) } func listZones(ctx context.Context, formatter Formatter) { zones := make(chan *route53types.HostedZone) go func() { paginator := route53.NewListHostedZonesPaginator(r53, &route53.ListHostedZonesInput{}) for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) fatalIfErr(err) for _, zone := range resp.HostedZones { zone := zone zones <- &zone } } close(zones) }() formatter.formatZoneList(zones, os.Stdout) } func isAuthRecord(zone *route53types.HostedZone, rrset *route53types.ResourceRecordSet) bool { return (rrset.Type == route53types.RRTypeSoa || rrset.Type == route53types.RRTypeNs) && *rrset.Name == *zone.Name } func expandSelfAliases(records []dns.RR, zone *route53types.HostedZone) { for _, record := range records { expandSelfAlias(record, zone) } } func expandSelfAlias(record dns.RR, zone *route53types.HostedZone) { if awsrr, ok := record.(*AWSRR); ok { record = awsrr.RR } if alias, ok := record.(*dns.PrivateRR); ok { rdata := alias.Data.(*ALIASRdata) if rdata.ZoneId == "$self" { rdata.ZoneId = strings.Replace(*zone.Id, "/hostedzone/", "", 1) rdata.Target = qualifyName(rdata.Target, *zone.Name) } } } type Key struct { Name string Rrtype uint16 Identifier string } type changeSorter struct { changes []route53types.Change } func (r changeSorter) Len() int { return len(r.changes) } func (r changeSorter) Swap(i, j int) { r.changes[i], r.changes[j] = r.changes[j], r.changes[i] } func (r changeSorter) Less(i, j int) bool { // sort non-aliases first if r.changes[i].ResourceRecordSet.AliasTarget == nil { return true } if r.changes[j].ResourceRecordSet.AliasTarget == nil { return false } return *r.changes[i].ResourceRecordSet.Name < *r.changes[j].ResourceRecordSet.Name } func groupRecords(records []dns.RR) map[Key][]dns.RR { // group records by name+type and optionally identifier grouped := map[Key][]dns.RR{} for _, record := range records { var identifier string if aws, ok := record.(*AWSRR); ok { identifier = aws.Identifier } if alias, ok := record.(*dns.PrivateRR); ok { // issue #195: alias records need to be keyed by the type of the alias too rdata := alias.Data.(*ALIASRdata) identifier += "@" + rdata.Type } key := Key{record.Header().Name, record.Header().Rrtype, identifier} grouped[key] = append(grouped[key], record) } return grouped } type importArgs struct { name string file string wait bool editauth bool replace bool upsert bool dryrun bool } func rrsetKey(rrset *route53types.ResourceRecordSet) string { key := fmt.Sprintf("%s %s", rrset.Type, *rrset.Name) if rrset.TTL != nil { key += fmt.Sprintf(" %d", *rrset.TTL) } var rrs []string for _, rr := range rrset.ResourceRecords { rrs = append(rrs, aws.ToString(rr.Value)) } if rrset.AliasTarget != nil { rrs = append(rrs, fmt.Sprintf("%s %s %t", aws.ToString(rrset.AliasTarget.DNSName), aws.ToString(rrset.AliasTarget.HostedZoneId), rrset.AliasTarget.EvaluateTargetHealth)) } sort.Strings(rrs) for _, rr := range rrs { key += " " + rr } return key } func validateBindFile(args importArgs) { var reader io.Reader if args.file == "-" { reader = os.Stdin } else { f, err := os.Open(args.file) fatalIfErr(err) defer f.Close() reader = f } parseBindFile(reader, args.file, "validate.test") } func importBind(ctx context.Context, args importArgs) { zone := lookupZone(ctx, args.name) var reader io.Reader if args.file == "-" { reader = os.Stdin } else { f, err := os.Open(args.file) fatalIfErr(err) defer f.Close() reader = f } records := parseBindFile(reader, args.file, *zone.Name) expandSelfAliases(records, zone) grouped := groupRecords(records) existing := map[string]*route53types.ResourceRecordSet{} if args.replace || args.upsert { rrsets, err := ListAllRecordSets(ctx, r53, *zone.Id) fatalIfErr(err) for _, rrset := range rrsets { if args.editauth || !isAuthRecord(zone, rrset) { rrset.Name = aws.String(unescaper.Replace(*rrset.Name)) existing[rrsetKey(rrset)] = rrset } } } additions := []route53types.Change{} for _, values := range grouped { rrset := ConvertBindToRRSet(values) if rrset != nil && (args.editauth || !isAuthRecord(zone, rrset)) { key := rrsetKey(rrset) if _, ok := existing[key]; ok { // no difference - leave it untouched delete(existing, key) } else { // new record, add or upsert if args.upsert { change := route53types.Change{ Action: route53types.ChangeActionUpsert, ResourceRecordSet: rrset, } additions = append(additions, change) } else { change := route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: rrset, } additions = append(additions, change) } } } } // remaining records in existing should be deleted deletions := []route53types.Change{} if !args.upsert { for _, rrset := range existing { change := route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: rrset, } deletions = append(deletions, change) } } if args.dryrun { if len(additions)+len(deletions) == 0 { fmt.Println("Dry-run, but no changes would have been made.") } else { fmt.Println("Dry-run, changes that would be made:") for _, addition := range additions { rrs := ConvertRRSetToBind(addition.ResourceRecordSet) for _, rr := range rrs { fmt.Printf("+ %s\n", rr.String()) } } for _, deletion := range deletions { rrs := ConvertRRSetToBind(deletion.ResourceRecordSet) for _, rr := range rrs { fmt.Printf("- %s\n", rr.String()) } } } } else { resp := batchChanges(ctx, additions, deletions, zone) fmt.Printf("%d records imported (%d changes / %d additions / %d deletions)\n", len(records), len(additions)+len(deletions), len(additions), len(deletions)) if args.wait && resp != nil { waitForChange(ctx, resp.ChangeInfo) } } } func batchChanges(ctx context.Context, additions, deletions []route53types.Change, zone *route53types.HostedZone) *route53.ChangeResourceRecordSetsOutput { // sort additions so aliases are last sort.Sort(changeSorter{additions}) changes := append(deletions, additions...) var resp *route53.ChangeResourceRecordSetsOutput for i := 0; i < len(changes); i += ChangeBatchSize { end := i + ChangeBatchSize if end > len(changes) { end = len(changes) } batch := route53types.ChangeBatch{ Changes: changes[i:end], } req := route53.ChangeResourceRecordSetsInput{ HostedZoneId: zone.Id, ChangeBatch: &batch, } var err error resp, err = r53.ChangeResourceRecordSets(ctx, &req) fatalIfErr(err) } return resp } func UnexpandSelfAliases(records []dns.RR, zone *route53types.HostedZone, full bool) { id := strings.Replace(*zone.Id, "/hostedzone/", "", 1) for _, rr := range records { if awsrr, ok := rr.(*AWSRR); ok { rr = awsrr.RR } if alias, ok := rr.(*dns.PrivateRR); ok { rdata := alias.Data.(*ALIASRdata) if rdata.ZoneId == id { rdata.ZoneId = "$self" if !full { rdata.Target = shortenName(rdata.Target, *zone.Name) } } } } } func exportBind(ctx context.Context, name string, full bool, writer io.Writer) { zone := lookupZone(ctx, name) ExportBindToWriter(ctx, r53, zone, full, writer) } type exportSorter struct { rrsets []*route53types.ResourceRecordSet zone string } func (r exportSorter) Len() int { return len(r.rrsets) } func (r exportSorter) Swap(i, j int) { r.rrsets[i], r.rrsets[j] = r.rrsets[j], r.rrsets[i] } func (r exportSorter) Less(i, j int) bool { if *r.rrsets[i].Name == *r.rrsets[j].Name { if r.rrsets[i].Type == route53types.RRTypeSoa { return true } return r.rrsets[i].Type < r.rrsets[j].Type } if *r.rrsets[i].Name == r.zone { return true } if *r.rrsets[j].Name == r.zone { return false } return *r.rrsets[i].Name < *r.rrsets[j].Name } func ExportBindToWriter(ctx context.Context, r53 *route53.Client, zone *route53types.HostedZone, full bool, out io.Writer) { rrsets, err := ListAllRecordSets(ctx, r53, *zone.Id) fatalIfErr(err) sort.Sort(exportSorter{rrsets, *zone.Name}) dnsname := *zone.Name fmt.Fprintf(out, "$ORIGIN %s\n", dnsname) for _, rrset := range rrsets { rrs := ConvertRRSetToBind(rrset) UnexpandSelfAliases(rrs, zone, full) for _, rr := range rrs { line := rr.String() if !full { parts := strings.Split(line, "\t") parts[0] = shortenName(parts[0], dnsname) if parts[3] == "CNAME" { parts[4] = shortenName(parts[4], dnsname) } line = strings.Join(parts, "\t") } fmt.Fprintln(out, line) } } } type createArgs struct { name string records []string wait bool append bool replace bool identifier string failover string healthCheckId string weight *int region string countryCode string continentCode string subdivisionCode string multivalue bool } func (args createArgs) validate() bool { if args.failover != "" && args.failover != "PRIMARY" && args.failover != "SECONDARY" { fmt.Println("failover must be PRIMARY or SECONDARY") return false } if args.replace && args.append { fmt.Println("you can only --append or --replace, not both at the same time") return false } extcount := 0 if args.failover != "" { extcount += 1 } if args.weight != nil { extcount += 1 } if args.region != "" { extcount += 1 } if args.countryCode != "" { extcount += 1 } if args.continentCode != "" { extcount += 1 } if args.multivalue { extcount += 1 } if args.subdivisionCode != "" && args.countryCode == "" { fmt.Println("country-code must be specified if subdivision-code is specified") return false } if extcount > 0 && args.identifier == "" { fmt.Println("identifier must be set when creating an extended record") return false } if extcount == 0 && args.identifier != "" { fmt.Println("identifier should only be set when creating an extended record") return false } if extcount > 1 { fmt.Println("failover, weight, region, country-code and continent-code are mutually exclusive") return false } return true } func (args createArgs) applyRRSetParams(rrset *route53types.ResourceRecordSet) { if args.identifier != "" { rrset.SetIdentifier = aws.String(args.identifier) } if args.failover != "" { rrset.Failover = route53types.ResourceRecordSetFailover(args.failover) } if args.healthCheckId != "" { rrset.HealthCheckId = aws.String(args.healthCheckId) } if args.weight != nil { rrset.Weight = aws.Int64(int64(*args.weight)) } if args.region != "" { rrset.Region = route53types.ResourceRecordSetRegion(args.region) } if args.continentCode != "" { rrset.GeoLocation = &route53types.GeoLocation{ ContinentCode: aws.String(args.continentCode), } } if args.countryCode != "" { rrset.GeoLocation = &route53types.GeoLocation{ CountryCode: aws.String(args.countryCode), } } if args.countryCode != "" && args.subdivisionCode != "" { rrset.GeoLocation = &route53types.GeoLocation{ CountryCode: aws.String(args.countryCode), SubdivisionCode: aws.String(args.subdivisionCode), } } if args.multivalue { rrset.MultiValueAnswer = aws.Bool(true) } } func equalStringPtrs(a, b *string) bool { if a == nil && b == nil { return true } else if a != nil && b != nil { return *a == *b } else { return false } } func equalCaseInsensitiveStringPtrs(a, b *string) bool { if a == nil && b == nil { return true } else if a != nil && b != nil { return strings.EqualFold(*a, *b) } else { return false } } func parseRecordList(args []string, zone *route53types.HostedZone) []dns.RR { records := []dns.RR{} origin := fmt.Sprintf("$ORIGIN %s\n", *zone.Name) for _, text := range args { record, err := dns.NewRR(origin + text) fatalIfErr(err) records = append(records, record) } return records } func createRecords(ctx context.Context, args createArgs) { zone := lookupZone(ctx, args.name) records := parseRecordList(args.records, zone) expandSelfAliases(records, zone) grouped := groupRecords(records) var existing []*route53types.ResourceRecordSet if args.replace || args.append { var err error existing, err = ListAllRecordSets(ctx, r53, *zone.Id) fatalIfErr(err) } additions := []route53types.Change{} deletions := []route53types.Change{} for _, values := range grouped { rrset := ConvertBindToRRSet(values) args.applyRRSetParams(rrset) addChange := route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: rrset, } additions = append(additions, addChange) if args.replace || args.append { // add DELETE if there is an existing record for _, candidate := range existing { if equalCaseInsensitiveStringPtrs(rrset.Name, candidate.Name) && rrset.Type == candidate.Type && equalStringPtrs(rrset.SetIdentifier, candidate.SetIdentifier) { change := route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: candidate, } deletions = append(deletions, change) if args.append { additions[len(additions)-1].ResourceRecordSet.ResourceRecords = append(additions[len(additions)-1].ResourceRecordSet.ResourceRecords, candidate.ResourceRecords...) } break } } } } resp := batchChanges(ctx, additions, deletions, zone) for _, record := range records { txt := strings.Replace(record.String(), "\t", " ", -1) fmt.Printf("Created record: '%s'\n", txt) } if args.wait { waitForChange(ctx, resp.ChangeInfo) } } func batchListAllRecordSets(ctx context.Context, r53 *route53.Client, id string, callback func(rrsets []*route53types.ResourceRecordSet)) error { paginator := route53.NewListResourceRecordSetsPaginator(r53, &route53.ListResourceRecordSetsInput{ HostedZoneId: &id, }) for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { return err } rrsets := make([]*route53types.ResourceRecordSet, 0, len(resp.ResourceRecordSets)) for _, rrset := range resp.ResourceRecordSets { rrset := rrset rrsets = append(rrsets, &rrset) } callback(rrsets) } return nil } // Paginate request to get all record sets. func ListAllRecordSets(ctx context.Context, r53 *route53.Client, id string) (rrsets []*route53types.ResourceRecordSet, err error) { err = batchListAllRecordSets(ctx, r53, id, func(results []*route53types.ResourceRecordSet) { rrsets = append(rrsets, results...) }) // unescape wildcards for _, rrset := range rrsets { rrset.Name = aws.String(unescaper.Replace(*rrset.Name)) } return } func deleteRecord(ctx context.Context, name string, match string, rtype string, wait bool, identifier string) { zone := lookupZone(ctx, name) rrsets, err := ListAllRecordSets(ctx, r53, *zone.Id) fatalIfErr(err) match = qualifyName(match, *zone.Name) changes := []route53types.Change{} for _, rrset := range rrsets { if *rrset.Name == match && rrset.Type == route53types.RRType(rtype) && (identifier == "" || (rrset.SetIdentifier != nil && *rrset.SetIdentifier == identifier)) { change := route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: rrset, } changes = append(changes, change) } } if len(changes) > 0 { req2 := route53.ChangeResourceRecordSetsInput{ HostedZoneId: zone.Id, ChangeBatch: &route53types.ChangeBatch{ Changes: changes, }, } resp, err := r53.ChangeResourceRecordSets(ctx, &req2) fatalIfErr(err) fmt.Printf("%d record sets deleted\n", len(changes)) if wait { waitForChange(ctx, resp.ChangeInfo) } } else { fmt.Println("Warning: no records matched - nothing deleted") } } func purgeRecords(ctx context.Context, name string, wait bool) { zone := lookupZone(ctx, name) purgeZoneRecords(ctx, zone, wait) } cli53-0.9.0/contrib/000077500000000000000000000000001517372254500141125ustar00rootroot00000000000000cli53-0.9.0/contrib/rpm/000077500000000000000000000000001517372254500147105ustar00rootroot00000000000000cli53-0.9.0/contrib/rpm/cli53.spec000066400000000000000000000023531517372254500165060ustar00rootroot00000000000000%global debug_package %{nil} %global import_path github.com/barnybug/cli53 %global build_path %{_builddir}/src/%{import_path} Name: cli53 Version: 0.8.7 Release: 1%{?dist} Summary: Command line tool for Amazon Route 53 License: MIT URL: https://%{import_path} Source0: https://%{import_path}/archive/%{version}.tar.gz BuildRequires: golang >= 1.5 %description Provides import and export from BIND format and simple command line management of Route 53 domains. Features: Import and export BIND format Create, delete and list hosted zones Create, delete and update individual records Create AWS extensions: failover, geolocation, latency, weighted and ALIAS records Create, delete and use reusable delegation sets %prep %setup -q %{__mkdir_p} %{build_path} %{__cp} -R ./* %{build_path} %build export GOPATH=%{_builddir} cd %{build_path} %{__make} build %install %{__mkdir_p} %{buildroot}/%{_bindir} %{__install} --preserve-timestamps --mode 755 %{build_path}/%{name} %{buildroot}%{_bindir}/%{name} %clean %{__rm} -rf %{buildroot} %files %attr(0755, root, root) %{_bindir}/%{name} %changelog * Mon Feb 13 2017 Daniel Aharon - 0.8.7-1 - Initial cli53-0.9.0/formatters.go000066400000000000000000000044221517372254500151710ustar00rootroot00000000000000package cli53 import ( "encoding/csv" "encoding/json" "fmt" "io" "text/tabwriter" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/urfave/cli/v2" ) type Formatter interface { formatZoneList(zones <-chan *route53types.HostedZone, w io.Writer) } type TextFormatter struct { } func (self *TextFormatter) formatZoneList(zones <-chan *route53types.HostedZone, w io.Writer) { for zone := range zones { data, err := json.MarshalIndent(zone, "", " ") if err != nil { fatalIfErr(err) } fmt.Fprintf(w, "%s\n", data) } } type JsonFormatter struct { } func (self *JsonFormatter) formatZoneList(zones <-chan *route53types.HostedZone, w io.Writer) { var all []*route53types.HostedZone for zone := range zones { all = append(all, zone) } if err := json.NewEncoder(w).Encode(all); err != nil { fatalIfErr(err) } } type JlFormatter struct { } func (self *JlFormatter) formatZoneList(zones <-chan *route53types.HostedZone, w io.Writer) { for zone := range zones { if err := json.NewEncoder(w).Encode(zone); err != nil { fatalIfErr(err) } } } type TableFormatter struct { } func zoneComment(zone *route53types.HostedZone) string { var ret string if zone.Config != nil && zone.Config.Comment != nil { ret = *zone.Config.Comment } return ret } func (self *TableFormatter) formatZoneList(zones <-chan *route53types.HostedZone, w io.Writer) { wr := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0) fmt.Fprintln(wr, "ID\tName\tRecord count\tComment") for zone := range zones { fmt.Fprintf(wr, "%s\t%s\t%d\t%s\n", (*zone.Id)[12:], *zone.Name, *zone.ResourceRecordSetCount, zoneComment(zone)) } wr.Flush() } type CSVFormatter struct { } func (self *CSVFormatter) formatZoneList(zones <-chan *route53types.HostedZone, w io.Writer) { wr := csv.NewWriter(w) wr.Write([]string{"id", "name", "record count", "comment"}) for zone := range zones { wr.Write([]string{(*zone.Id)[12:], *zone.Name, fmt.Sprint(*zone.ResourceRecordSetCount), zoneComment(zone)}) } wr.Flush() } func getFormatter(c *cli.Context) Formatter { switch c.String("format") { case "text": return &TextFormatter{} case "json": return &JsonFormatter{} case "jl": return &JlFormatter{} case "table": return &TableFormatter{} case "csv": return &CSVFormatter{} } return nil } cli53-0.9.0/formatters_test.go000066400000000000000000000040651517372254500162330ustar00rootroot00000000000000package cli53 import ( "bytes" "testing" "github.com/aws/aws-sdk-go-v2/aws" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/stretchr/testify/assert" ) func testZones() chan *route53types.HostedZone { ret := make(chan *route53types.HostedZone) go func() { zone := &route53types.HostedZone{ Id: aws.String("/hostedzone/Z1RWMUCMCPKCJX"), Name: aws.String("example.com."), Config: &route53types.HostedZoneConfig{ Comment: aws.String("comment"), }, ResourceRecordSetCount: aws.Int64(2), } ret <- zone close(ret) }() return ret } func formatTest(f Formatter) string { w := &bytes.Buffer{} f.formatZoneList(testZones(), w) return w.String() } func TestTextFormatter(t *testing.T) { f := &TextFormatter{} assert.Equal(t, "{\n \"CallerReference\": null,\n \"Id\": \"/hostedzone/Z1RWMUCMCPKCJX\",\n \"Name\": \"example.com.\",\n \"Config\": {\n \"Comment\": \"comment\",\n \"PrivateZone\": false\n },\n \"Features\": null,\n \"LinkedService\": null,\n \"ResourceRecordSetCount\": 2\n}\n", formatTest(f)) } func TestJsonFormatter(t *testing.T) { f := &JsonFormatter{} assert.Equal(t, "[{\"CallerReference\":null,\"Id\":\"/hostedzone/Z1RWMUCMCPKCJX\",\"Name\":\"example.com.\",\"Config\":{\"Comment\":\"comment\",\"PrivateZone\":false},\"Features\":null,\"LinkedService\":null,\"ResourceRecordSetCount\":2}]\n", formatTest(f)) } func TestJlFormatter(t *testing.T) { f := &JlFormatter{} assert.Equal(t, "{\"CallerReference\":null,\"Id\":\"/hostedzone/Z1RWMUCMCPKCJX\",\"Name\":\"example.com.\",\"Config\":{\"Comment\":\"comment\",\"PrivateZone\":false},\"Features\":null,\"LinkedService\":null,\"ResourceRecordSetCount\":2}\n", formatTest(f)) } func TestTableFormatter(t *testing.T) { f := &TableFormatter{} assert.Equal(t, "ID Name Record count Comment\nZ1RWMUCMCPKCJX example.com. 2 comment\n", formatTest(f)) } func TestCSVFormatter(t *testing.T) { f := &CSVFormatter{} assert.Equal(t, "id,name,record count,comment\nZ1RWMUCMCPKCJX,example.com.,2,comment\n", formatTest(f)) } cli53-0.9.0/go.mod000066400000000000000000000034051517372254500135620ustar00rootroot00000000000000module github.com/barnybug/cli53 go 1.24 toolchain go1.24.4 require ( github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/credentials v1.19.14 github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 github.com/aws/smithy-go v1.24.3 github.com/gucumber/gucumber v0.0.0-20180127021336-7d5c79e832a2 github.com/miekg/dns v1.1.65 github.com/stretchr/testify v1.4.0 github.com/urfave/cli/v2 v2.27.6 ) require ( github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/tools v0.32.0 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect ) cli53-0.9.0/go.sum000066400000000000000000000150401517372254500136050ustar00rootroot00000000000000github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2 h1:Ytu50ChAxCiDsOlBcBq8jbczXy6+QLb07T65DBJASRs= github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2/go.mod h1:R+2BNtUfTfhPY0RH18oL02q116bakeBWjanrbnVBqkM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg= github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg= github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gucumber/gucumber v0.0.0-20180127021336-7d5c79e832a2 h1:iR8wSrr/JCzL1Ul+dRVxtIOnP8DGg/m02nHZJ9PH6P0= github.com/gucumber/gucumber v0.0.0-20180127021336-7d5c79e832a2/go.mod h1:YbdHRK9ViqwGMS0rtRY+1I6faHvVyyurKPIPwifihxI= github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02 h1:v9ezJDHA1XGxViAUSIoO/Id7Fl63u6d0YmsAm+/p2hs= github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02/go.mod h1:RF16/A3L0xSa0oSERcnhd8Pu3IXSDZSK2gmGIMsttFE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= cli53-0.9.0/instances.go000066400000000000000000000067041517372254500147770ustar00rootroot00000000000000package cli53 import ( "context" "fmt" "regexp" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" ) type instancesArgs struct { name string off string regions []string wait bool ttl int match string internal bool aRecord bool dryRun bool } type InstanceRecord struct { name string value string } func instances(ctx context.Context, args instancesArgs, config aws.Config) { zone := lookupZone(ctx, args.name) fmt.Println("Getting DNS records") describeInstancesInput := ec2.DescribeInstancesInput{} if args.off == "" { filter := ec2types.Filter{ Name: aws.String("instance-state-name"), Values: []string{"running"}, } describeInstancesInput.Filters = []ec2types.Filter{filter} } var reMatch *regexp.Regexp if args.match != "" { var err error reMatch, err = regexp.Compile(args.match) if err != nil { fatalIfErr(err) } } insts := map[string]*ec2types.Instance{} for _, region := range args.regions { ec2conn := ec2.NewFromConfig(config, func(o *ec2.Options) { o.Region = region }) paginator := ec2.NewDescribeInstancesPaginator(ec2conn, &describeInstancesInput) for paginator.HasMorePages() { output, err := paginator.NextPage(ctx) fatalIfErr(err) for _, r := range output.Reservations { for _, i := range r.Instances { for _, tag := range i.Tags { // limit to instances with a Name tag if *tag.Key == "Name" { if reMatch != nil && !reMatch.MatchString(*tag.Value) { continue } instance := i insts[*tag.Value] = &instance continue } } } } } } if len(insts) == 0 { fmt.Println("No instances found") } var rtype string if args.aRecord { rtype = "A" } else { rtype = "CNAME" } suffix := "." + *zone.Name suffix = strings.TrimSuffix(suffix, ".") upserts := []route53types.Change{} for name, instance := range insts { var value *string if instance.State == nil || instance.State.Name != ec2types.InstanceStateNameRunning { value = &args.off } else if args.aRecord { if args.internal { value = instance.PrivateIpAddress } else { value = instance.PublicIpAddress } } else { if args.internal { value = aws.String(*instance.PrivateDnsName + ".") } else { value = aws.String(*instance.PublicDnsName + ".") } } // add domain suffix if missing dnsname := name if !strings.HasSuffix(dnsname, suffix) { dnsname += suffix } rr := route53types.ResourceRecord{ Value: value, } rrset := route53types.ResourceRecordSet{ Name: &dnsname, TTL: aws.Int64(int64(args.ttl)), Type: route53types.RRType(rtype), ResourceRecords: []route53types.ResourceRecord{rr}, } change := route53types.Change{ Action: route53types.ChangeActionUpsert, ResourceRecordSet: &rrset, } upserts = append(upserts, change) } if args.dryRun { fmt.Println("Dry-run, upserts that would be made:") for _, upsert := range upserts { rr := upsert.ResourceRecordSet fmt.Printf("+ %s %s %v\n", *rr.Name, rr.Type, *rr.ResourceRecords[0].Value) } } else { resp := batchChanges(ctx, upserts, []route53types.Change{}, zone) fmt.Printf("%d records upserted\n", len(upserts)) if args.wait && resp != nil { waitForChange(ctx, resp.ChangeInfo) } } } cli53-0.9.0/internal/000077500000000000000000000000001517372254500142665ustar00rootroot00000000000000cli53-0.9.0/internal/features/000077500000000000000000000000001517372254500161045ustar00rootroot00000000000000cli53-0.9.0/internal/features/create.feature000066400000000000000000000007251517372254500207300ustar00rootroot00000000000000@create Feature: create Scenario: I can create a domain When I run "cli53 create --comment hi $domain" Then the domain "$domain" is created Scenario: I can create a domain period When I run "cli53 create --comment hi $domain." Then the domain "$domain" is created Scenario: I can create a VPC private domain When I run "cli53 create --comment hi --vpc-id vpc-d70f05b5 --vpc-region eu-west-1 $domain" Then the domain "$domain" is created cli53-0.9.0/internal/features/delegationSets.feature000066400000000000000000000016101517372254500224310ustar00rootroot00000000000000@delegationSets Feature: reusable delegation sets Scenario: I can create with a delegation set Given I have a delegation set When I run "cli53 create --delegation-set-id $delegationSet $domain" Then the domain "$domain" is created Scenario: I can create a delegation set When I run "cli53 dscreate" Then the output matches "Created reusable delegation set ID: '(.+)'" And the delegation set "$1" is created Scenario: I can delete a delegation set Given I have a delegation set When I run "cli53 dsdelete $delegationSet" Then the delegation set "$delegationSet" is deleted Scenario: I can list delegation sets when there none When I run "cli53 dslist" Then the output contains "none" Scenario: I can list delegation sets with one Given I have a delegation set When I run "cli53 dslist" Then the output contains "- ID: /delegationset/" cli53-0.9.0/internal/features/delete.feature000066400000000000000000000014631517372254500207270ustar00rootroot00000000000000@delete Feature: delete Scenario: I can delete a domain by name Given I have a domain "$domain" When I run "cli53 delete $domain" Then the domain "$domain" is deleted Scenario: I can delete a domain by name period Given I have a domain "$domain" When I run "cli53 delete $domain." Then the domain "$domain" is deleted Scenario: I can delete and purge a big domain Given I have a domain "$domain" When I run "cli53 import --file tests/big.txt $domain" And I run "cli53 delete --purge $domain" Then the domain "$domain" is deleted Scenario: I can delete a domain with a child NS record Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a NS b.example.com.'" And I run "cli53 delete --purge $domain" Then the domain "$domain" is deleted cli53-0.9.0/internal/features/export.feature000066400000000000000000000015161517372254500210050ustar00rootroot00000000000000@export Feature: export Scenario: I can export a domain Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1'" And I run "cli53 export $domain" Then the output contains "$domain" Scenario: I can export a domain --full Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'www A 127.0.0.1'" When I run "cli53 rrcreate $domain 'alias 86400 AWS ALIAS A www $self false'" And I run "cli53 export --full $domain" Then the output contains "alias.$domain. 86400 AWS ALIAS A www.$domain. $self false" Scenario: I can export a domain to a file Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'www A 127.0.0.1'" And I run "cli53 export --output /tmp/testcli53 $domain" Then the output file "/tmp/testcli53" contains "$domain" cli53-0.9.0/internal/features/import.feature000066400000000000000000000122571517372254500210020ustar00rootroot00000000000000@import Feature: import Scenario: I can import a wildcard zone Given I have a domain "$domain" When I run "cli53 import --file tests/wildcard.txt $domain" Then the domain "$domain" export matches file "tests/wildcard.txt" Scenario: I can import a basic zone Given I have a domain "$domain" When I run "cli53 import --file tests/basic.txt $domain" Then the domain "$domain" export matches file "tests/basic.txt" Scenario: I can import an arpa zone Given I have a domain "0.1.10.in-addr.arpa" When I run "cli53 import --file tests/arpa.txt 0.1.10.in-addr.arpa" Then the domain "0.1.10.in-addr.arpa" export matches file "tests/arpa.txt" Scenario: I can import a big zone Given I have a domain "$domain" When I run "cli53 import --file tests/big.txt $domain" Then the domain "$domain" export matches file "tests/big.txt" Scenario: I can import a big zone with identifiers Given I have a domain "$domain" When I run "cli53 import --file tests/big2.txt $domain" Then the domain "$domain" export matches file "tests/big2.txt" Scenario: I can import a zone with failover extensions Given I have a domain "$domain" When I run "cli53 import --file tests/failover.txt $domain" Then the domain "$domain" export matches file "tests/failover.txt" Scenario: I can import a zone with geo extensions Given I have a domain "$domain" When I run "cli53 import --file tests/geo.txt $domain" Then the domain "$domain" export matches file "tests/geo.txt" # Scenario: I can import a zone with geo ALIAS records # Given I have a domain "$domain" # When I run "cli53 import --file tests/geo_alias.txt $domain" # Then the domain "$domain" export matches file "tests/geo_alias.txt" Scenario: I can import a zone with latency extensions Given I have a domain "$domain" When I run "cli53 import --file tests/latency.txt $domain" Then the domain "$domain" export matches file "tests/latency.txt" Scenario: I can import a zone with weighted extensions Given I have a domain "$domain" When I run "cli53 import --file tests/weighted.txt $domain" Then the domain "$domain" export matches file "tests/weighted.txt" @multivalue Scenario: I can import a zone with multivalue answer extensions Given I have a domain "$domain" When I run "cli53 import --file tests/multivalue.txt $domain" Then the domain "$domain" export matches file "tests/multivalue.txt" Scenario: I can import a zone with alias extensions Given I have a domain "$domain" When I run "cli53 import --file tests/alias.txt $domain" Then the domain "$domain" export matches file "tests/alias.txt" Scenario: I can import a zone with an alias with multiple types Given I have a domain "$domain" When I run "cli53 import --file tests/alias_multiple_types.txt $domain" Then the domain "$domain" export matches file "tests/alias_multiple_types.txt" Scenario: I can import (replace) a zone Given I have a domain "$domain" When I run "cli53 import --file tests/replace1.txt $domain" And I run "cli53 import --replace --file tests/replace2.txt $domain" Then the domain "$domain" export matches file "tests/replace2.txt" Scenario: I can import (upsert) a zone Given I have a domain "$domain" When I run "cli53 import --file tests/upsert1.txt $domain" And I run "cli53 import --upsert --file tests/upsert2.txt $domain" Then the domain "$domain" export matches file "tests/upsert3.txt" Scenario: I can import dry-run (with changes) Given I have a domain "$domain" When I run "cli53 import --file tests/replace1.txt $domain" And I run "cli53 import --replace --file tests/replace2.txt --dry-run $domain" Then the output contains "Dry-run" And the output contains "+ mail.$domain. 86400 IN A 10.0.0.4" And the output contains "- mail.$domain. 86400 IN A 10.0.0.2" Scenario: I can import dry-run (no changes) Given I have a domain "$domain" When I run "cli53 import --file tests/replace1.txt $domain" And I run "cli53 import --replace --file tests/replace1.txt --dry-run $domain" Then the output contains "no changes would have been made" Scenario: I can import a zone editing auth Given I have a domain "$domain" When I run "cli53 import --file tests/auth.txt --replace --editauth $domain" Then the domain "$domain" export matches file "tests/auth.txt" including auth Scenario: I can import a zone with no changes Given I have a domain "$domain" When I run "cli53 import --file tests/replace1.txt $domain" And I run "cli53 import --replace --file tests/replace1.txt $domain" Then the output contains "0 changes" Scenario: I can import a zone with a wildcard record with no changes Given I have a domain "$domain" When I run "cli53 import --file tests/replace3.txt $domain" And I run "cli53 import --replace --file tests/replace3.txt $domain" Then the output contains "0 changes" Scenario: I can import a zone as case-insensitive Given I have a domain "$domain" When I run "cli53 import --file tests/uppercase.txt $domain" And I run "cli53 import --replace --file tests/uppercase.txt $domain" Then the output contains "0 changes" cli53-0.9.0/internal/features/list.feature000066400000000000000000000030601517372254500204330ustar00rootroot00000000000000@list Feature: list Scenario: I can list domains Given I have a domain "$domain" When I run "cli53 list" Then the output contains "$domain" Scenario: I can list domains with --endpoint-url Given I have a domain "$domain" When I execute "cli53 list --endpoint-url https://route53.amazonaws.com" with var AWS_REGION as "us-east-1" Then the output contains "$domain" Scenario: I can list domains as csv Given I have a domain "$domain" When I run "cli53 list --format csv" Then the output contains "id,name,record count,comment" And the output contains "$domain.,2," Scenario: I can list domains as json Given I have a domain "$domain" When I run "cli53 list --format json" Then the output contains "[{" And the output contains "$domain" And the output contains "}]" Scenario: I can list domains as jl Given I have a domain "$domain" When I run "cli53 list --format jl" Then the output contains "{" And the output contains "$domain" And the output contains "}" Scenario: I can list domains as text Given I have a domain "$domain" When I run "cli53 list --format text" Then the output contains "$domain" Scenario: I can list domains as table Given I have a domain "$domain" When I run "cli53 list --format table" Then the output matches "ID +Name +Record count +Comment" And the output contains "$domain. 2" Scenario: list validates format parameter Given I have a domain "$domain" When I execute "cli53 list --format x" Then the exit code was 1 cli53-0.9.0/internal/features/rrcreate.feature000066400000000000000000000163431517372254500212770ustar00rootroot00000000000000@rrcreate Feature: rrcreate Scenario: I can create a wildcard record Given I have a domain "$domain" When I run "cli53 rrcreate $domain '* A 127.0.0.1'" Then the domain "$domain" has record "*.$domain. 3600 IN A 127.0.0.1" Scenario: I can create a resource record Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.1" Scenario: I can create a resource record (full) Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a.$domain. A 127.0.0.1'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.1" Scenario: I can create a failover record Given I have a domain "$domain" When I run "cli53 rrcreate -i "The First" --failover PRIMARY --health-check 6bb57c41-879a-42d0-acdd-ed6472f08eb9 $domain 'failover 300 IN A 127.0.0.1'" Then the domain "$domain" has record "failover.$domain. 300 IN A 127.0.0.1 ; AWS routing="FAILOVER" failover="PRIMARY" healthCheckId="6bb57c41-879a-42d0-acdd-ed6472f08eb9" identifier="The First"" Scenario: I can create a geolocation record Given I have a domain "$domain" When I run "cli53 rrcreate -i Africa --continent-code AF $domain 'geo 300 IN A 127.0.0.1'" Then the domain "$domain" has record "geo.$domain. 300 IN A 127.0.0.1 ; AWS routing="GEOLOCATION" continentCode="AF" identifier="Africa"" Scenario: I can create a geolocation record with a country code and subdivision code Given I have a domain "$domain" When I run "cli53 rrcreate -i California --country-code US --subdivision-code CA $domain 'geo 300 IN A 127.0.0.1'" Then the domain "$domain" has record "geo.$domain. 300 IN A 127.0.0.1 ; AWS routing="GEOLOCATION" countryCode="US" subdivisionCode="CA" identifier="California"" Scenario: I can create a latency record Given I have a domain "$domain" When I run "cli53 rrcreate -i USWest1 --region us-west-1 $domain 'latency 300 IN A 127.0.0.1'" Then the domain "$domain" has record "latency.$domain. 300 IN A 127.0.0.1 ; AWS routing="LATENCY" region="us-west-1" identifier="USWest1"" Scenario: I can create a weighted record Given I have a domain "$domain" When I run "cli53 rrcreate -i One --weight 1 $domain 'weighted 300 IN A 127.0.0.1'" Then the domain "$domain" has record "weighted.$domain. 300 IN A 127.0.0.1 ; AWS routing="WEIGHTED" weight=1 identifier="One"" Scenario: I can create a weighted record with zero weight Given I have a domain "$domain" When I run "cli53 rrcreate -i Zero --weight 0 $domain 'weighted 300 IN A 127.0.0.1'" Then the domain "$domain" has record "weighted.$domain. 300 IN A 127.0.0.1 ; AWS routing="WEIGHTED" weight=0 identifier="Zero"" @multivalue Scenario: I can create a multivalue answer record Given I have a domain "$domain" When I run "cli53 rrcreate -i One --multivalue $domain 'multivalue 300 IN A 127.0.0.1'" Then the domain "$domain" has record "multivalue.$domain. 300 IN A 127.0.0.1 ; AWS routing="MULTIVALUE" identifier="One"" Scenario: I can create an alias Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'www A 127.0.0.1'" When I run "cli53 rrcreate $domain 'alias 86400 AWS ALIAS A www $self false'" Then the domain "$domain" has record "alias.$domain. 86400 AWS ALIAS A www $self false" Scenario: I can create a round robin A record Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1' 'a A 127.0.0.2'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.1" And the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.2" Scenario: I can create an MX record with multiple entries Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'mail MX 10 mailserver1.' 'mail MX 20 mailserver2.'" Then the domain "$domain" has record "mail.$domain. 3600 IN MX 10 mailserver1." And the domain "$domain" has record "mail.$domain. 3600 IN MX 20 mailserver2." Scenario: I can create a TXT record with multiple values Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'txt TXT "a" "b"'" Then the domain "$domain" has record "txt.$domain. 3600 IN TXT "a" "b"" And the domain "$domain" has 3 records # NS+SOA+TXT Scenario: I cannot create the same resource record Given I have a domain "$domain" And I run "cli53 rrcreate $domain 'a A 127.0.0.1'" When I execute "cli53 rrcreate $domain 'a A 127.0.0.2'" Then the exit code was 1 And the output contains "already exists" Scenario: I can append a resource record that does not exists Given I have a domain "$domain" When I run "cli53 rrcreate --append $domain 'a A 127.0.0.1'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.1" Scenario: I can append a resource record that already exists Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1'" And I run "cli53 rrcreate --append $domain 'a A 127.0.0.2'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.1" And the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.2" Scenario: I cannot append the same resource record Given I have a domain "$domain" And I run "cli53 rrcreate $domain 'a A 127.0.0.1'" When I execute "cli53 rrcreate --append $domain 'a A 127.0.0.1'" Then the exit code was 1 And the output contains "Duplicate Resource Record" Scenario: I can replace a resource record Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1'" And I run "cli53 rrcreate --replace $domain 'a A 127.0.0.2'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.2" Scenario: replace is case-insensitive Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'record A 127.0.0.1'" And I run "cli53 rrcreate --replace $domain 'Record A 127.0.0.2'" Then the domain "$domain" has record "record.$domain. 3600 IN A 127.0.0.2" Scenario: I can replace multiple records Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1' 'mail MX 5 mailserver0.' 'mail MX 10 mailserver1.'" And I run "cli53 rrcreate --replace $domain 'a A 127.0.0.2' 'mail MX 20 mailserver2.'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.2" And the domain "$domain" has record "mail.$domain. 3600 IN MX 20 mailserver2." Scenario: I can replace a weighted record Given I have a domain "$domain" When I run "cli53 rrcreate -i One --weight 1 $domain 'a A 127.0.0.1'" And I run "cli53 rrcreate -i Two --weight 2 $domain 'a A 127.0.0.2'" And I run "cli53 rrcreate --replace -i One --weight 3 $domain 'a A 127.1.0.1'" Then the domain "$domain" has record "a.$domain. 3600 IN A 127.1.0.1 ; AWS routing="WEIGHTED" weight=3 identifier="One"" And the domain "$domain" has record "a.$domain. 3600 IN A 127.0.0.2 ; AWS routing="WEIGHTED" weight=2 identifier="Two"" Scenario: I can replace a wildcard record Given I have a domain "$domain" When I run "cli53 rrcreate $domain '*.wildcard A 127.0.0.1'" And I run "cli53 rrcreate --replace $domain '*.wildcard A 127.0.0.2'" Then the domain "$domain" has record "*.wildcard.$domain. 3600 IN A 127.0.0.2" cli53-0.9.0/internal/features/rrdelete.feature000066400000000000000000000022501517372254500212660ustar00rootroot00000000000000@rrdelete Feature: rrdelete Scenario: I can delete a resource record Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1'" And I run "cli53 rrdelete $domain a A" Then the domain "$domain" doesn't have record "a.$domain. 3600 IN A 127.0.0.1" Scenario: I can delete a resource record by identifier Given I have a domain "$domain" When I run "cli53 rrcreate -i One --weight 1 $domain 'weighted.$domain. 300 IN A 127.0.0.1'" And I run "cli53 rrcreate -i Two --weight 2 $domain 'weighted.$domain. 300 IN A 127.0.0.2'" And I run "cli53 rrdelete -i One $domain weighted A" Then the domain "$domain" doesn't have record "weighted.$domain. 300 IN A 127.0.0.1 ; AWS routing="WEIGHTED" weight=1 identifier="One"" And the domain "$domain" has record "weighted.$domain. 300 IN A 127.0.0.2 ; AWS routing="WEIGHTED" weight=2 identifier="Two"" Scenario: I can delete a wildcard record Given I have a domain "$domain" When I run "cli53 rrcreate $domain '*.wildcard A 127.0.0.1'" And I run "cli53 rrdelete $domain *.wildcard A" Then the domain "$domain" doesn't have record "*.wildcard.$domain. 3600 IN A 127.0.0.1" cli53-0.9.0/internal/features/rrpurge.feature000066400000000000000000000015451517372254500211540ustar00rootroot00000000000000@rrpurge Feature: rrpurge Scenario: I can purge a domain Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a A 127.0.0.1'" And I run "cli53 rrpurge --confirm $domain" Then the domain "$domain" doesn't have record "a.$domain. 3600 IN A 127.0.0.1" Scenario: I can purge a domain with wildcard records Given I have a domain "$domain" When I run "cli53 rrcreate $domain '*.wildcard A 127.0.0.1'" And I run "cli53 rrpurge --confirm $domain" Then the domain "$domain" doesn't have record "*.wildcard.$domain. 3600 IN A 127.0.0.1" Scenario: I can purge a domain with child NS record Given I have a domain "$domain" When I run "cli53 rrcreate $domain 'a NS b.example.com.'" And I run "cli53 rrpurge --confirm $domain" Then the domain "$domain" doesn't have record "a.$domain. 3600 IN NS b.example.com." cli53-0.9.0/internal/features/step_definitions.go000066400000000000000000000271571517372254500220150ustar00rootroot00000000000000package features import ( "bytes" "context" "fmt" "io/ioutil" "log" "math/rand" "os" "os/exec" "regexp" "strconv" "strings" "sync" "syscall" "time" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/route53" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" smithylogging "github.com/aws/smithy-go/logging" "github.com/barnybug/cli53" . "github.com/gucumber/gucumber" ) func getService() *route53.Client { cfg, err := config.LoadDefaultConfig( context.Background(), config.WithRegion("us-east-1"), config.WithRetryMaxAttempts(100), ) fatalIfErr(err) cfg.Logger = smithylogging.NewStandardLogger(os.Stderr) return route53.NewFromConfig(cfg) } func fatalIfErr(err error) { if err != nil { log.Fatalf("Unexpected error: %s", err) } } var cleanupIds = []string{} var cleanupDSIds = []string{} var runOutput string var retCode int var backReferences []string func domainExists(name string) bool { return domainId(name) != "" } func domainZone(name string) *route53types.HostedZone { r53 := getService() paginator := route53.NewListHostedZonesPaginator(r53, &route53.ListHostedZonesInput{}) for paginator.HasMorePages() { zones, err := paginator.NextPage(context.Background()) fatalIfErr(err) for _, zone := range zones.HostedZones { if *zone.Name == name+"." { zone := zone return &zone } } } return nil } func reusableDelegationSet(id string) *route53types.DelegationSet { r53 := getService() req := route53.GetReusableDelegationSetInput{Id: &id} resp, err := r53.GetReusableDelegationSet(context.Background(), &req) if err == nil { return resp.DelegationSet } return nil } func domainId(name string) string { if zone := domainZone(name); zone != nil { return *zone.Id } return "" } var seeded sync.Once func uniqueReference() string { seeded.Do(func() { rand.Seed(time.Now().UnixNano()) }) return fmt.Sprintf("%0x", rand.Int()) } func cleanupDomain(r53 *route53.Client, id string) { // delete all non-default SOA/NS records ctx := context.Background() rrsets, err := cli53.ListAllRecordSets(ctx, r53, id) fatalIfErr(err) changes := []route53types.Change{} for _, rrset := range rrsets { if rrset.Type != route53types.RRTypeNs && rrset.Type != route53types.RRTypeSoa { change := route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: rrset, } changes = append(changes, change) } } if len(changes) > 0 { req2 := route53.ChangeResourceRecordSetsInput{ HostedZoneId: &id, ChangeBatch: &route53types.ChangeBatch{ Changes: changes, }, } _, err = r53.ChangeResourceRecordSets(ctx, &req2) if err != nil { fmt.Printf("Warning: cleanup failed - %s\n", err) } } req3 := route53.DeleteHostedZoneInput{Id: &id} _, err = r53.DeleteHostedZone(ctx, &req3) if err != nil { fmt.Printf("Warning: cleanup failed - %s\n", err) } } func cleanupReusableDelegationSet(r53 *route53.Client, id string) { req := route53.DeleteReusableDelegationSetInput{Id: &id} _, err := r53.DeleteReusableDelegationSet(context.Background(), &req) if err != nil { fmt.Printf("Warning: cleanup failed - %s\n", err) } } // Split on whitespace, but leave quoted strings in tact func safeSplit(s string) []string { split := strings.Split(s, " ") var result []string var inquote string var block string for _, i := range split { if inquote == "" { if strings.HasPrefix(i, "'") || strings.HasPrefix(i, "\"") { inquote = string(i[0]) block = strings.TrimPrefix(i, inquote) + " " } else { result = append(result, i) } } else { if !strings.HasSuffix(i, inquote) { block += i + " " } else { block += strings.TrimSuffix(i, inquote) inquote = "" result = append(result, block) block = "" } } } return result } func domain(s string) string { domain := World["$domain"].(string) return strings.Replace(s, "$domain", domain, -1) } var unquoter = strings.NewReplacer(`\\`, `\`, `\"`, `"`) func unquote(s string) string { return unquoter.Replace(s) } var reMagic = regexp.MustCompile(`\$\w+`) func replaceMagics(s string) string { return reMagic.ReplaceAllStringFunc(s, func(m string) string { if v, ok := World[m]; ok { return v.(string) } if i, err := strconv.Atoi(m[1:]); err == nil { return backReferences[i] } return m }) } func coverageArgs(args []string) []string { // add coverage parameters to command coverage := fmt.Sprintf("coverage/%d.txt", rand.Int()) return append([]string{args[0], "-test.coverprofile", coverage}, args[1:]...) } func execute(cmd string, env ...string) { args := safeSplit(cmd) ps := exec.Command("./"+args[0], args[1:]...) ps.Env = append(os.Environ(), env...) out, err := ps.CombinedOutput() runOutput = string(out) if err, ok := err.(*exec.ExitError); ok { waitStatus := err.Sys().(syscall.WaitStatus) retCode = waitStatus.ExitStatus() } else if err != nil { T.Errorf("Error: %s Output: %s", err, out) } } func init() { Before("", func() { // randomize temporary test domain name World["$domain"] = fmt.Sprintf("example%s.com", uniqueReference()) }) After("", func() { delete(World, "$domain") delete(World, "$delegationSet") if len(cleanupIds) > 0 { // cleanup r53 := getService() for _, id := range cleanupIds { cleanupDomain(r53, id) } cleanupIds = []string{} } if len(cleanupDSIds) > 0 { // cleanup r53 := getService() for _, id := range cleanupDSIds { cleanupReusableDelegationSet(r53, id) } cleanupDSIds = []string{} } }) Given(`^I have a domain "(.+?)"$`, func(name string) { name = domain(name) // create a test domain r53 := getService() callerReference := uniqueReference() req := route53.CreateHostedZoneInput{ CallerReference: &callerReference, Name: &name, } resp, err := r53.CreateHostedZone(context.Background(), &req) fatalIfErr(err) cleanupIds = append(cleanupIds, *resp.HostedZone.Id) }) Given(`^I have a delegation set$`, func() { r53 := getService() callerReference := uniqueReference() req := route53.CreateReusableDelegationSetInput{ CallerReference: &callerReference, } resp, err := r53.CreateReusableDelegationSet(context.Background(), &req) fatalIfErr(err) id := *resp.DelegationSet.Id World["$delegationSet"] = id cleanupDSIds = append(cleanupDSIds, id) }) When(`^I run "(.+?)"$`, func(cmd string) { cmd = replaceMagics(cmd) args := safeSplit(cmd) if os.Getenv("COVERAGE") != "" { args = coverageArgs(args) } ps := exec.Command("./"+args[0], args[1:]...) out, err := ps.CombinedOutput() if err != nil { T.Errorf("Error: %s Output: %s", err, out) } else { runOutput = string(out) } }) When(`^I execute "(.+?)"$`, func(cmd string) { execute(domain(cmd)) }) When(`^I execute "(.+?)" with var (.+?) as "(.*?)"$`, func(cmd, name, value string) { execute(domain(cmd), name+"="+value) }) Then(`^the domain "(.+?)" is created$`, func(name string) { name = domain(name) id := domainId(name) if id == "" { T.Errorf("Domain %s was not created", name) } else { cleanupIds = append(cleanupIds, id) } }) Then(`^the domain "(.+?)" is deleted$`, func(name string) { name = domain(name) id := domainId(name) if id == "" { cleanupIds = []string{} // drop from cleanupIds } else { T.Errorf("Domain %s was not deleted", name) cleanupIds = append(cleanupIds, id) } }) Then(`^the domain "(.+?)" has (\d+) records$`, func(name string, expected int) { name = domain(name) r53 := getService() id := domainId(name) ctx := context.Background() rrsets, err := cli53.ListAllRecordSets(ctx, r53, id) fatalIfErr(err) actual := len(rrsets) if expected != actual { T.Errorf("Domain %s: Expected %d records, actually %d records ", name, expected, actual) } }) Then(`^the domain "(.+?)" has record "(.+)"$`, func(name, record string) { name = domain(name) record = domain(record) if !hasRecord(name, record) { T.Errorf("Domain %s: missing record %s", name, record) } }) Then(`^the domain "(.+?)" doesn't have record "(.+)"$`, func(name, record string) { name = domain(name) record = domain(record) if hasRecord(name, record) { T.Errorf("Domain %s: present record %s", name, record) } }) Then(`^the domain "(.+?)" export matches file "(.+?)"( including auth)?$`, func(name, filename, auth string) { name = domain(name) r53 := getService() zone := domainZone(name) out := new(bytes.Buffer) ctx := context.Background() cli53.ExportBindToWriter(ctx, r53, zone, false, out) actual := out.Bytes() rfile, err := os.Open(filename) fatalIfErr(err) defer rfile.Close() expected, err := ioutil.ReadAll(rfile) fatalIfErr(err) errors := compareDomains(expected, actual, auth != "") if len(errors) > 0 { T.Errorf(errors) } }) Then(`^the output contains "(.+?)"$`, func(s string) { s = unquote(domain(s)) if !strings.Contains(runOutput, s) { T.Errorf("Output did not contain \"%s\"\nactual: %s", s, runOutput) } }) Then(`^the output file "(.+?)" contains "(.+?)"$`, func(outputFile string, s string) { outputFile = unquote(outputFile) s = unquote(domain(s)) output, err := ioutil.ReadFile(outputFile) if err != nil { T.Errorf("Could not read %s", outputFile) } if !strings.Contains(string(output), s) { T.Errorf("Output did not contain \"%s\"\nactual: %s", s, runOutput) } }) Then(`^the output matches "(.+?)"$`, func(s string) { re, err := regexp.Compile(s) fatalIfErr(err) match := re.FindStringSubmatch(runOutput) if match == nil { T.Errorf("Output did not match \"%s\"", s) } backReferences = match }) Then(`^the exit code was (\d+)$`, func(code int) { if code != retCode { T.Errorf("Exit code expected: %d != actual: %d. Output: %s", code, retCode, runOutput) } }) Then(`^the delegation set "(.+?)" is created$`, func(id string) { id = replaceMagics(id) ds := reusableDelegationSet(id) if ds == nil { T.Errorf("Reusable delegation set %s was not created", id) } else { cleanupDSIds = append(cleanupDSIds, id) } }) Then(`^the delegation set "(.+?)" is deleted$`, func(id string) { id = replaceMagics(id) ds := reusableDelegationSet(id) if ds == nil { cleanupDSIds = []string{} } else { T.Errorf("Reusable delegation set %s was not deleted", id) cleanupDSIds = append(cleanupDSIds, id) } }) } func hasRecord(name, record string) bool { r53 := getService() zone := domainZone(name) ctx := context.Background() rrsets, err := cli53.ListAllRecordSets(ctx, r53, *zone.Id) fatalIfErr(err) for _, rrset := range rrsets { rrs := cli53.ConvertRRSetToBind(rrset) cli53.UnexpandSelfAliases(rrs, zone, false) for _, rr := range rrs { line := rr.String() line = strings.Replace(line, "\t", " ", -1) if record == line { return true } } } return false } func prepareZoneFile(b []byte, includeAuth bool) map[string]bool { s := string(b) s = strings.Replace(s, "\t", " ", -1) lines := strings.Split(s, "\n") ret := map[string]bool{} for _, line := range lines { if strings.HasPrefix(line, "$ORIGIN") { continue } if !includeAuth && (strings.Contains(line, " NS ") || strings.Contains(line, " SOA ")) { continue } ret[line] = true } return ret } func compareDomains(expected, actual []byte, includeAuth bool) string { mexpected := prepareZoneFile(expected, includeAuth) mactual := prepareZoneFile(actual, includeAuth) var errors string for record := range mexpected { if _, ok := mactual[record]; ok { delete(mactual, record) } else { errors += fmt.Sprintf("Expected record '%s' missing\n", record) } } for record := range mactual { errors += fmt.Sprintf("Unexpected record '%s' present\n", record) } return errors } cli53-0.9.0/internal/features/validate.feature000066400000000000000000000002741517372254500212550ustar00rootroot00000000000000@validate Feature: validate a zone file syntax Scenario: incorrect zone file fails validation When I execute "cli53 validate --file tests/validate2.txt" Then the exit code was 1 cli53-0.9.0/internal/features/validation.feature000066400000000000000000000041411517372254500216130ustar00rootroot00000000000000@validation Feature: parameter validation Scenario: identifier is required with failover When I execute "cli53 rrcreate --failover PRIMARY $domain 'a A 127.0.0.1'" Then the exit code was 1 Scenario: identifier is required with weight When I execute "cli53 rrcreate --weight 10 $domain 'a A 127.0.0.1'" Then the exit code was 1 Scenario: identifier is required with region When I execute "cli53 rrcreate --region us-west-1 $domain 'a A 127.0.0.1'" Then the exit code was 1 Scenario: identifier alone is invalid When I execute "cli53 rrcreate -i id $domain 'a A 127.0.0.1'" Then the exit code was 1 Scenario: failover must be PRIMARY/SECONDARY When I execute "cli53 rrcreate -i id --failover JUNK $domain 'a A 127.0.0.1'" Then the exit code was 1 Scenario: failover and weight are mutually exclusive When I execute "cli53 rrcreate -i id --failover PRIMARY --weight 10 $domain 'a A 127.0.0.1'" Then the exit code was 1 Scenario: passing --append and --replace at the same time makes no sense When I execute "cli53 rrcreate --append --replace $domain 'a A 127.0.0.2'" Then the exit code was 1 Scenario: create requires one argument When I execute "cli53 create a b" Then the exit code was 1 Scenario: delete requires one argument When I execute "cli53 delete a b" Then the exit code was 1 Scenario: import requires one argument When I execute "cli53 import a b" Then the exit code was 1 Scenario: export requires one argument When I execute "cli53 export a b" Then the exit code was 1 Scenario: rrcreate requires at least two arguments When I execute "cli53 rrcreate a" Then the exit code was 1 Scenario: rrdelete requires three arguments When I execute "cli53 rrdelete a b c d" Then the exit code was 1 Scenario: rrpurge requires one argument When I execute "cli53 rrpurge a b" Then the exit code was 1 Scenario: list expects no arguments When I execute "cli53 list a" Then the exit code was 1 Scenario: bad usage When I execute "cli53 list --bad" Then the exit code was 1 cli53-0.9.0/lexer.go000066400000000000000000000020741517372254500141230ustar00rootroot00000000000000package cli53 import ( "fmt" "strings" "unicode/utf8" ) type lexer struct { input string start int pos int width int } const eof = -1 func lex(input string) *lexer { l := &lexer{input: input} return l } func (l *lexer) emit() string { ret := l.input[l.start:l.pos] l.start = l.pos return ret } func (l *lexer) accept(valid string) bool { if strings.IndexRune(valid, l.next()) >= 0 { l.emit() return true } l.backup() return false } func (l *lexer) acceptAny() string { l.next() return l.emit() } func (l *lexer) acceptRun(pred func(rune) bool) string { for pred(l.next()) { } l.backup() return l.emit() } func (l *lexer) next() (rune rune) { if l.pos >= len(l.input) { l.width = 0 return eof } rune, l.width = utf8.DecodeRuneInString(l.input[l.pos:]) l.pos += l.width return rune } func (l *lexer) backup() { l.pos -= l.width } func (l *lexer) eof() bool { return l.pos >= len(l.input) } func (l *lexer) Error(msg string) error { return fmt.Errorf("%s: %s[%s]%s", msg, l.input[0:l.start], l.input[l.start:l.pos], l.input[l.pos:]) } cli53-0.9.0/main.go000066400000000000000000000342001517372254500137240ustar00rootroot00000000000000package cli53 import ( "context" "fmt" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/urfave/cli/v2" ) var r53 *route53.Client var version = "main" func theContext(c *cli.Context) (context.Context, func()) { if c.IsSet("timeout") { timeout := time.Second * time.Duration(c.Float64("timeout")) return context.WithTimeout(context.Background(), timeout) } return context.Background(), func() {} } // Main entry point for cli53 application func Main(args []string) int { commonFlags := []cli.Flag{ &cli.BoolFlag{ Name: "debug", Aliases: []string{"d"}, Usage: "enable debug logging", }, &cli.StringFlag{ Name: "profile", Usage: "profile to use from credentials file", }, &cli.StringFlag{ Name: "role-arn", Usage: "AWS role ARN to assume", }, &cli.StringFlag{ Name: "endpoint-url", Usage: "override Route53 endpoint (hostname or fully qualified URI)", }, &cli.Float64Flag{ Name: "timeout", Usage: "timeout in seconds", }, } app := cli.NewApp() app.Name = "cli53" app.Usage = "manage route53 DNS" app.Version = version app.Commands = []*cli.Command{ { Name: "list", Aliases: []string{"l"}, Usage: "list domains", Flags: append(commonFlags, &cli.StringFlag{ Name: "format", Aliases: []string{"f"}, Value: "table", Usage: "output format: text, json, jl, table, csv", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 0 { cli.ShowCommandHelp(c, "list") return cli.NewExitError("No parameters expected", 1) } formatter := getFormatter(c) if formatter == nil { return cli.NewExitError("Unknown format", 1) } ctx, cancel := theContext(c) defer cancel() listZones(ctx, formatter) return nil }, }, { Name: "create", Usage: "create a domain", ArgsUsage: "domain.name", Flags: append(commonFlags, &cli.StringFlag{ Name: "comment", Value: "", Usage: "comment on the domain", }, &cli.StringFlag{ Name: "vpc-id", Value: "", Usage: "create a private zone in the VPC", }, &cli.StringFlag{ Name: "vpc-region", Value: "", Usage: "VPC region (required if vpcId is specified)", }, &cli.StringFlag{ Name: "delegation-set-id", Value: "", Usage: "use the given delegation set", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 1 { cli.ShowCommandHelp(c, "create") return cli.NewExitError("Expected exactly 1 parameter", 1) } ctx, cancel := theContext(c) defer cancel() createZone(ctx, c.Args().First(), c.String("comment"), c.String("vpc-id"), c.String("vpc-region"), c.String("delegation-set-id")) return nil }, }, { Name: "delete", Usage: "delete a domain", ArgsUsage: "name|ID", Flags: append(commonFlags, &cli.BoolFlag{ Name: "purge", Usage: "remove any existing records on the domain (otherwise deletion will fail)", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 1 { cli.ShowCommandHelp(c, "delete") return cli.NewExitError("Expected exactly 1 parameter", 1) } domain := c.Args().First() ctx, cancel := theContext(c) defer cancel() deleteZone(ctx, domain, c.Bool("purge")) return nil }, }, { Name: "validate", Usage: "validate a bind zone file syntax", ArgsUsage: "name|ID", Flags: append(commonFlags, &cli.StringFlag{ Name: "file", Value: "", Usage: "bind zone filename, or - for stdin (required)", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 0 { cli.ShowCommandHelp(c, "validate") return cli.NewExitError("No parameters expected", 1) } args := importArgs{ name: c.Args().First(), file: c.String("file"), } validateBindFile(args) return nil }, }, { Name: "import", Usage: "import a bind zone file", ArgsUsage: "name|ID", Flags: append(commonFlags, &cli.StringFlag{ Name: "file", Value: "", Usage: "bind zone filename, or - for stdin (required)", }, &cli.BoolFlag{ Name: "wait", Usage: "wait for changes to become live", }, &cli.BoolFlag{ Name: "editauth", Usage: "include SOA and NS records from zone file", }, &cli.BoolFlag{ Name: "replace", Usage: "replace all existing records", }, &cli.BoolFlag{ Name: "upsert", Usage: "update or replace records, do not delete existing", }, &cli.BoolFlag{ Name: "dry-run", Aliases: []string{"n"}, Usage: "perform a trial run with no changes made", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 1 { cli.ShowCommandHelp(c, "import") return cli.NewExitError("Expected exactly 1 parameter", 1) } args := importArgs{ name: c.Args().First(), file: c.String("file"), wait: c.Bool("wait"), editauth: c.Bool("editauth"), replace: c.Bool("replace"), upsert: c.Bool("upsert"), dryrun: c.Bool("dry-run"), } ctx, cancel := theContext(c) defer cancel() importBind(ctx, args) return nil }, }, { Name: "instances", Usage: "dynamically update your dns with EC2 instance names", ArgsUsage: "name|ID", Flags: append(commonFlags, &cli.StringFlag{ Name: "off", Value: "", Usage: "if provided, then records for stopped instances will be updated. This option gives the dns name the CNAME should revert to", }, &cli.StringSliceFlag{ Name: "region", EnvVars: []string{"AWS_REGION"}, Usage: "a list of regions to check", }, &cli.BoolFlag{ Name: "wait", Usage: "wait for changes to become live", }, &cli.IntFlag{ Name: "ttl", Aliases: []string{"x"}, Value: 60, Usage: "resource record ttl", }, &cli.StringFlag{ Name: "match", Value: "", Usage: "regular expression to select which Name tags to use", }, &cli.BoolFlag{ Name: "internal", Aliases: []string{"i"}, Usage: "always use the internal hostname", }, &cli.BoolFlag{ Name: "a-record", Aliases: []string{"a"}, Usage: "write an A record (IP) instead of CNAME", }, &cli.BoolFlag{ Name: "dry-run", Aliases: []string{"n"}, Usage: "dry run - don't make any changes", }, ), Action: func(c *cli.Context) (err error) { config, err := getConfig(c) if err != nil { return err } r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 1 { cli.ShowCommandHelp(c, "instances") return cli.NewExitError("Expected exactly 1 parameter", 1) } args := instancesArgs{ name: c.Args().First(), off: c.String("off"), regions: c.StringSlice("region"), wait: c.Bool("wait"), ttl: c.Int("ttl"), match: c.String("match"), internal: c.Bool("internal"), aRecord: c.Bool("a-record"), dryRun: c.Bool("dry-run"), } ctx, cancel := theContext(c) defer cancel() instances(ctx, args, config) return nil }, }, { Name: "export", Usage: "export a bind zone file (to stdout)", ArgsUsage: "name|ID", Flags: append(commonFlags, &cli.BoolFlag{ Name: "full", Aliases: []string{"f"}, Usage: "export prefixes as full names", }, &cli.StringFlag{ Name: "output", Usage: "Write to an output file instead of STDOUT", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 1 { cli.ShowCommandHelp(c, "export") return cli.NewExitError("Expected exactly 1 parameter", 1) } outputFileName := c.String("output") writer := os.Stdout if len(outputFileName) > 0 { writer, err = os.Create(outputFileName) if err != nil { return err } defer writer.Close() } ctx, cancel := theContext(c) defer cancel() exportBind(ctx, c.Args().First(), c.Bool("full"), writer) return nil }, }, { Name: "rrcreate", Aliases: []string{"rc"}, Usage: "create one or more records", ArgsUsage: "zone record [record...]", Flags: append(commonFlags, &cli.BoolFlag{ Name: "wait", Usage: "wait for changes to become live", }, &cli.BoolFlag{ Name: "append", Usage: "append the record", }, &cli.BoolFlag{ Name: "replace", Usage: "replace the record", }, &cli.StringFlag{ Name: "identifier", Aliases: []string{"i"}, Usage: "record set identifier (for routed records)", }, &cli.StringFlag{ Name: "failover", Usage: "PRIMARY or SECONDARY on a failover routing", }, &cli.StringFlag{ Name: "health-check", Usage: "associated health check id for failover PRIMARY", }, &cli.IntFlag{ Name: "weight", Usage: "weight on a weighted routing", }, &cli.StringFlag{ Name: "region", Usage: "region for latency-based routing", }, &cli.StringFlag{ Name: "country-code", Usage: "country code for geolocation routing", }, &cli.StringFlag{ Name: "continent-code", Usage: "continent code for geolocation routing", }, &cli.StringFlag{ Name: "subdivision-code", Usage: "subdivision code for geolocation routing", }, &cli.BoolFlag{ Name: "multivalue", Usage: "use multivalue answer routing", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() < 2 { cli.ShowCommandHelp(c, "rrcreate") return cli.NewExitError("Expected at least 2 parameters", 1) } var weight *int if c.IsSet("weight") { weight = aws.Int(c.Int("weight")) } args := createArgs{ name: c.Args().Get(0), records: c.Args().Slice()[1:], wait: c.Bool("wait"), append: c.Bool("append"), replace: c.Bool("replace"), identifier: c.String("identifier"), failover: c.String("failover"), healthCheckId: c.String("health-check"), weight: weight, region: c.String("region"), countryCode: c.String("country-code"), continentCode: c.String("continent-code"), subdivisionCode: c.String("subdivision-code"), multivalue: c.Bool("multivalue"), } if !args.validate() { return cli.NewExitError("Validation error", 1) } ctx, cancel := theContext(c) defer cancel() createRecords(ctx, args) return nil }, }, { Name: "rrdelete", Aliases: []string{"rd"}, Usage: "delete a record", ArgsUsage: "zone prefix type", Flags: append(commonFlags, &cli.BoolFlag{ Name: "wait", Usage: "wait for changes to become live", }, &cli.StringFlag{ Name: "identifier", Aliases: []string{"i"}, Usage: "record set identifier to delete", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 3 { cli.ShowCommandHelp(c, "rrdelete") return cli.NewExitError("Expected exactly 3 parameters", 1) } ctx, cancel := theContext(c) defer cancel() deleteRecord(ctx, c.Args().Get(0), c.Args().Get(1), c.Args().Get(2), c.Bool("wait"), c.String("identifier")) return nil }, }, { Name: "rrpurge", Usage: "delete all the records (danger!)", ArgsUsage: "name|ID", Flags: append(commonFlags, &cli.BoolFlag{ Name: "confirm", Usage: "confirm you definitely want to do this!", }, &cli.BoolFlag{ Name: "wait", Usage: "wait for changes to become live", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 1 { cli.ShowCommandHelp(c, "rrpurge") return cli.NewExitError("Expected exactly 1 parameter", 1) } if !c.Bool("confirm") { return cli.NewExitError("You must --confirm this action", 1) } ctx, cancel := theContext(c) defer cancel() purgeRecords(ctx, c.Args().First(), c.Bool("wait")) return nil }, }, { Name: "dslist", Usage: "list reusable delegation sets", Flags: commonFlags, Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } ctx, cancel := theContext(c) defer cancel() listReusableDelegationSets(ctx) return nil }, }, { Name: "dscreate", Usage: "create a reusable delegation set", Flags: append(commonFlags, &cli.StringFlag{ Name: "zone-id", Value: "", Usage: "convert the given zone delegation set (optional)", }, ), Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } ctx, cancel := theContext(c) defer cancel() createReusableDelegationSet(ctx, c.String("zone-id")) return nil }, }, { Name: "dsdelete", Usage: "delete a reusable delegation set", ArgsUsage: "id", Flags: commonFlags, Action: func(c *cli.Context) (err error) { r53, err = getService(c) if err != nil { return err } if c.Args().Len() != 1 { cli.ShowCommandHelp(c, "dsdelete") return cli.NewExitError("Expected exactly 1 parameter", 1) } ctx, cancel := theContext(c) defer cancel() deleteReusableDelegationSet(ctx, c.Args().First()) return nil }, }, } err := app.Run(args) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return 0 } cli53-0.9.0/tests/000077500000000000000000000000001517372254500136145ustar00rootroot00000000000000cli53-0.9.0/tests/alias.txt000066400000000000000000000001011517372254500154360ustar00rootroot00000000000000www 86400 IN A 127.0.0.1 alias 86400 AWS ALIAS A www $self false cli53-0.9.0/tests/alias_multiple_types.txt000066400000000000000000000002021517372254500205770ustar00rootroot00000000000000www 86400 IN A 127.0.0.1 www 86400 IN AAAA ::1 alias 86400 AWS ALIAS A www $self false alias 86400 AWS ALIAS AAAA www $self false cli53-0.9.0/tests/arpa.txt000066400000000000000000000000351517372254500152760ustar00rootroot0000000000000098 3600 IN PTR blah.foo.com. cli53-0.9.0/tests/auth.txt000066400000000000000000000002361517372254500153170ustar00rootroot00000000000000@ 900 IN SOA ns1.somenameserver.com. blah.example.com. 1 7200 900 1209600 86400 @ 172800 IN NS ns1.somenameserver.com. @ 172800 IN NS ns2.somenameserver.com. cli53-0.9.0/tests/basic.txt000066400000000000000000000014451517372254500154420ustar00rootroot00000000000000@ 86400 IN A 10.0.0.1 @ 86400 IN MX 10 mail.example.com. @ 86400 IN MX 20 mail2.example.com. @ 900 IN SOA ns1.somenameserver.com. blah.example.com. 1 7200 900 1209600 86400 @ 172800 IN NS ns1.somenameserver.com. @ 172800 IN NS ns2.somenameserver.com. @ 86400 IN TXT "v=spf1 a mx a:cli53.example.com mx:mail.example.com ip4:10.0.0.0/24 ~all" @ 86400 IN SPF "v=spf1 a mx a:cli53.example.com mx:mail.example.com ip4:10.0.0.0/24 ~all" mail 86400 IN A 10.0.0.2 mail2 86400 IN A 10.0.0.3 root 86400 IN CNAME @ test 86400 IN TXT "multivalued" " txt \"quoted\" record" www 86400 IN A 10.0.0.1 web 86400 IN CNAME www google 86400 IN CNAME www.google.com. rfc3403-1 86400 IN NAPTR 100 10 "u" "sip+E2U" "!^.*$!sip:information@foo.se!i" . rfc3403-2 86400 IN NAPTR 100 50 "a" "z3950+N2L+N2C" "" cidserver.example.com. cli53-0.9.0/tests/big.txt000066400000000000000000000047141517372254500151240ustar00rootroot000000000000001 86400 IN A 10.0.0.1 2 86400 IN A 10.0.0.1 3 86400 IN A 10.0.0.1 4 86400 IN A 10.0.0.1 5 86400 IN A 10.0.0.1 6 86400 IN A 10.0.0.1 7 86400 IN A 10.0.0.1 8 86400 IN A 10.0.0.1 9 86400 IN A 10.0.0.1 10 86400 IN A 10.0.0.1 11 86400 IN A 10.0.0.1 12 86400 IN A 10.0.0.1 13 86400 IN A 10.0.0.1 14 86400 IN A 10.0.0.1 15 86400 IN A 10.0.0.1 16 86400 IN A 10.0.0.1 17 86400 IN A 10.0.0.1 18 86400 IN A 10.0.0.1 19 86400 IN A 10.0.0.1 20 86400 IN A 10.0.0.1 21 86400 IN A 10.0.0.1 22 86400 IN A 10.0.0.1 23 86400 IN A 10.0.0.1 24 86400 IN A 10.0.0.1 25 86400 IN A 10.0.0.1 26 86400 IN A 10.0.0.1 27 86400 IN A 10.0.0.1 28 86400 IN A 10.0.0.1 29 86400 IN A 10.0.0.1 30 86400 IN A 10.0.0.1 31 86400 IN A 10.0.0.1 32 86400 IN A 10.0.0.1 33 86400 IN A 10.0.0.1 34 86400 IN A 10.0.0.1 35 86400 IN A 10.0.0.1 36 86400 IN A 10.0.0.1 37 86400 IN A 10.0.0.1 38 86400 IN A 10.0.0.1 39 86400 IN A 10.0.0.1 40 86400 IN A 10.0.0.1 41 86400 IN A 10.0.0.1 42 86400 IN A 10.0.0.1 43 86400 IN A 10.0.0.1 44 86400 IN A 10.0.0.1 45 86400 IN A 10.0.0.1 46 86400 IN A 10.0.0.1 47 86400 IN A 10.0.0.1 48 86400 IN A 10.0.0.1 49 86400 IN A 10.0.0.1 50 86400 IN A 10.0.0.1 51 86400 IN A 10.0.0.1 52 86400 IN A 10.0.0.1 53 86400 IN A 10.0.0.1 54 86400 IN A 10.0.0.1 55 86400 IN A 10.0.0.1 56 86400 IN A 10.0.0.1 57 86400 IN A 10.0.0.1 58 86400 IN A 10.0.0.1 59 86400 IN A 10.0.0.1 60 86400 IN A 10.0.0.1 61 86400 IN A 10.0.0.1 62 86400 IN A 10.0.0.1 63 86400 IN A 10.0.0.1 64 86400 IN A 10.0.0.1 65 86400 IN A 10.0.0.1 66 86400 IN A 10.0.0.1 67 86400 IN A 10.0.0.1 68 86400 IN A 10.0.0.1 69 86400 IN A 10.0.0.1 70 86400 IN A 10.0.0.1 71 86400 IN A 10.0.0.1 72 86400 IN A 10.0.0.1 73 86400 IN A 10.0.0.1 74 86400 IN A 10.0.0.1 75 86400 IN A 10.0.0.1 76 86400 IN A 10.0.0.1 77 86400 IN A 10.0.0.1 78 86400 IN A 10.0.0.1 79 86400 IN A 10.0.0.1 80 86400 IN A 10.0.0.1 81 86400 IN A 10.0.0.1 82 86400 IN A 10.0.0.1 83 86400 IN A 10.0.0.1 84 86400 IN A 10.0.0.1 85 86400 IN A 10.0.0.1 86 86400 IN A 10.0.0.1 87 86400 IN A 10.0.0.1 88 86400 IN A 10.0.0.1 89 86400 IN A 10.0.0.1 90 86400 IN A 10.0.0.1 91 86400 IN A 10.0.0.1 92 86400 IN A 10.0.0.1 93 86400 IN A 10.0.0.1 94 86400 IN A 10.0.0.1 95 86400 IN A 10.0.0.1 96 86400 IN A 10.0.0.1 97 86400 IN A 10.0.0.1 98 86400 IN A 10.0.0.1 99 86400 IN A 10.0.0.1 100 86400 IN A 10.0.0.1 101 86400 IN A 10.0.0.1 102 86400 IN A 10.0.0.1 103 86400 IN A 10.0.0.1 104 86400 IN A 10.0.0.1 105 86400 IN A 10.0.0.1 106 86400 IN A 10.0.0.1 107 86400 IN A 10.0.0.1 108 86400 IN A 10.0.0.1 109 86400 IN A 10.0.0.1 cli53-0.9.0/tests/big2.txt000066400000000000000000000202171517372254500152020ustar00rootroot000000000000001 86400 IN A 10.0.0.1 2 86400 IN A 10.0.0.1 3 86400 IN A 10.0.0.1 4 86400 IN A 10.0.0.1 5 86400 IN A 10.0.0.1 6 86400 IN A 10.0.0.1 7 86400 IN A 10.0.0.1 8 86400 IN A 10.0.0.1 9 86400 IN A 10.0.0.1 10 86400 IN A 10.0.0.1 11 86400 IN A 10.0.0.1 12 86400 IN A 10.0.0.1 13 86400 IN A 10.0.0.1 14 86400 IN A 10.0.0.1 15 86400 IN A 10.0.0.1 16 86400 IN A 10.0.0.1 17 86400 IN A 10.0.0.1 18 86400 IN A 10.0.0.1 19 86400 IN A 10.0.0.1 20 86400 IN A 10.0.0.1 weighted 300 IN A 127.0.0.1 ; AWS routing="WEIGHTED" weight=1 identifier="1" weighted 300 IN A 127.0.0.2 ; AWS routing="WEIGHTED" weight=1 identifier="2" weighted 300 IN A 127.0.0.3 ; AWS routing="WEIGHTED" weight=1 identifier="3" weighted 300 IN A 127.0.0.4 ; AWS routing="WEIGHTED" weight=1 identifier="4" weighted 300 IN A 127.0.0.5 ; AWS routing="WEIGHTED" weight=1 identifier="5" weighted 300 IN A 127.0.0.6 ; AWS routing="WEIGHTED" weight=1 identifier="6" weighted 300 IN A 127.0.0.7 ; AWS routing="WEIGHTED" weight=1 identifier="7" weighted 300 IN A 127.0.0.8 ; AWS routing="WEIGHTED" weight=1 identifier="8" weighted 300 IN A 127.0.0.9 ; AWS routing="WEIGHTED" weight=1 identifier="9" weighted 300 IN A 127.0.0.10 ; AWS routing="WEIGHTED" weight=1 identifier="10" weighted 300 IN A 127.0.0.11 ; AWS routing="WEIGHTED" weight=1 identifier="11" weighted 300 IN A 127.0.0.12 ; AWS routing="WEIGHTED" weight=1 identifier="12" weighted 300 IN A 127.0.0.13 ; AWS routing="WEIGHTED" weight=1 identifier="13" weighted 300 IN A 127.0.0.14 ; AWS routing="WEIGHTED" weight=1 identifier="14" weighted 300 IN A 127.0.0.15 ; AWS routing="WEIGHTED" weight=1 identifier="15" weighted 300 IN A 127.0.0.16 ; AWS routing="WEIGHTED" weight=1 identifier="16" weighted 300 IN A 127.0.0.17 ; AWS routing="WEIGHTED" weight=1 identifier="17" weighted 300 IN A 127.0.0.18 ; AWS routing="WEIGHTED" weight=1 identifier="18" weighted 300 IN A 127.0.0.19 ; AWS routing="WEIGHTED" weight=1 identifier="19" weighted 300 IN A 127.0.0.20 ; AWS routing="WEIGHTED" weight=1 identifier="20" weighted 300 IN A 127.0.0.21 ; AWS routing="WEIGHTED" weight=1 identifier="21" weighted 300 IN A 127.0.0.22 ; AWS routing="WEIGHTED" weight=1 identifier="22" weighted 300 IN A 127.0.0.23 ; AWS routing="WEIGHTED" weight=1 identifier="23" weighted 300 IN A 127.0.0.24 ; AWS routing="WEIGHTED" weight=1 identifier="24" weighted 300 IN A 127.0.0.25 ; AWS routing="WEIGHTED" weight=1 identifier="25" weighted 300 IN A 127.0.0.26 ; AWS routing="WEIGHTED" weight=1 identifier="26" weighted 300 IN A 127.0.0.27 ; AWS routing="WEIGHTED" weight=1 identifier="27" weighted 300 IN A 127.0.0.28 ; AWS routing="WEIGHTED" weight=1 identifier="28" weighted 300 IN A 127.0.0.29 ; AWS routing="WEIGHTED" weight=1 identifier="29" weighted 300 IN A 127.0.0.30 ; AWS routing="WEIGHTED" weight=1 identifier="30" weighted 300 IN A 127.0.0.31 ; AWS routing="WEIGHTED" weight=1 identifier="31" weighted 300 IN A 127.0.0.32 ; AWS routing="WEIGHTED" weight=1 identifier="32" weighted 300 IN A 127.0.0.33 ; AWS routing="WEIGHTED" weight=1 identifier="33" weighted 300 IN A 127.0.0.34 ; AWS routing="WEIGHTED" weight=1 identifier="34" weighted 300 IN A 127.0.0.35 ; AWS routing="WEIGHTED" weight=1 identifier="35" weighted 300 IN A 127.0.0.36 ; AWS routing="WEIGHTED" weight=1 identifier="36" weighted 300 IN A 127.0.0.37 ; AWS routing="WEIGHTED" weight=1 identifier="37" weighted 300 IN A 127.0.0.38 ; AWS routing="WEIGHTED" weight=1 identifier="38" weighted 300 IN A 127.0.0.39 ; AWS routing="WEIGHTED" weight=1 identifier="39" weighted 300 IN A 127.0.0.40 ; AWS routing="WEIGHTED" weight=1 identifier="40" weighted 300 IN A 127.0.0.41 ; AWS routing="WEIGHTED" weight=1 identifier="41" weighted 300 IN A 127.0.0.42 ; AWS routing="WEIGHTED" weight=1 identifier="42" weighted 300 IN A 127.0.0.43 ; AWS routing="WEIGHTED" weight=1 identifier="43" weighted 300 IN A 127.0.0.44 ; AWS routing="WEIGHTED" weight=1 identifier="44" weighted 300 IN A 127.0.0.45 ; AWS routing="WEIGHTED" weight=1 identifier="45" weighted 300 IN A 127.0.0.46 ; AWS routing="WEIGHTED" weight=1 identifier="46" weighted 300 IN A 127.0.0.47 ; AWS routing="WEIGHTED" weight=1 identifier="47" weighted 300 IN A 127.0.0.48 ; AWS routing="WEIGHTED" weight=1 identifier="48" weighted 300 IN A 127.0.0.49 ; AWS routing="WEIGHTED" weight=1 identifier="49" weighted 300 IN A 127.0.0.50 ; AWS routing="WEIGHTED" weight=1 identifier="50" weighted 300 IN A 127.0.0.51 ; AWS routing="WEIGHTED" weight=1 identifier="51" weighted 300 IN A 127.0.0.52 ; AWS routing="WEIGHTED" weight=1 identifier="52" weighted 300 IN A 127.0.0.53 ; AWS routing="WEIGHTED" weight=1 identifier="53" weighted 300 IN A 127.0.0.54 ; AWS routing="WEIGHTED" weight=1 identifier="54" weighted 300 IN A 127.0.0.55 ; AWS routing="WEIGHTED" weight=1 identifier="55" weighted 300 IN A 127.0.0.56 ; AWS routing="WEIGHTED" weight=1 identifier="56" weighted 300 IN A 127.0.0.57 ; AWS routing="WEIGHTED" weight=1 identifier="57" weighted 300 IN A 127.0.0.58 ; AWS routing="WEIGHTED" weight=1 identifier="58" weighted 300 IN A 127.0.0.59 ; AWS routing="WEIGHTED" weight=1 identifier="59" weighted 300 IN A 127.0.0.60 ; AWS routing="WEIGHTED" weight=1 identifier="60" weighted 300 IN A 127.0.0.61 ; AWS routing="WEIGHTED" weight=1 identifier="61" weighted 300 IN A 127.0.0.62 ; AWS routing="WEIGHTED" weight=1 identifier="62" weighted 300 IN A 127.0.0.63 ; AWS routing="WEIGHTED" weight=1 identifier="63" weighted 300 IN A 127.0.0.64 ; AWS routing="WEIGHTED" weight=1 identifier="64" weighted 300 IN A 127.0.0.65 ; AWS routing="WEIGHTED" weight=1 identifier="65" weighted 300 IN A 127.0.0.66 ; AWS routing="WEIGHTED" weight=1 identifier="66" weighted 300 IN A 127.0.0.67 ; AWS routing="WEIGHTED" weight=1 identifier="67" weighted 300 IN A 127.0.0.68 ; AWS routing="WEIGHTED" weight=1 identifier="68" weighted 300 IN A 127.0.0.69 ; AWS routing="WEIGHTED" weight=1 identifier="69" weighted 300 IN A 127.0.0.70 ; AWS routing="WEIGHTED" weight=1 identifier="70" weighted 300 IN A 127.0.0.71 ; AWS routing="WEIGHTED" weight=1 identifier="71" weighted 300 IN A 127.0.0.72 ; AWS routing="WEIGHTED" weight=1 identifier="72" weighted 300 IN A 127.0.0.73 ; AWS routing="WEIGHTED" weight=1 identifier="73" weighted 300 IN A 127.0.0.74 ; AWS routing="WEIGHTED" weight=1 identifier="74" weighted 300 IN A 127.0.0.75 ; AWS routing="WEIGHTED" weight=1 identifier="75" weighted 300 IN A 127.0.0.76 ; AWS routing="WEIGHTED" weight=1 identifier="76" weighted 300 IN A 127.0.0.77 ; AWS routing="WEIGHTED" weight=1 identifier="77" weighted 300 IN A 127.0.0.78 ; AWS routing="WEIGHTED" weight=1 identifier="78" weighted 300 IN A 127.0.0.79 ; AWS routing="WEIGHTED" weight=1 identifier="79" weighted 300 IN A 127.0.0.80 ; AWS routing="WEIGHTED" weight=1 identifier="80" weighted 300 IN A 127.0.0.81 ; AWS routing="WEIGHTED" weight=1 identifier="81" weighted 300 IN A 127.0.0.82 ; AWS routing="WEIGHTED" weight=1 identifier="82" weighted 300 IN A 127.0.0.83 ; AWS routing="WEIGHTED" weight=1 identifier="83" weighted 300 IN A 127.0.0.84 ; AWS routing="WEIGHTED" weight=1 identifier="84" weighted 300 IN A 127.0.0.85 ; AWS routing="WEIGHTED" weight=1 identifier="85" weighted 300 IN A 127.0.0.86 ; AWS routing="WEIGHTED" weight=1 identifier="86" weighted 300 IN A 127.0.0.87 ; AWS routing="WEIGHTED" weight=1 identifier="87" weighted 300 IN A 127.0.0.88 ; AWS routing="WEIGHTED" weight=1 identifier="88" weighted 300 IN A 127.0.0.89 ; AWS routing="WEIGHTED" weight=1 identifier="89" weighted 300 IN A 127.0.0.90 ; AWS routing="WEIGHTED" weight=1 identifier="90" weighted 300 IN A 127.0.0.91 ; AWS routing="WEIGHTED" weight=1 identifier="91" weighted 300 IN A 127.0.0.92 ; AWS routing="WEIGHTED" weight=1 identifier="92" weighted 300 IN A 127.0.0.93 ; AWS routing="WEIGHTED" weight=1 identifier="93" weighted 300 IN A 127.0.0.94 ; AWS routing="WEIGHTED" weight=1 identifier="94" weighted 300 IN A 127.0.0.95 ; AWS routing="WEIGHTED" weight=1 identifier="95" weighted 300 IN A 127.0.0.96 ; AWS routing="WEIGHTED" weight=1 identifier="96" weighted 300 IN A 127.0.0.97 ; AWS routing="WEIGHTED" weight=1 identifier="97" weighted 300 IN A 127.0.0.98 ; AWS routing="WEIGHTED" weight=1 identifier="98" weighted 300 IN A 127.0.0.99 ; AWS routing="WEIGHTED" weight=1 identifier="99" weighted 300 IN A 127.0.0.100 ; AWS routing="WEIGHTED" weight=1 identifier="100" cli53-0.9.0/tests/failover.txt000066400000000000000000000004051517372254500161630ustar00rootroot00000000000000failover 300 IN A 127.0.0.1 ; AWS routing="FAILOVER" failover="PRIMARY" healthCheckId="6bb57c41-879a-42d0-acdd-ed6472f08eb9" identifier="failover-Primary" failover 300 IN A 127.0.0.2 ; AWS routing="FAILOVER" failover="SECONDARY" identifier="failover-Secondary" cli53-0.9.0/tests/geo.txt000066400000000000000000000002561517372254500151320ustar00rootroot00000000000000geo 300 IN A 127.0.0.2 ; AWS routing="GEOLOCATION" continentCode="AF" identifier="Africa" geo 300 IN A 127.0.0.1 ; AWS routing="GEOLOCATION" countryCode="GB" identifier="UK" cli53-0.9.0/tests/geo_alias.txt000066400000000000000000000003201517372254500162730ustar00rootroot00000000000000a 300 IN A 127.0.0.1 geo 300 IN A 127.0.0.2 ; AWS routing="GEOLOCATION" continentCode="AF" identifier="Africa" geo 86400 AWS ALIAS A a $self false ; AWS routing="GEOLOCATION" countryCode="GB" identifier="UK" cli53-0.9.0/tests/latency.txt000066400000000000000000000002661517372254500160200ustar00rootroot00000000000000latency 300 IN A 127.0.0.1 ; AWS routing="LATENCY" region="us-west-1" identifier="USWest1" latency 300 IN A 127.0.0.2 ; AWS routing="LATENCY" region="us-west-2" identifier="USWest2" cli53-0.9.0/tests/multivalue.txt000066400000000000000000000002241517372254500165420ustar00rootroot00000000000000multivalue 300 IN A 127.0.0.1 ; AWS routing="MULTIVALUE" identifier="One" multivalue 300 IN A 127.0.0.2 ; AWS routing="MULTIVALUE" identifier="Two" cli53-0.9.0/tests/replace1.txt000066400000000000000000000010701517372254500160470ustar00rootroot00000000000000@ 86400 IN A 10.0.0.1 @ 86400 IN MX 10 mail.example.com. @ 86400 IN MX 20 mail2.example.com. @ 900 IN SOA ns1.somenameserver.com. blah.example.com. 1 7200 900 1209600 86400 @ 172800 IN NS ns1.somenameserver.com. @ 172800 IN NS ns2.somenameserver.com. @ 86400 IN TXT "v=spf1 a mx a:cli53.example.com mx:mail.example.com ip4:10.0.0.0/24 ~all" mail 86400 IN A 10.0.0.2 mail2 86400 IN A 10.0.0.3 test 86400 IN TXT "multivalued" " txt \"quoted\" record" www 86400 IN A 10.0.0.1 www2 86400 IN A 10.0.0.1 unchanged 86400 IN A 10.1.0.1 alias 86400 AWS ALIAS A www $self false cli53-0.9.0/tests/replace2.txt000066400000000000000000000010301517372254500160440ustar00rootroot00000000000000@ 86400 IN A 10.0.0.2 @ 86400 IN MX 20 mail.example.com. @ 86400 IN MX 10 mail2.example.com. @ 900 IN SOA ns1.somenameserver.com. blah.example.com. 1 7200 900 1209600 86400 @ 172800 IN NS ns1.somenameserver.com. @ 172800 IN NS ns2.somenameserver.com. @ 86400 IN TXT "v=spf1 a mx a:cli53.example.com mx:mail.example.com ip4:10.1.0.0/24 ~all" mail 86400 IN A 10.0.0.4 mail2 86400 IN A 10.0.0.5 test 86400 IN TXT "one" www 86400 IN A 10.0.0.2 www2 86400 IN A 10.0.0.2 unchanged 86400 IN A 10.1.0.1 alias 86400 AWS ALIAS A www2 $self false cli53-0.9.0/tests/replace3.txt000066400000000000000000000000231517372254500160460ustar00rootroot00000000000000* 86400 IN CNAME @ cli53-0.9.0/tests/uppercase.txt000066400000000000000000000006651517372254500163530ustar00rootroot00000000000000@ 86400 IN A 10.0.0.1 @ 86400 IN MX 10 mail.example.com. @ 900 IN SOA ns1.somenameserver.com. blah.example.com. 1 7200 900 1209600 86400 @ 172800 IN NS ns1.somenameserver.com. @ 172800 IN NS ns2.somenameserver.com. @ 86400 IN TXT "v=spf1 a mx a:cli53.example.com mx:mail.example.com ip4:10.0.0.0/24 ~all" MAIL 86400 IN A 10.0.0.2 MAIL2 86400 IN A 10.0.0.3 TEST 86400 IN TXT "multivalued" " txt \"quoted\" record" WWW 86400 IN A 10.0.0.1 cli53-0.9.0/tests/upsert1.txt000066400000000000000000000010701517372254500157560ustar00rootroot00000000000000@ 86400 IN A 10.0.0.1 @ 86400 IN MX 10 mail.example.com. @ 86400 IN MX 20 mail2.example.com. @ 900 IN SOA ns1.somenameserver.com. blah.example.com. 1 7200 900 1209600 86400 @ 172800 IN NS ns1.somenameserver.com. @ 172800 IN NS ns2.somenameserver.com. @ 86400 IN TXT "v=spf1 a mx a:cli53.example.com mx:mail.example.com ip4:10.0.0.0/24 ~all" mail 86400 IN A 10.0.0.2 mail2 86400 IN A 10.0.0.3 test 86400 IN TXT "multivalued" " txt \"quoted\" record" www 86400 IN A 10.0.0.1 www2 86400 IN A 10.0.0.1 unchanged 86400 IN A 10.1.0.1 alias 86400 AWS ALIAS A www $self false cli53-0.9.0/tests/upsert2.txt000066400000000000000000000002611517372254500157600ustar00rootroot00000000000000test 86400 IN TXT "multivalued" " txt \"quoted\" record" www2 86400 IN A 10.0.0.5 www3 86400 IN A 10.0.0.5 unchanged 86400 IN A 10.1.0.1 alias 86400 AWS ALIAS A www $self false cli53-0.9.0/tests/upsert3.txt000066400000000000000000000011211517372254500157550ustar00rootroot00000000000000@ 86400 IN A 10.0.0.1 @ 86400 IN MX 10 mail.example.com. @ 86400 IN MX 20 mail2.example.com. @ 900 IN SOA ns1.somenameserver.com. blah.example.com. 1 7200 900 1209600 86400 @ 172800 IN NS ns1.somenameserver.com. @ 172800 IN NS ns2.somenameserver.com. @ 86400 IN TXT "v=spf1 a mx a:cli53.example.com mx:mail.example.com ip4:10.0.0.0/24 ~all" mail 86400 IN A 10.0.0.2 mail2 86400 IN A 10.0.0.3 test 86400 IN TXT "multivalued" " txt \"quoted\" record" www 86400 IN A 10.0.0.1 www2 86400 IN A 10.0.0.5 www3 86400 IN A 10.0.0.5 unchanged 86400 IN A 10.1.0.1 alias 86400 AWS ALIAS A www $self false cli53-0.9.0/tests/validate1.txt000066400000000000000000000000411517372254500162220ustar00rootroot00000000000000validate_ok 86400 IN A 127.0.0.1 cli53-0.9.0/tests/validate2.txt000066400000000000000000000000441517372254500162260ustar00rootroot00000000000000validate_fail 86400 ERR A 127.0.0.1 cli53-0.9.0/tests/weighted.txt000066400000000000000000000003561517372254500161610ustar00rootroot00000000000000weighted 300 IN A 127.0.0.1 ; AWS routing="WEIGHTED" weight=1 identifier="One" weighted 300 IN A 127.0.0.2 ; AWS routing="WEIGHTED" weight=2 identifier="Two" weighted 300 IN A 127.0.0.1 ; AWS routing="WEIGHTED" weight=0 identifier="Zero" cli53-0.9.0/tests/wildcard.txt000066400000000000000000000000261517372254500161440ustar00rootroot00000000000000* 86400 IN A 10.0.0.1 cli53-0.9.0/util.go000066400000000000000000000277741517372254500137770ustar00rootroot00000000000000package cli53 import ( "bufio" "context" "errors" "fmt" "math/rand" "os" "regexp" "strconv" "strings" "sync" "time" "unicode" "github.com/urfave/cli/v2" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/route53" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/aws/aws-sdk-go-v2/service/sts" smithylogging "github.com/aws/smithy-go/logging" ) // Qualify names, if relative func qualifyName(name, origin string) string { if name == "" || name == "@" { // root return origin } else if !strings.HasSuffix(name, ".") { // unqualified return name + "." + origin } else { // qualified return name } } func getConfig(c *cli.Context) (aws.Config, error) { ctx := context.Background() debug := c.Bool("debug") endpoint := c.String("endpoint-url") profile := c.String("profile") options := []func(*config.LoadOptions) error{ config.WithRetryMaxAttempts(100), } if profile != "" { options = append(options, config.WithSharedConfigProfile(profile)) } cfg, err := config.LoadDefaultConfig(ctx, options...) if err != nil { fallbackCfg, handled, fallbackErr := loadConfigWithSourceProfileFallback(ctx, effectiveSharedConfigProfile(profile)) if handled { if fallbackErr != nil { return aws.Config{}, fallbackErr } return fallbackCfg, nil } return aws.Config{}, err } if debug { cfg.Logger = smithylogging.NewStandardLogger(os.Stderr) cfg.ClientLogMode = aws.LogRetries | aws.LogRequestWithBody | aws.LogResponseWithBody } if cfg.Region == "" { cfg.Region = "us-east-1" } // SDK requires region to be set when endpoint-url is set. if endpoint != "" && cfg.Region == "" { return aws.Config{}, cli.NewExitError("AWS_REGION must be set when using --endpoint-url", 1) } return cfg, nil } func loadConfigWithSourceProfileFallback(ctx context.Context, profile string) (aws.Config, bool, error) { target, found, err := loadAWSProfileSettings(profile) if err != nil { return aws.Config{}, false, nil } if !found || target.RoleARN == "" || target.SourceProfileName == "" { return aws.Config{}, false, nil } source, found, err := loadAWSProfileSettings(target.SourceProfileName) if err != nil { return aws.Config{}, false, nil } if !found || source.LoginSession == "" { return aws.Config{}, false, nil } if target.MFASerial != "" { return aws.Config{}, true, fmt.Errorf("profile %q requires MFA serial %q; cli53 fallback does not support prompting for MFA", profile, target.MFASerial) } options := []func(*config.LoadOptions) error{ config.WithRetryMaxAttempts(100), config.WithSharedConfigProfile(target.SourceProfileName), } cfg, err := config.LoadDefaultConfig(ctx, options...) if err != nil { return aws.Config{}, true, err } if target.Region != "" { cfg.Region = target.Region } assumeRoleOptions := []func(*stscreds.AssumeRoleOptions){ func(o *stscreds.AssumeRoleOptions) { if target.ExternalID != "" { o.ExternalID = aws.String(target.ExternalID) } if target.RoleSessionName != "" { o.RoleSessionName = target.RoleSessionName } if target.Duration > 0 { o.Duration = target.Duration } }, } cfg.Credentials = aws.NewCredentialsCache(stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), target.RoleARN, assumeRoleOptions...)) return cfg, true, nil } func effectiveSharedConfigProfile(profile string) string { switch { case profile != "": return profile case os.Getenv("AWS_PROFILE") != "": return os.Getenv("AWS_PROFILE") case os.Getenv("AWS_DEFAULT_PROFILE") != "": return os.Getenv("AWS_DEFAULT_PROFILE") default: return "default" } } func sharedConfigFilesForDebug() []string { if path := os.Getenv("AWS_CONFIG_FILE"); path != "" { return []string{path} } return append([]string(nil), config.DefaultSharedConfigFiles...) } func sharedCredentialsFilesForDebug() []string { if path := os.Getenv("AWS_SHARED_CREDENTIALS_FILE"); path != "" { return []string{path} } return append([]string(nil), config.DefaultSharedCredentialsFiles...) } type awsProfileSettings struct { Name string Region string RoleARN string SourceProfileName string ExternalID string RoleSessionName string MFASerial string LoginSession string Duration time.Duration } func loadAWSProfileSettings(profile string) (awsProfileSettings, bool, error) { settings := awsProfileSettings{Name: profile} found := false for _, path := range sharedConfigFilesForDebug() { file, err := os.Open(path) if err != nil { if errors.Is(err, os.ErrNotExist) { continue } return awsProfileSettings{}, false, err } fileFound, fileErr := parseAWSConfigProfile(file, profile, &settings) _ = file.Close() if fileErr != nil { return awsProfileSettings{}, false, fmt.Errorf("parse %s: %w", path, fileErr) } found = found || fileFound } return settings, found, nil } func parseAWSConfigProfile(file *os.File, profile string, settings *awsProfileSettings) (bool, error) { scanner := bufio.NewScanner(file) targetSections := map[string]struct{}{"profile " + profile: {}} if profile == "default" { targetSections = map[string]struct{}{"default": {}} } found := false inTarget := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { section := strings.TrimSpace(line[1 : len(line)-1]) _, inTarget = targetSections[section] found = found || inTarget continue } if !inTarget { continue } key, value, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(strings.ToLower(key)) value = strings.TrimSpace(value) switch key { case "region": settings.Region = value case "role_arn": settings.RoleARN = value case "source_profile": settings.SourceProfileName = value case "external_id": settings.ExternalID = value case "role_session_name": settings.RoleSessionName = value case "mfa_serial": settings.MFASerial = value case "login_session": settings.LoginSession = value case "duration_seconds": seconds, err := strconv.Atoi(value) if err != nil { return false, fmt.Errorf("invalid duration_seconds %q", value) } settings.Duration = time.Duration(seconds) * time.Second } } if err := scanner.Err(); err != nil { return false, err } return found, nil } func getService(c *cli.Context) (*route53.Client, error) { cfg, err := getConfig(c) if err != nil { return nil, err } roleARN := c.String("role-arn") if roleARN != "" { cfg.Credentials = aws.NewCredentialsCache(stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), roleARN)) } return route53.NewFromConfig(cfg, func(o *route53.Options) { if endpoint := c.String("endpoint-url"); endpoint != "" { o.BaseEndpoint = aws.String(endpoint) } }), nil } func fatalIfErr(err error) { if err != nil { errorAndExit(fmt.Sprint(err)) } } func errorAndExit(msg string) { fmt.Fprintln(os.Stderr, "Error: "+msg) os.Exit(1) } var seeded sync.Once func uniqueReference() string { seeded.Do(func() { rand.Seed(time.Now().UnixNano()) }) return fmt.Sprintf("%0x", rand.Int()) } var unescaper = strings.NewReplacer(`\057`, "/", `\052`, "*") func zoneName(s string) string { return unescaper.Replace(strings.TrimRight(s, ".")) } var reZoneId = regexp.MustCompile("^(/hostedzone/)?Z[A-Z0-9]{9,}$") func isZoneId(s string) bool { return reZoneId.MatchString(s) } func lookupZone(ctx context.Context, nameOrId string) *route53types.HostedZone { if isZoneId(nameOrId) { // lookup by id id := nameOrId if !strings.HasPrefix(nameOrId, "/hostedzone/") { id = "/hostedzone/" + id } req := route53.GetHostedZoneInput{ Id: aws.String(id), } resp, err := r53.GetHostedZone(ctx, &req) var notFound *route53types.NoSuchHostedZone if errors.As(err, ¬Found) { errorAndExit(fmt.Sprintf("Zone '%s' not found", nameOrId)) } fatalIfErr(err) return resp.HostedZone } else { // lookup by name matches := []route53types.HostedZone{} req := route53.ListHostedZonesByNameInput{ DNSName: aws.String(nameOrId), } resp, err := r53.ListHostedZonesByName(ctx, &req) fatalIfErr(err) for _, zone := range resp.HostedZones { if zoneName(*zone.Name) == zoneName(nameOrId) { matches = append(matches, zone) } } switch len(matches) { case 0: errorAndExit(fmt.Sprintf("Zone '%s' not found", nameOrId)) case 1: return &matches[0] default: errorAndExit("Multiple zones match - you will need to use Zone ID to uniquely identify the zone") } } return nil } func waitForChange(ctx context.Context, change *route53types.ChangeInfo) { fmt.Printf("Waiting for sync") for { req := route53.GetChangeInput{Id: change.Id} resp, err := r53.GetChange(ctx, &req) fatalIfErr(err) if resp.ChangeInfo.Status == route53types.ChangeStatusInsync { fmt.Println("\nCompleted") break } else if resp.ChangeInfo.Status == route53types.ChangeStatusPending { fmt.Printf(".") } else { fmt.Printf("\nFailed: %s\n", resp.ChangeInfo.Status) break } time.Sleep(1 * time.Second) } } // Use shortened form of name with origin removed/abbreviated. func shortenName(name, origin string) string { if name == origin { return "@" } return strings.TrimSuffix(name, "."+origin) } var reQuotedValue = regexp.MustCompile(`"((?:\\"|[^"])*)"`) var reBackslashed = regexp.MustCompile(`\\(.)`) func splitValues(s string) []string { ret := []string{} for _, m := range reQuotedValue.FindAllStringSubmatch(s, -1) { val := reBackslashed.ReplaceAllString(m[1], "$1") ret = append(ret, val) } return ret } // parse a per RFC 1035 Section 5.1 func parseCharacterString(s string) string { if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { return reBackslashed.ReplaceAllString(s[1:len(s)-1], "$1") } else { return s } } var quoter = strings.NewReplacer(`\`, `\\`, `"`, `\"`) func quote(s string) string { return `"` + quoter.Replace(s) + `"` } type KeyValues []interface{} func (kvs KeyValues) GetOptString(key string) *string { for i := 0; i < len(kvs); i += 2 { if kvs[i] == key { if value, ok := kvs[i+1].(string); ok { return &value } } } return nil } func (kvs KeyValues) GetString(key string) string { val := kvs.GetOptString(key) if val != nil { return *val } return "" } func (kvs KeyValues) GetInt(key string) int { for i := 0; i < len(kvs); i += 2 { if kvs[i] == key { if value, ok := kvs[i+1].(int); ok { return value } } } return 0 } func (kvs KeyValues) String() string { var ret string for i := 0; i < len(kvs); i += 2 { key := kvs[i] value := kvs[i+1] if ret != "" { ret += " " } switch value := value.(type) { case string: ret += fmt.Sprintf("%s=%s", key, quote(value)) case int64, int: ret += fmt.Sprintf("%s=%v", key, value) } } return ret } func ParseKeyValues(input string) (result KeyValues, err error) { // result = append(result, "a", 2) l := lex(input) for { // alpha key key := l.acceptRun(unicode.IsLetter) if key == "" { err = l.Error("Expected key") return } // equals separator if !l.accept("=") { err = l.Error("Expected =") return } // value (string or int) var value interface{} if l.accept(`"`) { // quoted string str := "" for { if l.eof() { err = l.Error("Unterminated quoted string") return } else if l.accept(`\`) { str += l.acceptAny() } else if l.accept(`"`) { break } else { str += l.acceptAny() } } value = str } else if num := l.acceptRun(unicode.IsDigit); num != "" { value, err = strconv.Atoi(num) if err != nil { return } } else { err = l.Error("Unexpected token") return } result = append(result, key, value) if l.eof() { break } // whitespace between multiple key values if l.acceptRun(unicode.IsSpace) == "" { err = l.Error("Expected whitespace") return } } return } cli53-0.9.0/util_test.go000066400000000000000000000116531517372254500150230ustar00rootroot00000000000000package cli53 import ( "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestKeyValuesString(t *testing.T) { assert.Equal(t, "a=1", KeyValues{"a", 1}.String()) assert.Equal(t, `a=""`, KeyValues{"a", ""}.String()) assert.Equal(t, `a="1"`, KeyValues{"a", "1"}.String()) assert.Equal(t, `a="\""`, KeyValues{"a", `"`}.String()) assert.Equal(t, `a="\\"`, KeyValues{"a", `\`}.String()) } func mustParse(input string) KeyValues { result, err := ParseKeyValues(input) if err != nil { panic(err) } return result } func TestParseKeyValuesValid(t *testing.T) { assert.Equal(t, KeyValues{"a", 1}, mustParse("a=1")) assert.Equal(t, KeyValues{"a", ""}, mustParse(`a=""`)) assert.Equal(t, KeyValues{"identifier", 1}, mustParse("identifier=1")) assert.Equal(t, KeyValues{"mixedCase", 1}, mustParse("mixedCase=1")) assert.Equal(t, KeyValues{"a", "b"}, mustParse(`a="b"`)) assert.Equal(t, KeyValues{"a", `b"c`}, mustParse(`a="b\"c"`)) assert.Equal(t, KeyValues{"a", 1, "b", 2}, mustParse("a=1 b=2")) assert.Equal(t, KeyValues{"a", 1, "b", 2}, mustParse("a=1 b=2")) assert.Equal(t, KeyValues{"a", 1, "b", "c"}, mustParse(`a=1 b="c"`)) assert.Equal(t, KeyValues{"a", 1, "b", "c d"}, mustParse(`a=1 b="c d"`)) assert.Equal(t, KeyValues{"a", 1, "b", `c"\d`}, mustParse(`a=1 b="c\"\\d"`)) } func parsingError(input string) error { _, err := ParseKeyValues(input) return err } func TestParseKeyValuesErrors(t *testing.T) { assert.Error(t, parsingError("")) assert.Error(t, parsingError("a")) assert.Error(t, parsingError("1")) assert.Error(t, parsingError("a=")) assert.Error(t, parsingError(`a="`)) assert.Error(t, parsingError(`a="\"`)) assert.Error(t, parsingError(`a=1b=2`)) assert.Error(t, parsingError(`a=x`)) } func TestQuote(t *testing.T) { assert.Equal(t, `""`, quote("")) assert.Equal(t, `"a"`, quote("a")) assert.Equal(t, `"\"quo\\ted\""`, quote(`"quo\ted"`)) } func TestSplitValues(t *testing.T) { assert.Equal(t, []string{}, splitValues("")) assert.Equal(t, []string{""}, splitValues(`""`)) assert.Equal(t, []string{"abc"}, splitValues(`"abc"`)) assert.Equal(t, []string{"abc", "def"}, splitValues(`"abc" "def"`)) assert.Equal(t, []string{`a "quote" b`}, splitValues(`"a \"quote\" b"`)) } func TestParseCharacterString(t *testing.T) { assert.Equal(t, "", parseCharacterString("")) assert.Equal(t, "abc", parseCharacterString("abc")) assert.Equal(t, "abc", parseCharacterString(`"abc"`)) assert.Equal(t, "abc def", parseCharacterString(`"abc def"`)) assert.Equal(t, `abc" def`, parseCharacterString(`"abc\" def"`)) assert.Equal(t, `abc\ def`, parseCharacterString(`"abc\\ def"`)) } func TestIsZoneId(t *testing.T) { assert.True(t, isZoneId("Z1DXU7RZRUQ")) assert.True(t, isZoneId("Z1DXU7RZRUQP")) assert.True(t, isZoneId("Z1DXU7RZRUQPI")) assert.True(t, isZoneId("Z1DXU7RZRUQPIP")) assert.True(t, isZoneId("/hostedzone/Z1DXU7RZRUQPIP")) assert.False(t, isZoneId("example.com")) assert.False(t, isZoneId("example.com.")) assert.False(t, isZoneId("0.1.10.in-addr.arpa.")) } func TestQualifyName(t *testing.T) { assert.Equal(t, "example.com.", qualifyName("", "example.com.")) assert.Equal(t, "example.com.", qualifyName("@", "example.com.")) assert.Equal(t, "a.example.com.", qualifyName("a", "example.com.")) assert.Equal(t, "a.", qualifyName("a.", "example.com.")) assert.Equal(t, "a.b.example.com.", qualifyName("a.b", "example.com.")) } func TestShortenName(t *testing.T) { assert.Equal(t, "@", shortenName("example.com.", "example.com.")) assert.Equal(t, "a", shortenName("a.example.com.", "example.com.")) assert.Equal(t, "a.", shortenName("a.", "example.com.")) assert.Equal(t, "a.b", shortenName("a.b.example.com.", "example.com.")) assert.Equal(t, "fineexample.com.", shortenName("fineexample.com.", "example.com.")) } func TestLoadAWSProfileSettings(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config") content := `[default] region = us-east-1 login_session = arn:aws:iam::111111111111:user/test [profile demo] region = us-west-2 role_arn = arn:aws:iam::222222222222:role/demo source_profile = default external_id = ext-123 role_session_name = demo-session duration_seconds = 3600 ` require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600)) t.Setenv("AWS_CONFIG_FILE", configPath) source, found, err := loadAWSProfileSettings("default") require.NoError(t, err) require.True(t, found) assert.Equal(t, "us-east-1", source.Region) assert.Equal(t, "arn:aws:iam::111111111111:user/test", source.LoginSession) target, found, err := loadAWSProfileSettings("demo") require.NoError(t, err) require.True(t, found) assert.Equal(t, "us-west-2", target.Region) assert.Equal(t, "arn:aws:iam::222222222222:role/demo", target.RoleARN) assert.Equal(t, "default", target.SourceProfileName) assert.Equal(t, "ext-123", target.ExternalID) assert.Equal(t, "demo-session", target.RoleSessionName) assert.Equal(t, time.Hour, target.Duration) }