diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4ae930f..1a51104 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,8 +81,9 @@ AES-CTR runs **8-wide** on the block-aligned bulk (`src/crypto/crypto.zig`). ## FakeTLS fronting & domain selection -The FakeTLS ServerHello is a fixed three-record shape — `ServerHello` (one x25519 -key_share, a client-echoed cipher) + `ChangeCipherSpec` + one `ApplicationData` +The FakeTLS ServerHello is a fixed three-record shape — `ServerHello` (one key_share: +an X25519MLKEM768 `0x11ec` share when the client offered one, else a classical x25519 +share, plus a client-echoed cipher) + `ChangeCipherSpec` + one `ApplicationData` "certificate" record — validated by the Telegram client only for framing + the HMAC in `server-random`. Two hard constraints follow: @@ -92,23 +93,32 @@ in `server-random`. Two hard constraints follow: Treat it as frozen the moment a link is shared. (See `config.zig` / [COMPATIBILITY.md](COMPATIBILITY.md).) -2. **We can only mimic a domain whose genuine TLS 1.3 is single-round x25519.** Our - FakeTLS emits exactly one ServerHello with an **x25519** key_share and cannot - replicate a `HelloRetryRequest` or a non-x25519 group. So a *good* fronting - domain negotiates **x25519 in one round** for a modern (Chrome-shaped, MLKEM- - offering) ClientHello — the client offers key_shares for both MLKEM and x25519, - and such a server simply picks x25519, which is exactly what we emit. Big RU - sites like `rutube.ru`, `ozon.ru`, `vk.com`, `yandex.ru`, `dzen.ru` do this. - - Some domains do **not**: e.g. `wb.ru` and `mail.ru` prefer `secp521r1` and send a - `HelloRetryRequest` (they even fail an x25519-only hello). For such a domain a - genuine handshake is `ClientHello → HRR → ClientHello#2 → ServerHello(secp521r1)` - while ours is a single `ServerHello(x25519)` — a **passive ServerHello mismatch we - cannot fix without changing `tls_domain`**, which constraint (1) forbids. The - installer therefore probes the chosen domain and **warns** if it is a poor mimicry - target, so the choice is made well *before* the link is locked. There is no - runtime fix for an already-distributed link pointed at a poor domain — it is an - accepted residual. +2. **We can only mimic a domain whose genuine TLS 1.3 is single-round, and since June + 2026 it must support X25519MLKEM768.** Our FakeTLS emits exactly one ServerHello, + with either an X25519MLKEM768 (`0x11ec`) key_share (when the client offered it) or a + classical x25519 key_share — never an HRR, never a group the client didn't offer. + Two things make a domain a *good* target: + + - **Single round, no HRR.** For a modern (Chrome-shaped, MLKEM-offering) ClientHello + the domain must answer in one ServerHello. `wb.ru` / `mail.ru` prefer `secp521r1` + and send a `HelloRetryRequest` (genuine handshake: + `ClientHello → HRR → ClientHello#2 → ServerHello(secp521r1)`) while ours is a + single record — an unfixable **passive ServerHello mismatch**. + - **Post-quantum capable.** Since the night of 4→5 June 2026 the TSPU flags a domain + that negotiates *only* classical x25519 (declining X25519MLKEM768): iOS clients — + and everyone on their NAT egress IP — fronting such a domain get blocked. The + signal is a property of the **domain** (the censor probes the SNI out-of-band), so + our 0x11ec echo can't buy it back; the domain itself must negotiate + X25519MLKEM768. A PQ-capable domain that does so in one round is exactly what our + FakeTLS mimics via the 0x11ec echo, so it satisfies both requirements at once. See + [THREAT_MODEL.md](THREAT_MODEL.md) "Post-quantum key_share". + + The installer probes the chosen domain (`src/ctl/fronting_domain.zig`, offering + `X25519MLKEM768:X25519`) and **warns** if it does only classical x25519 or an HRR, + so the choice is made well *before* the link is locked. There is no runtime fix for + an already-distributed link pointed at a poor domain — it is an accepted residual, + and the `tls_domain` immutability in (1) means an existing deploy on a now-marked + domain cannot migrate without invalidating every link. **Masking / active probes** are independent of the link: non-validating (no-secret) traffic is transparently relayed to the mask target. Field-verified behavior @@ -116,9 +126,12 @@ traffic is transparently relayed to the mask target. Field-verified behavior - Fronting the **real** domain (`mask_port=443` → `tls_domain:443`) makes a prober see the genuine site + a CA-chained cert — **verified working** for a single-round - domain (probing the proxy fronting `rutube.ru` returned its real GlobalSign cert). + domain (probing the proxy fronting `rutube.ru` returned its real GlobalSign cert; + note that domain-selection guidance has since tightened to require X25519MLKEM768 — + see constraint 2 — so re-probe any candidate for PQ support before committing). This requires the proxy to resolve the domain — see the DNS note below. -- It only works for domains whose TLS 1.3 is **single-round x25519**. Our relay +- It only works for domains whose TLS 1.3 is **single-round** (x25519 or the PQ + hybrid). Our relay carries a single ClientHello↔ServerHello exchange, **not** a `HelloRetryRequest` multi-round. Fronting an HRR domain (e.g. `wb.ru`) yields an *incomplete* handshake (no certificate) — worse than a complete one. This is the same reason such domains diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index bd332d7..16f9e31 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -109,6 +109,23 @@ encapsulation: FakeTLS clients validate only record framing + the HMAC in server-random, passive fingerprinting keys on the *group and size*, and we are not a TLS terminator — so a cryptographically valid ciphertext is unnecessary.) +**The `tls_domain` must itself support X25519MLKEM768 (June-2026 TSPU marker).** The +echo above fixes the *connection-level* downgrade tell, but there is a second, +domain-level check the TSPU rolled out the night of 4→5 June 2026 that the echo does +**not** address. The censor appears to probe the *SNI domain* out-of-band for +post-quantum support and blocks the flow when the domain lacks it: a `tls_domain` +whose real TLS 1.3 negotiates only classical x25519 (no X25519MLKEM768) is a passive +marker, and iOS clients — plus **everyone sharing their NAT egress IP** — fronting +such a domain get dropped. Because the signal is a property of the domain (evidence: +the community's fix is "change the domain", `@Sni_checker_bot` checks a *domain*, a +self-signed backend must run OpenSSL 3.5+ to offer the group, and one domain's two IPs +can score differently), our own ServerHello can't buy it back. The only lever is +choosing a `tls_domain` that genuinely negotiates X25519MLKEM768 in a single round — +which our FakeTLS then mimics correctly via the 0x11ec echo. `mtbuddy install` / +`setup masking` now probe for this and warn. This collides with `tls_domain` +immutability: a live deploy already pinned to a classical-x25519 domain cannot migrate +without invalidating every distributed link. (See `src/ctl/fronting_domain.zig`.) + It does **not** defend against a third strategy that DPI vendors are now leaning on: **SNI ↔ destination-IP consistency.** When `tls_domain` is a well-known third-party domain that does not resolve to your proxy's IP, a DPI box can flag the flow *passively*, @@ -130,18 +147,21 @@ strongest to most practical: 2. **Prefer a plausible, less-prominent borrowed domain** over a globally famous one. A massively popular CDN-hosted domain is the easiest IP↔SNI mismatch to catch; a regional / less-watched HTTPS site is a weaker signal. It must still pass the - single-round-x25519 ServerHello check below. + single-round X25519MLKEM768 ServerHello check below. 3. **IP and egress hygiene.** Borrowed-SNI FakeTLS is worth most when the *IP itself* isn't already burned: prefer a fresh address, and use IPv6 rotation (`mtbuddy ipv6-hop`) and/or tunnel egress so the visible endpoint isn't a long-lived, reputation-flagged IP. -**Separate but related — ServerHello suitability.** The synthetic ServerHello does a -single-round x25519 key exchange with no HelloRetryRequest. A `tls_domain` whose real -TLS 1.3 server prefers a non-x25519 group or issues an HRR (e.g. `wb.ru`, `mail.ru` -select secp521r1) produces a *passive ServerHello mismatch* independent of the IP↔SNI -issue. `mtbuddy install` / `setup masking` warn about this; prefer a domain that -negotiates x25519 in a single round. +**Separate but related — ServerHello suitability.** The synthetic ServerHello is a +single-round exchange with no HelloRetryRequest, emitting either an X25519MLKEM768 +(0x11ec) or a classical x25519 key_share depending on what the client offered. A +`tls_domain` whose real TLS 1.3 server issues an HRR or prefers a group we can't emit +(e.g. `wb.ru`, `mail.ru` select secp521r1) produces a *passive ServerHello mismatch* +independent of the IP↔SNI issue. Combined with the June-2026 domain marker above, the +target to prefer is a domain that negotiates **X25519MLKEM768 in a single round**; +`mtbuddy install` / `setup masking` warn when a domain does only classical x25519 or +does an HRR. Bottom line: FakeTLS with a borrowed SNI is strong camouflage against handshake fingerprinting and active probing, but it is **not** a guarantee against IP↔SNI diff --git a/config.toml.example b/config.toml.example index f157899..a53a11d 100644 --- a/config.toml.example +++ b/config.toml.example @@ -210,9 +210,12 @@ port = 443 [censorship] # Domain to impersonate in TLS ServerHello and forward unauthenticated clients to. -# Choose a popular, high-traffic domain that negotiates single-round x25519 (most big -# sites: rutube.ru, ozon.ru, vk.com, yandex.ru). Avoid HRR/secp521r1 domains like -# wb.ru — our single-round FakeTLS can't match them. ⚠️ Immutable once links ship. +# Since June 2026 pick a popular domain that negotiates single-round X25519MLKEM768 +# (post-quantum) — a classical-x25519-only domain now marks iOS clients (and everyone +# on their NAT IP) for blocking. Verify a candidate + the exact IP you'll use with: +# openssl s_client -groups X25519MLKEM768 -connect :443 (needs OpenSSL 3.5+) +# or @Sni_checker_bot. Avoid HRR/secp521r1 domains (e.g. wb.ru) — our single-round +# FakeTLS can't match them either way. ⚠️ Immutable once links ship — choose carefully. tls_domain = "rutube.ru" # Forward unauthenticated / invalid connections to tls_domain (active probing defense). diff --git a/src/config.zig b/src/config.zig index d00ba3b..d8dd9d7 100644 --- a/src/config.zig +++ b/src/config.zig @@ -281,13 +281,25 @@ pub const Config = struct { /// as hex, so the tg:// link is a function of (secret, tls_domain). Changing /// tls_domain changes EVERY user's link — never do it on a live deployment. /// - /// Mimicry note: our 3-record FakeTLS emits ONE ServerHello with an x25519 - /// key_share and cannot replicate a HelloRetryRequest. Pick a domain whose - /// genuine TLS 1.3 negotiates **x25519 in a single round** (most big sites: - /// rutube.ru, ozon.ru, vk.com, yandex.ru, dzen.ru). Domains that prefer - /// secp521r1 / reject x25519 and HRR (e.g. wb.ru, mail.ru) produce a passive - /// ServerHello mismatch that cannot be fixed without changing tls_domain — - /// which the immutability rule above forbids. So choose well at install time. + /// Mimicry note: our 3-record FakeTLS emits ONE ServerHello, single-round, no + /// HelloRetryRequest. It echoes an X25519MLKEM768 (0x11ec) key_share when the + /// client offered one and a classical x25519 (0x001d) key_share otherwise — so it + /// can mimic a domain that negotiates **either group in a single round**. + /// + /// Since the June-2026 TSPU rollout, prefer a domain that negotiates + /// **X25519MLKEM768** (post-quantum). A domain that only does classical x25519 is + /// now a passive marker: iOS clients — and everyone sharing their NAT egress IP — + /// fronting such a domain get blocked (the TSPU appears to probe the SNI domain + /// out-of-band for PQ support, so this is a property of the *domain*, not of the + /// ServerHello we emit). Verify a candidate with: + /// openssl s_client -groups X25519MLKEM768 -connect :443 (OpenSSL 3.5+) + /// or the @Sni_checker_bot. Note one domain can have multiple IPs with mixed PQ + /// support (e.g. rutube.ru), giving inconsistent results — check the IP you'll use. + /// + /// Domains that prefer secp521r1 / reject x25519 and HRR (e.g. wb.ru, mail.ru) stay + /// unmimicable regardless (our single-round ServerHello can't match an HRR). None + /// of this is fixable without changing tls_domain — which the immutability rule + /// above forbids — so choose well at install time. /// See ARCHITECTURE.md "FakeTLS fronting & domain selection". tls_domain: []const u8 = default_tls_domain, users: std.StringHashMap([16]u8), diff --git a/src/ctl/fronting_domain.zig b/src/ctl/fronting_domain.zig index 11f5523..d72f430 100644 --- a/src/ctl/fronting_domain.zig +++ b/src/ctl/fronting_domain.zig @@ -5,7 +5,15 @@ const sys = @import("sys.zig"); const Tui = tui_mod.Tui; pub const FrontingVerdict = enum { + /// Negotiates X25519MLKEM768 (post-quantum hybrid, group 0x11ec) in a single + /// round — the ideal target. Our FakeTLS mimics it via the 0x11ec key_share echo. + pq_capable, + /// Reachable and does single-round x25519, but declines the X25519MLKEM768 offer. + /// Since the June-2026 TSPU rollout a classical-x25519-only domain is a passive + /// marker that blocks iOS clients (see THREAT_MODEL.md). single_round_x25519, + /// Reachable but does an HRR / prefers a non-x25519 group (e.g. wb.ru, mail.ru → + /// secp521r1). Our single-round ServerHello can't match it at all. reachable_without_x25519, not_reached, }; @@ -17,55 +25,90 @@ pub const FrontingCheckResult = enum { mismatch, }; +/// Classify an `openssl s_client` transcript. Assumes the hello offered +/// `X25519MLKEM768:X25519`, so a printed "Server Temp Key" is one of those two +/// groups: X25519MLKEM768 → post-quantum (good), otherwise x25519 (the iOS marker). +/// A domain that HRRs / rejects both never prints a temp key. pub fn classifyOpenSslOutput(output: []const u8) FrontingVerdict { - if (std.mem.indexOf(u8, output, "Server Temp Key") != null) return .single_round_x25519; + if (std.mem.indexOf(u8, output, "Server Temp Key") != null) { + // OpenSSL 3.5+ prints the hybrid group as "X25519MLKEM768". + if (std.mem.indexOf(u8, output, "MLKEM") != null) return .pq_capable; + return .single_round_x25519; + } if (std.mem.indexOf(u8, output, "CONNECTED") != null) return .reachable_without_x25519; return .not_reached; } -/// Best-effort warning if `domain` is a poor FakeTLS fronting target. Our 3-record -/// ServerHello (single x25519 key_share, no HelloRetryRequest) cannot mimic a domain -/// whose genuine TLS 1.3 prefers a non-x25519 group / does an HRR — e.g. wb.ru and -/// mail.ru pick secp521r1 and reject an x25519-only hello — producing a passive -/// ServerHello mismatch. +/// Best-effort warning if `domain` is a poor FakeTLS fronting target. Two things can +/// make it poor: +/// 1. It negotiates only classical x25519, not X25519MLKEM768. Since June 2026 the +/// TSPU flags this as a passive marker and blocks iOS clients (and everyone on +/// their NAT IP). A genuinely post-quantum domain (0x11ec) is the safe target, +/// and our 3-record FakeTLS mimics it via the 0x11ec ServerHello key_share echo. +/// 2. It does a HelloRetryRequest / prefers a non-x25519 group (e.g. wb.ru, mail.ru +/// pick secp521r1) — our single ServerHello can't replicate that at all. pub fn warnIfPoorFrontingDomain(ui: *Tui, allocator: std.mem.Allocator, domain: []const u8) FrontingCheckResult { if (!isSafeFrontingDomain(domain)) return .skipped; if (!sys.commandExists("openssl")) return .skipped; ui.step("Checking fronting-domain TLS suitability..."); + + // Probe as a modern client would: offer the post-quantum hybrid first, then plain + // x25519. A PQ-capable server answers X25519MLKEM768; an x25519-only server still + // connects and answers x25519 (the marked case). stderr is merged since OpenSSL + // splits it differently across versions. Groups MUST be uppercase (OpenSSL 1.1.1 + // rejects lowercase). + const verdict = runOpensslProbe(allocator, domain, "X25519MLKEM768:X25519") orelse .not_reached; + if (verdict != .not_reached) return warnFromVerdict(ui, domain, verdict); + + // The modern probe didn't connect. Distinguish "domain unreachable" from "local + // OpenSSL predates 3.5 / the domain rejected the hybrid offer" with a legacy + // x25519-only probe — the same check we shipped before PQ existed. + const legacy = runOpensslProbe(allocator, domain, "X25519") orelse .not_reached; + if (legacy == .not_reached) return warnFromVerdict(ui, domain, .not_reached); + + var b: [360]u8 = undefined; + if (std.fmt.bufPrint(&b, "Couldn't test X25519MLKEM768 for '{s}' (the domain rejected the hybrid offer, or this host's OpenSSL predates 3.5). It does single-round x25519 — since June 2026 that alone can mark iOS. Verify PQ support with @Sni_checker_bot.", .{domain}) catch null) |m| ui.info(m); + return .not_reached; +} + +/// Run one `openssl s_client` probe with the given `-groups` list. Returns null only +/// if the command couldn't be launched. +fn runOpensslProbe(allocator: std.mem.Allocator, domain: []const u8, groups: []const u8) ?FrontingVerdict { var cmd_buf: [512]u8 = undefined; - // Offer ONLY X25519 in a TLS 1.3 hello and capture the output (stderr merged, - // since OpenSSL splits these differently across versions). A domain that - // negotiates it in a single round prints "Server Temp Key: X25519..."; one - // that does an HRR / rejects x25519 still prints "CONNECTED" but no temp key. - // NOTE: the group MUST be uppercase "X25519" — OpenSSL 1.1.1 rejects lowercase. const cmd = std.fmt.bufPrint( &cmd_buf, - "echo | timeout 10 openssl s_client -connect {s}:443 -servername {s} -groups X25519 -tls1_3 2>&1", - .{ domain, domain }, - ) catch return .skipped; - const r = sys.exec(allocator, &.{ "bash", "-c", cmd }) catch return .skipped; + "echo | timeout 10 openssl s_client -connect {s}:443 -servername {s} -groups {s} -tls1_3 2>&1", + .{ domain, domain, groups }, + ) catch return null; + const r = sys.exec(allocator, &.{ "bash", "-c", cmd }) catch return null; defer r.deinit(); - - return warnFromVerdict(ui, domain, classifyOpenSslOutput(r.stdout)); + return classifyOpenSslOutput(r.stdout); } fn warnFromVerdict(ui: *Tui, domain: []const u8, verdict: FrontingVerdict) FrontingCheckResult { switch (verdict) { - .single_round_x25519 => return .ok, + .pq_capable => { + var b: [256]u8 = undefined; + if (std.fmt.bufPrint(&b, "'{s}' negotiates X25519MLKEM768 (post-quantum) — a good fronting target, no iOS marker.", .{domain}) catch null) |m| ui.ok(m); + return .ok; + }, .not_reached => { var b: [320]u8 = undefined; if (std.fmt.bufPrint(&b, "Couldn't reach '{s}:443' from here to verify its TLS — skipping (connectivity, not a bad domain).", .{domain}) catch null) |m| ui.info(m); return .not_reached; }, + .single_round_x25519 => { + ui.warn("This fronting domain negotiates only classical x25519, not X25519MLKEM768."); + var msg_buf: [360]u8 = undefined; + if (std.fmt.bufPrint(&msg_buf, " Since the June-2026 TSPU rollout a non-PQ domain is a passive marker: iOS clients (and everyone sharing their NAT IP) fronting '{s}' get blocked.", .{domain}) catch null) |m| ui.warn(m); + ui.hint(" Prefer a domain that negotiates X25519MLKEM768 in one round. Verify: openssl s_client -groups X25519MLKEM768 -connect :443 (OpenSSL 3.5+), or @Sni_checker_bot. tls_domain is IMMUTABLE once links ship — choose now."); + return .mismatch; + }, .reachable_without_x25519 => { - var ex_buf: [128]u8 = undefined; - const examples = frontingExamples(domain, &ex_buf); - ui.warn("This fronting domain doesn't negotiate single-round x25519."); - var msg_buf: [320]u8 = undefined; - if (std.fmt.bufPrint(&msg_buf, " '{s}' does a HelloRetryRequest or rejects x25519 (like wb.ru) — our FakeTLS ServerHello can't match it, so a passive observer sees a mismatch.", .{domain}) catch null) |m| ui.warn(m); - var hint_buf: [320]u8 = undefined; - if (std.fmt.bufPrint(&hint_buf, " Prefer a single-round-x25519 domain (e.g. {s}). tls_domain is IMMUTABLE once links are shared — choose now.", .{examples}) catch null) |m| ui.hint(m); + var msg_buf: [360]u8 = undefined; + if (std.fmt.bufPrint(&msg_buf, " '{s}' does a HelloRetryRequest or prefers a non-x25519 group (like wb.ru/mail.ru → secp521r1) — our single-round FakeTLS ServerHello can't match it, so a passive observer sees a mismatch.", .{domain}) catch null) |m| ui.warn(m); + ui.hint(" Prefer a domain that negotiates X25519MLKEM768 (or plain x25519) in one round. tls_domain is IMMUTABLE once links are shared — choose now."); return .mismatch; }, } @@ -81,20 +124,15 @@ fn isSafeFrontingDomain(domain: []const u8) bool { return true; } -/// Comma-separated recommended single-round-x25519 fronting domains, excluding -/// `current` so we never suggest the very domain that just failed the check. -fn frontingExamples(current: []const u8, buf: []u8) []const u8 { - const candidates = [_][]const u8{ "rutube.ru", "ozon.ru", "vk.com", "yandex.ru" }; - var w: usize = 0; - for (candidates) |c| { - if (std.ascii.eqlIgnoreCase(c, current)) continue; - const piece = std.fmt.bufPrint(buf[w..], "{s}{s}", .{ if (w == 0) "" else ", ", c }) catch break; - w += piece.len; - } - return buf[0..w]; +test "classifyOpenSslOutput detects post-quantum X25519MLKEM768" { + const out = + \\CONNECTED(00000003) + \\Server Temp Key: X25519MLKEM768, 253 bits + ; + try std.testing.expectEqual(FrontingVerdict.pq_capable, classifyOpenSslOutput(out)); } -test "classifyOpenSslOutput detects single-round x25519" { +test "classifyOpenSslOutput flags a classical-x25519-only domain" { const out = \\CONNECTED(00000003) \\Server Temp Key: X25519, 253 bits @@ -114,10 +152,3 @@ test "classifyOpenSslOutput detects domain that was not reached" { const out = "connect:errno=110\n"; try std.testing.expectEqual(FrontingVerdict.not_reached, classifyOpenSslOutput(out)); } - -test "frontingExamples excludes the current domain" { - var buf: [128]u8 = undefined; - try std.testing.expectEqualStrings("ozon.ru, vk.com, yandex.ru", frontingExamples("rutube.ru", &buf)); - try std.testing.expectEqualStrings("ozon.ru, vk.com, yandex.ru", frontingExamples("RuTube.RU", &buf)); - try std.testing.expectEqualStrings("rutube.ru, ozon.ru, vk.com, yandex.ru", frontingExamples("example.com", &buf)); -} diff --git a/src/ctl/synlimit.zig b/src/ctl/synlimit.zig index 288adfe..58c8952 100644 --- a/src/ctl/synlimit.zig +++ b/src/ctl/synlimit.zig @@ -12,6 +12,14 @@ //! //! Default OFF and gated behind a loud CGNAT/VPN warning: like the in-proxy guards, //! a per-IP SYN limiter false-positives when many real users share one egress IP. +//! +//! Over-limit action is DROP by default (correct for pure flood defense: don't waste +//! a reply packet per abusive SYN, don't confirm the port to a scanner). `--reject` +//! switches it to REJECT --reject-with tcp-reset, which is what you want when the +//! limiter is used to survive the June-2026 TSPU parallel-connect block rather than a +//! flood: a silent DROP makes a throttled client wait a full TCP retransmit (~20s to +//! connect), whereas an RST lets it retry immediately (~5s). See +//! blocking-2026-06 notes / THREAT_MODEL.md. const std = @import("std"); const tui_mod = @import("tui.zig"); @@ -58,8 +66,17 @@ pub const SynLimitOpts = struct { action: Action = .status, rate: []const u8 = "2/second", burst: []const u8 = "5", + /// Over-limit action: false = DROP (flood defense), true = REJECT --reject-with + /// tcp-reset (faster client reconnect when limiting for TSPU-evasion). + reject: bool = false, }; +/// The iptables `-j` target for over-limit SYNs. A fixed internal string (never user +/// input), baked verbatim into the generated script. +fn overLimitTarget(reject: bool) []const u8 { + return if (reject) "REJECT --reject-with tcp-reset" else "DROP"; +} + /// Run in CLI mode. pub fn run(ui: *Tui, allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void { // Default action is .status; any apply/remove flag below overrides it. @@ -94,6 +111,12 @@ pub fn run(ui: *Tui, allocator: std.mem.Allocator, args: *std.process.Args.Itera } opts.burst = val; opts.action = .apply; + } else if (std.mem.eql(u8, arg, "--reject")) { + // Over-limit SYNs get an RST instead of a silent drop (faster reconnect + // for TSPU-evasion). Combine with --preset/--rate/--burst to apply. + opts.reject = true; + } else if (std.mem.eql(u8, arg, "--drop")) { + opts.reject = false; } } try execute(ui, allocator, opts); @@ -228,6 +251,7 @@ pub fn execute(ui: *Tui, allocator: std.mem.Allocator, opts: SynLimitOpts) !void .port = port, .rate = opts.rate, .burst = opts.burst, + .target = overLimitTarget(opts.reject), .iptables = ipt.iptables, .ip6tables = ipt.ip6tables, }) catch { @@ -250,7 +274,7 @@ pub fn execute(ui: *Tui, allocator: std.mem.Allocator, opts: SynLimitOpts) !void // Verify the rule actually landed: if xt_hashlimit is missing the script's strict // IPv4 lines fail and the oneshot is marked failed — surface that instead of a // silent no-op that looks installed. - if (!sys.isServiceActive(SERVICE_NAME) or !chainHasDrop(allocator, ipt.iptables)) { + if (!sys.isServiceActive(SERVICE_NAME) or !chainHasLimitRule(allocator, ipt.iptables)) { ui.fail(tr(ui, "SYN rate-limit did not apply (is the hashlimit iptables module available?)", "Ограничение SYN не применилось (доступен ли модуль iptables hashlimit?)")); ui.hint("journalctl -u " ++ SERVICE_NAME ++ " --no-pager -n 20"); return; @@ -261,6 +285,7 @@ pub fn execute(ui: *Tui, allocator: std.mem.Allocator, opts: SynLimitOpts) !void .{ .label = tr(ui, "Port:", "Порт:"), .value = port, .style = .label_value }, .{ .label = tr(ui, "Rate:", "Частота:"), .value = opts.rate, .style = .label_value }, .{ .label = tr(ui, "Burst:", "Burst:"), .value = opts.burst, .style = .label_value }, + .{ .label = tr(ui, "Action:", "Действие:"), .value = if (opts.reject) "REJECT (tcp-reset)" else "DROP", .style = .label_value }, .{ .label = tr(ui, "Service:", "Сервис:"), .value = SERVICE_NAME, .style = .label_value }, .{ .label = "", .value = "", .style = .blank }, .{ .label = tr(ui, "Per source-IP, dropped pre-accept() in the kernel", "По каждому IP-источнику, дроп до accept() в ядре"), .style = .success }, @@ -272,13 +297,13 @@ pub fn execute(ui: *Tui, allocator: std.mem.Allocator, opts: SynLimitOpts) !void /// and by `mtbuddy status`. pub fn printStatus(ui: *Tui, allocator: std.mem.Allocator) void { if (sys.isServiceActive(SERVICE_NAME)) { - const drops = dropCount(allocator); + const drops = limitedCount(allocator); if (drops) |d| { var buf: [96]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "{s} ({s} {s})", .{ tr(ui, "Kernel SYN rate-limit is active", "Ограничение SYN на уровне ядра активно"), d, - tr(ui, "SYNs dropped", "SYN дропнуто"), + tr(ui, "SYNs blocked", "SYN заблокировано"), }) catch tr(ui, "Kernel SYN rate-limit is active", "Ограничение SYN на уровне ядра активно"); ui.ok(msg); allocator.free(d); @@ -296,6 +321,9 @@ const ScriptParams = struct { port: []const u8, rate: []const u8, burst: []const u8, + /// iptables `-j` target for over-limit SYNs — "DROP" or "REJECT --reject-with + /// tcp-reset". Internal constant (see overLimitTarget), never user input. + target: []const u8 = "DROP", iptables: []const u8, ip6tables: []const u8, }; @@ -319,6 +347,8 @@ pub fn renderScript(buf: []u8, p: ScriptParams) ![]const u8 { \\IPT={[iptables]s} \\IP6T={[ip6tables]s} \\MODE="$1" + \\# Over-limit target (DROP for flood defense, or REJECT --reject-with tcp-reset). + \\ACTION="{[target]s}" \\ \\# Idempotent reset: drop the jump, flush + delete the chain (both families). \\for T in "$IPT" "$IP6T"; do @@ -331,13 +361,13 @@ pub fn renderScript(buf: []u8, p: ScriptParams) ![]const u8 { \\ \\# IPv4 (required — a failure here fails the unit so the operator notices). \\"$IPT" -N "$CHAIN" 2>/dev/null || true - \\"$IPT" -A "$CHAIN" -m hashlimit --hashlimit-name mtproto_syn --hashlimit-mode srcip --hashlimit-above "$RATE" --hashlimit-burst "$BURST" --hashlimit-htable-expire 60000 -j DROP + \\"$IPT" -A "$CHAIN" -m hashlimit --hashlimit-name mtproto_syn --hashlimit-mode srcip --hashlimit-above "$RATE" --hashlimit-burst "$BURST" --hashlimit-htable-expire 60000 -j $ACTION \\"$IPT" -A "$CHAIN" -j RETURN \\"$IPT" -I INPUT -p tcp --dport "$PORT" --syn -j "$CHAIN" \\ \\# IPv6 (best-effort — hosts without IPv6 must not fail the unit). \\"$IP6T" -N "$CHAIN" 2>/dev/null || true - \\"$IP6T" -A "$CHAIN" -m hashlimit --hashlimit-name mtproto_syn --hashlimit-mode srcip --hashlimit-above "$RATE" --hashlimit-burst "$BURST" --hashlimit-htable-expire 60000 -j DROP 2>/dev/null || true + \\"$IP6T" -A "$CHAIN" -m hashlimit --hashlimit-name mtproto_syn --hashlimit-mode srcip --hashlimit-above "$RATE" --hashlimit-burst "$BURST" --hashlimit-htable-expire 60000 -j $ACTION 2>/dev/null || true \\"$IP6T" -A "$CHAIN" -j RETURN 2>/dev/null || true \\"$IP6T" -I INPUT -p tcp --dport "$PORT" --syn -j "$CHAIN" 2>/dev/null || true \\exit 0 @@ -348,6 +378,7 @@ pub fn renderScript(buf: []u8, p: ScriptParams) ![]const u8 { .port = p.port, .rate = p.rate, .burst = p.burst, + .target = p.target, .iptables = p.iptables, .ip6tables = p.ip6tables, }); @@ -395,23 +426,25 @@ fn flushChain(allocator: std.mem.Allocator, ipt: []const u8) void { sys.execSilent(allocator, &.{ "bash", "-c", cmd }); } -fn chainHasDrop(allocator: std.mem.Allocator, ipt: []const u8) bool { +/// True if the chain carries our over-limit rule, whichever target it uses (DROP for +/// flood defense, REJECT for the --reject / TSPU-evasion mode). +fn chainHasLimitRule(allocator: std.mem.Allocator, ipt: []const u8) bool { const result = sys.exec(allocator, &.{ ipt, "-nL", CHAIN }) catch return false; defer result.deinit(); if (result.exit_code != 0) return false; - return std.mem.indexOf(u8, result.stdout, "DROP") != null; + return lineHasLimitTarget(result.stdout); } -/// Sum the DROP rule's packet counter from `iptables -nvxL CHAIN`. Returns an owned -/// slice (caller frees) or null when unavailable. -fn dropCount(allocator: std.mem.Allocator) ?[]const u8 { +/// Sum the limit rule's packet counter from `iptables -nvxL CHAIN`. Returns an owned +/// slice (caller frees) or null when unavailable. Matches either DROP or REJECT. +fn limitedCount(allocator: std.mem.Allocator) ?[]const u8 { const ipt = iptablesCommands().iptables; const result = sys.exec(allocator, &.{ ipt, "-nvxL", CHAIN }) catch return null; defer result.deinit(); if (result.exit_code != 0) return null; var lines = std.mem.splitScalar(u8, result.stdout, '\n'); while (lines.next()) |line| { - if (std.mem.indexOf(u8, line, "DROP") == null) continue; + if (!lineHasLimitTarget(line)) continue; var tok = std.mem.tokenizeAny(u8, line, " \t"); const pkts = tok.next() orelse continue; if (!isValidNumber(pkts)) continue; @@ -420,6 +453,10 @@ fn dropCount(allocator: std.mem.Allocator) ?[]const u8 { return null; } +fn lineHasLimitTarget(s: []const u8) bool { + return std.mem.indexOf(u8, s, "DROP") != null or std.mem.indexOf(u8, s, "REJECT") != null; +} + fn parsePreset(s: []const u8) ?Preset { if (std.mem.eql(u8, s, "soft")) return .soft; if (std.mem.eql(u8, s, "medium")) return .medium; @@ -487,7 +524,34 @@ test "renderScript bakes params and stays brace-free for std.fmt" { try std.testing.expect(std.mem.indexOf(u8, out, "BURST=5") != null); try std.testing.expect(std.mem.indexOf(u8, out, "CHAIN=MTPROTO_SYNLIMIT") != null); try std.testing.expect(std.mem.indexOf(u8, out, "-I INPUT -p tcp --dport \"$PORT\" --syn -j \"$CHAIN\"") != null); + // Default over-limit target is DROP. + try std.testing.expect(std.mem.indexOf(u8, out, "ACTION=\"DROP\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "-j $ACTION") != null); // No raw curly braces should survive into the generated script. try std.testing.expect(std.mem.indexOfScalar(u8, out, '{') == null); try std.testing.expect(std.mem.indexOfScalar(u8, out, '}') == null); } + +test "renderScript uses REJECT tcp-reset when requested" { + var buf: [2048]u8 = undefined; + const out = try renderScript(&buf, .{ + .port = "443", + .rate = "54/minute", + .burst = "1", + .target = overLimitTarget(true), + .iptables = "/usr/sbin/iptables", + .ip6tables = "/usr/sbin/ip6tables", + }); + try std.testing.expect(std.mem.indexOf(u8, out, "ACTION=\"REJECT --reject-with tcp-reset\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "RATE=54/minute") != null); + // The over-limit rule references the ACTION variable, never a hard-coded DROP. + try std.testing.expect(std.mem.indexOf(u8, out, "-j $ACTION") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "-j DROP") == null); + try std.testing.expect(std.mem.indexOfScalar(u8, out, '{') == null); + try std.testing.expect(std.mem.indexOfScalar(u8, out, '}') == null); +} + +test "overLimitTarget maps the reject flag" { + try std.testing.expectEqualStrings("DROP", overLimitTarget(false)); + try std.testing.expectEqualStrings("REJECT --reject-with tcp-reset", overLimitTarget(true)); +}