tlsgate and sshgate: fingerprint-gating proxies for TLS and SSH

I run a few services on the public internet, and like everyone else who does, my logs are a constant wall of opportunistic scanners hammering the mail and SSH ports. None of it gets in (the real auth holds), but the noise makes it harder to spot anything that actually matters. So I wrote two small proxies to filter out the background junk before it reaches the backend. Both are open source under the MIT license.

tlsgate sits in front of a TLS service, reads the ClientHello, and computes a JA3 or JA4 fingerprint. sshgate does the same for SSH, reading the plaintext SSH_MSG_KEXINIT and computing a HASSH-style fingerprint. In both cases unknown fingerprints are blocked by default and only approved ones get forwarded.

The important caveat first

A TLS or SSH fingerprint is not a credential. Neither proxy terminates the protocol; they decide purely on the bytes of the handshake, and those bytes are entirely client-controlled. An attacker who crafts a handshake matching an approved fingerprint gets forwarded, and the fingerprints of common clients (Apple Mail, Thunderbird, OpenSSH, browsers) are publicly known.

So this is a noise filter, not access control. It cheaply turns away the constant background of scanners and credential-stuffing bots that don't bother matching a real client's fingerprint, which keeps logs and alerts readable. The actual security boundary is unchanged: real TLS/SSH termination plus the backend's own authentication. Don't relax backend hardening on the assumption that fingerprint gating gates access. It doesn't.

How they work

Each proxy listens on the public port and forwards to a backend you've moved to an internal-only port. Routes are generic and repeatable:

tlsgate serve \
  --route [::]:993=127.0.0.1:10993 \
  --route [::]:465=127.0.0.1:10465
sshgate serve \
  --route [::]:22=127.0.0.1:2222 \
  --db ./sshgate.db
The typical case for tlsgate is fronting a mail server on IMAPS (993) and SMTPS (465); for sshgate it's the SSH port. But the routing is generic, so neither is limited to that.

The handshake is parsed passively: no TLS termination, no man in the middle. tlsgate reassembles the ClientHello across TLS records, so large post-quantum hellos that span multiple records are handled, and any truncated or malformed handshake is rejected rather than recorded, so the store isn't polluted by partial parses.

Enrollment workflow

First time through, you let everything past so you can see what your real clients look like, then approve them and close the gate:

  1. Start with --allow-unknown so new fingerprints are recorded as pending but still forwarded.
  2. Connect from all your devices (phone, laptop, mail clients).
  3. Run list and approve each one, with a label.
  4. Remove --allow-unknown and redeploy. Now only approved fingerprints get through.
tlsgate list
tlsgate approve --label "Alice iPhone" <fingerprint>
You can also pre-approve a known fingerprint with --register before its first connection, so a known client is never blocked on cutover.

JA3 vs JA4 (tlsgate)

tlsgate computes both, and --fingerprint picks which one is the allow/block key.
  • JA3 is an MD5 over the TLS version, cipher list, extension list, curves, and EC point formats. It's order-sensitive, so a client that shuffles its extension order (GREASE, deliberate randomization) yields a different JA3 per connection, which can falsely block a legitimate client. Large public corpus, though.
  • JA4 sorts ciphers and extensions before hashing, so it's stable across reordering, which means fewer false blocks. It's also human-readable, e.g. t13d1516h2_8daaf6152771_b186095e22b6. This is what I'd recommend for a small self-seeded allow-list.
The choice only affects the false-positive rate, not how hard the fingerprint is to spoof (see the caveat above). Switching methods on an existing database refuses to start rather than silently orphaning every approval, so you re-approve after a reset.

Trusted source ranges

tlsgate also supports approve_ranges: CIDRs whose source IP bypasses the fingerprint gate entirely. The trust is per-connection and IP-scoped: a whitelisted connection never marks its fingerprint approved, so the same fingerprint arriving from a different IP is still gated normally. An attacker who clones a trusted client's JA3/JA4 gains nothing unless they also source from inside the range. Good for a management subnet; keep the ranges tight.

Alerts and flood limits

tlsgate can fire a Shoutrrr notification the first time a blocked connection arrives from a configured CIDR: Mattermost, Slack, Discord, Matrix, email, generic webhooks, and so on. Alerts are deduplicated in SQLite so repeated attempts from the same IP don't spam the channel. It refuses to start if a notification URL would deliver over cleartext, so webhook tokens are never sent in the clear.

Both proxies have two flood limits: a per-source-IP token bucket (~1 conn/s sustained, burst 120) dropped with a RATELIMIT line, and a global cap of 1024 concurrent connections dropped with OVERLOAD. Both are generous enough that legitimate clients, including many devices behind one NAT address, don't hit them. The fingerprint store can also be capped with max_fingerprints; oldest non-approved entries get pruned first, and approved fingerprints are never evicted.

Correlation

Both ship a correlate command that matches a fingerprint's known source IPs against your log lines (syslog/Postfix/Dovecot for tlsgate, auth.log/secure for sshgate) around the fingerprint's first/last seen timestamps. Handy for tying a mystery fingerprint back to an actual session.

tlsgate correlate <fingerprint>
sshgate correlate --log /var/log/auth.log <fingerprint>

Deploy

Both are written in Go and ship with Ansible playbooks that build the binary, create a dedicated system user, and manage a systemd service. tlsgate also publishes prebuilt multi-arch Docker images to GHCR and ships an example docker-compose.yml.

docker pull ghcr.io/kilo666mj/tlsgate:latest

A note on how these were built

Both projects were written with the help of Claude (via Claude Code). The code's been reviewed and tested, but the usual advice applies: read it before you run it, and read the security model in each README so you understand exactly what it does and does not protect.

Source and full docs: