TLS and reverse proxies
Plain HTTP only works for loopback development. Everything else terminates
TLS somewhere: either in ssoosshd itself, or in a reverse proxy in front of
it. This page covers both, and the two settings that fail silently until
somebody tries to log in.
Option A: ssoosshd terminates TLS
Section titled “Option A: ssoosshd terminates TLS”http: public_url: "https://ssh.example.com" address: "0.0.0.0" port: 443 tls: certificate_file: /etc/ssoossh/tls/server.crt private_key_file: /etc/ssoossh/tls/server.keyhttp.tls.certificate_file
and
http.tls.private_key_file
are paths, and both must be set – neither alone is a usable pair. PEM pasted
inline is not accepted, because it could not then be rotated without rewriting
the config file. With no pair configured the server serves plain HTTP, with
HTTP/2 cleartext (h2c) enabled.
Three more keys tune the profile, and all three fail startup on a value
crypto/tls rejects rather than logging and carrying on:
| Key | Default | Notes |
|---|---|---|
http.tls.min_version |
TLS1.3 |
TLS1.0/TLS1.1 resolve but log a deprecation warning |
http.tls.cipher_suites |
empty (Go’s defaults) | TLS 1.0-1.2 only; an explicit list must include TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 or TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, or net/http’s HTTP/2 setup refuses to serve |
http.tls.curves |
empty (Go’s defaults) | naming any curve replaces the defaults entirely rather than adding to them |
http.hsts ships as
max-age=31536000; includeSubDomains and is sent on every response, including
/healthz and /ping. Browsers ignore it over a connection they see as plain
HTTP, so it is harmless when a proxy in front is the one doing TLS – set it
empty if that proxy sets its own policy.
Renewing the certificate
Section titled “Renewing the certificate”ssoosshd re-reads both files on SIGHUP and serves the new certificate on
connections accepted after it. Nothing restarts and no connection drops.
# certbotcertbot renew --deploy-hook 'systemctl reload ssoosshd'The shipped unit has no ExecReload, so systemctl reload fails until you
add one in a drop-in:
[Service]ExecReload=/bin/kill -HUP $MAINPIDThen systemctl daemon-reload.
Where nothing can signal the process, poll instead:
http: tls: reload_interval: 1hhttp.tls.reload_interval
exists for the Kubernetes secret mount, which is replaced as a directory
symlink rather than written in place: no signal is available and a filesystem
watch on the paths would never fire. Zero or negative disables the timer,
leaving SIGHUP as the only trigger.
A reload that fails is logged at WARN and the previous certificate keeps
serving, so catching the files mid-rewrite costs nothing. The paths themselves
are fixed for the process’s lifetime; only the contents are re-read.
Option B: a reverse proxy terminates TLS
Section titled “Option B: a reverse proxy terminates TLS”The common case. Remove the tls block, listen on loopback, and tell
ssoosshd two things:
http: # What browsers reach. The https:// scheme is what marks the deployment # as HTTPS, even though this process listens on plain HTTP. public_url: "https://ssh.example.com" address: "127.0.0.1" port: 8080 # CIDRs of the proxy, trusted to set X-Forwarded-For / X-Forwarded-Proto. trusted_proxies: ["127.0.0.1/32"]Minimal nginx, proxying to ssoosshd on 127.0.0.1:8080:
server { listen 443 ssl; server_name ssh.example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}Any proxy works. The requirements, whichever you use:
- Forward the client address in
X-Forwarded-For, and the original scheme inX-Forwarded-Proto. - Pass the
Hostthrough, or set it topublic_url’s host. A request addressed to any other name is answered421 Misdirected Request; the health endpoints are exempt so probes can reach the server by IP. - Do not buffer the response on
GET /api/certs/requests/{id}/events. That is a realtext/event-stream, held open until the request resolves, and it is how a waiting client learns its certificate was signed. The hold can last up tocert_options.client_timeout(default 5m), so the proxy’s read timeout has to be at least that. In nginx that meansproxy_buffering off;and aproxy_read_timeoutpast the timeout; other proxies have their own spelling. A buffering proxy makes logins look like they hang and then fail, while the web UI shows the approval as done. - The deployment has no WebSocket endpoints. There is nothing to upgrade.
That address is not cosmetic. It is the source address recorded on the request, which means it is:
- the
{{.ClientIP}}field in key IDs, and therefore in the target host’ssshdauth log; - what
source policy matches against, so
with
trusted_proxiesunset every client appears to come from the proxy – very likely an internal range, so everyone silently lands in the most generous tier.ssoosshdwarns at startup when a source policy is configured andtrusted_proxiesis empty; - what
cert_options.console.allowed_networksgates on, which withouttrusted_proxieseither admits everything or nothing.
PROXY protocol
Section titled “PROXY protocol”http.trusted_proxies is
for an HTTP-level proxy that sets a header. A TCP-level proxy that prefixes
the connection with a PROXY protocol v1/v2 header is a different mechanism and
a different key:
http: proxy_protocol: ["10.0.0.0/24"]http.proxy_protocol is
empty by default, which disables PROXY protocol support entirely. Once it is
set, connections from any other source are rejected outright. It is mutually
exclusive with
http.unix_socket: PROXY
protocol is a TCP-connection concept and has nothing to prefix on a Unix
socket.
Listening on a Unix socket
Section titled “Listening on a Unix socket”http: unix_socket: /run/ssoossh/ssoosshd.sockaddress and port are ignored when this is set, and proxy_protocol must
be empty.
A Unix socket has no peer address, so the server trusts whoever connects to
it to name the client, whatever trusted_proxies says. It takes the
rightmost X-Forwarded-For entry, or X-Real-IP when that is absent. That
puts two requirements on the proxy and the socket:
- The proxy must append the address it saw, as the nginx example above does
with
$proxy_add_x_forwarded_for. A proxy that passes the client’s own header on untouched lets the client pick the address thatsource_policy, consoleallowed_networks, the rate limits and the audit log all use. One that sends neither header leaves every request with no client address. - Only the proxy may reach the socket.
ssoosshdcreates it with the process umask and never changes its mode, so restrict it through the directory that holds it: for example mode0750, with a group that onlyssoosshdand the proxy share. Any other local user who can connect can name an address of their own.
Load balancers
Section titled “Load balancers”Several ssoosshd serve api instances can sit behind haproxy, nginx, or a
cloud load balancer. No sticky sessions are required: approvals, certificate
delivery, and web sessions all cross instance boundaries. The full procedure,
including what NATS carries and what happens when it does not, is
Multi-instance and NATS.
Two things to carry over from above: the load balancer is the hop
trusted_proxies has to name, and it must not buffer the event stream.
The two silent failures
Section titled “The two silent failures”Both of these look like nothing until someone tries to log in.
| Symptom | Cause |
|---|---|
| the identity provider rejects the redirect URI | http.public_url is not what browsers actually reach |
| every audit record, key ID, and policy decision shows the proxy’s address | http.trusted_proxies does not name the proxy |
Worked configurations for both TLS options are on Server configuration examples.