Original Pages

Open-source tool · AWS

ssmx — a smarter AWS SSM CLI

Python · MIT · v0.2.1

Shell access, one-off commands and port forwarding over AWS Systems Manager — using names instead of instance IDs, endpoints and ports. You give it the RDS identifier; it looks up the endpoint and port, picks a sensible local port, and opens the tunnel. No bastion SSH keys, no open ports, no copy-pasting endpoints from the console.

github.com/originalpages/ssmx

The short version

  1. Install: pipx install git+https://github.com/originalpages/ssmx.git — plus the AWS CLI v2 and the Session Manager plugin — Install.
  2. Shell by name: ssmx connect api-1 — Open a shell.
  3. Tunnel to a database by name: ssmx forward bastion-1 --to prod-db — Port forward.
  4. Same IAM, same APIs. A nicer interface over the AWS CLI, not a different security model — IAM permissions.
What it is

Why: three commands become one

Tunnelling to a private RDS database with the plain AWS CLI means looking up the endpoint, looking up the bastion’s instance ID, and then pasting both into a JSON parameter blob:

AWS CLI — three calls
aws rds describe-db-instances --db-instance-identifier prod-db \
  --query 'DBInstances[0].Endpoint'                  # 1. find host + port
aws ec2 describe-instances --filters Name=tag:Name,Values=bastion-1 \
  --query 'Reservations[].Instances[].InstanceId'    # 2. find instance ID
aws ssm start-session --target i-0abc1234def5678ab \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["prod-db.abc123.eu-west-1.rds.amazonaws.com"],
    "portNumber":["5432"],"localPortNumber":["5432"]}'  # 3. open tunnel
ssmx — one call
ssmx forward bastion-1 --to prod-db
# prod-db (rds:postgres) -> prod-db.abc123.eu-west-1.rds.amazonaws.com:5432
# forwarding localhost:5432 via bastion-1 (i-0abc1234def5678ab) - Ctrl+C to stop
TaskAWS CLIssmx
Shell on an instanceaws ssm start-session --target i-0abc...ssmx connect web-1
Run a commandaws ssm send-command ... + poll for outputssmx run web-1 -- uptime
Tunnel to RDS / Aurora / Redisthree commands, abovessmx forward web-1 --to prod-db
Instance by Name tagmanual describe-instancesbuilt in
Read a SecureStringaws ssm get-parameter --with-decryption ...ssmx param get /app/DB_PASS

How it works

ssmx is a single Python file on top of boto3. It does the lookups itself, then hands the finished session to the aws CLI and the Session Manager plugin — the same path you would take by hand, with the same profile and region.

How ssmx forward opens a tunnelYour laptop runs ssmx forward. Step one: ssmx asks the RDS API for the endpoint of prod-db and the EC2 API for the instance ID of bastion-1. Step two: it starts a Session Manager port-forwarding session. Step three: traffic to localhost:5432 flows through AWS Systems Manager to the SSM Agent on bastion-1, which connects to prod-db on port 5432 inside the VPC. No inbound ports are open on the bastion.Your laptop$ ssmx forwardbastion-1 --to prod-dblocalhost:5432AWS APIsRDS · EC2 · ElastiCacheAWS Systems ManagerSession ManagerPRIVATE VPCbastion-1EC2 + SSM Agentno inbound portsprod-dbRDS Postgres :54321name → host:port, Name tag → instance ID (cached)23outbound HTTPS from the agent — no SSH keyspsql -h localhost -p 5432 now reaches prod-db
One command, three steps: look up the names, start a Session Manager port-forwarding session, then pipe localhost through the bastion to the database.

Because the connection runs through Systems Manager, the bastion needs no inbound security-group rules and nobody needs SSH keys. What the bastion does need is a network path to the database: its security group must be allowed into the database’s security group on the database port.

Getting it running

Install

Prerequisites

  • Python 3.9+
  • AWS CLI v2
  • Session Manager plugin — needed for connect and forward
  • Target instances must be SSM-managed: SSM Agent running, and an instance profile with AmazonSSMManagedInstanceCore

Recommended: pipx

An isolated install that puts ssmx on your PATH.

pipx install git+https://github.com/originalpages/ssmx.git

Or pip

pip install git+https://github.com/originalpages/ssmx.git

Or just the script

It is a single file with one dependency.

pip install boto3
curl -L https://raw.githubusercontent.com/originalpages/ssmx/main/ssmx.py -o ~/.local/bin/ssmx
chmod +x ~/.local/bin/ssmx

Check it works

ssmx --version
ssmx list

Usage, command by command

Find instances

ssmx list                  # all running, SSM-managed instances
ssmx ls -f api             # filter by Name tag or ID substring
ssmx list -o json | jq     # machine-readable
ssmx list
$ ssmx list
NAME         INSTANCE ID           TYPE          PRIVATE IP       PING
api-1        i-0abc1234def5678ab   t3.medium     10.0.1.12        Online
bastion-1    i-0fed9876cba54321f   t3.micro      10.0.0.5         Online

Open a shell

ssmx connect api-1                      # by Name tag
ssmx c i-0abc1234def5678ab              # or by instance ID

Run a one-off command

ssmx run api-1 -- systemctl status nginx
ssmx run api-1 -- tail -n 100 /var/log/app.log
ssmx run api-1 --timeout 600 -- ./long-job.sh

stdout and stderr come back to your terminal, and the remote exit code becomes ssmx’s exit code — so it works in scripts and && chains.

Port forward — the smart part

--to takes a name and works out what it is. It tries these in order and uses the first match:

  1. host:portUsed as-is — anything TCP: OpenSearch, Kafka, an internal ALB…
  2. RDS instanceInstance endpoint and port
  3. Aurora / RDS clusterWriter endpoint
  4. ElastiCache replication groupPrimary endpoint, or configuration endpoint in cluster mode
  5. ElastiCache cache clusterRedis, Valkey or Memcached node endpoint
ssmx forward bastion-1 --to prod-db                 # RDS: finds host + port
ssmx fwd bastion-1 --to orders-aurora               # Aurora cluster
ssmx fwd bastion-1 --to sessions-redis              # ElastiCache
ssmx fwd bastion-1 --to prod-db --local 15432       # pick your own local port
ssmx fwd bastion-1 --to search.internal:9200        # explicit host:port

The local port defaults to the remote port. Then, in another terminal:

second terminal
$ psql -h localhost -p 5432 -U admin mydb
$ redis-cli -p 6379

Already running the same database locally? Use --local, e.g. --local 15432 next to a local Postgres on 5432. Otherwise you get Address already in use.

Check what a name resolves to

Useful before connecting, or in scripts.

ssmx resolve prod-db
ssmx resolve api-1 --ec2
ssmx resolve prod-db -o json
ssmx resolve
$ ssmx resolve prod-db
prod-db (rds:postgres) -> prod-db.abc123.eu-west-1.rds.amazonaws.com:5432
$ ssmx resolve api-1 --ec2
api-1 -> i-0abc1234def5678ab

Parameter Store

ssmx param get /myapp/prod/DB_PASSWORD                   # decrypts SecureString
export DB_PASS=$(ssmx param get /myapp/prod/DB_PASSWORD)

ssmx param list /myapp/prod/

ssmx param put /myapp/prod/API_URL https://api.example.com --type String
ssmx param put /myapp/prod/DB_PASSWORD - --overwrite < secret.txt   # '-' = read stdin
ssmx param delete /myapp/prod/OLD_KEY                    # asks for confirmation; -y to skip

param put defaults to SecureString. Prefer the - (stdin) form for secrets so the value does not end up in your shell history.

The detail

Profiles, regions and SSO

ssmx uses the standard AWS credential chain via boto3, and passes the same profile and region to the aws CLI when it opens a session. Global options (--profile, --region, --no-cache, --cache-ttl) go before the command.

ssmx --profile prod connect api-1
ssmx --region us-east-1 list
AWS_PROFILE=staging ssmx fwd bastion-1 --to staging-db

# IAM Identity Center / SSO
aws sso login --profile prod
ssmx --profile prod list

The lookup cache

Resolving names costs one or more AWS API calls, so ssmx caches successful lookups and repeat commands start instantly.

  • What is cached: Name tag → instance ID, and resource name → host/port. Never credentials, parameter values or command output.
  • Where: ~/.cache/ssmx/cache.json (respects XDG_CACHE_HOME), file mode 600.
  • Scope: per profile + region, so prod and dev never mix.
  • Lifetime: 5 minutes by default. ssmx list also warms the cache.
ssmx --no-cache connect api-1          # bypass once
ssmx --cache-ttl 3600 fwd b-1 --to db    # longer TTL for this call
export SSMX_CACHE_TTL=0                # disable entirely
ssmx cache clear                       # wipe it
ssmx cache path                        # show location

If an instance was replaced and a cached ID is stale, the session fails fast; run ssmx cache clear (or wait for the TTL) and retry.

IAM permissions

Same APIs, same permissions as the AWS CLI. Minimum actions for the caller, per command:

CommandActions
listec2:DescribeInstances, ssm:DescribeInstanceInformation
connectec2:DescribeInstances, ssm:StartSession, ssm:TerminateSession
runec2:DescribeInstances, ssm:SendCommand, ssm:GetCommandInvocation
forwardconnect permissions + rds:DescribeDBInstances, rds:DescribeDBClusters, elasticache:DescribeReplicationGroups, elasticache:DescribeCacheClusters
param getssm:GetParameter (+ kms:Decrypt for a SecureString with a customer key)
param putssm:PutParameter (+ kms:Encrypt)
param listssm:DescribeParameters
param deletessm:DeleteParameter

forward also needs ssm:StartSession on the document AWS-StartPortForwardingSessionToRemoteHost. A starting-point policy covering every command — tighten the Resource entries for production:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Discovery",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ssm:DescribeInstanceInformation",
        "rds:DescribeDBInstances",
        "rds:DescribeDBClusters",
        "elasticache:DescribeReplicationGroups",
        "elasticache:DescribeCacheClusters"
      ],
      "Resource": "*"
    },
    {
      "Sid": "Sessions",
      "Effect": "Allow",
      "Action": "ssm:StartSession",
      "Resource": [
        "arn:aws:ec2:*:*:instance/*",
        "arn:aws:ssm:*::document/AWS-StartPortForwardingSessionToRemoteHost",
        "arn:aws:ssm:*:*:document/SSM-SessionManagerRunShell"
      ]
    },
    {
      "Sid": "EndOwnSessions",
      "Effect": "Allow",
      "Action": ["ssm:TerminateSession", "ssm:ResumeSession"],
      "Resource": "arn:aws:ssm:*:*:session/${aws:userid}-*"
    },
    {
      "Sid": "RunCommand",
      "Effect": "Allow",
      "Action": "ssm:SendCommand",
      "Resource": [
        "arn:aws:ec2:*:*:instance/*",
        "arn:aws:ssm:*::document/AWS-RunShellScript"
      ]
    },
    {
      "Sid": "RunCommandOutput",
      "Effect": "Allow",
      "Action": "ssm:GetCommandInvocation",
      "Resource": "*"
    },
    {
      "Sid": "ParameterStore",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParameter",
        "ssm:PutParameter",
        "ssm:DeleteParameter",
        "ssm:DescribeParameters"
      ],
      "Resource": "*"
    }
  ]
}

Tag conditions and path-scoped Parameter Store examples are in docs/iam.md.

Troubleshooting

SymptomFix
no AWS region configuredPass --region, set AWS_REGION, or add region to your profile
Token expired / SSO errorsaws sso login --profile <name>
SessionManagerPlugin is not foundInstall the Session Manager plugin (see Prerequisites)
Instance not in ssmx listNot SSM-managed: check the SSM Agent and the instance profile
matches several running instancesTwo instances share a Name tag — use the instance ID
Tunnel opens but the DB times outThe DB’s security group does not allow the tunnel instance
Address already in useSomething already listens on that port — use --local <other port>
Connects to an old or terminated instancessmx cache clear

Source, issues and pull requests: github.com/originalpages/ssmx.