diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 358e93c..3e02216 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,38 +41,20 @@ jobs: with: fetch-depth: 0 - - name: Set up Python runtime - uses: actions/setup-python@v4 + - name: GHCR Login + uses: docker/login-action@v2 with: - python-version: 3.x + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GH_GHCR }} - - name: Set up build cache - uses: actions/cache@v3 - id: cache - with: - key: ${{ runner.os }}-${{ hashFiles('.cache/**') }} - path: .cache - - - name: Install Python dependencies - run: | - pip install \ - "mkdocs-git-committers-plugin-2>=0.4.3" \ - "mkdocs-git-revision-date-localized-plugin>=1.0" \ - "mkdocs-minify-plugin>=0.3" \ - "mkdocs-redirects>=1.0" - - - name: Install MkDocs Insiders - if: github.event.repository.fork == false - env: - GH_TOKEN: ${{ secrets.GH_MKDOCS }} - run: | - pip install git+https://${GH_TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git + - run: docker pull ghcr.io/purpleclay/mkdocs-material-insiders - name: Build - run: mkdocs build env: GH_TOKEN: ${{ secrets.GH_MKDOCS }} GH_GOOGLE_ANALYTICS_KEY: ${{ secrets.GH_GOOGLE_ANALYTICS_KEY }} + run: docker run --rm -i -e GH_TOKEN=${GH_TOKEN} -e GH_GOOGLE_ANALYTICS_KEY=${GH_GOOGLE_ANALYTICS_KEY} -v ${PWD}:/docs ghcr.io/purpleclay/mkdocs-material-insiders build - name: HTML Test uses: wjdp/htmltest-action@master @@ -94,6 +76,4 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_MKDOCS }} GH_GOOGLE_ANALYTICS_KEY: ${{ secrets.GH_GOOGLE_ANALYTICS_KEY }} - run: | - mkdocs gh-deploy --force - mkdocs --version + run: docker run --rm -i -e GH_TOKEN=${GH_TOKEN} -e GH_GOOGLE_ANALYTICS_KEY=${GH_GOOGLE_ANALYTICS_KEY} -v ${PWD}:/docs ghcr.io/purpleclay/mkdocs-material-insiders gh-deploy --force diff --git a/.golangci.yml b/.golangci.yml index 2465372..d782e52 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,7 +4,6 @@ run: linters: disable-all: true enable: - - deadcode - dupl - gofumpt - goimports @@ -15,9 +14,7 @@ linters: - nakedret - revive - staticcheck - - structcheck - unused - - varcheck linters-settings: dupl: diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..1e82570 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,2 @@ +# Exclude tests from copy-paste detection as SonarCloud doesn't appear to like table driven tests +sonar.cpd.exclusions=**/*_test.go \ No newline at end of file diff --git a/README.md b/README.md index 195c631..5ecdaa4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dns53 -Dynamic DNS within Amazon Route53. Expose your EC2 quickly, easily and privately. +Dynamic DNS within Amazon Route53. Expose your EC2 quickly, easily, and privately. [![Build status](https://img.shields.io/github/workflow/status/purpleclay/dns53/ci?style=flat-square&logo=go)](https://github.com/purpleclay/dns53/actions?workflow=ci) [![License MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](/LICENSE) diff --git a/cmd/root.go b/cmd/root.go index b83ba98..c08ffc7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,16 +23,20 @@ SOFTWARE. package cmd import ( + "bytes" "context" "errors" "io" + "regexp" "strings" + "text/template" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" awsimds "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" awsr53 "github.com/aws/aws-sdk-go-v2/service/route53" tea "github.com/charmbracelet/bubbletea" + "github.com/gobeam/stringy" "github.com/purpleclay/dns53/internal/imds" "github.com/purpleclay/dns53/internal/r53" "github.com/purpleclay/dns53/internal/tui" @@ -66,7 +70,11 @@ type globalOptions struct { AWSProfile string } -var globalOpts = &globalOptions{} +var ( + globalOpts = &globalOptions{} + + domainRegex = regexp.MustCompile("[^a-zA-Z0-9-.]+") +) type options struct { phzID string @@ -76,6 +84,10 @@ type options struct { func Execute(out io.Writer) error { opts := options{} + // Capture in PreRun lifecycle + var cfg aws.Config + var metadata imds.Metadata + rootCmd := &cobra.Command{ Use: "dns53", Short: `Dynamic DNS within Amazon Route 53. Expose your EC2 quickly, easily and privately within a Route @@ -84,31 +96,34 @@ func Execute(out io.Writer) error { Example: examples, SilenceUsage: true, SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := awsConfig(globalOpts) + PreRunE: func(cmd *cobra.Command, args []string) error { + var err error + + cfg, err = awsConfig(globalOpts) if err != nil { return err } - // If a custom domain name has been provided, check that it can be resolved from IMDS imdsClient := imds.NewFromAPI(awsimds.NewFromConfig(cfg)) + if metadata, err = imdsClient.InstanceMetadata(context.Background()); err != nil { + return err + } - if opts.domainName != "" { - if err := domainNameSupported(opts.domainName, imdsClient); err != nil { - return err - } + if opts.domainName == "" { + return nil } - model, err := tui.Dashboard(tui.DashboardOptions{ + opts.domainName, err = resolveDomainName(opts.domainName, metadata) + return err + }, + RunE: func(cmd *cobra.Command, args []string) error { + model := tui.Dashboard(tui.DashboardOptions{ R53Client: r53.NewFromAPI(awsr53.NewFromConfig(cfg)), - IMDSClient: imdsClient, + Metadata: metadata, Version: version, PhzID: opts.phzID, DomainName: opts.domainName, }) - if err != nil { - return err - } return tea.NewProgram(model, tea.WithAltScreen()).Start() }, @@ -143,23 +158,45 @@ func awsConfig(opts *globalOptions) (aws.Config, error) { return config.LoadDefaultConfig(context.Background(), optsFn...) } -func domainNameSupported(domain string, imdsClient *imds.Client) error { +func resolveDomainName(domain string, metadata imds.Metadata) (string, error) { dmn := strings.ReplaceAll(domain, " ", "") - if strings.Contains(dmn, "{{.Name}}") { - metadata, err := imdsClient.InstanceMetadata(context.Background()) - if err != nil { - return err - } + if strings.Contains(dmn, "{{.Name}}") { if metadata.Name == "" { - return errors.New(`to use metadata within a custom domain name, please enable IMDS instance tags support + return "", errors.New(`to use metadata within a custom domain name, please enable IMDS instance tags support for your EC2 instance: $ dns53 imds --instance-metadata-tags on -Or read the official AWS documentation at: +Or read the official AWS documentation at: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html#allow-access-to-tags-in-IMDS`) } + + name := stringy.New(metadata.Name) + metadata.Name = name.KebabCase().ToLower() + } + + // Sanitise the copy of the metadata before resolving the template + metadata.IPv4 = strings.ReplaceAll(metadata.IPv4, ".", "-") + + // Execute the domain template + tmpl, err := template.New("domain").Parse(domain) + if err != nil { + return "", err + } + + var out bytes.Buffer + if err := tmpl.Execute(&out, metadata); err != nil { + return "", err } - return nil + dmn = out.String() + + // Final tidy up of the domain + dmn = strings.ReplaceAll(dmn, "--", "-") + dmn = strings.ReplaceAll(dmn, "..", ".") + dmn = strings.Trim(dmn, "-") + dmn = strings.Trim(dmn, ".") + dmn = domainRegex.ReplaceAllString(dmn, "") + + return dmn, nil } diff --git a/cmd/root_test.go b/cmd/root_test.go index 9afed98..a5fd65f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -26,7 +26,6 @@ import ( "testing" "github.com/purpleclay/dns53/internal/imds" - "github.com/purpleclay/dns53/internal/imds/imdsstub" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,41 +40,101 @@ func TestAWSConfig(t *testing.T) { assert.Equal(t, "eu-west-2", cfg.Region) } -func TestDomainNameSupported(t *testing.T) { +func TestResolveDomainName(t *testing.T) { + metadata := imds.Metadata{ + Name: "my-ec2", + } + tests := []struct { - name string - domain string + name string + domain string + expected string }{ { - name: "NoTemplating", - domain: "custom.domain", + name: "NoTemplating", + domain: "custom.domain", + expected: "custom.domain", + }, + { + name: "WithNameField", + domain: "custom.{{.Name}}", + expected: "custom.my-ec2", }, { - name: "WithNameField", - domain: "custom.{{.Name}}", + name: "WithNameFieldSpaces", + domain: "custom.{{ .Name }}", + expected: "custom.my-ec2", }, { - name: "WithNameFieldSpaces", - domain: "custom.{{ .Name }}", + name: "ReplacesDoubleHyphens", + domain: "another--custom.domain", + expected: "another-custom.domain", + }, + { + name: "ReplacesDoubleDots", + domain: "my-custom123..domain", + expected: "my-custom123.domain", + }, + { + name: "RemoveLeadingTrailingHyphen", + domain: "-this-is-a-custom.domain-", + expected: "this-is-a-custom.domain", + }, + { + name: "RemoveLeadingTrailingDot", + domain: ".a-custom.domain.", + expected: "a-custom.domain", + }, + { + name: "TrimUnsupportedCharacters", + domain: "custom@#.doma**in-123", + expected: "custom.domain-123", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := domainNameSupported(tt.domain, imds.NewFromAPI(imdsstub.New(t))) + domain, err := resolveDomainName(tt.domain, metadata) require.NoError(t, err) + require.Equal(t, tt.expected, domain) }) } } -func TestDomainNameSupportedNoInstanceTags(t *testing.T) { - err := domainNameSupported("custom.{{.Name}}", imds.NewFromAPI(imdsstub.NewWithoutTags(t))) +func TestResolveDomainNameNoInstanceTags(t *testing.T) { + _, err := resolveDomainName("custom.{{.Name}}", imds.Metadata{}) - assert.EqualError(t, err, `to use metadata within a custom domain name, please enable IMDS instance tags support + assert.EqualError(t, err, `to use metadata within a custom domain name, please enable IMDS instance tags support for your EC2 instance: $ dns53 imds --instance-metadata-tags on -Or read the official AWS documentation at: +Or read the official AWS documentation at: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html#allow-access-to-tags-in-IMDS`) } + +func TestResolveDomainNameTransformsNameTagToKebabCase(t *testing.T) { + domain, err := resolveDomainName("first.custom.{{.Name}}", imds.Metadata{Name: "MyEc2 123"}) + + require.NoError(t, err) + assert.Equal(t, "first.custom.my-ec2-123", domain) +} + +func TestResolveDomainNameStripsLeadingTrailingHyphenFromNameTag(t *testing.T) { + domain, err := resolveDomainName("second.custom.{{.Name}}", imds.Metadata{Name: "-MyEc2 123-"}) + + require.NoError(t, err) + assert.Equal(t, "second.custom.my-ec2-123", domain) +} + +func TestResolveDomainNameInvalidGoTemplate(t *testing.T) { + _, err := resolveDomainName("custom.{{.Name}", imds.Metadata{Name: "MyEc2 123"}) + + assert.Error(t, err) +} + +func TestResolveDomainNameUnrecognisedTemplateFields(t *testing.T) { + _, err := resolveDomainName("custom.{{.Unknown}}", imds.Metadata{}) + + assert.Error(t, err) +} diff --git a/docs/configure/custom-domain.md b/docs/configure/custom-domain.md index 49f2a61..0b8fc04 100644 --- a/docs/configure/custom-domain.md +++ b/docs/configure/custom-domain.md @@ -23,3 +23,16 @@ A templated domain leverages the text templating capabilities of the Go language ```sh dns53 --domain-name "{{.IPv4}}.{{.Region}}" ``` + +## Domain Validation + +A custom domain must be valid before assigning it to your EC2 instance. A series of checks must pass. + +A domain must: + +- not contain leading or trailing hyphens (`-`) and dots (`.`) +- not contain consecutive hyphens (`--`) or dots (`..`) +- not contain whitespace (` `) +- only contain valid characters from the sequence `[A-Za-z0-9-.]` + +`dns53` will automatically clean any domain name in an attempt to enforce these validation checks. diff --git a/go.mod b/go.mod index 4873ca1..f176663 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/bubbles v0.14.0 github.com/charmbracelet/bubbletea v0.22.1 github.com/charmbracelet/lipgloss v0.6.0 + github.com/gobeam/stringy v0.0.5 github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 github.com/purpleclay/testcontainers-imds v0.7.0 diff --git a/go.sum b/go.sum index dd5725e..1a984ba 100644 --- a/go.sum +++ b/go.sum @@ -344,6 +344,8 @@ github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8w github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobeam/stringy v0.0.5 h1:TvxQGSAqr/qF0SBVxa8Q67WWIo7bCWS0bM101WOd52g= +github.com/gobeam/stringy v0.0.5/go.mod h1:W3620X9dJHf2FSZF5fRnWekHcHQjwmCz8ZQ2d1qloqE= github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 0ac800c..25fc5cb 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -23,12 +23,10 @@ SOFTWARE. package tui import ( - "bytes" "context" "fmt" "os" "strings" - "text/template" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" @@ -57,7 +55,8 @@ type DashboardModel struct { // DashboardOptions defines all of the supported options when initialising // the Dashboard model type DashboardOptions struct { - IMDSClient *imds.Client + // IMDSClient *imds.Client + Metadata imds.Metadata R53Client *r53.Client Version string PhzID string @@ -92,10 +91,10 @@ func (e errMsg) Error() string { } // Dashboard creates the initial model for the TUI -func Dashboard(opts DashboardOptions) (*DashboardModel, error) { +func Dashboard(opts DashboardOptions) *DashboardModel { width, _, _ := term.GetSize(int(os.Stdout.Fd())) - m := &DashboardModel{opts: opts} + m := &DashboardModel{opts: opts, ec2: opts.Metadata} m.phz = list.New([]list.Item{}, list.NewDefaultDelegate(), width, 20) m.phz.Styles.HelpStyle = helpStyle @@ -107,7 +106,7 @@ func Dashboard(opts DashboardOptions) (*DashboardModel, error) { m.loading.Spinner = spinner.Dot m.loading.Style = spinnerStyle - return m, nil + return m } // Init initialises the model ready for its first update and render @@ -115,12 +114,11 @@ func (m DashboardModel) Init() tea.Cmd { return tea.Batch( m.loading.Tick, func() tea.Msg { - meta, err := m.opts.IMDSClient.InstanceMetadata(context.Background()) - if err != nil { - return errMsg{err} + if m.opts.PhzID != "" { + return m.queryHostedZone() } - return meta + return m.queryHostedZones() }, ) } @@ -136,15 +134,6 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch msg := msg.(type) { - case imds.Metadata: - m.ec2 = msg - - // If the PHZ is already known by this point, attempt an association - if m.opts.PhzID != "" { - cmds = append(cmds, m.queryHostedZone) - } else { - cmds = append(cmds, m.queryHostedZones) - } case []r53.PrivateHostedZone: // PHZ have been successfully retrieved. Load them into the list items := make([]list.Item, 0, len(msg)) @@ -277,41 +266,24 @@ func (m DashboardModel) queryHostedZone() tea.Msg { return errMsg{err} } - return associationRequest{phz: phz} + return phz } func (m DashboardModel) initAssociation() tea.Msg { - // Sanitise the IPv4 within the EC2 Metadata Object - ipv4 := m.ec2.IPv4 - m.ec2.IPv4 = strings.ReplaceAll(m.ec2.IPv4, ".", "-") - - var name string - if m.opts.DomainName != "" { - name = appendDomainSuffix(m.opts.DomainName, m.connected.phz.Name) - - // Check if the provided name contains a template - if strings.Contains(name, "{{") { - tmpl, err := template.New("dns").Parse(name) - if err != nil { - return errMsg{err} - } - - var out bytes.Buffer - if err := tmpl.Execute(&out, m.ec2); err != nil { - return errMsg{err} - } - - name = out.String() - } + name := m.opts.DomainName + if name == "" { + name = fmt.Sprintf("%s.dns53.%s", strings.ReplaceAll(m.ec2.IPv4, ".", "-"), m.connected.phz.Name) } else { - // By default include the dns53 suffix - name = fmt.Sprintf("%s.dns53.%s", m.ec2.IPv4, m.connected.phz.Name) + // Ensure root domain is appended as a suffix + if !strings.HasSuffix(name, "."+m.connected.phz.Name) { + name = fmt.Sprintf("%s.%s", name, m.connected.phz.Name) + } } record := r53.ResourceRecord{ PhzID: m.connected.phz.ID, Name: name, - Resource: ipv4, + Resource: m.ec2.IPv4, } if err := m.opts.R53Client.AssociateRecord(context.Background(), record); err != nil { @@ -320,15 +292,3 @@ func (m DashboardModel) initAssociation() tea.Msg { return connection{dns: name, phz: m.connected.phz} } - -func appendDomainSuffix(domain, root string) string { - if strings.HasSuffix(domain, "dns53."+domain) { - return domain - } - - // If suffix has only been partially set, trim it - domain = strings.TrimSuffix(domain, ".dns53") - domain = strings.TrimSuffix(domain, "."+root) - - return fmt.Sprintf("%s.dns53.%s", domain, root) -}