Cardea is an SSH bastion server with access control, session recording, and optional TPM-backed key protection.
  • Go 99.1%
  • Makefile 0.5%
  • Dockerfile 0.4%
Find a file
Héctor Molinero Fernández d72016266f
Merge pull request #47 from hectorm/dependabot/github_actions/github-actions-all-f8345070bf
Bump the github-actions-all group across 1 directory with 2 updates
2026-04-03 13:02:14 +02:00
.github Bump the github-actions-all group across 1 directory with 2 updates 2026-04-03 10:54:05 +00:00
cmd Add health and metrics endpoint 2026-01-18 17:20:09 +01:00
internal Reduce allocations in authorized_keys parser and increase input limits 2026-03-29 17:09:49 +02:00
resources/logo Add color-scheme-adaptive logo variants 2026-03-04 20:14:03 +01:00
.dockerignore Replace .dockerignore with a symlink to .gitignore so that the binary is built with the correct VCS metadata 2025-10-09 22:23:14 +02:00
.gitattributes First commit 2025-10-08 00:38:59 +02:00
.gitignore First commit 2025-10-08 00:38:59 +02:00
compose.yaml Bump the docker-compose-all group across 1 directory with 2 updates 2026-04-03 10:53:49 +00:00
Dockerfile Bump golang from 96b2878 to ce3f1c8 in the docker-all group 2026-03-23 00:43:59 +00:00
go.mod Bump the gomod-minor-patch group with 2 updates 2026-03-17 11:44:41 +00:00
go.sum Bump the gomod-minor-patch group with 2 updates 2026-03-17 11:44:41 +00:00
LICENSE First commit 2025-10-08 00:38:59 +02:00
Makefile Suppress command echo in lint targets 2026-01-25 19:19:25 +01:00
README.md Use restrict instead of no-pty in SFTP example 2026-03-06 21:29:25 +01:00
SECURITY.md First commit 2025-10-08 00:38:59 +02:00

Cardea

Cardea is an SSH bastion server with access control, session recording, and optional TPM-backed key protection.

Scope

Cardea is designed for small and mid-sized teams that manage infrastructure through code. Access rules live in a text file, so they can be reviewed in pull requests and versioned like any other configuration. It can be used as-is or as a building block for a larger system that generates the configuration from an external source of truth. No database or web UI is required.

How it works

Clients connect using any standard SSH client, encoding the target backend in the SSH username (e.g., user@backend@bastion, see format). The bastion authenticates the client, verifies access rules, and connects to the backend using its own key. Sessions can optionally be recorded in asciinema v3 format.

sequenceDiagram
    participant C as Client
    participant B as Bastion (Cardea)
    participant S as Backend Server

    C->>B: SSH connect (user@backend@bastion)
    B->>B: Validate client public key
    B->>B: Check access rules (permitconnect)
    B->>S: SSH connect (bastion's key)
    S-->>B: Session established
    B-->>C: Session established
    C->>B: Commands / Data
    B->>S: Forward commands
    B->>B: Record session (optional)
    S-->>B: Response
    B-->>C: Forward response

Note

The bastion's public key must be added to the backend servers' authorized_keys. Use TPM mode to protect the bastion's private key from extraction. The from option can restrict backend access to the bastion's IP.

Client connection

To connect, clients specify the backend server they wish to access as part of the SSH username. The following formats are supported:

# Using @ and : as delimiters
ssh -p <bastion-port> <user>@<host>[:<port>]@<bastion-host>
ssh -p <bastion-port> -o User=<user>@<host>[:<port>] <bastion-host>

# Using + as delimiter (to avoid ambiguity with the @ used by SSH)
ssh -p <bastion-port> <user>+<host>[+<port>]@<bastion-host>
ssh -p <bastion-port> -o User=<user>+<host>[+<port>] <bastion-host>

Examples

ssh -p 2222 alice@10.0.1.1@cardea.internal
ssh -p 2222 -o User=alice@10.0.1.1 cardea.internal

ssh -p 2222 alice+10.0.1.1@cardea.internal
ssh -p 2222 -o User=alice+10.0.1.1 cardea.internal

# Using an SSH config file
cat >> ~/.ssh/config <<-'EOF'
Host backend
    HostName cardea.internal
    Port 2222
    User alice@10.0.1.1
EOF
ssh backend

# Using sftp
sftp -P 2222 alice+10.0.1.1@cardea.internal
sftp -P 2222 -o User=alice@10.0.1.1 cardea.internal

# Using rsync
rsync -ave 'ssh -p 2222' alice+10.0.1.1@cardea.internal:/remote/dir/ /local/dir/
rsync -ave 'ssh -p 2222 -o User=alice@10.0.1.1' cardea.internal:/remote/dir/ /local/dir/

Installation

Docker

docker run -p '2222:2222' -u "$(id -u):$(id -g)" --mount 'type=bind,src=./data/,dst=/data/' ghcr.io/hectorm/cardea:v1

Images available on GitHub Container Registry and Docker Hub.

Prebuilt binaries

Download from the releases page. Builds are reproducible, immutable, and include provenance attestation.

Configuration

Command-line options

-listen string
      address for the SSH server (env CARDEA_LISTEN) (default ":2222")
-health-listen string
      address for the health/metrics server; disabled if empty (env CARDEA_HEALTH_LISTEN) (default "localhost:9222")
-key-strategy string
      key strategy for bastion host/backend authentication: file, tpm (env CARDEA_KEY_STRATEGY) (default "file")
-private-key-file string
      path to the host private key (env CARDEA_PRIVATE_KEY_FILE) (default "/etc/cardea/private_key")
-private-key-passphrase string
      passphrase for the private key (env CARDEA_PRIVATE_KEY_PASSPHRASE)
-private-key-passphrase-file string
      path to the file containing the private key passphrase (env CARDEA_PRIVATE_KEY_PASSPHRASE_FILE)
-tpm-device string
      path to the TPM device (env CARDEA_TPM_DEVICE) (default "/dev/tpmrm0")
-tpm-parent-handle string
      persistent handle for the parent key (e.g. 0x81000001); if not set, a transient key is created (env CARDEA_TPM_PARENT_HANDLE)
-tpm-parent-auth string
      authorization value for the parent key (env CARDEA_TPM_PARENT_AUTH)
-tpm-parent-auth-file string
      path to the file containing the parent key authorization (env CARDEA_TPM_PARENT_AUTH_FILE)
-tpm-key-file string
      path to the key blob (env CARDEA_TPM_KEY_FILE) (default "/etc/cardea/tpm_key.blob")
-tpm-key-auth string
      authorization value for the key (env CARDEA_TPM_KEY_AUTH)
-tpm-key-auth-file string
      path to the file containing the key authorization (env CARDEA_TPM_KEY_AUTH_FILE)
-authorized-keys-file string
      path to the authorized keys file (env CARDEA_AUTHORIZED_KEYS_FILE) (default "/etc/cardea/authorized_keys")
-known-hosts-file string
      path to the known hosts file (env CARDEA_KNOWN_HOSTS_FILE) (default "/etc/cardea/known_hosts")
-unknown-hosts-policy string
      policy for unknown hosts: strict (deny unknown), tofu (trust on first use) (env CARDEA_UNKNOWN_HOSTS_POLICY) (default "strict")
-banner-file string
      path to the banner file; disabled if empty (env CARDEA_BANNER_FILE)
-connections-max int
      maximum number of concurrent connections; 0 for unlimited (env CARDEA_CONNECTIONS_MAX) (default 1000)
-rate-limit-max int
      maximum number of unauthenticated requests per IP address; 0 for unlimited (env CARDEA_RATE_LIMIT_MAX) (default 10)
-rate-limit-time duration
      time window for rate limiting unauthenticated requests (env CARDEA_RATE_LIMIT_TIME) (default 5m0s)
-recordings-dir string
      path to the session recordings directory; disabled if empty (env CARDEA_RECORDINGS_DIR)
-recordings-retention-time duration
      retention time for the session recordings (env CARDEA_RECORDINGS_RETENTION_TIME) (default 720h0m0s)
-recordings-max-disk-usage string
      maximum disk usage for session recordings; accepts percentage (e.g. 90%) or fixed size (e.g. 1GB) (env CARDEA_RECORDINGS_MAX_DISK_USAGE) (default "90%")
-log-level string
      log level: debug, info, warn, error, quiet (env CARDEA_LOG_LEVEL) (default "info")
-version
      show version and exit

Authorized keys format

Cardea uses a variation of the SSH authorized keys format to define access rules and options for each key.

permitconnect="user1@host1:port1,user2@host2:port2",permitopen="host1:port1,host2:port2",command="cmd",no-pty,no-port-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...

Required

  • permitconnect: comma-separated list of allowed backend server connections (can be specified multiple times).
    • Format: <user>@<host>[:<port>] or <user>+<host>[+<port>], where <user> is the backend server username.
    • Supports glob patterns (defined by the Go filepath.Match function) for users.
    • Supports glob patterns and CIDR blocks for hosts.
    • Supports glob patterns and ranges (e.g., 8000-8999) for ports.
    • If no port is specified, the default SSH port (22) is used.
    • If multiple permitconnect options for the same public key are present, the first match is used and the options specified in that match are applied.
    • Example: permitconnect="alice@*.internal,alice@10.0.0.0/16".

Optional

  • permitopen: comma-separated list of allowed local port forwarding destinations (can be specified multiple times).
    • Format: <host>:<port>.
    • Supports glob patterns and CIDR blocks for hosts.
    • Supports glob patterns and ranges (e.g., 8000-8999) for ports.
    • By default, only localhost traffic to any port is allowed.
    • Example: permitopen="localhost:1-65535,127.0.0.1/8:1-65535,[::1/128]:1-65535".
  • permitlisten: comma-separated list of allowed remote port forwarding bind addresses (can be specified multiple times).
    • Format: <host>:<port>.
    • Supports glob patterns and CIDR blocks for hosts.
    • Supports glob patterns and ranges (e.g., 8000-8999) for ports.
    • By default, remote port forwarding is disabled.
    • Example: permitlisten="localhost:8080,0.0.0.0:8000-8999".
  • permitsocketopen: allowed local Unix socket forwarding destination (can be specified multiple times).
    • Format: absolute or relative socket path.
    • Supports glob patterns. Use a bare * to allow any path.
    • Absolute requests only match absolute patterns, and relative requests only match relative patterns.
    • By default, local socket forwarding is disabled.
    • Example: permitsocketopen="/var/run/docker.sock",permitsocketopen="/tmp/*.sock".
  • permitsocketlisten: allowed remote Unix socket forwarding bind path (can be specified multiple times).
    • Format: absolute or relative socket path.
    • Supports glob patterns. Use a bare * to allow any path.
    • Absolute requests only match absolute patterns, and relative requests only match relative patterns.
    • By default, remote socket forwarding is disabled.
    • Example: permitsocketlisten="/tmp/agent.sock".
  • environment: controls environment variables on the backend session (can be specified multiple times). Three forms are supported:
    • NAME=value: sets a server-side variable. The client cannot override it. If specified multiple times for the same name, the last one wins.
    • +PATTERN: allows the client to forward variables matching the glob pattern.
    • -PATTERN: blocks the client from forwarding variables matching the glob pattern.
    • By default, all client environment variables are blocked. Rules are evaluated in order; the last matching + or - rule wins.
    • The backend SSH server must be configured to accept the variables (e.g., AcceptEnv NAME in OpenSSH's sshd_config).
    • Example: environment="LANG=en_US.UTF-8",environment="+TERM",environment="+LC_*",environment="-LC_MESSAGES".
  • from: comma-separated list of source IP patterns that are allowed to use this key (can be specified multiple times).
    • Supports glob patterns and CIDR blocks for hosts.
    • Supports negation with ! prefix (patterns are evaluated in order, negation overrides previous matches).
    • Example: from="10.0.0.0/8,!10.0.1.1".
  • start-time: timestamp before which the key is not yet valid.
    • Format: YYYYMMDD[HHMM[SS]][Z], where Z indicates UTC (local time is used if omitted).
    • If multiple start-time options are specified, the one furthest in the future is used.
    • Example: start-time="20060102150405Z".
  • expiry-time: timestamp after which the key is no longer valid.
    • Format: YYYYMMDD[HHMM[SS]][Z], where Z indicates UTC (local time is used if omitted).
    • If multiple expiry-time options are specified, the one closest to the present is used.
    • Example: expiry-time="20060102150405Z".
  • time-window: recurring time-based access control window (can be specified multiple times).
    • Format: <window>[,<window>...], where each window is a set of space-separated constraints (AND logic) and multiple windows are comma-separated (OR logic).
    • Constraint types: dow (day of week, 06 or sunsat), month (112 or jandec), day (131), hour (023), min (059), sec (059), tz (IANA timezone).
    • Constraints support single values (e.g., hour:8), inclusive ranges (e.g., hour:8-17), and multiple disjoint ranges with / (e.g., hour:8-13/15-17).
    • Omitted constraint types match any value (e.g., hour:8-17 matches hours 817 on any day).
    • Wrap-around ranges are not supported (e.g., dow:fri-mon is invalid; use dow:fri-sun/mon instead).
    • If tz: is omitted, system local time is used. Each window in a comma-separated list can have its own tz: value.
    • If multiple time-window options are specified, windows are accumulated (OR logic).
    • Example: time-window="dow:mon-fri hour:8-17 tz:Europe/Madrid".
    • Example: time-window="dow:mon-thu hour:8-17 tz:Europe/Madrid,dow:fri hour:8-14 tz:Europe/Madrid".
  • command: force execution of a specific command.
    • Example: command="nologin".
  • no-pty: disable pseudo-terminal allocation.
  • pty: enable pseudo-terminal allocation (overrides restrict or no-pty).
  • no-port-forwarding: disable both local and remote port forwarding.
  • port-forwarding: enable port forwarding (overrides restrict or no-port-forwarding).
  • no-socket-forwarding: disable both local and remote Unix socket forwarding.
  • socket-forwarding: enable socket forwarding (overrides restrict or no-socket-forwarding).
  • restrict: enable all restrictions (equivalent to no-pty,no-port-forwarding,no-socket-forwarding).
    • Use with pty, port-forwarding, or socket-forwarding to selectively re-enable features.
    • Example: restrict,pty,permitconnect="*@*:22" (allows PTY but no port or socket forwarding).
  • no-recording: disable session recording for this key.
  • recording: enable session recording (overrides no-recording).

Extensions

The format supports comments, directives, line continuation, and pipe expansion:

  • #: comment (at line start or end of line).
  • \: joins the next line (must immediately precede the newline).
  • |: allows multiple keys to share the same options (e.g., permitconnect="..." KEY1 | KEY2 | KEY3).

Directives:

  • #define NAME value: defines a macro ([A-Za-z_][A-Za-z0-9_]*) that is expanded everywhere, including inside quoted values.

Example:

# === Keys ===
#define ALICE_KEY ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... alice@example.com
#define BOB_KEY ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... bob@example.com
#define CAROL_KEY ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... carol@example.com
#define DAVE_KEY ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... dave@example.com
#define CI_KEY ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... deploy@ci

# === Teams ===
#define SRE_TEAM ALICE_KEY | BOB_KEY
#define DEV_TEAM CAROL_KEY | DAVE_KEY
#define ALL_TEAMS SRE_TEAM | DEV_TEAM

# === Servers ===
#define DEV_SERVERS *@dev.example.com:22
#define STAGING_SERVERS *@staging.example.com:22
#define PROD_SERVERS \
  # web server
  *@example.com:22, \
  # API server
  *@api.example.com:22

# === Server groups ===
#define NON_PROD_SERVERS DEV_SERVERS,STAGING_SERVERS
#define ALL_SERVERS NON_PROD_SERVERS,PROD_SERVERS

# === Option templates ===
#define WORKING_HOURS dow:mon-fri hour:8-17 tz:Europe/Madrid
#define SFTP_OPTS command="internal-sftp",restrict

# === Access rules ===

# SRE: full access to all servers
permitconnect="ALL_SERVERS",permitopen="*:*" SRE_TEAM

# Developers: non-production environments only
permitconnect="DEV_SERVERS" DEV_TEAM
permitconnect="STAGING_SERVERS",time-window="WORKING_HOURS" DEV_TEAM

# CI/CD: SFTP-only deploy to production
permitconnect="PROD_SERVERS",SFTP_OPTS CI_KEY

# Git: repository access for everyone
permitconnect="*@git.example.com:22" ALL_TEAMS | CI_KEY

Known hosts format

Cardea uses the standard OpenSSH known hosts format to verify backend server host keys when connecting from the bastion to backend servers.

[host]:port ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...

Unknown hosts policy

The --unknown-hosts-policy option controls how Cardea handles connections to backend servers whose host key is not present in the known hosts file.

  • strict (default): reject connections to unknown hosts. The connection fails if the backend server's host key is not in the known hosts file or does not match.
  • tofu (trust on first use): automatically add unknown host keys to the known hosts file on first connection. Subsequent connections are verified against the stored key. When a new host is trusted, a warning is logged with the fingerprint and public key.

Note

TOFU is convenient but vulnerable to man-in-the-middle attacks on first connection. Use strict mode in production environments where backend host keys can be pre-populated.

Certificate authority

Cardea supports @cert-authority entries for SSH certificate-based host verification, allowing backend servers to present certificates signed by a trusted CA instead of requiring individual host keys. Host patterns support wildcards (e.g., *, *.internal, *:22).

Example:

# Trust individual host keys
[10.0.1.1]:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...
[10.0.1.2]:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...

# Trust a CA for all backend servers on port 22
@cert-authority *:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...

# Trust a CA for a specific domain
@cert-authority *.internal:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...

Using certificate authorities simplifies host key management in environments with many backend servers, as only the CA public key needs to be distributed rather than individual host keys.

TPM mode

Note

TPM support is available on Linux and Windows.

Cardea supports storing its private key in a TPM 2.0 module (--key-strategy=tpm), preventing key extraction even if the server is compromised. This protects against key exfiltration via disk theft, backups, or accidental disclosure. The key blob is bound to the TPM that created it and cannot be used elsewhere.

Persistent SRK provisioning

By default, Cardea creates a transient SRK on each startup. For environments requiring a persistent SRK, provision it with tpm2-tools:

# Create primary key matching Cardea's SRK template (see internal/tpm/key.go)
tpm2_createprimary -C o -G ecc256:aes128cfb -g sha256 -c /tmp/srk.ctx -a 'fixedtpm|fixedparent|sensitivedataorigin|userwithauth|noda|restricted|decrypt' # -p file:<auth-file>

# Persist at handle 0x81000001
tpm2_evictcontrol -C o -c /tmp/srk.ctx 0x81000001

# Use with Cardea
cardea --key-strategy=tpm --tpm-parent-handle=0x81000001 # --tpm-parent-auth-file=<auth-file>

# To remove the persistent handle (if needed)
tpm2_evictcontrol -C o -c 0x81000001

License

Licensed under the European Union Public Licence v. 1.2 or later © Héctor Molinero Fernández. Review the license conditions before use or distribution.