Skip to content
Source: docs/operations.md
Commit: e309257dd1138f3355cff3dbd2c5c08ef6162708
Source 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:

volumes:
  - tlsgate-data:/var/lib/tlsgate
  - /var/log/syslog:/var/log/syslog:ro

Then run:

docker compose exec tlsgate /tlsgate correlate <fingerprint>

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.

{
  "approve_ranges": ["198.51.100.0/24", "2001:db8:1234:5600::/59"]
}
approve_ranges:
  - "198.51.100.0/24"
  - "2001:db8:1234:5600::/59"

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 require CAP_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.1 is the container itself.
  • A client connects only during enrollment: inspect tlsgate list -v and 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 -f or docker compose logs -f.

Logs

journalctl -u tlsgate -f

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 RATELIMIT line. 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 OVERLOAD line. This catches the distributed/IPv6 case the per-IP limiter misses. The systemd unit sets LimitNOFILE=8192 to leave headroom above the resulting socket count.
  • Per routemax_concurrent reserves 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

  1. Set allow_unknown: true in ansible/group_vars/tlsgate.yml and deploy
  2. Connect from all your devices (phone, laptop, etc.)
  3. Run tlsgate list and approve each one
  4. Set allow_unknown: false and 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.