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:
| Target | Interactive shell (-t) | Ad-hoc exec | ssh -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— assh -Ldestination ({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-authfor dev). Certificate signature, type, expiry, and principal are all enforced byssh.CertChecker. - OPA is the only command-authorization gate —
interactive_session/command_exec/tunnelare 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.