diff --git a/README.md b/README.md index 8ec66b0..a573c8f 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ Trunnel (a play on the [East Side Trolley Tunnel], or a wooden peg used to form a strong connection between pieces of wood) helps automate securely connecting to your private AWS infrastructure through AWS Systems Manager (SSM). It -replaces manual SSH management with automated SSM discovery and a self-healing CDK bastion. Trunnel does NOT handle -fetching database credentials, but does make it easier to securely make the connection. +replaces manual SSH management with automated SSM discovery and a self-healing CDK bastion. Trunnel can discover the +bastion and RDS instance you need to connect to, fetch database credentials stored in Secrets Manager, and bore the +encrypted tunnel — all from a single CLI. This tool was designed in response to help reduce minor frustrations like, @@ -73,26 +74,38 @@ bastion.export_security_group("BastionSG-Production") ## Trunnel CLI -The Trunnel CLI makes it easy to find the bastion host and connect to your RDS database via an encrypted SSM tunnel. -This tool is basically a wrapper around the AWS CLI and the SSM Session Manager Plugin that helps lookup the correct -values for bridging the connection. It does this by finding resources that are tagged according with some configurable -`key=value` pair. +The Trunnel CLI makes it easy to find the bastion host and connect to your RDS database via an encrypted SSM tunnel, and +to fetch connection credentials stored in AWS Secrets Manager. Resources are located by tag `key=value` pairs. -### Example Usage +### Installation + +Before beginning, you must first have the following installed: -To connect to an RDS instance tagged with App=my-service: +- AWS CLI v2 +- SSM Session Manager Plugin + +Install into your project's development dependencies (for example using `uv`): ```bash -$ trunnel --rds-key Service --rds-value payments-api --reconnect +uv add --dev "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +``` + +### trunnel connect + +Opens an encrypted SSM port-forward tunnel to a private RDS instance. + +```bash +$ trunnel connect --rds-key Service --rds-value payments-api --reconnect šŸ” Searching AWS... + Select a Bastion: - • i-0abcd1234efgh5678 - Production-Bastion - • i-09876fedcba54321 - Staging-Bastion + 1) i-0abcd1234efgh5678 - Production-Bastion + 2) i-09876fedcba54321 - Staging-Bastion -Enter Bastion ID: i-0abcd1234efgh5678 +Enter number: 1 -šŸš€ Trunnel Active: localhost:5432 -> payments-api-db.cluster.aws.com +šŸšŽ Trunnel Active: localhost:5432 -> payments-api-db.cluster.aws.com šŸ”— payments-api-db via Production-Bastion (i-0abcd1234efgh5678) Starting session with SessionId: developer-0123456789abcdef @@ -100,46 +113,122 @@ Port 5432 opened for session developer-0123456789abcdef. Waiting for connections... ``` -The Trunnel CLI can read You might consider using [direnv](https://direnv.net/) to help automate the resource tagging -definitions. For example, +```bash +$ trunnel connect --help + +Usage: trunnel connect [OPTIONS] + + Securely bore a tunnel to RDS via Trunnel. + +Options: + --bastion-key TEXT Tag key for Bastion. [env var: TRUNNEL_CONNECT_BASTION_KEY; default: Role] + --bastion-value TEXT Tag value for Bastion. [env var: TRUNNEL_CONNECT_BASTION_VALUE; default: Bastion] + --rds-key TEXT Tag key for RDS. [env var: TRUNNEL_CONNECT_RDS_KEY; required] + --rds-value TEXT Tag value for RDS. [env var: TRUNNEL_CONNECT_RDS_VALUE; required] + --local-port INTEGER [env var: TRUNNEL_CONNECT_LOCAL_PORT; default: 5432] + --profile TEXT AWS CLI profile. [env var: TRUNNEL_CONNECT_PROFILE] + --reconnect Auto-retry on disconnect. [env var: TRUNNEL_CONNECT_RECONNECT] + --help Show this message and exit. +``` + +### trunnel secrets + +Looks up an AWS Secrets Manager secret by tag and prints its value to stdout. ```bash -# .envrc -export TRUNNEL_RDS_KEY=Service -export TRUNNEL_RDS_VALUE=payments-api +$ trunnel secrets --secret-key Stack --secret-value payments-api + +šŸ” Searching AWS Secrets Manager... +{"username":"app","password":"s3cr3t","host":"payments-api-db.cluster.aws.com","port":5432} ``` -You can also view the full help text by passing `--help`, +If multiple secrets match the tag filter, Trunnel prompts you to choose: ```bash -$ trunnel --help +Select a Secret: + 1) payments-api/db-credentials - Primary database credentials + 2) payments-api/readonly-credentials - Read-only replica credentials -Usage: trunnel [OPTIONS] +Enter number: 1 +``` - Securely bore a tunnel to RDS via Trunnel. +```bash +$ trunnel secrets --help + +Usage: trunnel secrets [OPTIONS] + + Fetch and print a Secrets Manager secret by tag. + +Options: + --secret-key TEXT Tag key to filter secrets. [env var: TRUNNEL_SECRETS_SECRET_KEY; required] + --secret-value TEXT Tag value to filter secrets. [env var: TRUNNEL_SECRETS_SECRET_VALUE; required] + --profile TEXT AWS CLI profile. [env var: TRUNNEL_SECRETS_PROFILE] + --help Show this message and exit. +``` + +### trunnel psql + +Looks up credentials from Secrets Manager, opens an SSM tunnel, and drops you straight into a `psql` session. Requires +`psql` to be installed alongside the AWS CLI prerequisites. + +```bash +$ trunnel psql \ + --rds-key Stack --rds-value payments-api \ + --secret-key Stack --secret-value payments-api-user-alice + +šŸ” Searching AWS... + +šŸšŽ Opening tunnel: localhost:5432 -> payments-api-db.cluster.aws.com +šŸ”— payments-api-db via Production-Bastion (i-0abcd1234efgh5678) +ā³ Waiting for tunnel... +🐘 Connecting as alice... +psql (16.2) +Type "help" for help. + +payments_api=# +``` + +The RDS and secret tags can differ — useful when a shared database has per-user secrets with different tags. + +```bash +$ trunnel psql --help + +Usage: trunnel psql [OPTIONS] + + Fetch credentials, open a tunnel, and launch psql. Options: - --bastion-key TEXT Tag key for Bastion. [env var: TRUNNEL_BASTION_KEY; default: Role] - --bastion-value TEXT Tag value for Bastion. [env var: TRUNNEL_BASTION_VALUE; default: Bastion] - --rds-key TEXT Tag key for RDS. [env var: TRUNNEL_RDS_KEY; required] - --rds-value TEXT Tag value for RDS. [env var: TRUNNEL_RDS_VALUE; required] - --local-port INTEGER [env var: TRUNNEL_LOCAL_PORT; default: 5432] - --profile TEXT AWS CLI profile. [env var: TRUNNEL_PROFILE] - --reconnect Auto-retry on disconnect. [env var: TRUNNEL_RECONNECT] + --bastion-key TEXT Tag key for Bastion. [env var: TRUNNEL_PSQL_BASTION_KEY; default: Role] + --bastion-value TEXT Tag value for Bastion. [env var: TRUNNEL_PSQL_BASTION_VALUE; default: Bastion] + --rds-key TEXT Tag key for RDS. [env var: TRUNNEL_PSQL_RDS_KEY; required] + --rds-value TEXT Tag value for RDS. [env var: TRUNNEL_PSQL_RDS_VALUE; required] + --secret-key TEXT Tag key for the secret. [env var: TRUNNEL_PSQL_SECRET_KEY; required] + --secret-value TEXT Tag value for the secret. [env var: TRUNNEL_PSQL_SECRET_VALUE; required] + --local-port INTEGER [env var: TRUNNEL_PSQL_LOCAL_PORT; default: 5432] + --profile TEXT AWS CLI profile. [env var: TRUNNEL_PSQL_PROFILE] --help Show this message and exit. ``` -### Installation +### Environment variables -Before beginning, you must first have the following installed, +All options can be set via environment variables. Each subcommand has its own prefix: -- AWS CLI v2 -- SSM Session Manager Plugin +| Subcommand | Prefix | Example | +| ----------------- | ------------------ | ---------------------------------- | +| `trunnel connect` | `TRUNNEL_CONNECT_` | `TRUNNEL_CONNECT_RDS_KEY=Service` | +| `trunnel secrets` | `TRUNNEL_SECRETS_` | `TRUNNEL_SECRETS_SECRET_KEY=Stack` | +| `trunnel psql` | `TRUNNEL_PSQL_` | `TRUNNEL_PSQL_RDS_KEY=Stack` | -Next, it into your project's development dependencies (for example using `uv`): +You might consider using [direnv](https://direnv.net/) to set these per project. For example, ```bash -uv add --dev "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +# .envrc +export TRUNNEL_CONNECT_RDS_KEY=Stack +export TRUNNEL_CONNECT_RDS_VALUE=payments-api +export TRUNNEL_PSQL_RDS_KEY=Stack +export TRUNNEL_PSQL_RDS_VALUE=payments-api +export TRUNNEL_PSQL_SECRET_KEY=Stack +export TRUNNEL_PSQL_SECRET_VALUE=payments-api-user-alice ``` [East Side Trolley Tunnel]: https://en.wikipedia.org/wiki/East_Side_Trolley_Tunnel diff --git a/packages/trunnel-cli/README.md b/packages/trunnel-cli/README.md index f72f7eb..4b45ec5 100644 --- a/packages/trunnel-cli/README.md +++ b/packages/trunnel-cli/README.md @@ -1,10 +1,10 @@ # trunnel-cli -> Automated SSM tunneling for private RDS instances. +> Automated SSM tunneling and credential lookup for private RDS instances. -`trunnel-cli` is the developer-facing component of the Trunnel project. It automates the discovery of Bastion hosts and -RDS instances via AWS tags and establishes an encrypted SSM tunnel to allow local database clients to connect to private -RDS instances. +`trunnel-cli` is the developer-facing component of the Trunnel project. It automates the discovery of Bastion hosts, RDS +instances, and Secrets Manager credentials via AWS tags, then establishes an encrypted SSM tunnel so local database +clients can connect to private RDS instances. ## Installation @@ -17,5 +17,91 @@ RDS instances. To add to your project using `uv`, ```bash -uv add --group deploy "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +uv add --dev "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +``` + +## Commands + +### `trunnel connect` + +Opens an encrypted SSM port-forward tunnel to a private RDS instance. Resources are located by tag `key=value` pairs. + +```bash +trunnel connect --rds-key Service --rds-value payments-api +``` + +| Option | Default | Env var | +| ----------------- | ------------ | ------------------------------- | +| `--bastion-key` | `Role` | `TRUNNEL_CONNECT_BASTION_KEY` | +| `--bastion-value` | `Bastion` | `TRUNNEL_CONNECT_BASTION_VALUE` | +| `--rds-key` | _(required)_ | `TRUNNEL_CONNECT_RDS_KEY` | +| `--rds-value` | _(required)_ | `TRUNNEL_CONNECT_RDS_VALUE` | +| `--local-port` | `5432` | `TRUNNEL_CONNECT_LOCAL_PORT` | +| `--profile` | | `TRUNNEL_CONNECT_PROFILE` | +| `--reconnect` | | `TRUNNEL_CONNECT_RECONNECT` | + +### `trunnel secrets` + +Looks up an AWS Secrets Manager secret by tag and prints its `SecretString` to stdout. If multiple secrets match, you +are prompted to choose one by name. + +```bash +trunnel secrets --secret-key Stack --secret-value payments-api +``` + +The output is suitable for piping into tools like `jq`: + +```bash +trunnel secrets --secret-key Stack --secret-value payments-api | jq '.password' +``` + +| Option | Default | Env var | +| ---------------- | ------------ | ------------------------------ | +| `--secret-key` | _(required)_ | `TRUNNEL_SECRETS_SECRET_KEY` | +| `--secret-value` | _(required)_ | `TRUNNEL_SECRETS_SECRET_VALUE` | +| `--profile` | | `TRUNNEL_SECRETS_PROFILE` | + +### `trunnel psql` + +Looks up credentials from Secrets Manager, opens an SSM tunnel in the background, and launches `psql`. Requires `psql` +to be installed. The RDS and secret tags can differ — useful when a shared database has per-user secrets. + +```bash +trunnel psql \ + --rds-key Stack --rds-value payments-api \ + --secret-key Stack --secret-value payments-api-user-alice +``` + +If port 5432 is already in use locally (e.g. a local Postgres), use `--local-port` to pick a free port: + +```bash +trunnel psql --rds-key Stack --rds-value payments-api \ + --secret-key Stack --secret-value payments-api-user-alice \ + --local-port 15432 +``` + +| Option | Default | Env var | +| ----------------- | ------------ | ---------------------------- | +| `--bastion-key` | `Role` | `TRUNNEL_PSQL_BASTION_KEY` | +| `--bastion-value` | `Bastion` | `TRUNNEL_PSQL_BASTION_VALUE` | +| `--rds-key` | _(required)_ | `TRUNNEL_PSQL_RDS_KEY` | +| `--rds-value` | _(required)_ | `TRUNNEL_PSQL_RDS_VALUE` | +| `--secret-key` | _(required)_ | `TRUNNEL_PSQL_SECRET_KEY` | +| `--secret-value` | _(required)_ | `TRUNNEL_PSQL_SECRET_VALUE` | +| `--local-port` | `5432` | `TRUNNEL_PSQL_LOCAL_PORT` | +| `--profile` | | `TRUNNEL_PSQL_PROFILE` | + +## Environment variables + +All options can be configured via environment variables. Each subcommand uses its own prefix, which makes it easy to use +[direnv](https://direnv.net/) per project: + +```bash +# .envrc +export TRUNNEL_CONNECT_RDS_KEY=Stack +export TRUNNEL_CONNECT_RDS_VALUE=payments-api +export TRUNNEL_PSQL_RDS_KEY=Stack +export TRUNNEL_PSQL_RDS_VALUE=payments-api +export TRUNNEL_PSQL_SECRET_KEY=Stack +export TRUNNEL_PSQL_SECRET_VALUE=payments-api-user-alice ``` diff --git a/packages/trunnel-cli/pyproject.toml b/packages/trunnel-cli/pyproject.toml index 7cb8492..5a9d20d 100644 --- a/packages/trunnel-cli/pyproject.toml +++ b/packages/trunnel-cli/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ [project.optional-dependencies] types = [ - "boto3-stubs[ec2,rds,resourcegroupstaggingapi]>=1.34.0", + "boto3-stubs[ec2,rds,resourcegroupstaggingapi,secretsmanager]>=1.34.0", ] [project.scripts] diff --git a/packages/trunnel-cli/src/trunnel_cli/discovery.py b/packages/trunnel-cli/src/trunnel_cli/discovery.py index ec4be7a..78b12e3 100644 --- a/packages/trunnel-cli/src/trunnel_cli/discovery.py +++ b/packages/trunnel-cli/src/trunnel_cli/discovery.py @@ -9,6 +9,7 @@ from mypy_boto3_ec2 import EC2Client from mypy_boto3_rds import RDSClient from mypy_boto3_resourcegroupstaggingapi import ResourceGroupsTaggingAPIClient + from mypy_boto3_secretsmanager import SecretsManagerClient @dataclass @@ -28,7 +29,15 @@ class Database: port: int -type Discoverable = Bastion | Database +@dataclass +class Secret: + """Secrets Manager secret metadata.""" + + id: str + name: str + + +type Discoverable = Bastion | Database | Secret @dataclass @@ -46,12 +55,15 @@ class TunnelDiscoverer: Injected RDS client. tag_client : ResourceGroupsTaggingAPIClient, optional Resource tagging API client. + secrets_client : SecretsManagerClient, optional + Injected Secrets Manager client. """ session: boto3.Session = field(default_factory=lambda: boto3.Session()) ec2_client: EC2Client | None = None rds_client: RDSClient | None = None tag_client: ResourceGroupsTaggingAPIClient | None = None + secrets_client: SecretsManagerClient | None = None def __post_init__(self) -> None: """ @@ -62,6 +74,9 @@ def __post_init__(self) -> None: self._tags: ResourceGroupsTaggingAPIClient = self.tag_client or self.session.client( "resourcegroupstaggingapi" ) + self._secrets: SecretsManagerClient = self.secrets_client or self.session.client( + "secretsmanager" + ) def find_bastions(self, key: str, value: str) -> list[Bastion]: """ @@ -152,3 +167,54 @@ def find_rds(self, key: str, value: str) -> list[Database]: ) return matches + + def fetch_secret(self, secret_id: str) -> str: + """ + Fetch the plaintext value of a secret by name or ARN. + + Parameters + ---------- + secret_id : str + The secret name or ARN. + + Returns + ------- + str + The secret's string value. + """ + response = self._secrets.get_secret_value(SecretId=secret_id) + value = response.get("SecretString") or response.get("SecretBinary", b"").decode() + if not value: + raise ValueError( + f"Secret '{secret_id}' has no value. " + "Expected a secret stored as JSON in SecretString or SecretBinary." + ) + return value + + def find_secrets(self, tag_key: str, tag_value: str) -> list[Secret]: + """ + Find Secrets Manager secrets by tag key/value pair. + + Parameters + ---------- + tag_key : str + The tag key to filter by. + tag_value : str + The tag value to filter by. + + Returns + ------- + list[Secret] + A list of discovered Secret resources. + """ + paginator = self._secrets.get_paginator("list_secrets") + secrets: list[Secret] = [] + for page in paginator.paginate( + Filters=[ + {"Key": "tag-key", "Values": [tag_key]}, + {"Key": "tag-value", "Values": [tag_value]}, + ] + ): + for s in page.get("SecretList", []): + secrets.append(Secret(id=s["Name"], name=s.get("Description") or s["Name"])) + return secrets diff --git a/packages/trunnel-cli/src/trunnel_cli/main.py b/packages/trunnel-cli/src/trunnel_cli/main.py index e42e07c..180020d 100644 --- a/packages/trunnel-cli/src/trunnel_cli/main.py +++ b/packages/trunnel-cli/src/trunnel_cli/main.py @@ -5,10 +5,14 @@ from __future__ import annotations import json +import os import shutil +import socket import subprocess import time -from functools import partial +from collections.abc import Callable, Generator +from contextlib import contextmanager +from typing import Any import boto3 import click @@ -44,14 +48,25 @@ def _select_resource[T: Discoverable](resources: list[T], label: str) -> T: return resources[0] click.secho(f"\nSelect a {label}:", fg="yellow", bold=True) - options = {r.id: r for r in resources} - for r in resources: - click.echo(f" • {click.style(r.id, fg='cyan')} - {r.name}") + for i, r in enumerate(resources, 1): + click.echo(f" {click.style(str(i), fg='cyan', bold=True)}) {r.id} - {r.name}") - choice_id = click.prompt( - f"\nEnter {label} ID", type=click.Choice(list(options.keys())), show_choices=False + choice = click.prompt( + "\nEnter number", + type=click.IntRange(1, len(resources)), ) - return options[choice_id] + return resources[choice - 1] + + +@contextmanager +def _aws_errors(label: str) -> Generator[None, None, None]: + """Translate unexpected AWS/boto3 exceptions into a clean ClickException.""" + try: + yield + except click.ClickException: + raise + except Exception as e: + raise click.ClickException(f"{label}: {e}") from e def _verify_prerequisites() -> None: @@ -63,20 +78,142 @@ def _verify_prerequisites() -> None: ) -opt = partial(click.option, show_envvar=True) +def _assert_port_free(port: int) -> None: + """Raise early if the local port is already in use.""" + try: + with socket.create_connection(("localhost", port), timeout=0.5): + raise click.ClickException( + f"Port {port} is already in use (local Postgres?). " + f"Choose a free port with --local-port." + ) + except OSError: + pass # port is free + + +def _wait_for_port(port: int, tunnel: subprocess.Popen[bytes], timeout: float = 30.0) -> bool: + """Poll localhost: until it accepts a connection, the tunnel exits, or timeout expires.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if tunnel.poll() is not None: + return False + try: + with socket.create_connection(("localhost", port), timeout=1): + return True + except OSError: + time.sleep(0.5) + return False + + +def _parse_db_secret(secret_string: str) -> dict[str, str]: + """Parse a Secrets Manager SecretString as RDS JSON credentials.""" + try: + data = json.loads(secret_string) + except json.JSONDecodeError as e: + raise click.ClickException(f"Secret is not valid JSON: {e}") from e + for field in ("username", "password"): + if field not in data: + raise click.ClickException(f"Secret JSON missing required field: '{field}'") + return data + + +def _build_ssm_cmd( + bastion_id: str, rds_host: str, rds_port: int, local_port: int, profile: str | None +) -> list[str]: + params = json.dumps( + { + "host": [rds_host], + "portNumber": [str(rds_port)], + "localPortNumber": [str(local_port)], + } + ) + cmd = [ + "aws", + "ssm", + "start-session", + "--target", + bastion_id, + "--document-name", + "AWS-StartPortForwardingSessionToRemoteHost", + "--parameters", + params, + ] + if profile: + cmd.extend(["--profile", profile]) + return cmd + + +# --------------------------------------------------------------------------- +# Reusable option groups +# --------------------------------------------------------------------------- + + +def _bastion_opts(f: Callable[..., Any]) -> Callable[..., Any]: + f = click.option( + "--bastion-value", + default="Bastion", + show_default=True, + show_envvar=True, + help="Tag value for Bastion.", + )(f) + f = click.option( + "--bastion-key", + default="Role", + show_default=True, + show_envvar=True, + help="Tag key for Bastion.", + )(f) + return f + + +def _rds_opts(f: Callable[..., Any]) -> Callable[..., Any]: + f = click.option("--rds-value", required=True, show_envvar=True, help="Tag value for RDS.")(f) + f = click.option("--rds-key", required=True, show_envvar=True, help="Tag key for RDS.")(f) + return f + + +def _secret_opts(f: Callable[..., Any]) -> Callable[..., Any]: + f = click.option( + "--secret-value", + required=True, + show_envvar=True, + help="Tag value for the Secrets Manager secret.", + )(f) + f = click.option( + "--secret-key", + required=True, + show_envvar=True, + help="Tag key for the Secrets Manager secret.", + )(f) + return f + + +def _profile_opt(f: Callable[..., Any]) -> Callable[..., Any]: + return click.option("--profile", type=str, show_envvar=True, help="AWS CLI profile.")(f) + +def _local_port_opt(f: Callable[..., Any]) -> Callable[..., Any]: + return click.option( + "--local-port", default=5432, type=int, show_default=True, show_envvar=True + )(f) -@click.command(context_settings={"auto_envvar_prefix": "TRUNNEL"}) -@click.pass_context -@opt("--bastion-key", default="Role", show_default=True, help="Tag key for Bastion.") -@opt("--bastion-value", default="Bastion", show_default=True, help="Tag value for Bastion.") -@opt("--rds-key", required=True, help="Tag key for RDS.") -@opt("--rds-value", required=True, help="Tag value for RDS.") -@opt("--local-port", default=5432, type=int, show_default=True) -@opt("--profile", type=str, help="AWS CLI profile.") -@opt("--reconnect", is_flag=True, help="Auto-retry on disconnect.") -def main( - ctx: click.Context, + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +@click.group(context_settings={"auto_envvar_prefix": "TRUNNEL"}) +def main() -> None: + """Trunnel: Precision-bored SSM tunnels to private RDS instances.""" + + +@main.command() +@_bastion_opts +@_rds_opts +@_local_port_opt +@_profile_opt +@click.option("--reconnect", is_flag=True, show_envvar=True, help="Auto-retry on disconnect.") +def connect( bastion_key: str, bastion_value: str, rds_key: str, @@ -93,37 +230,14 @@ def main( discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) click.echo("šŸ” Searching AWS...") - try: + with _aws_errors("EC2 Lookup Failed"): target_bastion = _select_resource( discoverer.find_bastions(bastion_key, bastion_value), "Bastion" ) - except Exception as e: - raise click.ClickException(f"EC2 Lookup Failed: {e}") from e - try: + with _aws_errors("RDS Lookup Failed"): target_rds = _select_resource(discoverer.find_rds(rds_key, rds_value), "RDS") - except Exception as e: - raise click.ClickException(f"RDS Lookup Failed: {e}") from e - params = json.dumps( - { - "host": [target_rds.id], - "portNumber": [str(target_rds.port)], - "localPortNumber": [str(local_port)], - } - ) - aws_cmd = [ - "aws", - "ssm", - "start-session", - "--target", - target_bastion.id, - "--document-name", - "AWS-StartPortForwardingSessionToRemoteHost", - "--parameters", - params, - ] - if profile: - aws_cmd.extend(["--profile", profile]) + aws_cmd = _build_ssm_cmd(target_bastion.id, target_rds.id, target_rds.port, local_port, profile) try: while True: @@ -147,3 +261,104 @@ def main( click.echo("\nšŸ‘‹ Trunnel interrupted. Goodbye!") else: click.echo("\nšŸ‘‹ Trunnel disconnected. Goodbye!") + + +@main.command() +@_secret_opts +@_profile_opt +def secrets( + secret_key: str, + secret_value: str, + profile: str | None, +) -> None: + """ + Fetch and print a Secrets Manager secret by tag. + """ + discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) + click.echo("šŸ” Searching AWS Secrets Manager...") + + with _aws_errors("Secrets Lookup Failed"): + target = _select_resource(discoverer.find_secrets(secret_key, secret_value), "Secret") + + with _aws_errors(f"Failed to fetch secret '{target.name}'"): + secret_string = discoverer.fetch_secret(target.id) + + click.echo(secret_string) + + +@main.command() +@_bastion_opts +@_rds_opts +@_secret_opts +@_local_port_opt +@_profile_opt +def psql( + bastion_key: str, + bastion_value: str, + rds_key: str, + rds_value: str, + secret_key: str, + secret_value: str, + local_port: int, + profile: str | None, +) -> None: + """ + Fetch credentials, open a tunnel, and launch psql. + """ + _verify_prerequisites() + if not shutil.which("psql"): + raise click.UsageError("Dependency 'psql' not found. Install PostgreSQL client tools.") + + discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) + click.echo("šŸ” Searching AWS...") + + with _aws_errors("EC2 Lookup Failed"): + target_bastion = _select_resource( + discoverer.find_bastions(bastion_key, bastion_value), "Bastion" + ) + with _aws_errors("RDS Lookup Failed"): + target_rds = _select_resource(discoverer.find_rds(rds_key, rds_value), "RDS") + with _aws_errors("Secrets Lookup Failed"): + target_secret = _select_resource( + discoverer.find_secrets(secret_key, secret_value), "Secret" + ) + with _aws_errors(f"Failed to fetch secret '{target_secret.name}'"): + creds = _parse_db_secret(discoverer.fetch_secret(target_secret.id)) + + _assert_port_free(local_port) + + aws_cmd = _build_ssm_cmd(target_bastion.id, target_rds.id, target_rds.port, local_port, profile) + + click.secho( + f"\nšŸšŽ Opening tunnel: localhost:{local_port} -> {target_rds.name}", + fg="green", + bold=True, + ) + click.secho(f"šŸ”— {target_rds.id} via {target_bastion.name} ({target_bastion.id})", fg="cyan") + + tunnel = subprocess.Popen(aws_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + click.echo("ā³ Waiting for tunnel...") + if not _wait_for_port(local_port, tunnel): + raise click.ClickException(f"Tunnel did not become ready on port {local_port}.") + + psql_cmd = [ + "psql", + "-h", + "localhost", + "-p", + str(local_port), + "-U", + creds["username"], + ] + if dbname := creds.get("dbname") or creds.get("database"): + psql_cmd.extend(["-d", dbname]) + + env = {**os.environ, "PGPASSWORD": creds["password"]} + click.secho(f"🐘 Connecting as {creds['username']}...", fg="green") + subprocess.run(psql_cmd, env=env, check=False) + except KeyboardInterrupt: + click.echo("\nšŸ‘‹ Interrupted. Goodbye!") + finally: + tunnel.terminate() + tunnel.wait() diff --git a/packages/trunnel-infra/README.md b/packages/trunnel-infra/README.md index 8fe9f18..f67b610 100644 --- a/packages/trunnel-infra/README.md +++ b/packages/trunnel-infra/README.md @@ -11,8 +11,8 @@ specifically for SSM-based tunneling. - **Auto-Rotation:** Automatically triggers an ASG Instance Refresh when a referenced SSM Parameter (AMI ID) is updated. - **Least Privilege:** Automatically manages Security Group egress/ingress based on the provided RDS targets using the CDK Connections API. -- **Unattended Upgrades:** Automatically applies security updates on every instance launch via `dnf-automatic` - (Amazon Linux 2023 default). +- **Unattended Upgrades:** Automatically applies security updates on every instance launch via `dnf-automatic` (Amazon + Linux 2023 default). ## Unattended Security Updates diff --git a/pyproject.toml b/pyproject.toml index 7f1d881..b4d27e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,16 @@ [tool.uv] managed = true dev-dependencies = [ - "boto3-stubs[ec2,rds,resourcegroupstaggingapi]>=1.34.0", + "trunnel-cli[types]", "pytest>=8.0.0", "pytest-mock>=3.14.0", "ruff>=0.6.0", "ty>=0.0.18", ] +[tool.uv.sources] +trunnel-cli = { workspace = true } + [tool.uv.workspace] members = ["packages/*"] diff --git a/uv.lock b/uv.lock index 35239a4..a1edbc3 100644 --- a/uv.lock +++ b/uv.lock @@ -10,10 +10,10 @@ members = [ [manifest.dependency-groups] dev = [ - { name = "boto3-stubs", extras = ["ec2", "rds", "resourcegroupstaggingapi"], specifier = ">=1.34.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "ruff", specifier = ">=0.6.0" }, + { name = "trunnel-cli", extras = ["types"], editable = "packages/trunnel-cli" }, { name = "ty", specifier = ">=0.0.18" }, ] @@ -123,6 +123,9 @@ rds = [ resourcegroupstaggingapi = [ { name = "mypy-boto3-resourcegroupstaggingapi" }, ] +secretsmanager = [ + { name = "mypy-boto3-secretsmanager" }, +] [[package]] name = "botocore" @@ -270,6 +273,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/5c/9a95415e3945ef10d467a613a1c27b447e48d46cdb5b1b14dfd96638f2f2/mypy_boto3_resourcegroupstaggingapi-1.42.3-py3-none-any.whl", hash = "sha256:22d866b17926dfb8222c6407b80f7b64fe87b631df8ffd1a6a24b3cff337388e", size = 25332, upload-time = "2025-12-04T21:10:50.586Z" }, ] +[[package]] +name = "mypy-boto3-secretsmanager" +version = "1.42.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/58/ccae71b7b7f550eab01d600e956d57e6e6bb9148dbf5d116696d0dc43369/mypy_boto3_secretsmanager-1.42.8.tar.gz", hash = "sha256:5ab42f35ce932765ebb1684146f478a87cc4b83bef950fd1aa0e268b88d59c81", size = 19863, upload-time = "2025-12-11T22:12:51.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/42/90cef7241c98f6e504cabc9a99d89dd38b84e4d40ff0774c89bc871ffb18/mypy_boto3_secretsmanager-1.42.8-py3-none-any.whl", hash = "sha256:50c891a88e725a8dba7444018e47590ea63d8e938abe2b1c0b25e5413f39d51d", size = 27243, upload-time = "2025-12-11T22:12:44.389Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -394,7 +406,7 @@ wheels = [ [[package]] name = "trunnel-cli" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/trunnel-cli" } dependencies = [ { name = "boto3" }, @@ -403,20 +415,20 @@ dependencies = [ [package.optional-dependencies] types = [ - { name = "boto3-stubs", extra = ["ec2", "rds", "resourcegroupstaggingapi"] }, + { name = "boto3-stubs", extra = ["ec2", "rds", "resourcegroupstaggingapi", "secretsmanager"] }, ] [package.metadata] requires-dist = [ { name = "boto3", specifier = ">=1.34.0" }, - { name = "boto3-stubs", extras = ["ec2", "rds", "resourcegroupstaggingapi"], marker = "extra == 'types'", specifier = ">=1.34.0" }, + { name = "boto3-stubs", extras = ["ec2", "rds", "resourcegroupstaggingapi", "secretsmanager"], marker = "extra == 'types'", specifier = ">=1.34.0" }, { name = "click", specifier = ">=8.1.0" }, ] provides-extras = ["types"] [[package]] name = "trunnel-infra" -version = "0.1.0" +version = "0.3.0" source = { editable = "packages/trunnel-infra" } dependencies = [ { name = "aws-cdk-lib" },