Skip to main content

SSH Gateway

honey ssh-server runs an inbound SSH gateway: users connect with a native ssh client, authenticate with an SSH certificate, and honey proxies the session to a host from your inventory — recorded, policy-gated, and audited. It is honey's downstream SSH machinery (host dialing, PTY, recording, OPA policy, command-risk, audit) turned around into a front door, so the gateway adds only the inbound server, certificate auth, and resource routing.

Users never receive per-host keys; the gateway holds the connection to the target and the operator issues short-lived user certificates from honey's own SSH CA. Every session is subject to the same gates as the rest of honey.

Quick start

# 1. Create the gateway's SSH CA (once). Prints the CA public key.
honey ssh-ca init

# 2. Issue a short-lived cert for a user's public key.
honey ssh-ca sign --pubkey alice.pub --principal alice --ttl 1h
# -> writes alice-cert.pub

# 3. Run the gateway. It auto-trusts the CA created by `ssh-ca init`.
honey ssh-server --listen :12222

# 4. Connect with a native ssh client. The resource is an inventory host name.
ssh -t -i alice -i alice-cert.pub alice@gateway-host -p 12222 <resource> # interactive shell
ssh -i alice -i alice-cert.pub alice@gateway-host -p 12222 <resource> uptime # ad-hoc command
echo 'SELECT now()' | ssh -i alice -i alice-cert.pub alice@gateway-host -p 12222 <resource> psql # stdin pipe

<resource> is the first argument of the ssh command and names a host from the same inventory honey search uses (literal name; an IP is accepted too). With a trailing command the gateway runs it non-interactively; with no command and a PTY (-t) it opens an interactive shell.

The target login user is resolved from the record's ssh_user meta, then defaults.ssh_user, then the certificate principal — the principal is the authorization identity, not necessarily the account on the target.

Target types

<resource> may be any connectable record from the inventory; the gateway routes it through the same executor seam the web terminal uses:

TargetInteractive shell (-t)Ad-hoc execssh -L
SSH host
Docker container✓ (container exec)✓ (via the container)
Kubernetes pod✓ (ephemeral debug container)✓ (SPDY port-forward)
honey-mesh record✓ (forwarded to the owning node)
Proxmox serial / TrueNAS shell✓ (provider console)

k8s pods use an ephemeral debug container (needs k8s ≥1.25 + RBAC to create pods/ephemeralcontainers) since the pod's own image often has no shell. Proxmox and TrueNAS records are console-only: an interactive shell works, but exec and port-forward are rejected (a serial/shell console is neither a command channel nor a TCP endpoint). All target types are recorded, masked, guarded, and OPA-gated identically.

Certificate authentication

The gateway accepts only SSH certificates signed by a trusted CA (plain public keys are rejected). ssh.CertChecker verifies the signature, the user certificate type, the validity window, and that the ssh login name is a listed principal. The honey actor is then derived from the certificate:

  • --cert-attr principal (default) — the validated login principal.
  • --cert-attr key_id — the certificate's key id.

--user-attr is a label recorded alongside the actor for audit. It is deny-by-default: with no trusted CA configured (and without --no-auth) the gateway refuses to start.

Trust is configured in priority order: --trusted-ca <file> (repeatable) → ssh_gateway.trusted_ca in config → the built-in CA from honey ssh-ca init (auto-trusted from the state dir). --no-auth disables authentication entirely and is for local development only.

Issuing certificates

honey ssh-ca init # create + print the CA public key
honey ssh-ca print-ca # print it again (for --trusted-ca / config)
honey ssh-ca sign --pubkey user.pub --principal alice --principal ops \
--key-id alice --ttl 1h --out alice-cert.pub

Certificates carry the permit-pty and permit-port-forwarding extensions. Keep the TTL short and re-issue — that is the point of a CA over distributing keys.

Self-service enrollment

For hands-off issuance, an operator mints a one-time code and the user redeems it with their public key (no key distribution, no shared secret beyond the code). The endpoints live on honey web:

# Operator (authenticated): mint a one-time code.
honey ssh-ca enroll-code --principal alice --ttl 1h
# POST /api/v1/ssh/enroll-code -> {code, expires_in_seconds, ca}

# User: generate a key and redeem the code.
ssh-keygen -t ed25519 -f id_honey -N ''
curl -sS -X POST https://honey-web/api/v1/ssh/enroll \
-d "{\"code\":\"<code>\",\"public_key\":\"$(cat id_honey.pub)\"}"
# -> {cert, ca, principals, valid_before_unix} (save cert as id_honey-cert.pub)

ssh -i id_honey -i id_honey-cert.pub alice@gateway-host -p 12222 <resource>

The code is single-use, expires in 10 minutes, and the granted principals come from the operator (the redeemer cannot escalate). /api/v1/ssh/enroll-code is authenticated; /api/v1/ssh/enroll is authorized by the code itself.

Sharing one port with the web UI

honey web --ssh-mux serves this gateway on the same port as the web UI, so a single firewall rule covers both:

honey web --ssh-mux --listen 0.0.0.0:8765

curl http://<host>:8765/ # web UI
ssh alice@<host> -p 8765 <resource> uptime # gateway, same port

Each connection is routed by its first bytes: an SSH client opens with SSH-2.0-…, which no HTTP request can begin with, so the two protocols are told apart before either server sees the socket. Authentication is unchanged — the SSH half still requires a certificate from a trusted CA, and the gateway is built before the port is bound, so a missing CA fails the command outright rather than starting a web server with a broken SSH half.

