Commit:
e309257dd1138f3355cff3dbd2c5c08ef6162708Source updated: · Edit this page
Operations¶
Fingerprint management, alerting, Gatehub synchronization, storage limits, logs, and the enrollment workflow.
Managing fingerprints¶
# List all seen fingerprints
tlsgate list
# Include full passive TLS metadata, including the JA3 string
tlsgate list -v
# Correlate a fingerprint with Postfix/Dovecot/mailcow syslog lines
tlsgate correlate <fingerprint>
# Approve a fingerprint (optionally label it)
tlsgate approve --label "Alice iPhone" <fingerprint>
# Pre-approve a fingerprint before its first connection (seed the allow-list
# ahead of cutover so a known client is never blocked on first contact). The
# fingerprint must be a full hash matching the database's method (ja3 or ja4).
tlsgate approve --register --label "Alice iPhone" <fingerprint>
# Block a fingerprint (--register pre-blocks one not yet seen)
tlsgate block <fingerprint>
# Label an already-approved fingerprint
tlsgate label <fingerprint> "Alice MacBook"
# Delete a fingerprint entry
tlsgate delete <fingerprint>
# Purge all fingerprints (one-off, e.g. before switching ja3<->ja4).
# Pass --fingerprint to also record the new method so the next serve starts
# clean; omit it to wipe while keeping the current method.
tlsgate reset --fingerprint ja4
All commands accept --db <path> to point at a non-default database.
Default database: /var/lib/tlsgate/db.sqlite
When tlsgate is running with Docker Compose, run management commands inside the running service container so they use the same mounted database:
docker compose exec tlsgate /tlsgate list
docker compose exec tlsgate /tlsgate approve --label "Alice iPhone" <fingerprint>
correlate reads /var/log/syslog by default and matches the fingerprint's
known IPs around its first/last seen timestamps. Use --log <path> for another
log file and --window 5m to widen the matching window.
In a container, /var/log/syslog is not present unless you mount it explicitly
into the running service. With Compose, add a read-only log bind mount:
Then run:
Correlation is most useful with host networking because tlsgate sees real
client IPs. With bridge networking, Docker NAT may record the Docker gateway IP
instead. If tlsgate is not already running, a one-off docker run works too,
but it must mount the exact same database volume or host path used by the
service.
Labels are operator notes, not identities. JA3 and JA4 describe a client implementation and algorithm set; multiple devices can share a fingerprint and an attacker can copy one.
Runtime configuration and blocked range alerts¶
serve reads runtime and alert configuration from
/var/lib/tlsgate/config.json, or another path passed with
--config <path>. Routes, the database path, fingerprint method, enrollment
policy, PROXY protocol, fingerprint reset policy, drain timeout, and SMTP event
settings can all be kept there. Explicit command-line flags override their JSON
counterparts.
If alert_ranges are configured, a blocked connection from
a matching CIDR sends a Shoutrrr notification the first time each source IP is
seen for that range. Alerts are deduplicated in SQLite, so repeated blocked
attempts from the same IP/range do not spam the channel while its record is
retained. The newest 10,000 pairs are kept; evicted pairs can notify again.
Ansible always deploys this runtime config. Prefer the
router-advertised IPv6 delegated prefix over the narrower /64 shown on a
single host interface.
For Ansible-managed alert config, create a local ignored file at
ansible/group_vars/tlsgate.yml:
---
notification_urls:
- "mattermost://[email protected]/primary/logw"
- "mattermost://[email protected]/secondary/logw"
notification_mode: failover
# Cap stored fingerprints (0/omitted = 100000; -1 = unlimited).
# Approved entries are never evicted.
max_fingerprints: 100000
# Source CIDRs that bypass the fingerprint gate (always forwarded, never
# auto-approve a fingerprint). Keep tight.
approve_ranges:
- "198.51.100.0/24"
alert_ranges:
- name: home
cidrs:
- "198.51.100.10/32"
- "2001:db8:1234:5600::/59"
Do not commit this file; it may contain notification service secrets and private network ranges.
notification_urls are Shoutrrr service URLs, so the same alert path can send
to Mattermost, Slack, Discord, Gotify, Matrix, Teams, Telegram, generic
webhooks, email, and other supported services. tlsgate refuses to start if a
notification URL would deliver over cleartext (an +http scheme or a
disabletls override), so alert content and webhook tokens are never sent in
the clear.
notification_mode defaults to failover, which tries URLs in order and stops
after the first successful delivery. Set it to broadcast to send every alert
to every URL and treat any failed destination as a failed delivery.
{
"notification_urls": [
"mattermost://[email protected]/primary/logw",
"mattermost://[email protected]/secondary/logw"
],
"notification_mode": "failover",
"max_fingerprints": 100000,
"approve_ranges": ["198.51.100.0/24", "2001:db8:1234:5600::/59"],
"control_plane": {
"url": "https://gatehub.example.com",
"instance_id": "mail-tls",
"token": "replace-with-node-token",
"sync_interval": "30s"
},
"alert_ranges": [
{
"name": "home",
"cidrs": ["198.51.100.10/32", "2001:db8:1234:5600::/59"]
}
]
}
Configuration fields:
| Field | Required | Meaning |
|---|---|---|
notification_urls |
With alert ranges | Shoutrrr destinations; cleartext transports are rejected. |
notification_mode |
No | failover (default) or broadcast. |
max_fingerprints |
No | 0 or omitted uses 100000; -1 explicitly allows unlimited storage. |
approve_ranges |
No | Source CIDRs that bypass fingerprint gating for that connection. |
alert_ranges |
No | Named CIDR groups whose blocked connections trigger alerts. |
control_plane.url |
Enables sync | Gatehub base URL. |
control_plane.instance_id |
With URL | Stable name for this TLSGate instance. |
control_plane.token |
One auth method | Bearer token for Gatehub. |
control_plane.client_cert |
For mTLS | Client certificate path. |
control_plane.client_key |
For mTLS | Client private-key path. |
control_plane.ca |
For mTLS | CA bundle used to verify Gatehub. |
control_plane.server_name |
No | TLS name override when URL host and certificate differ. |
control_plane.sync_interval |
No | Go duration such as 30s; defaults to 30 seconds. |
Use doctor with the same flags as the service to validate configuration,
routes, fingerprint method, and PROXY mode without opening the SQLite store,
connecting to a backend, sending alerts, or binding a port:
tlsgate doctor \
--db /var/lib/tlsgate/db.sqlite \
--config /var/lib/tlsgate/config.json \
--route '[::]:993=127.0.0.1:10993' \
--fingerprint ja4 \
--proxy-protocol off
Gatehub sync¶
tlsgate can optionally sync observed fingerprints and pull approval decisions
from gatehub. Configure control_plane in the JSON config used by serve:
{
"control_plane": {
"url": "https://gatehub.example.com",
"instance_id": "mail-tls",
"token": "replace-with-node-token",
"sync_interval": "30s"
}
}
When control_plane.url is empty or omitted, sync is disabled and tlsgate
behaves exactly as before. The sync client periodically uploads the local
SQLite fingerprint state, then applies returned decisions locally with the same
store path used by approve --register. Set token for bearer-token auth, or
set client_cert, client_key, and ca for mTLS auth. The optional
server_name field overrides TLS server-name verification when the URL host
does not match the server certificate.
Current Gatehub policy responses may also publish expiring trusted_ranges.
TLSGate atomically replaces the dynamic portion of its source allowlist on each
sync while preserving local approve_ranges. If Gatehub omits the field,
TLSGate preserves its current dynamic set for compatibility with older servers.
Trusted source ranges (approve_ranges)¶
approve_ranges lists CIDRs whose source IP bypasses the fingerprint gate:
connections from those addresses are always forwarded, even if the client
presents an unknown, pending, or blocked fingerprint.
Trust is per-connection and IP-scoped only. A whitelisted connection never
marks its fingerprint approved, so the same fingerprint arriving from a
non-whitelisted 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.
New fingerprints from whitelisted IPs are still recorded as pending (not
blocked) so you keep visibility into what trusted hosts present; they appear
in tlsgate list for review. Whitelisted connections log with a WHITELIST
tag.
This is the safe posture. It deliberately does not auto-approve
fingerprints, because the store is keyed by fingerprint, not IP — approving a
fingerprint would extend trust to every IP that can replay it. Use
approve_ranges for a trusted management subnet or a known-good origin; keep
the ranges as tight as possible. Removing a CIDR revokes its bypass
immediately, with no residual approvals left behind.
Limiting store growth¶
Every parseable ClientHello from an unknown client is recorded, including blocked ones. The per-IP rate limit slows a single source, but many addresses (e.g. a wide IPv6 range) can still grow the SQLite database over time.
The store is capped at 100000 entries when max_fingerprints is omitted or
set to 0. Set a smaller positive value for a tighter cap or -1 to explicitly
allow unlimited fingerprint rows. When the store exceeds the cap, the oldest
non-approved entries are pruned first — in the transaction recording each
new fingerprint, at startup, and once a minute. The periodic pass also covers
rows inserted by control-plane decisions.
Approved fingerprints are never evicted, so the allow-list is unaffected;
if approved entries alone exceed the cap, the store is allowed to stay above it
rather than drop a real client. Pick a cap comfortably above your number of
real clients (which is small) so legitimate pending entries survive long enough
to be reviewed.
Gatekit v0.5.0 also limits each fingerprint to 128 source IPs, 128 ports, and
128 recent sightings, and rejects observation metadata larger than 64 KiB.
Opening an existing database trims excess history and clears oversized metadata;
verdicts, labels, and total observation counts are preserved. These per-entry
limits apply even with max_fingerprints: -1. Back up the database before an
upgrade if you need the complete historical data. These limits do not bound
blocked-range alert deduplication records. TLSGate separately retains at most
10,000 range/IP pairs, trimming existing excess rows on open and enforcing the
cap atomically on each successful alert record. The oldest inserted pairs are
evicted first and may alert again on a later blocked connection. This cap also
applies when fingerprint storage is unlimited; reset does not clear deduplication.
Control-plane sync uses pages of at most 16 fingerprints and rejects HTTP redirects. Configure its final HTTPS endpoint directly.
Troubleshooting¶
- Permission denied opening SQLite: the runtime identity needs write access to the database directory, not just the database file. Containers run as UID/GID 65532.
bind: permission denied: ports below 1024 requireCAP_NET_BIND_SERVICE; use a high port such as 1993 for initial testing.- Backend connection refused: verify the backend from TLSGate's network
namespace. Under bridge networking,
127.0.0.1is the container itself. - A client connects only during enrollment: inspect
tlsgate list -vand approve the intended fingerprint before removing--allow-unknown. - Fingerprint method mismatch: the database records JA3 or JA4. Switching requires an explicit reset and complete re-enrollment; do not reset casually.
- TLS fails immediately with PROXY v2 enabled: every backend listener must be configured to expect PROXY v2 before TLS bytes.
- The service is running but unreachable: check listeners and firewall,
then inspect
journalctl -u tlsgate -fordocker compose logs -f.
Logs¶
Log lines show status per connection:
PENDING fp="abc123..." ja3="771,4865-4866..."
APPROVED fp="abc123..."
BLOCKED fp="def456..."
RATELIMIT dropping connection
OVERLOAD at capacity, dropping connection
Three layered limits protect against floods. Their values can be set in the
JSON config with connection_rate_per_ip, connection_burst_per_ip,
max_concurrent_connections, and each route's optional max_concurrent:
- Per source IP — a token bucket (~1 conn/s sustained, burst 120) checked
before any handshake read or database write. A single IP over its budget is
dropped with a
RATELIMITline. This bounds connection floods and fingerprint-store growth from randomized ClientHellos from one address. It throttles the rate of new entries per IP, not the lifetime total, and an attacker spread across many IPv6 addresses can still stay under the per-IP ceiling. - Global — by default at most 1024 connections are processed at
once across all listeners, capping goroutines, file descriptors, and backend
dials. Connections beyond the cap are dropped with an
OVERLOADline. This catches the distributed/IPv6 case the per-IP limiter misses. The systemd unit setsLimitNOFILE=8192to leave headroom above the resulting socket count. - Per route —
max_concurrentreserves the remainder of the process-wide budget for other listeners when one service is flooded. Omit it (or leave it at zero in generated configuration) when only the global ceiling is wanted.
These defaults are generous enough that legitimate clients — including many devices behind one NAT address — do not hit them.
Prometheus metrics¶
Set metrics_listen to expose a Prometheus text endpoint at /metrics. Prefer
a loopback address such as 127.0.0.1:9192 when a host-local Prometheus can
scrape it; do not expose operational metrics on a public listener.
TLSGate exports active and total connections, rate-limit rejections, global
and per-route overload rejections, route capacity, and backend connection
failures. Route series carry only the configured listener and numeric port
labels, so their cardinality is fixed by the route configuration. Use
rate(tlsgate_connections_total[5m]) grouped by port for per-port traffic
rates and alert on sustained increases in tlsgate_rate_limited_total,
tlsgate_overload_total, or tlsgate_backend_connection_failures_total.
Fingerprint entries also store passive ClientHello metadata when available: SNI, ALPN protocols, supported TLS versions, signature algorithms, and the full JA3 string. This does not require terminating TLS.
The ClientHello is parsed strictly: the handshake is reassembled across TLS records (so large, e.g. post-quantum, hellos that span multiple records are handled), and any truncated or malformed handshake is rejected rather than recorded as a fingerprint, so the store is not polluted by partial parses.
Verbose TLS metadata may show values such as GREASE(0x6a6a). These are
reserved TLS placeholder values intentionally sent by modern clients to keep
servers tolerant of unknown TLS codes. They are not unknown protocol versions
or signature algorithms. JA3 excludes GREASE from fingerprint computation while
verbose metadata retains it. JA4 encodes non-alphanumeric ALPN endpoint bytes
using its canonical hexadecimal fallback, and logs quote fingerprint fields.
Setup workflow¶
- Set
allow_unknown: trueinansible/group_vars/tlsgate.ymland deploy - Connect from all your devices (phone, laptop, etc.)
- Run
tlsgate listand approve each one - Set
allow_unknown: falseand re-deploy
Upgrading fingerprint format and SMTP transport¶
This security update corrects JA3 GREASE handling and JA4 ALPN encoding. Existing
keys may change, so serving a populated database without the current format
marker fails with an explicit reset/re-enrollment instruction. No approvals or
blocks are automatically translated. approve --register and block --register
also reject mixing new keys into an older-format database. Listing and managing
existing entries remain available for review.
For this format transition, stop all serving processes sharing the database
before backing it up and resetting; do not use a SIGHUP handoff with an older
binary still writing old-format observations. Keep a copy of decisions for
manual review, and reconcile any old Gatehub decisions before resuming sync.
Reset with tlsgate reset --db PATH --fingerprint ja3 (or ja4), then start the
new binary using the same method and re-enroll/review clients. Alternatively,
serve --reset-fingerprints explicitly permits resetting an incompatible
format during startup. Remove that flag after the transition. Reset deletes
fingerprint decisions and history; restore the pre-upgrade backup if rolling
back the binary. Fresh databases adopt the new format without a reset.
Strict mode now denies pending entries from untrusted sources. Ending
--allow-unknown enrollment therefore requires approving the clients that
should retain access. A connection from a trusted source remains a per-connection
bypass and does not approve its fingerprint globally.
SMTP notification URLs must explicitly include encryption=ImplicitTLS,
using the server's implicit TLS listener (typically port 465), for example:
smtp://USER:[email protected]:465/[email protected]&[email protected]&encryption=ImplicitTLS
TLS certificates are verified. URLs using omitted/Auto encryption, None, or ExplicitTLS are rejected, including on port 465, to make the required transport unambiguous. Shoutrrr's ExplicitTLS mode does not require STARTTLS support and therefore cannot provide the required guarantee. A server offering only STARTTLS needs an implicit TLS endpoint or a different encrypted notification service before this update can load that configuration.