Skip to content

feat: opt-in per-IP limit on pending handshakes - #579

Open
AndreyOsipuk wants to merge 2 commits into
9seconds:masterfrom
AndreyOsipuk:feat/pending-handshake-per-ip-upstream
Open

AndreyOsipuk wants to merge 2 commits into
9seconds:masterfrom
AndreyOsipuk:feat/pending-handshake-per-ip-upstream

Conversation

@AndreyOsipuk

@AndreyOsipuk AndreyOsipuk commented Oct 6, 2026 •

Copy link
Copy Markdown

Why

A connection occupies a worker (concurrency) until it authenticates, goes to the fronting domain or hits the handshake timeout. A single address that opens hundreds of connections and never completes the handshake can keep the whole budget busy, and every other client gets concurrency limited. We hit exactly this in production on another MTProto proxy (telemt): one client opened ~1100 TCP connections and the proxy stopped serving everyone. mtg has the same exposure: there is no per-IP limit of any kind.

Limiting connections per IP at the firewall (connlimit) is the wrong tool: behind carrier-grade NAT many real users share one address, and Telegram opens a burst of connections when sending media. We tried it and it cut photo uploads for real users.

What

[defense.pending-handshakes]
max-per-ip = 32   # 0 (default) disables the limit
dry-run = false   # true: only count, never reject
  • Only unauthenticated handshakes are counted. The slot is released as soon as the secret is verified or the connection is fronted, so authenticated sessions and media bursts (which authenticate within milliseconds) are not affected.
  • Over the limit the connection is closed before reading anything.
  • dry-run admits everything and reports what would have been rejected, to pick a threshold from real traffic first.
  • Event EventPendingHandshakeLimit; Prometheus/StatsD metric pending_handshake_limit{action="rejected"|"observed"}.
  • Entries exist only while an address has pending handshakes, so memory is bounded by in-flight connections.

The same limit is proposed for telemt (telemt/telemt#950) and for mtg-multi (dolonet#47).

Tests

Unit tests for the limiter (disabled, reject and release, dry-run, independent addresses, concurrent acquire/release never exceeds the limit) and an end-to-end proxy test (stalled handshakes from one IP: the extra connection is closed, other IPs are served, the slot is freed when a handshake ends). go vet ./..., go test -race ./... and GOOS=windows go build ./... pass. Disabled by default, no behaviour change unless configured.

A connection holds a worker until it authenticates, goes to the fronting
domain or hits the handshake timeout. One address that opens hundreds of
connections and never completes the handshake can keep the whole
concurrency budget busy and starve everyone else.

[defense.pending-handshakes] max-per-ip limits concurrent unauthenticated
handshakes from one IP. The slot is released as soon as the secret is
verified or the connection is fronted, so authenticated sessions and
bursts of short media connections (authenticated within milliseconds) are
not counted. dry-run observes the limit without enforcing it. Disabled by
default. Event PendingHandshakeLimit, metric pending_handshake_limit{action}.
The allowlist loads asynchronously and rejects every connection until it
is ready, which on a slow CI runner looked like a rejection by the limit
(0 admitted). Also apply De Morgan's law as staticcheck suggests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant