From f8dcc535da28e322aa1aa5cf91132fa661b8bada Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Fri, 12 Dec 2025 05:27:36 -0800 Subject: [PATCH 1/6] init --- cmd/rds-iam-psql/README.md | 114 ++++++++++++++++++++++++++++++ cmd/rds-iam-psql/main.go | 139 +++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 cmd/rds-iam-psql/README.md create mode 100644 cmd/rds-iam-psql/main.go diff --git a/cmd/rds-iam-psql/README.md b/cmd/rds-iam-psql/README.md new file mode 100644 index 0000000..b6dae57 --- /dev/null +++ b/cmd/rds-iam-psql/README.md @@ -0,0 +1,114 @@ +# rds-iam-psql + +A simple CLI tool that bridges AWS RDS IAM authentication into an interactive `psql` session. It generates a short-lived IAM auth token and launches `psql` with the token as the password, so you never have to manage database passwords. + +## Why? + +RDS IAM authentication lets you connect to PostgreSQL using your AWS credentials instead of a static database password. However, the auth tokens are temporary (15 minutes) and cumbersome to generate manually. This tool handles token generation automatically and drops you into a familiar `psql` shell. + +## Installation + +```bash +go install github.com/corbaltcode/go-libraries/cmd/rds-iam-psql@latest +``` + +Or build from source: + +```bash +cd ./cmd/rds-iam-psql +go build +``` + +## Prerequisites + +- **psql** installed and available in your PATH +- **AWS credentials** configured (via environment variables, `~/.aws/credentials`, IAM role, etc.) +- **RDS IAM authentication enabled** on your database instance +- A database user configured for IAM authentication (created with `CREATE USER myuser WITH LOGIN; GRANT rds_iam TO myuser;`) + +## Usage + +```bash +rds-iam-psql -host -user -db [options] +``` + +### Required Flags + +| Flag | Description | +|------|-------------| +| `-host` | RDS endpoint hostname (without port), e.g. `mydb.abc123.us-east-1.rds.amazonaws.com` | +| `-user` | Database username configured for IAM auth | +| `-db` | Database name to connect to | + +### Optional Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `-port` | `5432` | PostgreSQL port | +| `-region` | auto | AWS region. If omitted, inferred from AWS config or the hostname | +| `-profile` | | AWS shared config profile to use (e.g. `dev`, `prod`) | +| `-psql` | `psql` | Path to the `psql` binary | +| `-sslmode` | `require` | SSL mode (`require`, `verify-full`, etc.) | +| `-search-path` | | PostgreSQL `search_path` to set on connection (e.g. `myschema,public`) | + +## Examples + +Basic connection: + +```bash +rds-iam-psql -host mydb.abc123.us-east-1.rds.amazonaws.com -user app_user -db myapp +``` + +With a specific AWS profile and schema: + +```bash +rds-iam-psql \ + -host mydb.abc123.us-east-1.rds.amazonaws.com \ + -user app_user \ + -db myapp \ + -profile production \ + -search-path "app_schema,public" +``` + +Using a non-standard port and explicit region: + +```bash +rds-iam-psql \ + -host mydb.abc123.us-east-1.rds.amazonaws.com \ + -port 5433 \ + -user admin \ + -db postgres \ + -region us-east-1 +``` + +## How It Works + +1. Loads your AWS credentials from the standard credential chain +2. Generates a temporary RDS IAM auth token using `auth.BuildAuthToken` +3. Launches `psql` with: + - `PGPASSWORD` set to the auth token + - `PGSSLMODE` set according to `-sslmode` + - `PGOPTIONS` set if `-search-path` is provided +4. Attaches stdin/stdout/stderr for interactive use + +## Setting Up IAM Auth on RDS + +1. Enable IAM authentication on your RDS instance +2. Create a database user and grant IAM privileges: + ```sql + CREATE USER myuser WITH LOGIN; + GRANT rds_iam TO myuser; + ``` +3. Attach an IAM policy allowing `rds-db:connect` to your AWS user/role: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "rds-db:connect", + "Resource": "arn:aws:rds-db:::dbuser:/" + } + ] + } + ``` diff --git a/cmd/rds-iam-psql/main.go b/cmd/rds-iam-psql/main.go new file mode 100644 index 0000000..d076047 --- /dev/null +++ b/cmd/rds-iam-psql/main.go @@ -0,0 +1,139 @@ +// rds-iam-psql.go +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/exec" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" +) + +func main() { + var ( + host = flag.String("host", "", "RDS PostgreSQL endpoint hostname (no port, e.g. mydb.abc123.us-east-1.rds.amazonaws.com)") + port = flag.Int("port", 5432, "RDS PostgreSQL port (default 5432)") + user = flag.String("user", "", "Database user name") + dbName = flag.String("db", "", "Database name") + region = flag.String("region", "", "AWS region for the RDS instance (e.g. us-east-1). If empty, uses AWS config or tries to infer from host.") + profile = flag.String("profile", "", "Optional AWS shared config profile (e.g. dev)") + psqlPath = flag.String("psql", "psql", "Path to psql binary") + sslMode = flag.String("sslmode", "require", "PGSSLMODE for psql (e.g. require, verify-full)") + searchPath = flag.String("search-path", "", "Optional PostgreSQL search_path to set (e.g. 'myschema,public')") + ) + flag.Parse() + + if *host == "" || *user == "" || *dbName == "" { + log.Fatalf("host, user, and db are required\n\nUsage example:\n %s -host mydb.abc123.us-east-1.rds.amazonaws.com -port 5432 -user myuser -db mydb -search-path \"login,public\" -region us-east-1\n", os.Args[0]) + } + + ctx := context.Background() + + // Load AWS config (standard RDS/IAM auth expects your AWS creds, *not* the DB password). + var cfg aws.Config + var err error + if *profile != "" { + cfg, err = awsconfig.LoadDefaultConfig(ctx, awsconfig.WithSharedConfigProfile(*profile)) + } else { + cfg, err = awsconfig.LoadDefaultConfig(ctx) + } + if err != nil { + log.Fatalf("failed to load AWS config: %v", err) + } + + awsRegion := *region + if awsRegion == "" { + awsRegion = cfg.Region + } + if awsRegion == "" { + // Last resort: try to infer from the hostname if it looks like a standard RDS endpoint. + if inferred := inferRegionFromHost(*host); inferred != "" { + awsRegion = inferred + } + } + + if awsRegion == "" { + log.Fatalf("AWS region is not set; pass -region or set AWS_REGION / configure your AWS profile") + } + + endpointWithPort := fmt.Sprintf("%s:%d", *host, *port) + + // Generate the IAM auth token. + authToken, err := auth.BuildAuthToken(ctx, endpointWithPort, awsRegion, *user, cfg.Credentials) + if err != nil { + log.Fatalf("failed to build RDS IAM auth token: %v", err) + } + + // Prepare psql command. We pass the token through PGPASSWORD and SSL mode via PGSSLMODE. + cmd := exec.Command( + *psqlPath, + "--host", *host, + "--port", fmt.Sprintf("%d", *port), + "--username", *user, + "--dbname", *dbName, + ) + + // Attach stdio so it behaves like an interactive shell. + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Inherit existing env and add PG vars. + env := os.Environ() + env = append(env, + "PGPASSWORD="+authToken, + "PGSSLMODE="+*sslMode, + ) + + // If a search path is provided, wire it through PGOPTIONS. + if sp := strings.TrimSpace(*searchPath); sp != "" { + // Build our addition: one -c flag. + add := "-c search_path=" + sp + + // Check if PGOPTIONS already exists; if so, append. + found := false + for i, e := range env { + if strings.HasPrefix(e, "PGOPTIONS=") { + current := strings.TrimPrefix(e, "PGOPTIONS=") + if strings.TrimSpace(current) == "" { + env[i] = "PGOPTIONS=" + add + } else { + env[i] = "PGOPTIONS=" + current + " " + add + } + found = true + break + } + } + if !found { + env = append(env, "PGOPTIONS="+add) + } + } + + cmd.Env = env + + if err := cmd.Run(); err != nil { + // psql will print its own error messages; just propagate the exit code. + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + log.Fatalf("failed to run psql: %v", err) + } +} + +// inferRegionFromHost tries to pull the AWS region out of a typical RDS hostname like +// "mydb.abc123.us-east-1.rds.amazonaws.com". If it can't, it returns "". +func inferRegionFromHost(host string) string { + parts := strings.Split(host, ".") + for i := 0; i < len(parts); i++ { + if parts[i] == "rds" && i > 0 { + return parts[i-1] + } + } + return "" +} From 200599b9d491e3bc1642c4bc2b2869af087cc182 Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Thu, 18 Dec 2025 20:25:02 -0800 Subject: [PATCH 2/6] Fix ctrl-c issue, and remove parsing region from db hostname --- cmd/rds-iam-psql/main.go | 64 +++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/cmd/rds-iam-psql/main.go b/cmd/rds-iam-psql/main.go index d076047..659518c 100644 --- a/cmd/rds-iam-psql/main.go +++ b/cmd/rds-iam-psql/main.go @@ -1,4 +1,3 @@ -// rds-iam-psql.go package main import ( @@ -8,7 +7,9 @@ import ( "log" "os" "os/exec" + "os/signal" "strings" + "syscall" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -51,12 +52,6 @@ func main() { if awsRegion == "" { awsRegion = cfg.Region } - if awsRegion == "" { - // Last resort: try to infer from the hostname if it looks like a standard RDS endpoint. - if inferred := inferRegionFromHost(*host); inferred != "" { - awsRegion = inferred - } - } if awsRegion == "" { log.Fatalf("AWS region is not set; pass -region or set AWS_REGION / configure your AWS profile") @@ -93,10 +88,8 @@ func main() { // If a search path is provided, wire it through PGOPTIONS. if sp := strings.TrimSpace(*searchPath); sp != "" { - // Build our addition: one -c flag. add := "-c search_path=" + sp - // Check if PGOPTIONS already exists; if so, append. found := false for i, e := range env { if strings.HasPrefix(e, "PGOPTIONS=") { @@ -117,23 +110,46 @@ func main() { cmd.Env = env - if err := cmd.Run(); err != nil { - // psql will print its own error messages; just propagate the exit code. - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - log.Fatalf("failed to run psql: %v", err) + // --- Ctrl-C handling --- + // The key idea: keep psql in the same foreground process group so it can read + // from the terminal. We intercept SIGINT only to prevent THIS wrapper from + // exiting; psql will still receive SIGINT normally and cancel the current + // query / line as expected. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + if err := cmd.Start(); err != nil { + log.Fatalf("failed to start psql: %v", err) } -} -// inferRegionFromHost tries to pull the AWS region out of a typical RDS hostname like -// "mydb.abc123.us-east-1.rds.amazonaws.com". If it can't, it returns "". -func inferRegionFromHost(host string) string { - parts := strings.Split(host, ".") - for i := 0; i < len(parts); i++ { - if parts[i] == "rds" && i > 0 { - return parts[i-1] + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + + for { + select { + case sig := <-sigCh: + switch sig { + case os.Interrupt: + // Swallow SIGINT so this wrapper doesn't exit. + // psql still gets SIGINT (same terminal foreground process group). + continue + case syscall.SIGTERM: + // If we're being terminated, pass it through to psql and exit accordingly. + if cmd.Process != nil { + _ = cmd.Process.Signal(syscall.SIGTERM) + } + } + case err := <-waitCh: + // psql exited; now we exit with the same code. + if err == nil { + return + } + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + log.Fatalf("psql failed: %v", err) } } - return "" } + From a59d002953481f76fe932baff31e3e0a396a7293 Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Thu, 18 Dec 2025 20:41:17 -0800 Subject: [PATCH 3/6] Sts check --- cmd/rds-iam-psql/main.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmd/rds-iam-psql/main.go b/cmd/rds-iam-psql/main.go index 659518c..43a6870 100644 --- a/cmd/rds-iam-psql/main.go +++ b/cmd/rds-iam-psql/main.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + "github.com/aws/aws-sdk-go-v2/service/sts" ) func main() { @@ -48,6 +49,11 @@ func main() { log.Fatalf("failed to load AWS config: %v", err) } + // Fail fast + print identity (account/arn/role-ish). + if err := printCallerIdentity(ctx, cfg); err != nil { + log.Fatalf("AWS credentials check failed: %v", err) + } + awsRegion := *region if awsRegion == "" { awsRegion = cfg.Region @@ -153,3 +159,19 @@ func main() { } } +func printCallerIdentity(ctx context.Context, cfg aws.Config) error { + stsClient := sts.NewFromConfig(cfg) + + out, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return fmt.Errorf("STS GetCallerIdentity failed (creds invalid/expired or STS not allowed): %w", err) + } + + account := aws.ToString(out.Account) + arn := aws.ToString(out.Arn) + + fmt.Printf("AWS Account: %s\n", account) + fmt.Printf("Caller ARN: %s\n", arn) + + return nil +} From ece0181e437f3a12a80fa91967c914b11f5b0254 Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Thu, 18 Dec 2025 20:41:39 -0800 Subject: [PATCH 4/6] White space --- cmd/rds-iam-psql/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/rds-iam-psql/main.go b/cmd/rds-iam-psql/main.go index 43a6870..f6213b0 100644 --- a/cmd/rds-iam-psql/main.go +++ b/cmd/rds-iam-psql/main.go @@ -175,3 +175,4 @@ func printCallerIdentity(ctx context.Context, cfg aws.Config) error { return nil } + From ac386aa4f076709e4c7c354311e39cb71fdf7728 Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Thu, 18 Dec 2025 20:45:21 -0800 Subject: [PATCH 5/6] tighter sts print --- cmd/rds-iam-psql/main.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cmd/rds-iam-psql/main.go b/cmd/rds-iam-psql/main.go index f6213b0..d6e2c6f 100644 --- a/cmd/rds-iam-psql/main.go +++ b/cmd/rds-iam-psql/main.go @@ -167,12 +167,7 @@ func printCallerIdentity(ctx context.Context, cfg aws.Config) error { return fmt.Errorf("STS GetCallerIdentity failed (creds invalid/expired or STS not allowed): %w", err) } - account := aws.ToString(out.Account) - arn := aws.ToString(out.Arn) - - fmt.Printf("AWS Account: %s\n", account) - fmt.Printf("Caller ARN: %s\n", arn) - + fmt.Printf("Caller ARN: %s\n", aws.ToString(out.Arn)) return nil } From d46322af740cc377805bf3abf1d9db4b37c84fd6 Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Thu, 18 Dec 2025 20:45:41 -0800 Subject: [PATCH 6/6] go fmt --- cmd/rds-iam-psql/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/rds-iam-psql/main.go b/cmd/rds-iam-psql/main.go index d6e2c6f..8406c0d 100644 --- a/cmd/rds-iam-psql/main.go +++ b/cmd/rds-iam-psql/main.go @@ -170,4 +170,3 @@ func printCallerIdentity(ctx context.Context, cfg aws.Config) error { fmt.Printf("Caller ARN: %s\n", aws.ToString(out.Arn)) return nil } -