From 347ca970eba95508fcbc32e078f20854210ab9f8 Mon Sep 17 00:00:00 2001 From: Aidan Steele Date: Wed, 14 Jun 2023 11:47:32 -0700 Subject: [PATCH] init commit --- .github/workflows/release.yml | 28 ++++++ .gitignore | 2 + .goreleaser.yaml | 54 +++++++++++ README.md | 41 ++++++++ ec2ic/dialer.go | 115 ++++++++++++++++++++++ go.mod | 29 ++++++ go.sum | 58 ++++++++++++ main.go | 57 +++++++++++ proxy.go | 174 ++++++++++++++++++++++++++++++++++ 9 files changed, 558 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 README.md create mode 100644 ec2ic/dialer.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 proxy.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d1f4267 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: release + +on: + push: + tags: + - '*' + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - run: git fetch --force --tags + + - uses: actions/setup-go@v4 + with: + go-version: stable + + - uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cde0123 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..a2ce31a --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,54 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +before: + hooks: + - go mod tidy +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - arm64 + - amd64 +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of uname. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip +brews: + - name: rdsconn + tap: + owner: aidansteele + name: homebrew-taps + commit_author: + name: Aidan Steele + email: aidan.steele@glassechidna.com.au + homepage: https://github.com/aidansteele/rdsconn + description: rdsconn makes connecting to an AWS RDS instance inside a VPC from your laptop easier +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + +# The lines beneath this are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6ab639 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# rdsconn + +On June 14th, 2023 AWS [launched][aws-blog] new connectivity options for +_EC2 Instance Connect_. This functionality also works for non-EC2 resources in +VPCs. You _could_ run the official AWS CLI (>= v2.12.0) using the following command, +but `rdsconn` aims to make the RDS experience easier. + +``` +aws ec2-instance-connect open-tunnel \ + --private-ip-address 10.1.2.150 \ + --instance-connect-endpoint-id eice-06d8b7ad48example \ + --remote-port 5432 \ + --local-port 5432 +``` + +## Installation + +On macOS, `brew install aidansteele/taps/rdsconn`. On other platforms: see +published binaries in the releases tab of the GitHub repo. + +## Usage + +1. Create an EC2 Instance Connect endpoint in your VPC. Ensure that your RDS DB + instance's security group allows the EIC endpoint to connect to it. +2. Have valid AWS credentials configured. E.g. either as environment variables, + default credentials in your config file, or a profile with `AWS_PROFILE=name` + env var set. +3. Run `rdsconn proxy`. The CLI will prompt you to select an RDS DB instance from + the list of DBs in your account. Hit enter to confirm selection. +4. The message `Proxy running. Now waiting to serve connections to localhost:5432...` + will appear. You can now run `psql ... -h 127.0.0.1` (or `mysql ...`) + +## Future plans + +* Flesh out this README more +* Detect incorrect configurations and provide helpful error messages to user. + E.g. missing endpoints, security groups, routes, etc. +* Add a `client` subcommand that uses RDS IAM authentication to launch and + authenticate a child process `psql` CLI (using PGPASSWORD etc env vars) + +[aws-blog]: https://aws.amazon.com/blogs/compute/secure-connectivity-from-public-to-private-introducing-ec2-instance-connect-endpoint-june-13-2023/ diff --git a/ec2ic/dialer.go b/ec2ic/dialer.go new file mode 100644 index 0000000..de2a801 --- /dev/null +++ b/ec2ic/dialer.go @@ -0,0 +1,115 @@ +package ec2ic + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/gorilla/websocket" + "io" + "net" + "net/http" + "net/url" + "time" +) + +type Dialer struct { + cfg aws.Config + endpointId string + endpointDns string + duration time.Duration +} + +func NewDialer(ctx context.Context, cfg aws.Config, endpointId string, duration time.Duration) (*Dialer, error) { + api := ec2.NewFromConfig(cfg) + + if duration == 0 { + duration = time.Hour + } + + describe, err := api.DescribeInstanceConnectEndpoints(ctx, &ec2.DescribeInstanceConnectEndpointsInput{InstanceConnectEndpointIds: []string{endpointId}}) + if err != nil { + return nil, fmt.Errorf("describing ec2 instance connect endpoint: %w", err) + } + + return &Dialer{ + cfg: cfg, + endpointId: endpointId, + endpointDns: *describe.InstanceConnectEndpoints[0].DnsName, + duration: duration, + }, nil +} + +func (icd *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" && network != "tcp4" { + return nil, fmt.Errorf("only tcp supported") + } + + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("splitting host and port: %w", err) + } + + resolved, err := net.ResolveIPAddr("ip", host) + if err != nil { + return nil, fmt.Errorf("resolving private ip: %w", err) + } + + q := url.Values{} + q.Set("instanceConnectEndpointId", icd.endpointId) + q.Set("maxTunnelDuration", fmt.Sprintf("%d", int(icd.duration.Seconds()))) + q.Set("remotePort", port) + q.Set("privateIpAddress", resolved.String()) + + r, _ := http.NewRequest("GET", fmt.Sprintf("wss://%s/openTunnel?%s", icd.endpointDns, q.Encode()), nil) + + sum := sha256.Sum256([]byte{}) + sumStr := hex.EncodeToString(sum[:]) + + creds, err := icd.cfg.Credentials.Retrieve(ctx) + if err != nil { + panic(fmt.Sprintf("%+v", err)) + } + + s := v4.NewSigner() + signed, _, err := s.PresignHTTP(ctx, creds, r, sumStr, "ec2-instance-connect", icd.cfg.Region, time.Now()) + if err != nil { + panic(fmt.Sprintf("%+v", err)) + } + + conn, _, err := websocket.DefaultDialer.DialContext(ctx, signed, http.Header{}) + if err != nil { + return nil, fmt.Errorf("dialing websocket: %w", err) + } + + return &icdConn{ + Conn: conn, + r: websocket.JoinMessages(conn, ""), + }, nil +} + +type icdConn struct { + *websocket.Conn + r io.Reader +} + +var _ net.Conn = (*icdConn)(nil) + +func (i *icdConn) Read(b []byte) (n int, err error) { + return i.r.Read(b) +} + +func (i *icdConn) Write(b []byte) (n int, err error) { + err = i.Conn.WriteMessage(websocket.BinaryMessage, b) + n = len(b) + return +} + +func (i *icdConn) SetDeadline(t time.Time) error { + i.SetReadDeadline(t) + i.SetWriteDeadline(t) + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ec1d64 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/aidansteele/rdsconn + +go 1.20 + +require ( + github.com/aws/aws-sdk-go-v2 v1.18.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.18.26 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.25 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 // indirect + github.com/aws/aws-sdk-go-v2/service/ec2 v1.100.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 // indirect + github.com/aws/aws-sdk-go-v2/service/rds v1.45.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.12.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.19.1 // indirect + github.com/aws/smithy-go v1.13.5 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect + github.com/spf13/cobra v1.7.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c28fe7f --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +github.com/aws/aws-sdk-go-v2 v1.18.1 h1:+tefE750oAb7ZQGzla6bLkOwfcQCEtC5y2RqoqCeqKo= +github.com/aws/aws-sdk-go-v2 v1.18.1/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2/config v1.18.26 h1:ivCHcSmKd1+9rBlqVsxZHB35eCW88KWbMdG2VL3BuBw= +github.com/aws/aws-sdk-go-v2/config v1.18.26/go.mod h1:NVmd//z/PNl7U+ZU2EnuffxOA060JWzgbH3BnqQrUoY= +github.com/aws/aws-sdk-go-v2/credentials v1.13.25 h1:5wROoMcUC7nAE66e0b3IIht6Tos76M4HC+GQw8MeqxU= +github.com/aws/aws-sdk-go-v2/credentials v1.13.25/go.mod h1:W9I2660WXSwZQ23mM1Ks72+UGeyirIxuU7/KzN7daeA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 h1:LxK/bitrAr4lnh9LnIS6i7zWbCOdMsfzKFBI6LUCS0I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4/go.mod h1:E1hLXN/BL2e6YizK1zFlYd8vsfi2GTjbjBazinMmeaM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 h1:A5UqQEmPaCFpedKouS4v+dHCTUo2sKqhoKO9U5kxyWo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34/go.mod h1:wZpTEecJe0Btj3IYnDx/VlUzor9wm3fJHyvLpQF0VwY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 h1:srIVS45eQuewqz6fKKu6ZGXaq6FuFg5NzgQBAM6g8Y4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28/go.mod h1:7VRpKQQedkfIEXb4k52I7swUnZP0wohVajJMRn3vsUw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 h1:LWA+3kDM8ly001vJ1X1waCuLJdtTl48gwkPKWy9sosI= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35/go.mod h1:0Eg1YjxE0Bhn56lx+SHJwCzhW+2JGtizsrx+lCqrfm0= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.100.0 h1:Wo/HHkC8PcVPXAB9WNwMmGHiA+b6VsJiT/DW2nB2D1w= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.100.0/go.mod h1:tIctCeX9IbzsUTKHt53SVEcgyfxV2ElxJeEB+QUbc4M= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 h1:bkRyG4a929RCnpVSTvLM2j/T4ls015ZhhYApbmYs15s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28/go.mod h1:jj7znCIg05jXlaGBlFMGP8+7UN3VtCkRBG2spnmRQkU= +github.com/aws/aws-sdk-go-v2/service/rds v1.45.1 h1:Pxwe38BtUudOFqCNBFrzw0Gxufs0YioaTFnpubJTg7Y= +github.com/aws/aws-sdk-go-v2/service/rds v1.45.1/go.mod h1:goBDR4OPrsnKpYyU0GHGcEnlTmL8O+eKGsWeyOAFJ5M= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.11 h1:cNrMc266RsZJ8V1u1OQQONKcf9HmfxQFqgcpY7ZJBhY= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.11/go.mod h1:HuCOxYsF21eKrerARYO6HapNeh9GBNq7fius2AcwodY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.11 h1:h2VhtCE5PBiJefmlVCjJRSzBfFcQeAE10SXIGkXw1jQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.11/go.mod h1:E4VrHCPzmVB/KFXtqBGKb3c8zpbNBgKe3fisDNLAW5w= +github.com/aws/aws-sdk-go-v2/service/sts v1.19.1 h1:ehPTnLR/es8TL1fpBfq8qw9cAwOpQr47fLmZD9yhHjk= +github.com/aws/aws-sdk-go-v2/service/sts v1.19.1/go.mod h1:dp0yLPsLBOi++WTxzCjA/oZqi6NPIhoR+uF7GeMU9eg= +github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= +github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..988cfbd --- /dev/null +++ b/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "fmt" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/spf13/cobra" +) + +func main() { + rootCmd := &cobra.Command{ + RunE: runList, + } + + proxyCmd := &cobra.Command{ + Use: "proxy", + Aliases: []string{"p"}, + RunE: runProxy, + } + + proxyCmd.PersistentFlags().String("endpoint-id", "", "") + proxyCmd.PersistentFlags().Int("local-port", 0, "") + + rootCmd.AddCommand(proxyCmd) + + ctx := context.Background() + err := rootCmd.ExecuteContext(ctx) + if err != nil { + panic(fmt.Sprintf("%+v", err)) + } +} + +func runList(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return fmt.Errorf(": %w", err) + } + + rdsapi := rds.NewFromConfig(cfg) + + p := rds.NewDescribeDBInstancesPaginator(rdsapi, &rds.DescribeDBInstancesInput{}) + for p.HasMorePages() { + page, err := p.NextPage(ctx) + if err != nil { + return fmt.Errorf(": %w", err) + } + + for _, instance := range page.DBInstances { + fmt.Fprintln(cmd.OutOrStdout(), *instance.DBInstanceIdentifier) + } + } + + return nil +} diff --git a/proxy.go b/proxy.go new file mode 100644 index 0000000..c657a28 --- /dev/null +++ b/proxy.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "fmt" + "github.com/aidansteele/rdsconn/ec2ic" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/rds" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "io" + "net" + "sort" + "time" +) + +func runProxy(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if len(args) > 1 { + return fmt.Errorf("expected either zero or one non-flag arguments: the rds instance id. %d provided", len(args)) + } + + f := cmd.PersistentFlags() + endpointId, _ := f.GetString("endpoint-id") + localPort, _ := f.GetInt("local-port") + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return fmt.Errorf(": %w", err) + } + + rdsapi := rds.NewFromConfig(cfg) + var instance rdstypes.DBInstance + + if len(args) == 1 { + instanceId := args[0] + describeDBs, err := rdsapi.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{DBInstanceIdentifier: &instanceId}) + if err != nil { + return fmt.Errorf(": %w", err) + } + + instance = describeDBs.DBInstances[0] + } else { + instance, err = promptForInstanceId(ctx, rdsapi) + if err != nil { + return fmt.Errorf("prompting for instance id: %w", err) + } + } + + if endpointId == "" { + vpcId := *instance.DBSubnetGroup.VpcId + + ec2api := ec2.NewFromConfig(cfg) + describe, err := ec2api.DescribeInstanceConnectEndpoints(ctx, &ec2.DescribeInstanceConnectEndpointsInput{ + Filters: []types.Filter{{ + Name: aws.String("vpc-id"), + Values: []string{vpcId}, + }}, + }) + if err != nil { + return fmt.Errorf(": %w", err) + } + + endpoint := describe.InstanceConnectEndpoints[0] + endpointId = *endpoint.InstanceConnectEndpointId + } + + dialer, err := ec2ic.NewDialer(ctx, cfg, endpointId, time.Hour) + + listener, localPort, err := listenerAndPort(localPort, int(instance.Endpoint.Port)) + if err != nil { + return fmt.Errorf(": %w", err) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Proxy running. Now waiting to serve connections to localhost:%d...\n", localPort) + + for { + local, err := listener.Accept() + if err != nil { + return fmt.Errorf(": %w", err) + } + + remote, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", *instance.Endpoint.Address, instance.Endpoint.Port)) + if err != nil { + return fmt.Errorf(": %w", err) + } + + go serve(ctx, local, remote) + } + + return nil +} + +func promptForInstanceId(ctx context.Context, api *rds.Client) (rdstypes.DBInstance, error) { + instances := map[string]rdstypes.DBInstance{} + ids := []string{} + + p := rds.NewDescribeDBInstancesPaginator(api, &rds.DescribeDBInstancesInput{}) + for p.HasMorePages() { + page, err := p.NextPage(ctx) + if err != nil { + return rdstypes.DBInstance{}, fmt.Errorf("paginating rds instances: %w", err) + } + + for _, instance := range page.DBInstances { + id := *instance.DBInstanceIdentifier + instances[id] = instance + ids = append(ids, id) + } + } + + sort.Strings(ids) + + prompt := promptui.Select{ + Label: "Select an RDS instance", + Items: ids, + } + + _, instanceId, err := prompt.Run() + if err != nil { + return rdstypes.DBInstance{}, fmt.Errorf("prompting user for rds instance: %w", err) + } + + return instances[instanceId], nil +} + +func listenerAndPort(localPort, remotePort int) (net.Listener, int, error) { + if localPort != 0 { + l, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", localPort)) + return l, 0, err + } + + for port := remotePort; port < remotePort+100; port++ { + l, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port)) + if err == nil { + return l, port, nil + } + } + + return nil, 0, fmt.Errorf("unable to allocate local port") +} + +func serve(ctx context.Context, local, remote net.Conn) { + g, _ := errgroup.WithContext(ctx) + + g.Go(func() error { + _, err := io.Copy(local, remote) + if err != nil { + return fmt.Errorf("remote->local: %w", err) + } + + return nil + }) + + g.Go(func() error { + _, err := io.Copy(remote, local) + if err != nil { + return fmt.Errorf("local->remote: %w", err) + } + + return nil + }) + + err := g.Wait() + if err != nil { + fmt.Println(fmt.Sprintf("%+v", err)) + } +}