Two limits come with it:

  • TCP passthrough only. Anything that terminates HTTP in front of the port (an ALB in HTTP mode, a CDN) will not pass SSH through.
  • The client must send its identification string first, which OpenSSH does.

Without the flag nothing changes: run honey ssh-server on its own port.

Port-forwarding

ssh -L reaches a service on a resolved host's loopback — e.g. a database bound to 127.0.0.1 on the target:

ssh -N -L 15432:<resource>:5432 alice@gateway-host -p 12222
# then connect a client to localhost:15432

The forward's destination host is the inventory resource; the gateway SSH-dials it and connects to 127.0.0.1:<port> on that host. Each forward is authorized by the OPA tunnel action (same input shape as the web tunnel gate) before any connection is made.

Recording & audit

Sessions are recorded to .hrec.jsonl under the record dir when ssh_gateway.record is set (or --record-dir / defaults.record_dir), with trigger=ssh-gateway, so they appear in honey recordings (list, play, grep, stats, export to asciinema — see Session recordings). Every decision (session open, command, exit, tunnel, denials) is written to the audit log with source=ssh-gateway, visible via honey audit tail / honey audit export.

Policy gates

The gateway reuses honey's shared gate, so one policy governs the web UI, MCP, the recipe engine, and the gateway:

  • interactive_session — opening an interactive shell ({actor, target}).
  • command_exec — an ad-hoc command ({actor, command, target}); the command-risk analysis is included as data (input.command.max_severity, signals) for the policy to act on, but never denies by itself.
  • tunnel — a ssh -L destination ({actor, target:{scheme,host,port}}).

Point the gateway at a policy directory with ssh_gateway.policy_dir (or the shared HONEY_POLICY_DIR). OPA is the gateway's only command-authorization gate: a nil policy allows unconditionally, and with no policy configured nothing here is ever denied, regardless of command risk.

Data masking

Redact secrets from a session's output — both the live stream and the recording — with literal values and/or regular expressions:

ssh_gateway:
mask:
values: ["s3cr3t-token"] # exact strings
patterns: ["AKIA[0-9A-Z]{16}"] # RE2 regexes

Matches are replaced with [MASKED]. The redactor is streaming and holds back only a tiny lookback so interactive output (prompts, recent lines) is not delayed, and it never flushes through a partial secret. Limits: masking is pattern/value based — it cannot redact a value it was not told about; a regex match longer than the lookback split across two reads, or a secret split at the buffer cap on an adversarial infinite stream, is best-effort.

Interactive guardrails

Opt-in per-command gating of an interactive shell. Each typed command line is run through the same OPA command_exec decision as an ad-hoc command:

ssh_gateway:
guardrail:
mode: enforce # off (default) | audit | enforce
  • off — no interception (zero overhead).
  • audit — the command runs; the verdict is recorded (interactive_command).
  • enforce — a command OPA denies is discarded before it runs (its Enter is replaced with a kill-line) and the client sees a policy notice.

This is a best-effort speed bump, not a security boundary. It reconstructs command lines from raw PTY bytes; a PTY does its own line editing (readline history, arrow/escape sequences, bracketed paste), so reconstruction can desync — and nothing stops a determined user from reaching a shell it never sees into: base64 ... | sh, opening vi and running :!sh, dropping into a Python REPL, or a multi-line here-doc all defeat line-level inspection. Treat enforce as guidance and an audit trail, never as the reason a dangerous command couldn't run.

Because it calls the same OPA command_exec decision as everything else, with no ssh_gateway.policy_dir (or HONEY_POLICY_DIR) configured, enforce blocks nothing at all — a nil policy allows unconditionally.

Bastion / ProxyJump

The gateway is a normal SSH server, so it sits behind an existing bastion with standard client config — no honey-specific setup:

# ~/.ssh/config
Host honey-gw
HostName gateway-host
Port 12222
ProxyJump bastion.example.com
IdentityFile ~/.ssh/alice
CertificateFile ~/.ssh/alice-cert.pub

Then ssh -t honey-gw <resource>. To restrict the bastion to only forward to the gateway, use PermitOpen gateway-host:12222 on the bastion.

Configuration reference

ssh_gateway:
listen: "0.0.0.0:12222" # default localhost:12222
host_key: "" # dir for the host key (default: state dir)
trusted_ca: # CA public key files (else the built-in ssh-ca)
- /etc/honey/ssh_ca.pub
user_attr: principal # audit label
cert_attr: principal # actor field: principal | key_id
record: true # write .hrec.jsonl recordings
policy_dir: /etc/honey/policy
mask:
values: []
patterns: []
guardrail:
mode: off # off | audit | enforce

Flags on honey ssh-server (--listen, --host-key, --trusted-ca, --user-attr, --cert-attr, --no-auth) override the config block.

Security model

  • Bounded action space — a user can only reach hosts in the inventory and can only do what the gates allow; the gateway holds the target credentials.
  • Deny-by-default — no trusted CA ⇒ the gateway will not start (except --no-auth for dev). Certificate signature, type, expiry, and principal are all enforced by ssh.CertChecker.
  • OPA is the only command-authorization gateinteractive_session / command_exec / tunnel are all decided by policy; command-risk severity is data the policy can act on, never a gate on its own. With no policy configured, every action allows.
  • Best-effort layers — output masking (pattern/value based) and the interactive guardrail (PTY line reconstruction, see above) are a speed bump and an audit trail with the documented limits above, never a substitute for policy.