Skip to content

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

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

AndreyOsipuk wants to merge 6 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.
@dolonet

dolonet commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

@AndreyOsipuk this change also went into dolonet/mtg-multi (#47), and review there produced follow-ups in dolonet#50 that apply here:

  • Log flood. Every rejected connection logs too many pending handshakes from this ip at Info. Under the flood this option is meant for, that is one line per connection. In the fork the line is rate-limited (dolonet/mtg-multi@6be4c6f).
  • Test needs the network and can hang. TestProxy's pending-handshake test waits for the allowlist in a loop with no deadline, and the closed connection is fronted to the real fronting host. In the fork the wait has a 5 s deadline and fronting goes to a local server, so the test runs offline (dolonet/mtg-multi@d427a18).
  • Scope in the docs. The option stops silent and slow connections only; IPv6 is counted per /128, so a client with a /64 is effectively not limited; clients behind CGNAT share the limit. The fork says so in proxy_opts.go and example.config.toml (dolonet/mtg-multi@98f6bd3), plus events tests (dolonet/mtg-multi@6d1ef30).

Could you port them, so that @9seconds reviews the version that was already reviewed?

The limit stops silent and slow connections, not every flood: a connection
that sends garbage goes to the fronting domain at once, releases its slot
and then holds a worker under the idle timeout. IPv6 is counted per /128,
so a client with a /64 is effectively not limited. Behind a shared address
(CGNAT) one abusive client can take all slots and block its neighbours for
up to the handshake timeout, so dry-run comes first.

Updated in example.config.toml and the ProxyOpts / limiter comments.
Add the new event to the noop stream/observer cases and an event stream
test that checks both observers get it with its stream ID, timestamp and
action.
- the retry loop after the pending connection is closed pauses 50ms between
  attempts, so it no longer can run out before the proxy notices the EOF
  and releases the slot;
- waiting for the allowlist has a 5s deadline instead of looping forever;
- the closed pending connection goes to a local fronting server
  (DomainFrontingHost/Port on 127.0.0.1) instead of the real fronting host,
  so the test needs no external network.

The setup moves to helpers to be reused by the next test. Passes 30 runs
with -race, and with the network disabled.
Each connection rejected by the per-IP pending-handshake limit wrote an
Info line, which is a lot under the very flood the limit is for. The line
is now written at most once per 10 seconds with the number of suppressed
ones. The EventPendingHandshakeLimit event is still sent for every
rejection: it feeds the pending_handshake_limit metric.

The throttle is a small internal package, internal/logthrottle: one line
per interval, the rest counted and reported with the next line.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.15044% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.41%. Comparing base (753d051) to head (3ebf640).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
mtglib/pending_handshakes.go 89.74% 2 Missing and 2 partials ⚠️
internal/cli/run_proxy.go 0.00% 2 Missing ⚠️
stats/statsd.go 0.00% 2 Missing ⚠️
events/noop.go 90.90% 1 Missing ⚠️
mtglib/proxy.go 94.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #579      +/-   ##
==========================================
+ Coverage   66.65%   67.41%   +0.76%     
==========================================
  Files         110      112       +2     
  Lines        4000     4103     +103     
==========================================
+ Hits         2666     2766     +100     
- Misses       1189     1192       +3     
  Partials      145      145              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@AndreyOsipuk

Copy link
Copy Markdown
Author

Ported the follow-ups from dolonet#50. The head is now 3ebf640.

  • Log flood (3ebf640, from dolonet/mtg-multi@6be4c6f). too many pending handshakes from this ip is written at most once per 10 s, with a suppressed count. EventPendingHandshakeLimit is still sent for every rejection, so the metric is exact. mtg has no log rate limiter, so this adds a small one, internal/logthrottle, with its own tests. Test: TestProxyPendingHandshakeRejectionLogIsRateLimited (6 rejections give 6 events and 1 log line).
  • Test needs the network and can hang (75a43b5, from dolonet/mtg-multi@d427a18). The allowlist wait has a 5 s deadline. Fronting goes to a local server on 127.0.0.1 through DomainFrontingHost/DomainFrontingPort. The retry after the slot is released pauses 50 ms between attempts. Checked with --network none: go test -race -count=10 -run TestProxyPendingHandshake ./mtglib passes. dolonet/mtg-multi@4c320ee was already here as 1a5c9a8.
  • Scope in the docs (7e1ed02, from dolonet/mtg-multi@98f6bd3). example.config.toml, ProxyOpts.PendingHandshakesPerIP and the limiter comment now say:
    • the limit stops only silent and slow connections;
    • IPv6 is counted per /128, so a client with a /64 is effectively not limited;
    • clients behind CGNAT share the limit, so use dry-run first.
  • Events tests (ac4beb8, from dolonet/mtg-multi@6d1ef30). EventPendingHandshakeLimit is in the noop stream and observer cases. There is also an event stream test for it.

Checks: go vet ./..., go test -race ./..., GOOS=windows go build ./..., GOOS=darwin go vet ./... and golangci-lint v2.12.2 (0 issues) all pass. gofmt passes on every file this PR touches except two: mtglib/proxy.go (import order) and internal/config/config.go (field alignment). Those are already unformatted on master, and I left them as they are. CI is green.

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.

3 participants