From a59cfa3f32c38453bb97d7af9d3625aa2ee12129 Mon Sep 17 00:00:00 2001 From: andrey23127 Date: Fri, 23 Oct 2026 22:51:24 +0300 Subject: [PATCH 1/5] Release 0.9.84: bot rules over TCP and UDP, scanners into the IP filter, search by file hash, hole punch fixes (#35, #37, #38), 0xB2 frame types (#36) Bots * A source is judged only once its queries span bots.min_span_secs (10 s). A burst of 30 packets in 15 ms read as 120 000 qpm and got a 24 h ban. * "Uniform timing" sources (~567 qpm, one hash per packet: one client build with broken UDP pacing, ordinary users) are throttled for 24 h to bots.throttle_qpm (60) and still answered, not banned. bots.uniform_action = "ban" restores the old behaviour. Floods are banned. * A banned source that keeps sending through its ban (bots.ipfilter_min_dropped, 10 000 datagrams; x10 if throttled) is appended to storage.ipfilter_path as "a.b.c.d - a.b.c.d , 000 , bot", only when no line covers it, and the filter is reloaded at once. Recorded in bot-ipfilter.txt next to the config and put back when the filter file is replaced (update service). Ledger event ipfilter_added. * The IP filter applies to UDP as well as TCP. * The IP filter file is watched (30 s mtime poll) and reloaded on change. * A new IP filter (bot added, file changed, SIGHUP, /api/reload) closes the TCP sessions from addresses it now blocks; before, the filter was read only at accept and a scanner stayed logged in until it reconnected. Counted as ipfilter_kick. * Bots tab: THROTTLED and IP FILTER badges; filter by state (IP filter, banned, throttled, over) and rule, search, sort by last sanction (default), first seen, dropped, files, rate, IP. * Detections are dropped a day after their ban or throttle ended (they stayed until restart); hashes kept per bot capped at 2 000 (was 20 000); /api/memsize counts them. Scanners and hammers (from a day of /api/activity, 100 000 sources) * Per source and hour, TCP OP_GETSOURCES and UDP source requests counted by file. Scanner: >= bots.scan_min_files (2000) files, >= scan_min_distinct (1000) different, each <= scan_max_repeats (3) times, judged after scan_min_minutes (10) -> scan_action "ban". Hammer: >= hammer_min_files (3000), each file >= hammer_min_rate (5) times a minute -> hammer_action "throttle". jdonkey users (each download once a minute) are neither. "log" records only. Ordinary eMule: ~180 files/h; busiest ordinary source < 1000/h. * bots.exempt (default 178.250.170.0/28, ed2k.shortypower.org): never judged, banned or written to the IP filter. * A bot ban covers TCP: sessions closed, login refused for 24 h (bot_kick, bot_tcp_refused). Throttled sources get throttle_qpm TCP source answers a minute. Search activity (to tune the bot rules from data) * Per source (IPv4, IPv6 /64): TCP/UDP searches, more-results pages, results; TCP/UDP source requests, files per request, files asked, distinct files (64-register HyperLogLog, no hash kept); gaps between requests, busiest minute, requests per hour. * GET /api/activity (totals, by UTC hour, spread over sources), GET /api/activity/sources (rows, sortable, csv), POST /api/activity/reset. * bots.activity_stats (off by default: ~25 MB and a record update per request while on), bots.activity_max_sources (100 000); both apply from the Settings tab, and switching off clears the table. * Distinct-files estimate mixes each hash (structured hashes were overcounted ~9x) and never exceeds the files asked. LowID hole punch (issue #35) * OP_IDCHANGE TCP flags carry 0x20000 when the server accepts OP_LOWID_HOLEPUNCH_REQUEST, so a client can tell it from Lugdunum's NAT callback (0x1000) and send 0x60 only where it is understood. * README: CT_SERVER_FLAGS 0x1000 removed from the IPv6 contract (it is Lugdunum's SRVCAP_NATTRAVERSAL); the server keys on CT_MOD_IP_V6 only. Search by file hash * ed2k::HASH, ed2k:SIZE:HASH, ed2k:// links and ed2k magnets in a search string return that file from the hash index (size checked when given), over TCP and UDP, as Lugdunum's Is_ed2k_search. ed2k.shortypower.org sends ed2k::HASH in UDP 0x90 searches and got nothing from this server. * Search result integers in the narrowest type (UINT8/UINT16/UINT32), as Lugdunum; the service showed no source counts from 0x83 tags. Hole punch: per-client UDP port (issue #37) * The port handed to a peer in 0x61 came from a per-IP map written by every 0x9F and global query; two LowID clients behind one IP got each other's (or their own) port. Each client's last 0x9F source port is now kept by user hash and preferred; the per-IP port only for a client alone on its IP; then the announced port. * 0x9F counts only from the session's own IP. The 0x60 port no longer replaces the keepalive port. Dropped with the session. Hole punch: peer ID in 0x61 (issue #38) * OP_LOWID_HOLEPUNCH_INFO ends with peer_id(4): the requester gets its 0x60 target id, the target the requester's id. 29 bytes; clients read the id only from 29 bytes or more. NAT-T documentation (issue #36) * README: the retired eMule 0.72a NAT-T edition is no longer the reference client; its 0xB2 frame type 0x01 collides with eMuleAI's QUIC. The peer-to-peer part of the contract keeps only what the server relies on. * README: table of 0xB2 frame types in use (eMuleAI, emule-qt). * No server change: 0x60-0x62, 0x9F and the 0x20000 flag stay. New optional section [bots]. Compatible with every eMule since obfuscation appeared (0.48a), 0.49c included. Tests: 590 unit + 27 integration pass. Checked against a running server: a 30-packet burst is not flagged; a sustained flood is banned, then appended to the filter file once, reloaded and recorded; a replaced filter file gets the line back; OP_IDCHANGE flags 0x205dd; a logged-in session is closed when its address is added to the filter file; /api/activity counts TCP and UDP searches and source requests and resets; two LowID clients on one IP each get the other's keepalive port and id in a 29-byte 0x61; a TCP client asking 2 000 different files is banned, its session closed and its reconnect refused; a UDP 0x90 search for ed2k::HASH of an indexed file gets its 0x99, a wrong size or unknown hash nothing. --- CHANGELOG.md | 84 ++++++++++---- README.md | 29 +++-- config/config.toml | 7 +- src/config.rs | 105 ++++++++++++++--- src/main.rs | 10 ++ src/proto/search.rs | 84 ++++++++++++++ src/proto/tags.rs | 27 +++++ src/server/bot_detector.rs | 225 +++++++++++++++++++++++++++++++++---- src/server/bot_ipfilter.rs | 5 + src/server/search.rs | 40 +++++-- src/server/udp.rs | 14 ++- src/state/mod.rs | 30 ++++- src/web.rs | 79 ++++++++++++- 13 files changed, 652 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 980a279..9a81bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ Earlier releases are described in their release commit messages. ## 0.9.84 -Bot detection that stops banning ordinary users, scanners into the IP filter -for good (and off their TCP sessions), search activity per source to find -better criteria, and LowID hole punching: the support flag (issue #35), the -right UDP port for two clients behind one IP (#37), the peer ID in 0x61 -(#38). One new optional +Bot rules built from a day of live traffic: list scanners and hammering +clients caught over TCP and UDP, ordinary users (jdonkey included) left +alone, search services exempt, scanners into the IP filter for good and off +their TCP sessions. Search by file hash (`ed2k::HASH`) as Lugdunum does. LowID +hole punching: the support flag (issue #35), the right UDP port for two +clients behind one IP (#37), the peer ID in 0x61 (#38). One new optional section, `[bots]`. ### Bots @@ -41,7 +42,17 @@ section, `[bots]`. logged in until it reconnected (seen: 3 hours). Every filter swap (a bot added, the file changed, SIGHUP, `/api/reload`) now wakes each session to check its own address. Counted as `ipfilter_kick`. -- The Bots tab shows THROTTLED and IP FILTER next to BANNED. +- The Bots tab shows THROTTLED and IP FILTER next to BANNED, and can be + filtered by state (in the IP filter, banned 24 h, throttled 24 h, sanction + over) and by rule (scanner, hammer, flood/uniform timing), searched by + IP, country, provider, client or reason, and sorted by last sanction + (the default), first seen, datagrams dropped since, files asked, rate or + IP. A tally of each state sits next to the filters. +- **Detections no longer pile up in memory.** A detection, with up to its + hashes, stayed until restart. It is now dropped a day after its ban or + throttle ended and it went quiet; the ledger on disk keeps it. The hashes + kept per bot are capped at 2 000 instead of 20 000 (up to ~0.5 MB per + scanner before), and `/api/memsize` now counts them. ### Scanners and hammers, over TCP too @@ -52,15 +63,27 @@ ordinary source stayed under 1 000. Two kinds of source did not, and nearly all of them over TCP, where nothing was judged before: - **Scanners**, walking a list: 2 000 to 20 000 files an hour, nearly all - different (one asked 3 900 files in 6 minutes). New rule: at least - `bots.scan_min_files` (2 000) files in an hour, at least - `bots.scan_min_distinct` (1 000) different → `scan_action` ("ban"). - Over the 7 hours it matches 4 to 7 sources. -- **Hammers**, the same file or two up to 10 times a second, mostly one - mobile client build: at least `bots.hammer_min_files` (3 000) in an hour, - fewer than `bots.hammer_max_distinct` (100) different → `hammer_action` - ("throttle": `throttle_qpm` answers a minute, the session stays). About - 20 sources. + different, each about once. Rule: at least `bots.scan_min_files` (2 000) + files in an hour, at least `scan_min_distinct` (1 000) different, each + asked at most `scan_max_repeats` (3) times on average, judged no sooner + than `scan_min_minutes` (10) into the hour → `scan_action` ("ban"). +- **Hammers**: the same file or two 5 to 600 times a minute, mostly a + program built on jed2k with its default nick. Rule: at least + `hammer_min_files` (3 000) in an hour, each file at least + `hammer_min_rate` (5) times a minute on average → `hammer_action` + ("throttle": `throttle_qpm` answers a minute, the session stays). +- **Not bots: jdonkey users.** The Android client asks for every download + without a peer once a minute. A first version of these rules, run for a + day on the live server, flagged 80 sources. It banned as scanners 5 + sources in their first 1 to 6 minutes (2 000 new downloads look like a + list then) and 2 that asked each of ~1 000 files 4 to 9 times, and + throttled as hammers 12 jdonkey users with 50 to 90 stalled downloads, + each asked about once a minute. Under the rules above the 2 and the 12 + are left alone and the 5 are judged at minute 10; the 6 scanners that + asked each file about once and 55 hammers remain. +- **`bots.exempt`**: addresses never judged, banned or written to the IP + filter. Default `178.250.170.0/28`, ed2k.shortypower.org, which sends + about 2 600 UDP searches a day from each of 11 addresses. - Both count TCP `OP_GETSOURCES` and UDP source requests by file, per source, over an hour from its first request. Different files are estimated from the 200th file on, so most sources cost a counter only. @@ -69,7 +92,7 @@ all of them over TCP, where nothing was judged before: source requests beyond its allowance go unanswered. - `"log"` as an action only writes the match to the log and the ledger. -### Search activity (temporary) +### Search activity The rate detector misses a list scanner: one walked 4 400 files at about 150 requests a minute, below the 200 qpm a source needs to be looked at, and was @@ -88,9 +111,12 @@ threshold, and others, from real traffic: - The distinct-files estimate mixes each hash first: sources asking 700 files were estimated at 6 000, from hashes with long runs of zero bytes. It is also never above the files asked. -- `bots.activity_stats` (on) and `bots.activity_max_sources` (100 000 - records, about 200 bytes each; further sources only in the totals). Both - apply from the Settings tab without a restart. +- `bots.activity_stats` (off by default) and `bots.activity_max_sources` + (100 000 records, about 200 bytes each; further sources only in the + totals). Both apply from the Settings tab without a restart. On for a day + on the live server it took ~25 MB and a record update on each of 21 + million requests, so it is meant for tuning, not to stay on; switched + off, the table is cleared within a minute. ### LowID hole punch flag (issue #35) @@ -103,6 +129,26 @@ threshold, and others, from real traffic: On Lugdunum that bit is `SRVCAP_NATTRAVERSAL`; eMuleQt withdrew it and aMule stops sending it. This server only ever keyed on the `CT_MOD_IP_V6` tag. +### Search by file hash + +- A search string that names a file by hash is answered from the hash + index, as Lugdunum's `Is_ed2k_search` does: `ed2k::HASH`, + `ed2k:SIZE:HASH`, `ed2k://|file|NAME|SIZE|HASH|…`, + `magnet:?xt=urn:ed2k:HASH`, `magnet:?xt=urn:ed2khash:HASH`. With a size, + the size must match. TCP and UDP alike; the result obeys the content + filter like any other. +- ed2k.shortypower.org looks files up with `ed2k::HASH` in a UDP search + (0x90) from 11 addresses, about 8 000 a day. Every one returned nothing + here, because the string was searched as a word (and then dropped as one + no file has), so the service found no file on servers running this + software while Lugdunum servers answered. +- Counted in the Health tab ("Searches by file hash"). +- Integers in search results (size, sources, complete sources, type, + length, bitrate) are written in the narrowest type, UINT8 up to 255 and + UINT16 up to 65 535, as Lugdunum writes them. ed2k.shortypower.org read + every tag of this server's answer except the source counts, which it + takes only in the narrow forms; eMule and aMule read every width. + ### Hole punch: per-client UDP port (issue #37) - The UDP port handed to a peer in `OP_LOWID_HOLEPUNCH_INFO` came from a map diff --git a/README.md b/README.md index a5e7257..6779db3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ stable at the scale of the largest real eD2k servers (tens of millions of files) - Inverted keyword index with boolean search trees and numeric size filters. Results are ranked by source count, the result cap is configurable, and a query word no indexed file contains no longer empties the search. +- **Search by file hash**, as Lugdunum: a search string `ed2k::HASH`, + `ed2k:SIZE:HASH`, an `ed2k://|file|…|` link or a `magnet:?xt=urn:ed2k:` + link returns that one file, over TCP and UDP. Hash lookup services such as + ed2k.shortypower.org use it. - Memory-optimized in-memory index (sharded slab, intrusive hash index, name interning, jemalloc tuning) — roughly **half the RAM** of the Lugdunum reference at the same scale. @@ -485,11 +489,14 @@ keys are read on every request, so the Settings tab changes them at once. | `ipfilter_min_dropped` | 10000 | A banned source that sends this many datagrams into its ban is added to `storage.ipfilter_path` for good (ten times as many for a throttled one); `0` = never | | `scan_min_files` | 2000 | **Scanner** rule: a source that asks for sources of this many files within an hour (TCP and UDP, repeats included)… | | `scan_min_distinct` | 1000 | …of which at least this many different… | +| `scan_min_minutes` | 10 | …judged no sooner than this far into the hour… | +| `scan_max_repeats` | 3 | …each file asked at most this many times on average… | | `scan_action` | `"ban"` | …gets this for 24 h: `"ban"`, `"throttle"` or `"log"` (ledger and log only) | | `hammer_min_files` | 3000 | **Hammer** rule: this many files asked within an hour… | -| `hammer_max_distinct` | 100 | …of fewer than this many different ones… | +| `hammer_min_rate` | 5 | …each file asked at least this many times a minute on average… | | `hammer_action` | `"throttle"` | …gets this for 24 h: `"throttle"`, `"ban"` or `"log"` | -| `activity_stats` | `true` | Collect search activity per source for `/api/activity` (temporary, see below) | +| `exempt` | `["178.250.170.0/28"]` | Addresses and networks (`a.b.c.d`, `a.b.c.d/nn`, IPv6 too) the bot rules never judge, ban or add to the IP filter: search services that query for their users (default: ed2k.shortypower.org) | +| `activity_stats` | `false` | Collect search activity per source for `/api/activity`, to tune these rules (see below). About 25 MB and a record update per request while on | | `activity_max_sources` | 100000 | Sources it keeps a record for, about 200 bytes each; requests of further sources only go into the totals | A line added to the IP filter looks like the rest of the file, marked `bot`: @@ -509,10 +516,14 @@ The scanner and hammer rules come from a day's activity of 91 000 sources (`/api/activity`, 2026-10-20). An ordinary eMule asks the server it is connected to for sources of 15 files every 5 minutes, about 180 files an hour, however long its download list; over UDP, about 25 files every 5 -minutes. The busiest ordinary source asked under 1 000 files an hour. A list -scanner asked 2 000 to 20 000 an hour, nearly all different. A hammer asks for -the same file or two up to 10 times a second, mostly one mobile client build -with a bug, so it is throttled (`throttle_qpm` answers a minute) and not +minutes. jdonkey (the Android client built on jed2k) asks for every +download that has no peer once a minute, so a user with 80 stalled +downloads sends 4 800 requests an hour, and one who adds 2 000 downloads +sends 2 000 different files in the first minute. Neither is a scan: a list +scanner asks thousands of different files an hour, each about once, which is +what `scan_min_minutes` and `scan_max_repeats` test. A hammer asks the same +file or two 5 to 600 times a minute, mostly a program built on jed2k that +kept its default nick; it is throttled (`throttle_qpm` answers a minute), not banned. Each source is judged over an hour from its first request; different files are counted from its 200th file on (a 64-byte estimate, only for sources that reach it). @@ -528,7 +539,7 @@ blocks is closed, counted as `ipfilter_kick` in `/api/block_stats`. Before, the filter was read only when a connection was accepted, so a client already logged in stayed until it reconnected. -#### Search activity (`/api/activity`, temporary) +#### Search activity (`/api/activity`) The rate detector judges a source by queries per minute, and a scanner that walks a list of thousands of files at ~150 requests a minute stays under any @@ -552,8 +563,8 @@ reset: | `POST /api/activity/reset` | Start a new period | UDP requests are counted after the admission budgets, i.e. those the server -went on to handle. With `activity_stats = false` nothing is collected, and -what was collected stays until a reset. +went on to handle. Off by default; turn it on for a day when the rules need +tuning, then off again. Switched off, the table is cleared within a minute. ### `[admission]` Server-wide ceilings on the work remote peers can cause, however the load is diff --git a/config/config.toml b/config/config.toml index 58adaf4..84c5dfd 100644 --- a/config/config.toml +++ b/config/config.toml @@ -307,11 +307,14 @@ whitelist_hashes = "https://ed2k.emule-security.org/files/whitelist_hashes.txt" # # datagrams sent into a ban; 0 = never # scan_min_files = 2000 # scanner: files asked for sources in an hour # scan_min_distinct = 1000 # ... of which this many different +# scan_min_minutes = 10 # ... judged no sooner than this +# scan_max_repeats = 3 # ... each asked at most this often # scan_action = "ban" # "ban", "throttle" or "log" # hammer_min_files = 3000 # hammer: files asked in an hour -# hammer_max_distinct = 100 # ... fewer than this many different +# hammer_min_rate = 5 # ... each file this many times a minute # hammer_action = "throttle" # "throttle", "ban" or "log" -# activity_stats = true # search activity per source for /api/activity +# exempt = ["178.250.170.0/28"] # never judged: search services +# activity_stats = false # search activity per source for /api/activity # activity_max_sources = 100000 # records kept (~200 bytes each) [admission] diff --git a/src/config.rs b/src/config.rs index 67c0eaf..2c760c3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -128,10 +128,13 @@ pub struct BotsConfig { /// for that long. A throttled source needs ten times as many. 0 = never. #[serde(default = "default_bots_ipfilter_min_dropped")] pub ipfilter_min_dropped: u64, - /// Collect search activity per source for `/api/activity` (temporary, - /// to choose bot criteria from data). Read on every request, so the - /// admin Settings tab switches it without a restart. - #[serde(default = "default_true_bots")] + /// Collect search activity per source for `/api/activity`, to tune the + /// bot rules from data. Off by default: on, it updates a record on every + /// search and source request (about 20 million a day) and holds up to + /// `activity_max_sources` of them, ~25 MB. Read on every request, so the + /// admin Settings tab switches it without a restart; switched off, the + /// table is cleared within a minute. + #[serde(default)] pub activity_stats: bool, /// Sources `/api/activity` keeps a record for (about 200 bytes each); /// requests of further sources are only counted in the totals. @@ -143,18 +146,31 @@ pub struct BotsConfig { pub scan_min_files: u64, #[serde(default = "default_bots_scan_min_distinct")] pub scan_min_distinct: u64, + /// ... judged no sooner than this many minutes into the hour (a client + /// that has just added 2 000 downloads asks for all of them at once) ... + #[serde(default = "default_bots_scan_min_minutes")] + pub scan_min_minutes: u64, + /// ... and asking each file at most this many times on average: a list + /// walk asks once, a client again every minute or every 20. + #[serde(default = "default_bots_scan_max_repeats")] + pub scan_max_repeats: f64, /// "ban" (default), "throttle" or "log". #[serde(default = "default_bots_scan_action")] pub scan_action: String, - /// Hammer rule: this many files asked in an hour, fewer than - /// `hammer_max_distinct` different, gets `hammer_action`. + /// Hammer rule: this many files asked in an hour, each of them at least + /// `hammer_min_rate` times a minute on average, gets `hammer_action`. #[serde(default = "default_bots_hammer_min_files")] pub hammer_min_files: u64, - #[serde(default = "default_bots_hammer_max_distinct")] - pub hammer_max_distinct: u64, + #[serde(default = "default_bots_hammer_min_rate")] + pub hammer_min_rate: f64, /// "throttle" (default), "ban" or "log". #[serde(default = "default_bots_hammer_action")] pub hammer_action: String, + /// Addresses and networks the bot rules never judge: search and + /// statistics services that query on behalf of their users. Default: + /// ed2k.shortypower.org (178.250.170.0/28). + #[serde(default = "default_bots_exempt")] + pub exempt: Vec, } impl Default for BotsConfig { @@ -164,19 +180,55 @@ impl Default for BotsConfig { uniform_action: default_bots_uniform_action(), throttle_qpm: default_bots_throttle_qpm(), ipfilter_min_dropped: default_bots_ipfilter_min_dropped(), - activity_stats: default_true_bots(), + activity_stats: false, activity_max_sources: default_bots_activity_max(), scan_min_files: default_bots_scan_min_files(), scan_min_distinct: default_bots_scan_min_distinct(), scan_action: default_bots_scan_action(), hammer_min_files: default_bots_hammer_min_files(), - hammer_max_distinct: default_bots_hammer_max_distinct(), + hammer_min_rate: default_bots_hammer_min_rate(), + exempt: default_bots_exempt(), + scan_min_minutes: default_bots_scan_min_minutes(), + scan_max_repeats: default_bots_scan_max_repeats(), hammer_action: default_bots_hammer_action(), } } } impl BotsConfig { + /// Is `ip` in `exempt`? Entries are an address or address/prefix, IPv4 + /// or IPv6 (an IPv4-mapped IPv6 address counts as IPv4). An entry that + /// does not parse matches nothing. + pub fn is_exempt(&self, ip: std::net::IpAddr) -> bool { + use std::net::IpAddr; + if self.exempt.is_empty() { + return false; + } + let ip = match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4), + v4 => v4, + }; + self.exempt.iter().any(|e| { + let (addr, bits) = match e.trim().split_once('/') { + Some((a, b)) => (a, b.trim().parse::().ok()), + None => (e.trim(), None), + }; + match (addr.parse::(), ip) { + (Ok(IpAddr::V4(net)), IpAddr::V4(x)) => { + let bits = bits.unwrap_or(32).min(32); + let mask = if bits == 0 { 0 } else { u32::MAX << (32 - bits) }; + u32::from(net) & mask == u32::from(x) & mask + } + (Ok(IpAddr::V6(net)), IpAddr::V6(x)) => { + let bits = bits.unwrap_or(128).min(128); + let mask = if bits == 0 { 0 } else { u128::MAX << (128 - bits) }; + u128::from(net) & mask == u128::from(x) & mask + } + _ => false, + } + }) + } + pub fn throttle_uniform(&self) -> bool { !self.uniform_action.trim().eq_ignore_ascii_case("ban") } @@ -194,9 +246,6 @@ fn default_bots_throttle_qpm() -> u32 { fn default_bots_ipfilter_min_dropped() -> u64 { 10_000 } -fn default_true_bots() -> bool { - true -} fn default_bots_activity_max() -> usize { 100_000 } @@ -212,8 +261,17 @@ fn default_bots_scan_action() -> String { fn default_bots_hammer_min_files() -> u64 { 3_000 } -fn default_bots_hammer_max_distinct() -> u64 { - 100 +fn default_bots_hammer_min_rate() -> f64 { + 5.0 +} +fn default_bots_scan_min_minutes() -> u64 { + 10 +} +fn default_bots_scan_max_repeats() -> f64 { + 3.0 +} +fn default_bots_exempt() -> Vec { + vec!["178.250.170.0/28".into()] } fn default_bots_hammer_action() -> String { "throttle".into() @@ -1429,6 +1487,23 @@ whitelist_hashes = "" mod tests { use super::*; + #[test] + fn bot_exemptions_match_addresses_and_networks() { + let b = BotsConfig::default(); + assert!(b.is_exempt("178.250.170.3".parse().unwrap())); + assert!(b.is_exempt("178.250.170.13".parse().unwrap())); + assert!(b.is_exempt("::ffff:178.250.170.10".parse().unwrap())); + assert!(!b.is_exempt("178.250.170.16".parse().unwrap())); + let b = BotsConfig { + exempt: vec!["10.1.2.3".into(), "2001:db8::/32".into(), "junk".into()], + ..Default::default() + }; + assert!(b.is_exempt("10.1.2.3".parse().unwrap())); + assert!(!b.is_exempt("10.1.2.4".parse().unwrap())); + assert!(b.is_exempt("2001:db8:1::5".parse().unwrap())); + assert!(!b.is_exempt("2001:db9::5".parse().unwrap())); + } + fn with_network(extra: &str) -> Result { let toml_str = format!( "[server]\nname = \"t\"\ndesc = \"t\"\nthis_ip = \"\"\nversion_major = 17\n\ diff --git a/src/main.rs b/src/main.rs index cb35766..7c2c1e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -927,6 +927,16 @@ async fn async_main(args: Args, cfg: Config) -> Result<()> { for ip in ended { ed2k_server::server::bot_detector::ban_ended(&state_clean, ip); } + // Detections a day past their ban, and the search activity + // table once it is switched off. + let n = ed2k_server::server::bot_detector::sweep_detections(&state_clean); + if n > 0 { + info!(dropped = n, "bot detections past their ban dropped from memory"); + } + if !state_clean.live_cfg.load().bots.activity_stats && !state_clean.activity.is_empty() { + state_clean.activity.reset(); + info!("bots.activity_stats is off: search activity table cleared"); + } // Q1: sweep expired CSAM-publisher bans and stale per-user // distinct-file sets. Both use publisher_blacklist_seconds — for diff --git a/src/proto/search.rs b/src/proto/search.rs index a7fe25a..a1b848b 100644 --- a/src/proto/search.rs +++ b/src/proto/search.rs @@ -114,6 +114,59 @@ pub enum SearchNode { }, } +/// A search string that names one file by its hash, as Lugdunum reads it +/// (`Is_ed2k_search`): `ed2k::HASH`, `ed2k:SIZE:HASH`, +/// `ed2k://|file|NAME|SIZE|HASH|…`, `magnet:?xt=urn:ed2k:HASH`, +/// `magnet:?xt=urn:ed2khash:HASH`, or an ` Option<([u8; 16], u64)> { + let s = s.trim(); + let b = s.as_bytes(); + let starts = |p: &str| b.len() >= p.len() && b[..p.len()].eq_ignore_ascii_case(p.as_bytes()); + fn digits(t: &str) -> (u64, usize) { + let n = t.bytes().take_while(u8::is_ascii_digit).count(); + (t[..n].parse::().unwrap_or(0), n) + } + // `NAME|SIZE|HASH` after the `ed2k://|file|` part. + fn link(rest: &str) -> Option<(&str, u64)> { + let (_, after_name) = rest.split_once('|')?; + let (size, n) = digits(after_name); + let (_, hash) = after_name[n..].split_once('|')?; + Some((hash, size)) + } + let (hash, size) = if starts("ed2k://|file|") { + link(&s[13..])? + } else if starts(" Option<([u8; 16], u64)> { + match tree { + SearchNode::Term(s) => ed2k_file_ref(s), + _ => None, + } +} + pub fn parse(payload: &[u8]) -> Result { let mut slice = payload; let node = parse_node(&mut slice, 0)?; @@ -1717,4 +1770,35 @@ mod markers_are_per_word { "plain words still use contains" ); } + + #[test] + fn ed2k_file_references_are_read_as_lugdunum_reads_them() { + let h = "193C85BB48D1F49B859BA1D744BD4D46"; + let want: [u8; 16] = [ + 0x19, 0x3C, 0x85, 0xBB, 0x48, 0xD1, 0xF4, 0x9B, 0x85, 0x9B, 0xA1, 0xD7, 0x44, 0xBD, + 0x4D, 0x46, + ]; + assert_eq!(ed2k_file_ref(&format!("ed2k::{h}")), Some((want, 0))); + assert_eq!(ed2k_file_ref(&format!("ED2K::{}", h.to_lowercase())), Some((want, 0))); + assert_eq!(ed2k_file_ref(&format!("ed2k:12345:{h}")), Some((want, 12345))); + assert_eq!( + ed2k_file_ref(&format!("ed2k://|file|Some name.avi|734003200|{h}|/")), + Some((want, 734003200)) + ); + assert_eq!(ed2k_file_ref(&format!("magnet:?xt=urn:ed2k:{h}&dn=x")), Some((want, 0))); + assert_eq!(ed2k_file_ref(&format!("magnet:?xt=urn:ed2khash:{h}")), Some((want, 0))); + assert_eq!(ed2k_file_ref("ed2k::nothex"), None); + assert_eq!(ed2k_file_ref(&format!("ed2k::{}", &h[..30])), None); + assert_eq!(ed2k_file_ref("ed2k donkey"), None); + assert_eq!(ed2k_file_ref(h), None, "a bare hash is a word, as in Lugdunum"); + } + + #[test] + fn the_hash_search_service_payload_parses_to_a_file_reference() { + // OP_GLOB_SEARCHREQ3 body from ed.shortypower.org, after its tag set. + let mut p = vec![0x01, 0x26, 0x00]; + p.extend_from_slice(b"ed2k::193C85BB48D1F49B859BA1D744BD4D46"); + let tree = parse(&p).unwrap(); + assert!(ed2k_file_search(&tree).is_some()); + } } diff --git a/src/proto/tags.rs b/src/proto/tags.rs index 07aadea..f7bb825 100644 --- a/src/proto/tags.rs +++ b/src/proto/tags.rs @@ -78,6 +78,23 @@ pub struct Tag { pub value: TagValue, } +impl TagValue { + /// An integer in the narrowest type that holds it: UINT8 up to 255, + /// UINT16 up to 65 535, else UINT32. What Lugdunum writes in search + /// results (`bldNetIntTag1`), and what eMule reads. Some third-party + /// parsers only take the narrow forms: ed2k.shortypower.org showed no + /// source counts for this server while it read every other tag. + pub fn uint(v: u32) -> TagValue { + if v <= 0xFF { + TagValue::U8(v as u8) + } else if v <= 0xFFFF { + TagValue::U16(v as u16) + } else { + TagValue::U32(v) + } + } +} + impl Tag { pub fn byte(name: u8, value: TagValue) -> Self { Self { @@ -395,6 +412,16 @@ pub fn write_tag_list(out: &mut BytesMut, tags: &[Tag]) { #[cfg(test)] mod tests { + + #[test] + fn integers_take_the_narrowest_type() { + assert!(matches!(TagValue::uint(13), TagValue::U8(13))); + assert!(matches!(TagValue::uint(255), TagValue::U8(255))); + assert!(matches!(TagValue::uint(256), TagValue::U16(256))); + assert!(matches!(TagValue::uint(65_535), TagValue::U16(65_535))); + assert!(matches!(TagValue::uint(65_536), TagValue::U32(65_536))); + } + use super::*; #[test] diff --git a/src/server/bot_detector.rs b/src/server/bot_detector.rs index d3be755..8e187c8 100644 --- a/src/server/bot_detector.rs +++ b/src/server/bot_detector.rs @@ -49,6 +49,9 @@ pub struct QueryInfo { /// IPv6 /64 by default), so a flood spread across one IPv6 prefix is one bot. pub fn record_query(state: &Arc, peer: SocketAddr, q: QueryInfo, hashes: &[FileHash]) { let raw_ip = peer.ip(); + if state.live_cfg.load().bots.is_exempt(raw_ip) { + return; + } let ip = state.source_key(raw_ip); let now = Instant::now(); let tracker = state.bot_query_log.entry(ip).or_default(); @@ -251,6 +254,9 @@ pub struct ScanWindow { /// ordinary client asks fewer than 200 an hour (live, 2026-10-20), so /// most sources never pay for the 64 bytes. distinct: Option>, + /// Files added to `distinct` (those from the `HLL_FROM`th on): what the + /// repeat ratio is measured over. + counted: u32, /// Judged in this window already. flagged: bool, } @@ -262,6 +268,10 @@ impl ScanWindow { fn distinct(&self) -> f64 { self.distinct.as_ref().map_or(0.0, |h| h.estimate().min(self.files as f64)) } + #[cfg(test)] + fn started_ago(d: Duration) -> Self { + ScanWindow { start: Instant::now().checked_sub(d), ..Default::default() } + } pub fn has_distinct(&self) -> bool { self.distinct.is_some() } @@ -271,17 +281,20 @@ impl ScanWindow { } /// Source requests of `raw_ip` (TCP or UDP), judged per hour against two -/// rules measured on the live server (2026-10-20, 91 000 sources over 7 h): +/// rules measured on the live server (2026-10-20/21, 100 000 sources a day): +/// +/// - **Scanner**: at least `bots.scan_min_files` files asked (2 000), at +/// least `bots.scan_min_distinct` of them different (1 000), each asked at +/// most `bots.scan_max_repeats` times on average (3), judged no sooner than +/// `bots.scan_min_minutes` (10) into the hour. It walks a list. An +/// ordinary eMule asks 15 files every 5 minutes, about 180 an hour; jdonkey +/// asks every download without peers once a minute. Banned by default. +/// - **Hammer**: at least `bots.hammer_min_files` files asked (3 000), each +/// at least `bots.hammer_min_rate` times a minute on average (5): the same +/// file or two, up to 10 times a second. Mostly a client built on jed2k +/// with its default nick; throttled by default. /// -/// - **Scanner**: at least `bots.scan_min_files` files asked (2 000) of which -/// at least `bots.scan_min_distinct` different (1 000). It walks a list. -/// An ordinary eMule asks 15 files every 5 minutes, about 180 an hour, -/// however long its download list; the busiest ordinary source asked -/// under 1 000 an hour. Banned by default (`scan_action`). -/// - **Hammer**: at least `bots.hammer_min_files` files asked (3 000) of -/// fewer than `bots.hammer_max_distinct` different ones (100): the same -/// file or two, over and over, up to 10 a second. Mostly one mobile client -/// build with a bug; its users are real, so it is throttled by default. +/// Addresses in `bots.exempt` (search services) are never judged. pub fn record_sources(state: &Arc, raw_ip: IpAddr, hashes: &[FileHash], tcp: bool) { if hashes.is_empty() { return; @@ -289,6 +302,9 @@ pub fn record_sources(state: &Arc, raw_ip: IpAddr, hashes: &[FileHa let ip = state.source_key(raw_ip); let cfg = state.live_cfg.load(); let b = &cfg.bots; + if b.is_exempt(raw_ip) { + return; + } let verdict = { let mut w = state.scan_windows.entry(ip).or_default(); if w.expired() { @@ -300,6 +316,7 @@ pub fn record_sources(state: &Arc, raw_ip: IpAddr, hashes: &[FileHa for x in hashes { h.add(x); } + w.counted = w.counted.saturating_add(hashes.len() as u32); } if w.flagged { return; @@ -309,16 +326,39 @@ pub fn record_sources(state: &Arc, raw_ip: IpAddr, hashes: &[FileHa if files < b.scan_min_files.min(b.hammer_min_files) { return; } - let distinct = w.distinct(); + let distinct = w.distinct().min(w.counted as f64).max(1.0); let mins = w.start.map_or(0.0, |s| s.elapsed().as_secs_f64() / 60.0).max(1.0); - let v = if files >= b.scan_min_files && distinct >= b.scan_min_distinct as f64 { + // How often each file was asked, on average: over the files counted + // since `distinct` started, and per minute over the whole window. + let repeats = w.counted as f64 / distinct; + let per_file_per_min = files as f64 / distinct / mins; + // ⚠ A SCANNER IS JUDGED ONLY AFTER `scan_min_minutes`. jdonkey (the + // Android jed2k client) asks the server for every download without + // peers once a minute. Someone who adds 2 000 downloads sends 2 000 + // different files in the first minute, exactly like a list scanner + // (seen live, 2026-10-21: five of them banned within a minute). By + // minute 10 it has asked each of them about 10 times, a scanner + // about once. + let v = if files >= b.scan_min_files + && distinct >= b.scan_min_distinct as f64 + && mins >= b.scan_min_minutes as f64 + && repeats <= b.scan_max_repeats + { Some(( - format!("scanner: {files} files ({distinct:.0} different) in {mins:.0} min"), + format!( + "scanner: {files} files ({distinct:.0} different, each {repeats:.1}×) in {mins:.0} min" + ), b.scan_action.clone(), )) - } else if files >= b.hammer_min_files && distinct < b.hammer_max_distinct as f64 { + } else if files >= b.hammer_min_files && per_file_per_min >= b.hammer_min_rate { + // By rate per file, not by how few files: jdonkey with 80 + // downloads asks 3 000 times in 40 minutes too, but each file + // once a minute as designed. A hammer asks the same one 5 to 600 + // times a minute. Some(( - format!("hammer: {files} requests for {distinct:.0} file(s) in {mins:.0} min"), + format!( + "hammer: {files} requests for {distinct:.0} file(s) in {mins:.0} min ({per_file_per_min:.1}/min each)" + ), b.hammer_action.clone(), )) } else { @@ -327,9 +367,9 @@ pub fn record_sources(state: &Arc, raw_ip: IpAddr, hashes: &[FileHa if v.is_some() { w.flagged = true; } - v.map(|(r, a)| (r, a, files as f64 / mins)) + v.map(|(r, a)| (r, a, files as f64 / mins, files)) }; - let Some((reason, action, per_min)) = verdict else { return }; + let Some((reason, action, per_min, files)) = verdict else { return }; let action = action.trim().to_ascii_lowercase(); if action == "log" || action == "off" { tracing::warn!(%ip, tcp, %reason, "bot rule matched (action = log, nothing done)"); @@ -341,6 +381,11 @@ pub fn record_sources(state: &Arc, raw_ip: IpAddr, hashes: &[FileHa } let throttled = action == "throttle"; flag(state, ip, raw_ip, reason, per_min, -1.0, throttled, hashes); + // The files of the hour that matched, so "files asked" in the Bots tab + // is not just the one request that crossed the line. + if let Some(mut d) = state.bot_detections.get_mut(&ip) { + d.hashes.total = d.hashes.total.max(files); + } } /// Ban or throttle `ip` for 24 h and record it as a detection, as the rate @@ -479,6 +524,28 @@ pub fn ban_ended(state: &ServerState, key: IpAddr) { } } +/// How long a detection stays in memory (and in the Bots tab) once its ban +/// or throttle is over and it has sent nothing: a day. The ledger on disk +/// keeps it for good. +pub const DETECTION_KEEP: Duration = Duration::from_secs(24 * 3600); + +/// Drop detections whose ban or throttle has ended and that have been quiet +/// for `DETECTION_KEEP`. Each one carries up to `BotHashes::MAX_DISTINCT` +/// hashes; they were never removed before, so the map grew with every day of +/// uptime. Returns how many were dropped. +pub fn sweep_detections(state: &ServerState) -> usize { + let now = SystemTime::now(); + let before = state.bot_detections.len(); + state.bot_detections.retain(|ip, d| { + if state.is_bot_banned(*ip) || state.bot_throttle_admit_peek(*ip) { + return true; + } + let last = d.last_dropped.map_or(d.last_seen, |l| l.max(d.last_seen)); + now.duration_since(last).map_or(true, |age| age < DETECTION_KEEP) + }); + before - state.bot_detections.len() +} + /// Where the ledger goes: the configured path, `bot-log.jsonl` next to the /// config file when empty, or nowhere for "off". pub fn ledger_path(configured: &str, config_file: &std::path::Path) -> Option { @@ -793,9 +860,19 @@ mod scan_rule_tests { assert!(s.bot_detections.get(&ip).is_none()); } + /// Judging at once, so the tests need not wait ten minutes; one test + /// below keeps the default. + fn st_now() -> Arc { + let s = st(); + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.bots.scan_min_minutes = 0; + s.live_cfg.store(Arc::new(cfg)); + s + } + #[test] fn a_list_scanner_is_banned() { - let s = st(); + let s = st_now(); let ip: IpAddr = "198.51.100.31".parse().unwrap(); for i in 0..1999 { record_sources(&s, ip, &[h(i)], true); @@ -810,7 +887,7 @@ mod scan_rule_tests { #[test] fn batched_udp_requests_count_by_file() { - let s = st(); + let s = st_now(); let ip: IpAddr = "198.51.100.32".parse().unwrap(); for r in 0..80u32 { let batch: Vec = (0..25).map(|k| h(r * 25 + k)).collect(); @@ -835,9 +912,9 @@ mod scan_rule_tests { #[test] fn many_files_asked_a_few_times_each_is_neither() { - // 2 500 requests over 500 files: above the hammer's distinct limit, - // below the scanner's. - let s = st(); + // 2 500 requests over 500 files: too few different files for a + // scanner, too few requests for a hammer. + let s = st_now(); let ip: IpAddr = "198.51.100.34".parse().unwrap(); for i in 0..2500u32 { record_sources(&s, ip, &[h(i % 500)], true); @@ -848,7 +925,7 @@ mod scan_rule_tests { #[test] fn action_log_only_records() { - let s = st(); + let s = st_now(); let mut cfg = (**s.live_cfg.load()).clone(); cfg.bots.scan_action = "log".into(); s.live_cfg.store(Arc::new(cfg)); @@ -860,6 +937,76 @@ mod scan_rule_tests { assert!(s.bot_detections.get(&ip).is_none()); } + #[test] + fn a_burst_of_new_downloads_is_not_judged_at_once() { + // jdonkey after adding 2 000 downloads: all of them in the first + // minute. With the default ten minutes it is not a scanner yet. + let s = st(); + let ip: IpAddr = "198.51.100.37".parse().unwrap(); + for i in 0..2500 { + record_sources(&s, ip, &[h(i)], true); + } + assert!(!s.is_bot_banned(ip)); + } + + #[test] + fn files_asked_again_and_again_are_not_a_scan() { + // jdonkey with 1 000 downloads, ten minutes in: each asked about ten + // times. Built up with the rule off, then judged on the next request. + let s = st(); + let ip: IpAddr = "198.51.100.38".parse().unwrap(); + s.scan_windows.insert(ip, ScanWindow::started_ago(Duration::from_secs(11 * 60))); + let set = |n: u64| { + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.bots.scan_min_files = n; + cfg.bots.hammer_min_files = u64::MAX; + s.live_cfg.store(Arc::new(cfg)); + }; + set(u64::MAX); + for _ in 0..10 { + for i in 0..1000 { + record_sources(&s, ip, &[h(i)], true); + } + } + set(2000); + record_sources(&s, ip, &[h(0)], true); + assert!(!s.is_bot_banned(ip)); + // The same volume, each file once: a scan. + let ip2: IpAddr = "198.51.100.40".parse().unwrap(); + s.scan_windows.insert(ip2, ScanWindow::started_ago(Duration::from_secs(11 * 60))); + for i in 0..2000 { + record_sources(&s, ip2, &[h(10_000 + i)], true); + } + assert!(s.is_bot_banned(ip2)); + } + + #[test] + fn many_downloads_once_a_minute_are_not_a_hammer() { + // jdonkey with 80 stalled downloads: 3 000 requests, but each file + // well under five times a minute (here all at once, so the window is + // one minute; 3 000 / 80 / 1 = 37.5 a minute would be a hammer, so + // the window is aged first). + let s = st(); + let ip: IpAddr = "198.51.100.39".parse().unwrap(); + s.scan_windows.insert(ip, ScanWindow::started_ago(Duration::from_secs(40 * 60))); + for i in 0..3000u32 { + record_sources(&s, ip, &[h(i % 80)], true); + } + assert!(s.bot_throttle_admit(ip).is_none()); + assert!(!s.is_bot_banned(ip)); + } + + #[test] + fn a_search_service_is_never_judged() { + let s = st_now(); + let ip: IpAddr = "178.250.170.5".parse().unwrap(); + for i in 0..3000 { + record_sources(&s, ip, &[h(i)], false); + } + assert!(!s.is_bot_banned(ip)); + assert!(s.bot_detections.get(&ip).is_none()); + } + #[test] fn a_ban_wakes_the_tcp_sessions() { let s = st(); @@ -868,3 +1015,35 @@ mod scan_rule_tests { assert!(rx.has_changed().unwrap()); } } + +#[cfg(test)] +mod sweep_tests { + use super::*; + + #[test] + fn a_detection_is_dropped_a_day_after_its_sanction() { + let s = Arc::new(ServerState::for_test()); + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.bots.scan_min_minutes = 0; + s.live_cfg.store(Arc::new(cfg)); + let ip: IpAddr = "198.51.100.50".parse().unwrap(); + for i in 0..2000u32 { + let mut h = [0u8; 16]; + h[..4].copy_from_slice(&i.to_le_bytes()); + h[8..12].copy_from_slice(&i.wrapping_mul(2_654_435_761).to_le_bytes()); + record_sources(&s, ip, &[h], true); + } + assert!(s.is_bot_banned(ip)); + assert_eq!(sweep_detections(&s), 0, "kept while banned"); + // Ban over, last seen just under a day ago: kept. + s.banned_bots.clear(); + let day = DETECTION_KEEP; + s.bot_detections.get_mut(&ip).unwrap().last_seen = + SystemTime::now() - (day - Duration::from_secs(60)); + assert_eq!(sweep_detections(&s), 0); + // A day and more: dropped. + s.bot_detections.get_mut(&ip).unwrap().last_seen = SystemTime::now() - (day + Duration::from_secs(60)); + assert_eq!(sweep_detections(&s), 1); + assert!(s.bot_detections.get(&ip).is_none()); + } +} diff --git a/src/server/bot_ipfilter.rs b/src/server/bot_ipfilter.rs index dbf8877..b889dc5 100644 --- a/src/server/bot_ipfilter.rs +++ b/src/server/bot_ipfilter.rs @@ -103,6 +103,11 @@ pub fn reassert(filter: &Path, list: &Path) -> usize { /// Add `ip` to the IP filter and reload it, off the caller's thread. A no-op /// without `storage.ipfilter_path` or outside a runtime. pub fn add_async(state: &std::sync::Arc, ip: Ipv4Addr) { + // Never an exempt address (`bots.exempt`), however it came to be banned. + if state.live_cfg.load().bots.is_exempt(ip.into()) { + tracing::info!(%ip, "bot ipfilter: address is in bots.exempt, not added"); + return; + } let filter_path = state.live_cfg.load().storage.ipfilter_path.clone(); if filter_path.is_empty() { tracing::info!(%ip, "bot ipfilter: no storage.ipfilter_path, not added"); diff --git a/src/server/search.rs b/src/server/search.rs index e42fe36..174c484 100644 --- a/src/server/search.rs +++ b/src/server/search.rs @@ -161,6 +161,29 @@ impl SearchRequest { } } +/// A search that names one file by hash (`ed2k::HASH`, an ed2k link, a +/// magnet; see `proto::search::ed2k_file_ref`) is answered from the hash +/// index, as Lugdunum does, and not looked up as words. `None` when the +/// search is not one. The file is served under the same rules as any search +/// result: it has a source, its size matches when one was given, and the +/// content filter does not withhold it. Both the TCP and the UDP path go +/// through here, before the unknown-word rewrite, which would drop the +/// hash as a word no file has. +pub fn search_by_hash( + state: &ServerState, + tree: &SearchNode, +) -> Option> { + let (hash, size) = crate::proto::search::ed2k_file_search(tree)?; + state.note_hash_search(); + let rec = state.file_slab.get_by_hash(&hash).filter(|r| { + !r.sources.is_empty() + && (size == 0 || r.size == size) + && !state.filter.is_withheld(&r.hash, r.name()) + }); + debug!(hash = %hex::encode(hash), size, found = rec.is_some(), "search by file hash"); + Some(rec.into_iter().collect()) +} + /// Process a search request. Returns the SEARCHRESULT frame to send back. /// Run a search and return the best matching file entries, at most /// `limits.max_search_results` of them, best-sourced first. @@ -171,6 +194,9 @@ pub fn handle_search( req: SearchRequest, ) -> Vec { state.note_search(); + if let Some(found) = search_by_hash(state, &req.tree) { + return found; + } // Drop words no indexed file contains, so one typo does not empty the whole // search. The rewritten tree replaces `req.tree` for EVERY stage below — @@ -493,18 +519,18 @@ pub fn build_search_result_page( let size_hi = (file.size >> 32) as u32; let mut tags = vec![ Tag::byte(FT_FILENAME, TagValue::String(file.name().to_string())), - Tag::byte(FT_FILESIZE, TagValue::U32(size_lo)), + Tag::byte(FT_FILESIZE, TagValue::uint(size_lo)), ]; if size_hi > 0 { - tags.push(Tag::byte(FT_FILESIZE_HI, TagValue::U32(size_hi))); + tags.push(Tag::byte(FT_FILESIZE_HI, TagValue::uint(size_hi))); } tags.push(Tag::byte( FT_SOURCES, - TagValue::U32(file.sources.len() as u32), + TagValue::uint(file.sources.len() as u32), )); tags.push(Tag::byte( FT_COMPLETE_SOURCES, - TagValue::U32(file.complete_source_count()), + TagValue::uint(file.complete_source_count()), )); // FT_FILETYPE as an INTEGER — what the SRV_TCPFLG_TYPETAGINTEGER // capability bit (0x0080) promises, and what eserver 17.6+ emits. @@ -518,7 +544,7 @@ pub fn build_search_result_page( // category that matches nothing. let ftype = crate::proto::search::ed2k_file_type_id(&file.name().to_lowercase()); if ftype != crate::proto::search::ed2k_file_type::ANY { - tags.push(Tag::byte(FT_FILETYPE, TagValue::U32(ftype))); + tags.push(Tag::byte(FT_FILETYPE, TagValue::uint(ftype))); } push_media_tags(state, &file.hash, &mut tags); @@ -753,13 +779,13 @@ pub(crate) fn prepared_tree(state: &ServerState, tree: &SearchNode) -> SearchNod pub(crate) fn push_media_tags(state: &ServerState, hash: &[u8; 16], tags: &mut Vec) { let Some(m) = state.file_slab.media_of_hash(hash) else { return }; if m.bitrate() != 0 { - tags.push(Tag::byte(0xD4, TagValue::U32(m.bitrate()))); + tags.push(Tag::byte(0xD4, TagValue::uint(m.bitrate()))); } if let Some(c) = state.file_slab.codecs.name(m.codec()) { tags.push(Tag::byte(0xD5, TagValue::String(c.to_string()))); } if m.length() != 0 { - tags.push(Tag::byte(0xD3, TagValue::U32(m.length()))); + tags.push(Tag::byte(0xD3, TagValue::uint(m.length()))); } } diff --git a/src/server/udp.rs b/src/server/udp.rs index a5aff23..d6a6794 100644 --- a/src/server/udp.rs +++ b/src/server/udp.rs @@ -2024,6 +2024,10 @@ fn udp_search_ranked( state: &ServerState, tree: crate::proto::search::SearchNode, ) -> Vec { + // `ed2k::HASH` and the like: one file by hash, as over TCP. + if let Some(found) = crate::server::search::search_by_hash(state, &tree) { + return found; + } // Same unknown-word rewrite as the TCP path, applied before anything // else reads the tree. If only one channel dropped unknown words, the // same query would return results over TCP and nothing over UDP. @@ -2188,14 +2192,14 @@ fn udp_search_datagram(state: &ServerState, entry: &crate::state::file_id::FileR let size_hi = (entry.size >> 32) as u32; let mut tags = vec![ Tag::byte(FT_FILENAME, TagValue::String(entry.name().to_string())), - Tag::byte(FT_FILESIZE, TagValue::U32(size_lo)), + Tag::byte(FT_FILESIZE, TagValue::uint(size_lo)), ]; if size_hi > 0 { - tags.push(Tag::byte(FT_FILESIZE_HI, TagValue::U32(size_hi))); + tags.push(Tag::byte(FT_FILESIZE_HI, TagValue::uint(size_hi))); } tags.push(Tag::byte( FT_SOURCES, - TagValue::U32(entry.sources.len() as u32), + TagValue::uint(entry.sources.len() as u32), )); // FT_COMPLETE_SOURCES (0x30) MUST be sent or eMule's "Complete" // column stays at "0% (0)" forever — the UDP search response path @@ -2203,14 +2207,14 @@ fn udp_search_datagram(state: &ServerState, entry: &crate::state::file_id::FileR // as complete (issue #28; see FileRecord::complete_source_count). tags.push(Tag::byte( FT_COMPLETE_SOURCES, - TagValue::U32(entry.complete_source_count()), + TagValue::uint(entry.complete_source_count()), )); // FT_FILETYPE as an integer — see the note in server/search.rs. The // UDP and TCP result paths must carry the same tags, or a client // gets different metadata depending on which one answered it. let ftype = crate::proto::search::ed2k_file_type_id(&entry.name().to_lowercase()); if ftype != crate::proto::search::ed2k_file_type::ANY { - tags.push(Tag::byte(FT_FILETYPE, TagValue::U32(ftype))); + tags.push(Tag::byte(FT_FILETYPE, TagValue::uint(ftype))); } crate::server::search::push_media_tags(state, &hash, &mut tags); write_tag_list(&mut out, &tags); diff --git a/src/state/mod.rs b/src/state/mod.rs index ccaa6b6..a85eac4 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -828,6 +828,8 @@ pub struct ServerState { /// counts in the posting lists — so ranking needs no scan at all — starts to /// pay for its cost on every publish. If it stays near zero, it does not. pub search_rank_capped: std::sync::atomic::AtomicU64, + /// Searches that named one file by hash (`ed2k::HASH`, links, magnets). + pub search_by_hash: std::sync::atomic::AtomicU64, /// Total searches served, as the denominator for the above. pub search_total: std::sync::atomic::AtomicU64, /// Searches in which at least one word was dropped as unknown to the index @@ -894,7 +896,9 @@ impl BotHashes { /// 16 bytes each plus the set's overhead: about 0.6 MB for a bot that /// fills it. Detections are few; the cap is there for the one that walks /// the whole index. - pub const MAX_DISTINCT: usize = 20_000; + /// 2 000: enough to tell a list scan and to look the files up + /// (`/api/bots/files`); 20 000 cost up to half a megabyte per scanner. + pub const MAX_DISTINCT: usize = 2_000; pub fn add(&mut self, h: FileHash) { self.total += 1; @@ -1357,6 +1361,12 @@ impl ServerState { /// hot path before any parsing, so it must stay a single cheap lookup. /// Expired entries are not removed here (the 60s cleanup task sweeps them); /// we just treat them as not-banned. + /// Is `ip` throttled now? Unlike `bot_throttle_admit` it takes no token. + pub fn bot_throttle_admit_peek(&self, ip: std::net::IpAddr) -> bool { + self.throttled_bots + .get(&self.source_key(ip)) + .is_some_and(|e| e.0.elapsed() < Self::BOT_BAN_TTL) + } pub fn is_bot_banned(&self, ip: std::net::IpAddr) -> bool { self.banned_bots .get(&self.source_key(ip)) @@ -1585,6 +1595,7 @@ impl ServerState { block_stats: DashMap::new(), conn_per_ip: DashMap::new(), search_rank_capped: std::sync::atomic::AtomicU64::new(0), + search_by_hash: std::sync::atomic::AtomicU64::new(0), search_total: std::sync::atomic::AtomicU64::new(0), search_words_dropped: std::sync::atomic::AtomicU64::new(0), offer_over_soft_batches: std::sync::atomic::AtomicU64::new(0), @@ -1648,6 +1659,17 @@ impl ServerState { .load(std::sync::atomic::Ordering::Relaxed) } + /// A search named one file by hash. + pub fn note_hash_search(&self) { + self.search_by_hash + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + + pub fn search_by_hash_count(&self) -> u64 { + self.search_by_hash + .load(std::sync::atomic::Ordering::Relaxed) + } + pub fn search_words_dropped_count(&self) -> u64 { self.search_words_dropped .load(std::sync::atomic::Ordering::Relaxed) @@ -2205,6 +2227,12 @@ impl ServerState { IPADDR, size_of::(), ); + // Their hash sets live on the heap: slots of a hash and a control byte. + misc += self + .bot_detections + .iter() + .map(|d| (d.hashes.distinct.capacity() * 17 + d.hashes.recent.capacity() * 16) as u64) + .sum::(); misc += dm_slots( self.udp_sockets.capacity(), 2, diff --git a/src/web.rs b/src/web.rs index 8147a74..b170841 100644 --- a/src/web.rs +++ b/src/web.rs @@ -808,6 +808,7 @@ struct StatsResp { /// Searches that had at least one unknown word dropped rather than /// returning nothing. searches_words_dropped: u64, + searches_by_hash: u64, /// OFFERFILES batches cut at `limits.soft_limit_files`, and records dropped. offer_over_soft_batches: u64, offer_over_soft_records: u64, @@ -912,6 +913,7 @@ async fn api_stats(State(s): State) -> Json { searches_served: search_served, searches_rank_capped: search_capped, searches_words_dropped: s.server.search_words_dropped_count(), + searches_by_hash: s.server.search_by_hash_count(), offer_over_soft_batches: s.server.offer_over_soft_stats().0, offer_over_soft_records: s.server.offer_over_soft_stats().1, soft_limit_files: s.server.live_cfg.load().limits.soft_limit_files, @@ -3726,8 +3728,33 @@ tr:hover td{background:#1e2035}

Bot Detection

- IPs flagged for abnormal UDP query patterns, judged over at least bots.min_span_secs (10 s) so a short burst is not a flood. Flood > 600 qpm: banned for 24h, its UDP traffic dropped at the recv loop. Uniform timing > 200 qpm with stddev < 50 ms: throttled for 24h to bots.throttle_qpm (60), still answered — mostly one client build with broken pacing. A source that keeps sending through its ban (bots.ipfilter_min_dropped, 10 000 dropped; ten times that when throttled) is added to the IP filter file for good, marked bot. TCP sessions are not affected until then. + Sources flagged by the bot rules (README, [bots]). Scanner: thousands of different files asked for sources in an hour, each about once — banned 24h, over TCP and UDP. Hammer: the same file or few asked 5+ times a minute — throttled 24h to bots.throttle_qpm, still answered. Flood (> 600 UDP qpm over 10 s or more) is banned, uniform timing throttled. A source that keeps sending through its ban (bots.ipfilter_min_dropped) is added to the IP filter for good, marked bot. Addresses in bots.exempt are never judged. A detection is dropped from this table a day after its ban or throttle ended; the ledger keeps it.

+
+ + + + + +

Keep a record: export CSV · JSON lines · @@ -3883,6 +3910,7 @@ async function refreshStatus() { Hard file limit reached${fmt(sys.offer_over_hard_packets)} packets rejected limits.hard_limit_files = ${sys.hard_limit_files ? fmt(sys.hard_limit_files) : 'off'}; an OFFERFILES declaring this many records or more is rejected and the connection closed OFFERFILES v1${(()=>{const o=sys.offer_v1||{};if(!sys.offerfiles_v1)return `off${o.sessions?` · ${fmt(o.sessions)} sessions advertised before it was turned off`:''}`;if(sys.offerfiles_v1_error)return `not advertised: ${escapeHtml(sys.offerfiles_v1_error)}`;return `${escapeHtml(sys.offerfiles_v1_policy||'')}
${fmt(o.requested||0)} logins asked · ${fmt(o.sessions)} advertised · ${fmt(o.batches)} batches, ${fmt(o.records)} records · waited for own pace ${fmt(o.conn_waits)} (${fmt(o.conn_wait_ms)} ms) · for the server-wide ceiling ${fmt(o.global_waits)} (${fmt(o.global_wait_ms)} ms, longest ${fmt(o.global_wait_max_ms)} ms, ${fmt(o.global_waiting_now)} waiting now) · above batch size ${fmt(o.oversized)}`})()} limits.offerfiles_v1 — batch size and pace, advertised in SERVERIDENT only to clients that ask for it at login (issue #19); early batches wait, never dropped; other clients publish exactly as with v1 off Unknown words dropped${fmt(sys.searches_words_dropped)} searches in which a word no indexed file contains was ignored instead of emptying the search + Searches by file hash${fmt(sys.searches_by_hash)} ed2k::HASH, ed2k links and magnets, answered from the hash index (hash lookup services) Ranking scan cap hit${fmt(sys.searches_rank_capped)} (${sys.searches_rank_capped_pct.toFixed(1)}%) ranked over part of the candidate set, not all of it — raise limits.search_rank_scan if this is a large share Banned bots (24h)${fmt(st.banned_bots)} NAT-T capable clients${fmt(st.natt_capable_clients)} connected clients with the NAT-traversal mod @@ -4109,17 +4137,55 @@ showTab = function(name, btn) { if (name === 'settings' && !settingsLoaded) refreshSettings(); }; +let botsRows = []; async function refreshBots() { - const rows = await fetch('/api/bots').then(r => r.json()); + botsRows = await fetch('/api/bots').then(r => r.json()) || []; + renderBots(); +} +function botState(b) { + if (b.ipfilter_added) return 'ipfilter'; + if (b.banned) return 'banned'; + if (b.throttled) return 'throttled'; + return 'ended'; +} +function botRule(b) { + const r = (b.reason || '').toLowerCase(); + if (r.startsWith('scanner')) return 'scanner'; + if (r.startsWith('hammer')) return 'hammer'; + return 'rate'; +} +function renderBots() { const el = document.getElementById('bots-table'); - if (!rows || rows.length === 0) { - el.innerHTML = '

No bots detected.
'; + const q = (document.getElementById('bf-q').value || '').trim().toLowerCase(); + const st = document.getElementById('bf-state').value; + const rule = document.getElementById('bf-rule').value; + const sort = document.getElementById('bf-sort').value; + let rows = botsRows.filter(b => + (!st || botState(b) === st) + && (!rule || botRule(b) === rule) + && (!q || [b.ip, b.country, b.provider, b.tcp_client, b.reason] + .some(v => v && String(v).toLowerCase().includes(q)))); + const key = { + last: b => -b.last_seen_secs_ago, + first: b => -b.first_seen_secs_ago, + dropped: b => b.dropped_after_ban || 0, + files: b => b.hashes_total || 0, + qpm: b => b.queries_per_minute || 0, + }[sort]; + if (sort === 'ip') rows.sort((a, b) => a.ip.localeCompare(b.ip, undefined, {numeric: true})); + else rows.sort((a, b) => key(b) - key(a)); + const tally = {ipfilter: 0, banned: 0, throttled: 0, ended: 0}; + botsRows.forEach(b => tally[botState(b)]++); + document.getElementById('bf-count').textContent = `${rows.length} of ${botsRows.length} · ` + + `${tally.ipfilter} IP filter · ${tally.banned} banned · ${tally.throttled} throttled · ${tally.ended} over`; + if (rows.length === 0) { + el.innerHTML = `
${botsRows.length ? 'No bot matches the filter.' : 'No bots detected.'}
`; return; } el.innerHTML = '' + '' + '' - + '' + + '' + rows.map(b => { // -1 is our sentinel for "not enough samples to compute stddev" const stddev = (b.interval_stddev_ms < 0) ? '—' : b.interval_stddev_ms.toFixed(0); @@ -4146,7 +4212,8 @@ async function refreshBots() { + `` + `` + + `${b.ipfilter_added ? ' IP FILTER' : ''}` + + `${botState(b) === 'ended' ? ' OVER' : ''}` + `` + ``; }).join('') From 878fc830f60dde3907811baede83bb2ece42e19b Mon Sep 17 00:00:00 2001 From: andrey23127 Date: Sun, 25 Oct 2026 02:22:41 +0300 Subject: [PATCH 2/5] Release 0.9.85: Lugdunum's NAT callback, hole-punch extension (#39) Lugdunum's NAT callback (issue #39), read from Lugdunum's code and checked against aMule's implementation, so a client written for it works here unchanged. * Login: 0x1000 in the OP_IDCHANGE TCP flags for every client, as Lugdunum. A client that set 0x1000 in CT_SERVER_FLAGS gets an odd LowID and a 24-byte OP_IDCHANGE whose last field is its TCP port as seen (connection source port for a LowID, its own port for a HighID). Other clients get even LowIDs. * UDP 0xA6 , server-key obfuscated, from a LowID at its TCP address, is forwarded to the target as []: by UDP 0xA7 to the target's endpoint when it sent 0xA6 itself within 20 s, else TCP 0x37. Crypt byte and hash as Lugdunum; nothing sent when one side requires the crypt layer and the other can't. No answer to the requester. * Stricter than Lugdunum: the target must have set 0x1000, and requests are limited per requester (burst 10, then 1/s). * network.lugdunum_natt (default on, live). Status tab: forwards, refusals, capable clients; Blocks tab: natcb_* reasons. * No conflict with 0x60-0x62 / 0x9F / 0x20000 (unchanged, separate state), with eNode-go's 0xA6/0xA7 (other directions), or with stock clients (unknown flag bit ignored, OP_IDCHANGE read up to byte 20). Hole-punch extension, flag 0x100000 (agreed in #39), only between two clients that both set 0x100000 with 0x1000 in CT_SERVER_FLAGS: * 0x60 wakes the target with Lugdunum's 0x37; the server waits up to 3 s for the target's 0x9F and sends 0x61 (30 bytes, peer_crypt appended) with that fresh port; without it, the same 0x61 with today's ports. * A repeated 0x60 while one waits adds nothing. A session that sent an obfuscated 0x9F has its plain ones ignored. * Every other pair: today's 29-byte 0x61 at once, no 0x37. * network.holepunch_extension (default on, live; needs lugdunum_natt). Bridge: a 0x60 whose target set 0x1000, lacks the extension and has sent no 0x9F in its session also sends that target 0x37, so Lugdunum-only clients (NeoMule's CanLow2Low) can be reached by 0x60. * README: tables of OP_IDCHANGE flag bits (eMule, Lugdunum, NeoLoader / ed2kNET, IPv6 extension, eNode-go, this server; first free 0x200000) and of client CT_SERVER_FLAGS bits; the extension and bridge documented. * Config template: highid_verify_observe = false (the verdict mode feeds the same counters). Tests: 603 unit + 27 integration pass. Checked against a running server: two 0x1000 clients get odd LowIDs and a 24-byte OP_IDCHANGE with the seen port, a stock client an even LowID and 20 bytes; an obfuscated 0xA6 reaches the target as a 27-byte 0x37 with the requester's UDP endpoint and hash; a target that asked within 20 s gets 0xA7 by UDP from the obfuscation port; a target without 0x1000 and a plain 0xA6 get nothing. Extension, scripted clients against a running server: an extension pair gets 0x37 to the target, then 30-byte 0x61 right after the target's 0x9F with its fresh port and crypt byte; with the target silent, 0x61 after 3 s with the announced port; mixed and legacy pairs get 29-byte 0x61 at once and no 0x37; a 0x1000 target that never sent 0x9F gets 0x61 plus 0x37; a plain 0x9F from another port after an obfuscated one does not move the client's port. --- CHANGELOG.md | 74 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 122 ++++++++- config/config.toml | 13 +- src/config.rs | 17 ++ src/main.rs | 14 ++ src/proto/opcodes.rs | 38 ++- src/server/connection.rs | 24 +- src/server/holepunch.rs | 479 ++++++++++++++++++++++++++++++++++-- src/server/login.rs | 114 ++++++++- src/server/lugdunum_natt.rs | 424 +++++++++++++++++++++++++++++++ src/server/mod.rs | 1 + src/server/udp.rs | 75 +++++- src/state/mod.rs | 32 +++ src/web.rs | 46 +++- 16 files changed, 1406 insertions(+), 71 deletions(-) create mode 100644 src/server/lugdunum_natt.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a81bc3..aa5a973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,80 @@ Earlier releases are described in their release commit messages. +## 0.9.85 + +Lugdunum's NAT callback (issue #39), so a client written for the original +eserver's NAT traversal works here unchanged, beside this server's own hole +punch; the hole-punch extension built on it with aMule and emule-qt; and a +bridge from `0x60` to clients that speak only Lugdunum's scheme. Two new +keys, `network.lugdunum_natt` and `network.holepunch_extension` (both on). + +### Lugdunum's NAT callback + +Read from Lugdunum's code and checked against aMule's implementation: + +- **Login.** `0x1000` in the `OP_IDCHANGE` TCP flags, for every client, as + Lugdunum sends it. A client that set `0x1000` in `CT_SERVER_FLAGS` gets an + odd LowID and four more `OP_IDCHANGE` bytes: its TCP port as the server + sees it (the source port of its connection for a LowID, its own port for a + HighID). aMule turns the callback on only with both. Other clients get + even LowIDs, so an odd LowID always means a capable client. +- **Request.** UDP `0xA6` ``, server-key + obfuscated, from a logged-in LowID at the address of its TCP session. The + datagram's source port is the requester's endpoint as the server sees it. +- **Forward.** ``, plus the requester's + user hash and bit `0x80` when both support the crypt layer and one asks for + it; nothing when one side requires it and the other can't. By UDP `0xA7` + to the target's own endpoint when the target sent `0xA6` itself less than + 20 s ago, else by TCP `0x37`. The requester gets no answer, as on Lugdunum. +- **Stricter than Lugdunum**: the target must have set `0x1000` (Lugdunum + also forwards to stock clients, which don't know `0x37`), and each + requester gets a burst of 10 requests, then one a second. +- **No conflict.** The two schemes share no opcode or state: `0x60`-`0x62`, + `0x9F` and `0x20000` are unchanged, and a client may use either. `0xA6` is + server-to-client only in eNode-go's dialect, and `0xA7` goes only to a + client that itself sent `0xA6`. Stock eMule ignores the `0x1000` server bit + and reads `OP_IDCHANGE` only up to byte 20, as with Lugdunum. +- Status tab: forwards (TCP, UDP), refusals and capable clients; reasons in + the Blocks tab as `natcb_*`. +- The config template sets `highid_verify_observe = false`: the verdict + mode feeds the same counters. + +### Hole-punch extension (flag `0x100000`) + +`0x60` with fresh ports and no standing keepalive. Only between two clients +that both set `0x100000` with `0x1000` in `CT_SERVER_FLAGS`; the server sets +`0x100000` in `OP_IDCHANGE`. Every other pair gets today's punch byte for +byte. + +- The target is woken with Lugdunum's `0x37`, carrying the requester's port. + The server waits up to 3 s for the target's `0x9F`, then sends `0x61` to + both with that fresh port, 30 bytes: `peer_crypt` (the peer's + `(CT_SERVER_FLAGS >> 9) & 7`) appended. Resends at +1.2 s and +3 s. +- No `0x9F` within 3 s: the same `0x61` with the ports used today. A lost + `0x9F` costs the wait, not the punch. No new failure reason. +- A repeated `0x60` while one waits adds nothing. +- On a server with the extension, a session that sent an obfuscated `0x9F` + has its plain ones ignored (counted as `natt_keepalive_plain_ignored`). +- Status tab: coordinations started, released by a fresh `0x9F`, timed out; + clients with the extension. + +### Bridge to Lugdunum-only targets + +A client that implements Lugdunum's callback but not `0x61` (NeoMule's +"CanLow2Low") could not be reached by `0x60`. A `0x60` whose target set +`0x1000`, did not announce the extension and has sent no `0x9F` in its +session now also sends that target Lugdunum's `0x37` (same crypt rule as on +the `0xA6` path), so it dials the requester. The requester's side is +unchanged. Status tab: "bridged". + +### README + +- A table of the `OP_IDCHANGE` TCP flag bits taken by eMule, Lugdunum, + NeoLoader/ed2kNET, the IPv6 extension, eNode-go and this server (first free + bit now `0x200000`), and one of the client's `CT_SERVER_FLAGS` bits. +- The extension and the bridge described next to the `0x60` contract. + ## 0.9.84 Bot rules built from a day of live traffic: list scanners and hammering diff --git a/Cargo.lock b/Cargo.lock index 59927c3..327674f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "ed2k-server" -version = "0.9.84" +version = "0.9.85" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 316e536..b17c9de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ed2k-server" -version = "0.9.84" +version = "0.9.85" edition = "2021" description = "Modern eDonkey2000 / eMule index server (test/MVP build)" license = "MIT" diff --git a/README.md b/README.md index 6779db3..a84be10 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,8 @@ a watched file) or needs a **restart**. | `highid_verify_observe` | **Observe only.** After a client gets HighID from the plain TCP probe, also send OP_HELLO in the background and count whether it answers with the user hash that logged in. Nothing changes for the client. Counters on the Status tab, cases in `/api/highid_mismatches`. Default off | live | | `highid_downgrade_on_wrong_hash` | **Verdict.** The same background check, acted on: a port that answers with a *different* client's user hash marks (IP, port, login hash), and that client's next logins get LowID while the mark lasts (see below). Default off | live | | `highid_wrong_hash_ttl_secs` | How long a wrong-hash mark lasts. Default 86400 (a day) | live | +| `lugdunum_natt` | Lugdunum's NAT callback (see *NAT traversal*): flag `0x1000` in `OP_IDCHANGE`, odd LowIDs and a 24-byte `OP_IDCHANGE` for clients that set `0x1000`, UDP `0xA6` forwarded as TCP `0x37` / UDP `0xA7`. Default on | live | +| `holepunch_extension` | The hole-punch extension (see *NAT traversal*): flag `0x100000` in `OP_IDCHANGE`. Only for two clients that both announced `0x100000` with `0x1000`: a `0x60` wakes the target with `0x37`, waits up to 3 s for its fresh `0x9F`, then sends a 30-byte `0x61` with the peer's crypt setting. Also: plain `0x9F` ignored from a session that sent an obfuscated one. Needs `lugdunum_natt`. Default on | live | | `ipv6_enabled` | Accept clients over IPv6 on the same TCP port, plus an IPv6 UDP socket on `tcp_port + 4`. Separate IPv6-only sockets, so the IPv4 path is unchanged; a host without IPv6 logs a warning and serves IPv4 only. Default off | restart | | `listen_ip6` | Bind address for the IPv6 sockets. Default `::` | restart | | `this_ip6` | The server's public IPv6, announced in `OP_SERVERIDENT` when `ipv6_publish_sources` is on. Empty = not announced | live | @@ -799,7 +801,8 @@ parse `0xF9`, so sending it is safe against any server. **Is this server doing it?** A server that coordinates the punch sets bit `0x20000` in the `OP_IDCHANGE` TCP flags (issue #35). Send `0x60` only to a server that set it: some servers drop the connection on an unknown opcode. -Lugdunum's own NAT callback is a different scheme, announced with `0x1000`. +Lugdunum's NAT callback is a different scheme, announced with `0x1000`; this +server speaks it too (below), and a client may use either. **2. Refresh your external UDP port — the keepalive (critical).** While connected, send `OP_SERVER_NATT_KEEPALIVE` (`0x9F`) **from your client UDP socket** (not the TCP @@ -844,7 +847,8 @@ the `target_id` of its `0x60`, so it can match a `0x61` to one of several requests in flight; the target gets the requester's ID. It was added in 0.9.84; earlier servers send 25 bytes. **Read it only when the payload is at least 29 bytes**, and accept longer payloads: fields may be appended later, never -inserted. +inserted. Between two clients using the extension (below) it is 30 bytes: +`peer_crypt(1)` is appended. (`0x60`–`0x62` are unused in the server TCP opcode space, which runs `0x01`–`0x44`, and `0x9F` was free in the server UDP namespace — no conflict on the server link.) @@ -879,6 +883,74 @@ Pick anything else, and open an issue or a PR here to add your row. The server never sends or reads `0xB2`; the table is kept here only because the NAT-T clients already meet in this README. +### Lugdunum's NAT callback (compatibility, issue #39) + +This server also runs the NAT callback of the original eserver (Lugdunum +17.x), so a client written for it works here unchanged. It is read from +Lugdunum's code and checked against aMule's implementation; the two schemes +share no opcode and run side by side. Off with `network.lugdunum_natt = false`. + +| Step | Wire | +|---|---| +| Login | the client sets `0x1000` in `CT_SERVER_FLAGS` | +| Login answer | `0x1000` in the `OP_IDCHANGE` TCP flags (for every client, as Lugdunum); for a client that set `0x1000`, 4 more bytes: `[20-23]` its TCP port as the server sees it (the source port of its connection for a LowID, its own port for a HighID). Its LowID is odd; other clients get even LowIDs | +| Request, client → server, UDP | `0xA6` ``, server-key obfuscated (sent from the client UDP socket to the server's obfuscation UDP port, so the source port is the one peers must punch) | +| Forward, server → target | ``, plus the requester's user hash (16) with `0x80` set in `crypt` when both sides support the crypt layer and one requests it. `crypt` is the requester's `(CT_SERVER_FLAGS >> 9) & 7`. By UDP `0xA7` to the target's own endpoint when the target sent `0xA6` itself less than 20 s ago, else by TCP `0x37` | + +The requester gets no answer, as on Lugdunum: the target punches towards it. +aMule then sends the reverse `0xA6`, which brings the target's endpoint to the +requester the same way. Both must be logged-in LowIDs, the request must come +from the requester's TCP address, and nothing is sent when one side requires +the crypt layer and the other can't use it. Two rules are stricter than +Lugdunum: the target must have set `0x1000` (Lugdunum also forwards to stock +clients, which don't know `0x37`), and a requester gets a burst of 10 requests, +then one a second. Refusals are counted in the Blocks tab as `natcb_*`; the +Status tab shows forwards, refusals and capable clients. + +How the two schemes compare: Lugdunum's needs no keepalive (the request +itself carries a fresh endpoint), but only the target learns the other side's +address. If the requester's NAT drops packets from addresses it has not sent +to (address- or port-restricted, most home routers), the target's punch does +not get through until the reverse request. `0x60` answers both sides at once, +with endpoint, user hash and role, and has an explicit failure (`0x62`). + +**Bridge to Lugdunum-only targets.** Some clients implement Lugdunum's +callback and not `0x61` (NeoMule's "CanLow2Low"). A `0x60` whose target set +`0x1000`, did not announce the extension below and has sent no `0x9F` in its +session also sends that target the usual `0x37`, so it dials the requester. +Having sent a `0x9F` shows a client reads `0x61`. The requester's side is +unchanged. Counted on the Status tab as "bridged". + +### Hole-punch extension (issue #39) + +`0x60` with fresh ports and no standing keepalive, agreed with aMule and +emule-qt. It applies only when the server sets `0x100000` in the `OP_IDCHANGE` +flags and **both** clients set `0x100000` together with `0x1000` in +`CT_SERVER_FLAGS` at login. Every other pair gets exactly the punch above: +`0x61` at once, 29 bytes, no `0x37`. Off with +`network.holepunch_extension = false` (it needs `lugdunum_natt`). + +1. The requester sends a `0x9F` (server-key obfuscated when it has the key) + and the `0x60`, in either order. +2. The server wakes the target with Lugdunum's `0x37`, byte-identical, + carrying the requester's best port so far. +3. The target sends `0x9F` at 0, 0.5 and 1 s and starts punching towards that + endpoint. It dials only as the role in `0x61` says. +4. On the target's first `0x9F` the server sends `0x61` to both, 30 bytes, + with `peer_crypt` = the peer's `(CT_SERVER_FLAGS >> 9) & 7` (support, + request, require). Resends follow at +1.2 s and +3 s as usual. +5. With no `0x9F` from the target within 3 s, the server sends the same + `0x61` with the ports it would have used anyway (the order under step 2 of + the contract), or `0x62` reason 4 where it always would. A lost `0x9F` + costs the wait, never the punch. + +A `0x60` repeated while one is waiting adds nothing. On a server with +`0x100000`, once a session has sent an obfuscated `0x9F`, its plain ones are +ignored: only the server's key makes one, so a forged datagram can't move +that client's port. Clients without the extension keep working as before. +The Status tab shows coordinations started, released by a fresh `0x9F`, and +timed out. + **5. Keep your LowID source alive.** A share-only client is TCP-silent for hours. This server keeps such a source by **(a)** counting your `0x9F` UDP keepalive as activity against the session idle timer (idle backstop is 900 s, refreshed by any @@ -893,6 +965,45 @@ sending the `0x9F` keepalive; the rest is the server's bookkeeping. Get the cade wrong and a live share-only source is silently evicted after a few minutes — the symptom is identical to a successful connection, which makes it easy to misdiagnose. + +### `OP_IDCHANGE` TCP flag bits in use + +The second word of `OP_IDCHANGE` (server → client) is shared by every server +family, and each has added bits to it. To keep them apart, the ones known to +be taken (corrections and new rows welcome, as for the `0xB2` table): + +| Bit | Meaning | Defined by | This server | +|---|---|---|---| +| `0x0001` | zlib compression | eMule | set | +| `0x0008` | new tags | eMule | set | +| `0x0010` | Unicode | eMule | set | +| `0x0040` | related search | eMule | set | +| `0x0080` | integer file-type tag | eMule | set | +| `0x0100` | large files | eMule | set | +| `0x0400` | TCP obfuscation | eMule | set | +| `0x1000` | NAT callback (`0xA6` / `0x37` / `0xA7`) | Lugdunum | set (since 0.9.85) | +| `0x2000` | IPv6 (read by NeoLoader); ed2kNET uses `0x1000`/`0x2000` unofficially | NeoLoader, ed2kNET | no | +| `0x4000` | inline IPv6 sources | IPv6 extension | with `ipv6_publish_sources` | +| `0x8000` | `OP_IPV6_UPDATE` accepted (issue #34) | this server | with `ipv6_publish_sources` | +| `0x10000` | meta search | eNode-go | no | +| `0x20000` | LowID hole punch, `0x60`-`0x62` (issue #35) | this server | set | +| `0x40000` | NAT rendezvous (`FlagNatRendezvous`, since 2026-10-20) | eNode-go | no | +| `0x80000` | server search (`FlagServerSearch`, reserved, not sent) | eNode-go | no | +| `0x100000` | hole-punch extension (issue #39) | this server | with `holepunch_extension` | + +The first bit free everywhere is `0x200000`. + +The client's `CT_SERVER_FLAGS` at login (client → server) is a different word +with its own bits: + +| Bit | Meaning | Defined by | +|---|---|---| +| `0x0001`–`0x0010`, `0x0100` | zlib, IP in login, aux port, new tags, Unicode, large files | eMule | +| `0x0200` / `0x0400` / `0x0800` | crypt layer supported / requested / required | eMule | +| `0x1000` | NAT callback; odd LowID, 24-byte `OP_IDCHANGE` | Lugdunum | +| `0x10000` | asks for meta-search rows | eNode-go | +| `0x100000` | hole-punch extension, always with `0x1000` | this server | + --- ## IPv6 @@ -927,9 +1038,10 @@ Every element is ignored by a client that does not know it. The client announces the extension with the `CT_MOD_IP_V6` tag alone. An earlier version of this table also listed `CT_SERVER_FLAGS` bit `0x1000`; do -not send it: on Lugdunum that bit is `SRVCAP_NATTRAVERSAL`, and a client that -sets it gets an odd LowID, a longer `OP_IDCHANGE` and NAT callback notices -(issue #35). This server never read it. +not send it: that bit is Lugdunum's `SRVCAP_NATTRAVERSAL`, and a client that +sets it gets an odd LowID, a longer `OP_IDCHANGE` and NAT callback notices, +on Lugdunum (issue #35) and, since 0.9.85, here too (issue #39). It never +meant IPv6 to this server. Rules the server applies: diff --git a/config/config.toml b/config/config.toml index 84c5dfd..7e962a2 100644 --- a/config/config.toml +++ b/config/config.toml @@ -100,10 +100,21 @@ hairpin_lan_clients = false # # No answer (silent close, reset, timeout — often just the peer's IP filter) # NEVER costs HighID. The hairpin path above is separate and unaffected. -highid_verify_observe = true +highid_verify_observe = false # counting only; the verdict below feeds the same counters highid_downgrade_on_wrong_hash = true highid_wrong_hash_ttl_secs = 86400 # 24 h +# Lugdunum's NAT callback (README, NAT traversal): clients that set 0x1000 at +# login get an odd LowID and a 24-byte OP_IDCHANGE, and their UDP 0xA6 +# requests are forwarded to the target as TCP 0x37 / UDP 0xA7. Runs beside the +# 0x60-0x62 hole punch. +# lugdunum_natt = true +# The hole-punch extension (issue #39): flag 0x100000. Only for two clients that +# both announced 0x100000 and 0x1000 at login: a 0x60 wakes the target with 0x37 +# and waits up to 3 s for its fresh 0x9F before sending 0x61. Everyone else sees +# no change. Needs lugdunum_natt. +# holepunch_extension = true + # ── IPv6 ──────────────────────────────────────────────────────────────────── # Accept clients over IPv6: separate IPv6-only sockets, TCP on tcp_port and UDP # on tcp_port + 4. The IPv4 path is unchanged, and a host without IPv6 logs a diff --git a/src/config.rs b/src/config.rs index 2c760c3..c28f86d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -935,6 +935,23 @@ pub struct NetworkConfig { #[serde(default = "default_highid_wrong_hash_ttl_secs")] pub highid_wrong_hash_ttl_secs: u64, + /// Lugdunum's NAT callback (issue #39): flag 0x1000 in `OP_IDCHANGE`, + /// odd LowIDs and a 24-byte `OP_IDCHANGE` for clients that set 0x1000 at + /// login, UDP 0xA6 requests forwarded as TCP 0x37 / UDP 0xA7. Runs + /// beside this server's own hole punch (0x60-0x62). Default on. + #[serde(default = "default_true")] + pub lugdunum_natt: bool, + + /// The hole-punch extension (issue #39): flag 0x100000 in `OP_IDCHANGE`. + /// Only for a pair of clients that both announced 0x100000 and 0x1000 at + /// login: a 0x60 wakes the target with 0x37, the server waits up to 3 s + /// for the target's fresh 0x9F before sending 0x61 (30 bytes, with the + /// peer's crypt setting), and a session that sent an obfuscated 0x9F has + /// its plain ones ignored. Every other client sees no change. Needs + /// `lugdunum_natt`. Default on. + #[serde(default = "default_true")] + pub holepunch_extension: bool, + /// Accept client connections over IPv6, on the same TCP port. /// /// A separate listener rather than a dual-stack socket: the IPv4 path stays diff --git a/src/main.rs b/src/main.rs index 7c2c1e2..f9770c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -897,6 +897,20 @@ async fn async_main(args: Args, cfg: Config) -> Result<()> { state_clean .natt_udp_ports .retain(|_, (_, ts)| ts.elapsed() < OBSERVED_PORT_TTL); + // Entries of sessions that ended without the normal logout + // path, and waiters whose coordination timed out. + state_clean + .natt_keepalive_seen + .retain(|h, _| state_clean.clients.contains_key(h)); + state_clean + .natt_keepalive_waiters + .retain(|_, v| { + v.retain(|s| !s.is_closed()); + !v.is_empty() + }); + state_clean + .lugdunum_natt_seen + .retain(|_, s| s.at.elapsed() < ed2k_server::server::lugdunum_natt::SEEN_TTL); // Bot detector keeps per-IP sliding windows. IPs that haven't // queried in 5 minutes can be dropped — their window is empty // and re-creating it on next query is cheap. Without this diff --git a/src/proto/opcodes.rs b/src/proto/opcodes.rs index c9683ac..498ee70 100644 --- a/src/proto/opcodes.rs +++ b/src/proto/opcodes.rs @@ -136,13 +136,12 @@ pub const CAPABLE_SUPPORTCRYPT: u32 = 0x0200; pub const CAPABLE_REQUESTCRYPT: u32 = 0x0400; /// The client accepts nothing else — a plain connection to it will be dropped. pub const CAPABLE_REQUIRECRYPT: u32 = 0x0800; -/// ⚠ NOT AN IPv6 BIT ANY MORE, AND THE SERVER DOES NOT READ IT (issue #35). -/// The IPv6 contract first named 0x1000 in CT_SERVER_FLAGS as "can parse -/// IPv6 sources". On Lugdunum the same bit is SRVCAP_NATTRAVERSAL: a client -/// that sets it gets an odd LowID, a longer OP_IDCHANGE and NAT callback -/// notices (seen on eMule Sunrise). eMuleQt withdrew it and aMule stops -/// sending it; IPv6 capability is the `CT_MOD_IP_V6` tag alone. Kept only so -/// the collision tests name the bit. +/// ⚠ NOT AN IPv6 BIT (issue #35). The IPv6 contract first named 0x1000 in +/// CT_SERVER_FLAGS as "can parse IPv6 sources". It is Lugdunum's +/// SRVCAP_NATTRAVERSAL: a client that sets it gets an odd LowID, a longer +/// OP_IDCHANGE and NAT callback notices, there and here since 0.9.85 +/// (`server::lugdunum_natt`, issue #39). IPv6 capability is the +/// `CT_MOD_IP_V6` tag alone. Kept under this name for the collision tests. pub const CAPABLE_IPV6: u32 = 0x1000; // Server flags advertised in IDCHANGE @@ -220,6 +219,16 @@ pub const SRVFLG_IPV6_UPDATE: u32 = 0x0000_8000; /// (IPv6), 0x10000 (eNode-go meta search). pub const SRVFLG_LOWID_HOLEPUNCH: u32 = 0x0002_0000; +/// The hole-punch extension (issue #39), the same bit in both directions: in +/// the `OP_IDCHANGE` flags the server offers it, in `CT_SERVER_FLAGS` at +/// login a client announces it (always together with Lugdunum's 0x1000). +/// When the server and both peers have it, a 0x60 wakes the target with +/// Lugdunum's 0x37, waits up to 3 s for its fresh 0x9F, and only then sends +/// 0x61, 30 bytes with the peer's crypt setting appended. 0x40000 and 0x80000 +/// belong to eNode-go (`FlagNatRendezvous`, `FlagServerSearch`); 0x100000 is +/// the first bit nobody uses, on the server side and the client side. +pub const SRVFLG_HOLEPUNCH_EXT: u32 = 0x0010_0000; + // OFFERFILES self-source markers - SPEC.md §3.3 pub const SELF_COMPLETE_ID: u32 = 0xFBFB_FBFB; pub const SELF_COMPLETE_PORT: u16 = 0xFBFB; @@ -301,6 +310,21 @@ mod capability_tests { & SRVFLG_LOWID_HOLEPUNCH, 0 ); + // The extension bit (issue #39) is clear of all of those and of + // eNode-go's 0x40000 (NAT rendezvous) and 0x80000 (server search). + assert_eq!(SRVFLG_HOLEPUNCH_EXT.count_ones(), 1); + assert_eq!( + (0x0000_05DD | 0x1000 | SRVFLG_IPV6 | SRVFLG_IPV6_UPDATE | 0x0001_0000 + | SRVFLG_LOWID_HOLEPUNCH | 0x0004_0000 | 0x0008_0000) + & SRVFLG_HOLEPUNCH_EXT, + 0 + ); + // On the client side, clear of eMule's capability bits, Lugdunum's + // 0x1000 and eNode-go's meta-search request 0x10000. + assert_eq!( + (0x0000_0F1F | 0x1000 | 0x0001_0000) & SRVFLG_HOLEPUNCH_EXT, + 0 + ); } #[test] diff --git a/src/server/connection.rs b/src/server/connection.rs index 8b9e38a..b61127e 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -466,6 +466,7 @@ pub(crate) fn end_session(state: &ServerState, c: &ClientHandle) { // Its keepalive port goes too: a reconnect from another address // must not be handed out with the old mapping (issue #37). state.natt_udp_ports.remove(&c.user_hash); + state.natt_keepalive_seen.remove(&c.user_hash); // Retract the id→user mapping. Guarded inside by user hash. state.unindex_client_id(c.assigned_id, &c.user_hash); state.remove_sources_of(&c.user_hash); @@ -680,6 +681,8 @@ async fn dispatch( state.client_ipv6.remove(&new_client.user_hash); } } + // A new session proves its 0x9F handling afresh (issue #39). + state.natt_keepalive_seen.remove(&new_client.user_hash); let prev = state .clients .insert(new_client.user_hash, new_client.clone()); @@ -723,7 +726,7 @@ async fn dispatch( *client = Some(new_client.clone()); let ident = wants_v1 || crate::server::login::ident_at_login(&new_client); sess.ident_sent = ident; - for f in crate::server::login::build_welcome_batch_with(cfg, state, &new_client, ident) { + for f in crate::server::login::build_welcome_batch_with(cfg, state, &new_client, ident, peer.port()) { framed.send(f).await?; } } @@ -1010,21 +1013,10 @@ async fn dispatch( state.admission.note_relay_rejected(); return Ok(()); } - crate::server::holepunch::handle_holepunch_request( - state, - &c.user_hash, - c.assigned_id, - c.ip, - c.port, - requester_udp_port, - target_id, - ); - // Best-effort: re-coordinate a couple of times over the next few - // seconds so a lost/late INFO on the server TCP link doesn't - // leave the two peers punching at non-overlapping times. Detached - // task; reuses the same handler, sends nothing new on success. - crate::server::holepunch::schedule_info_retries( - std::sync::Arc::clone(state_arc), + // Today's punch, or the extension when both peers use it, + // with the resends and the bridge (issue #39). + crate::server::holepunch::request( + state_arc, c.user_hash, c.assigned_id, c.ip, diff --git a/src/server/holepunch.rs b/src/server/holepunch.rs index 2b4ae90..45753c5 100644 --- a/src/server/holepunch.rs +++ b/src/server/holepunch.rs @@ -30,11 +30,33 @@ //! observed will not match the port needed peer-to-peer and the punch fails. //! There is no server-only fix for symmetric-both without relaying data, which //! we deliberately refuse. The feature is therefore best-effort. +//! +//! EXTENSION (issue #39, flag 0x100000). Today's punch relies on the standing +//! 0x9F keepalives for the ports it hands out. When the server offers the +//! extension and BOTH peers announced it at login (0x100000 with 0x1000 in +//! `CT_SERVER_FLAGS`), a 0x60 goes like this instead: +//! 1. the requester sends a 0x9F and the 0x60, in either order; +//! 2. the target is woken with Lugdunum's 0x37, byte-identical, carrying the +//! requester's best port so far; +//! 3. the target sends 0x9F (0, 0.5 and 1 s) and starts punching; +//! 4. on the target's first 0x9F the server sends 0x61 to both (30 bytes: +//! the peer's crypt setting appended), then resends at +1.2 s and +3 s; +//! 5. with no 0x9F within 3 s, it sends the same 0x61 with the ports it +//! would have used anyway. A lost 0x9F costs 3 s, never the punch. +//! Any other pair gets exactly today's punch: 29 bytes, at once, no 0x37. +//! +//! BRIDGE (issue #39). A client that implements only Lugdunum's callback +//! (NeoMule's "CanLow2Low") ignores 0x61. A 0x60 whose target set 0x1000, +//! did not announce the extension and has sent no 0x9F in its session also +//! sends that target a 0x37, so it can dial the requester. Having sent a +//! 0x9F proves a client understands 0x61. use crate::proto::{opcodes::*, Frame}; -use crate::state::ServerState; +use crate::server::lugdunum_natt::{self, SRVCAP_NATTRAVERSAL}; +use crate::state::{ClientHandle, ServerState}; use bytes::{BufMut, BytesMut}; -use std::net::IpAddr; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; use std::time::Duration; use tracing::{debug, warn}; @@ -86,6 +108,23 @@ fn observed_udp_port(state: &ServerState, user_hash: &[u8; 16], ip: IpAddr, anno /// Length of an OP_LOWID_HOLEPUNCH_INFO payload (25 before the peer id). pub const INFO_LEN: usize = 4 + 2 + 2 + 16 + 1 + 4; +/// The same with the peer's crypt setting, for a pair using the extension. +pub const INFO_EXT_LEN: usize = INFO_LEN + 1; + +/// How long an extension coordination waits for the target's 0x9F. +pub const EXT_WAIT: Duration = Duration::from_secs(3); + +/// The server offers the extension: it is on, and so is Lugdunum's callback, +/// whose 0x37 wakes the target. +pub fn extension_enabled(cfg: &crate::config::Config) -> bool { + cfg.network.holepunch_extension && cfg.network.lugdunum_natt +} + +/// A client with these login flags uses the extension here. +pub fn has_extension(state: &ServerState, server_flags: u32) -> bool { + const BOTH: u32 = SRVFLG_HOLEPUNCH_EXT | SRVCAP_NATTRAVERSAL; + extension_enabled(&state.live_cfg.load()) && server_flags & BOTH == BOTH +} /// Reasons sent in OP_LOWID_HOLEPUNCH_FAIL. pub const FAIL_TARGET_NOT_CONNECTED: u8 = 1; @@ -140,10 +179,11 @@ pub fn handle_holepunch_request( t.user_hash, t.is_high_id, t.is_alive(), + t.server_flags, ) }); - let Some((t_ip, t_tcp, t_udp, t_hash, t_high, t_alive)) = target_data else { + let Some((t_ip, t_tcp, t_udp, t_hash, t_high, t_alive, t_flags)) = target_data else { debug!(requester_id, target_id, "holepunch: target not connected"); send_fail( state, @@ -215,6 +255,22 @@ pub fn handle_holepunch_request( let requester_udp_eff = observed_udp_port(state, requester_user_hash, requester_ip, requester_udp_port); + // A pair using the extension also gets each other's crypt setting + // (issue #39); everyone else keeps the 29 bytes. + let r_flags = state + .clients + .get(requester_user_hash) + .map_or(0, |c| c.server_flags); + let ext = has_extension(state, r_flags) && has_extension(state, t_flags); + let (crypt_of_target, crypt_of_requester) = if ext { + ( + Some(lugdunum_natt::crypt_setting(t_flags)), + Some(lugdunum_natt::crypt_setting(r_flags)), + ) + } else { + (None, None) + }; + // Each side also gets the server ID of the peer described (issue #38): // the requester its 0x60 target id, so it can tell which of several // requests in flight this answers; the target the requester's id. @@ -225,6 +281,7 @@ pub fn handle_holepunch_request( &t_hash, if requester_is_initiator { 0 } else { 1 }, target_id, + crypt_of_target, ); let to_target = build_info( requester_ip, @@ -233,6 +290,7 @@ pub fn handle_holepunch_request( requester_user_hash, if requester_is_initiator { 1 } else { 0 }, requester_id, + crypt_of_requester, ); // Both lookups below take fresh short-lived guards (the earlier target guard @@ -319,34 +377,243 @@ pub fn schedule_info_retries( }; tokio::spawn(async move { let _guards = guards; - // Two extra attempts at +1.2s and +3.0s, IN ADDITION to the immediate - // coordination already done by the caller. Small and short so a - // genuinely unreachable pair still fails fast. - const DELAYS_MS: [u64; 2] = [1200, 3000]; - let mut elapsed = 0u64; - for &at in DELAYS_MS.iter() { - tokio::time::sleep(Duration::from_millis(at - elapsed)).await; - elapsed = at; - // Stop early if the requester has disconnected; no point re-coordinating. - if !state - .clients - .get(&requester_user_hash) - .map(|c| c.is_alive()) - .unwrap_or(false) - { - break; + retry_loop( + &state, + requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + requester_udp_port, + target_id, + ) + .await; + }); +} + +/// The resends: two extra attempts at +1.2s and +3.0s after a coordination +/// that has just been done. Small and short so a genuinely unreachable pair +/// still fails fast. +async fn retry_loop( + state: &ServerState, + requester_user_hash: [u8; 16], + requester_id: u32, + requester_ip: IpAddr, + requester_tcp_port: u16, + requester_udp_port: u16, + target_id: u32, +) { + const DELAYS_MS: [u64; 2] = [1200, 3000]; + let mut elapsed = 0u64; + for &at in DELAYS_MS.iter() { + tokio::time::sleep(Duration::from_millis(at - elapsed)).await; + elapsed = at; + // Stop early if the requester has disconnected; no point re-coordinating. + if !requester_alive(state, &requester_user_hash) { + break; + } + let _ = handle_holepunch_request( + state, + &requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + requester_udp_port, + target_id, + ); + } +} + +fn requester_alive(state: &ServerState, user_hash: &[u8; 16]) -> bool { + state + .clients + .get(user_hash) + .map(|c| c.is_alive()) + .unwrap_or(false) +} + +fn count(state: &ServerState, key: &str) { + *state.block_stats.entry(key.to_string()).or_insert(0) += 1; +} + +/// The whole handling of an OP_LOWID_HOLEPUNCH_REQUEST: the extension when +/// both peers use it, otherwise today's punch with its resends, plus the +/// bridge to a Lugdunum-only target. +#[allow(clippy::too_many_arguments)] +pub fn request( + state: &Arc, + requester_user_hash: [u8; 16], + requester_id: u32, + requester_ip: IpAddr, + requester_tcp_port: u16, + requester_udp_port: u16, + target_id: u32, +) { + // A target the punch can be coordinated with at all; the checks and + // their FAIL answers stay in `handle_holepunch_request`. + let target = state + .client_by_assigned_id(target_id) + .filter(|t| t.user_hash != requester_user_hash && !t.is_high_id && t.is_alive()); + if let Some(t) = &target { + let r_flags = state + .clients + .get(&requester_user_hash) + .map_or(0, |c| c.server_flags); + if has_extension(state, r_flags) && has_extension(state, t.server_flags) { + if requester_udp_port != 0 { + if let Some(mut me) = state.clients.get_mut(&requester_user_hash) { + me.udp_port = requester_udp_port; + } } - let _ = handle_holepunch_request( - &state, - &requester_user_hash, + match start_extension( + state, + requester_user_hash, requester_id, requester_ip, requester_tcp_port, requester_udp_port, - target_id, - ); + t, + ) { + Ext::Started | Ext::Pending => return, + Ext::Fallback => {} + } } + } + handle_holepunch_request( + state, + &requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + requester_udp_port, + target_id, + ); + if let Some(t) = &target { + bridge(state, &requester_user_hash, requester_ip, t); + } + schedule_info_retries( + Arc::clone(state), + requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + requester_udp_port, + target_id, + ); +} + +#[derive(Debug, PartialEq, Eq)] +enum Ext { + /// The target was woken; 0x61 follows from the background task. + Started, + /// One is already running for this pair: this 0x60 adds nothing. + Pending, + /// Do today's punch instead. + Fallback, +} + +/// The requester's 0x37 body for the target, with the best port the server +/// has for the requester now. None when the crypt settings rule it out. +fn wake_body(state: &ServerState, requester_user_hash: &[u8; 16], requester_ip: IpAddr, target_flags: u32) -> Option> { + let req = state.clients.get(requester_user_hash).map(|c| c.clone())?; + let port = observed_udp_port(state, requester_user_hash, requester_ip, req.udp_port); + lugdunum_natt::forward_body(&req, SocketAddr::new(requester_ip, port), target_flags) +} + +#[allow(clippy::too_many_arguments)] +fn start_extension( + state: &Arc, + requester_user_hash: [u8; 16], + requester_id: u32, + requester_ip: IpAddr, + requester_tcp_port: u16, + requester_udp_port: u16, + target: &ClientHandle, +) -> Ext { + // Crypt settings that rule out the 0x37 rule out nothing in 0x61: such a + // pair gets today's punch. + let Some(body) = wake_body(state, &requester_user_hash, requester_ip, target.server_flags) else { + return Ext::Fallback; + }; + let target_id = target.assigned_id; + let guards = match state.admission.try_retry(requester_user_hash, target_id) { + Ok(g) => g, + Err(crate::admission::RetrySkip::Coalesced) => return Ext::Pending, + Err(crate::admission::RetrySkip::Busy) => return Ext::Fallback, + }; + // Listen before waking the target, so its 0x9F cannot slip past. + let (tx, rx) = tokio::sync::oneshot::channel(); + state + .natt_keepalive_waiters + .entry(target.user_hash) + .or_default() + .push(tx); + let outcome = target.send_frame(Frame::new(lugdunum_natt::OP_NAT_CALLBACK_TCP, body)); + state + .admission + .push + .note(crate::admission::PushKind::Holepunch, outcome); + count(state, "holepunch_ext_started"); + let state = Arc::clone(state); + tokio::spawn(async move { + let _guards = guards; + let fresh = matches!(tokio::time::timeout(EXT_WAIT, rx).await, Ok(Ok(()))); + count(&state, if fresh { "holepunch_ext_fresh" } else { "holepunch_ext_timeout" }); + if !requester_alive(&state, &requester_user_hash) { + return; + } + let _ = handle_holepunch_request( + &state, + &requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + requester_udp_port, + target_id, + ); + retry_loop( + &state, + requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + requester_udp_port, + target_id, + ) + .await; }); + Ext::Started +} + +/// A 0x9F from `user_hash`'s own address was accepted: release the +/// extension coordinations waiting for it. +pub fn keepalive_arrived(state: &ServerState, user_hash: &[u8; 16]) { + if let Some((_, waiters)) = state.natt_keepalive_waiters.remove(user_hash) { + for w in waiters { + let _ = w.send(()); + } + } +} + +/// Lugdunum's 0x37 for a target that cannot read 0x61: it set 0x1000, did +/// not announce the extension, and has sent no 0x9F in this session. +fn bridge(state: &ServerState, requester_user_hash: &[u8; 16], requester_ip: IpAddr, target: &ClientHandle) { + if !state.live_cfg.load().network.lugdunum_natt + || target.server_flags & SRVCAP_NATTRAVERSAL == 0 + || has_extension(state, target.server_flags) + || state.natt_keepalive_seen.contains_key(&target.user_hash) + { + return; + } + let Some(body) = wake_body(state, requester_user_hash, requester_ip, target.server_flags) else { + count(state, "holepunch_bridge_crypt_mismatch"); + return; + }; + let outcome = target.send_frame(Frame::new(lugdunum_natt::OP_NAT_CALLBACK_TCP, body)); + state + .admission + .push + .note(crate::admission::PushKind::Holepunch, outcome); + count(state, "holepunch_bridged"); } /// OP_LOWID_HOLEPUNCH_INFO payload, 29 bytes: @@ -354,6 +621,8 @@ pub fn schedule_info_retries( /// + peer_user_hash(16) + role(1) + peer_id(4 LE) /// `peer_id` (issue #38) is the server ID of the peer described; a client /// reads it only from a payload of 29 bytes or more (it was 25 before). +/// For a pair using the extension, 30: + peer_crypt(1), the peer's +/// `(CT_SERVER_FLAGS >> 9) & 7` (support, request, require), as in 0x37. fn build_info( ip: IpAddr, tcp_port: u16, @@ -361,8 +630,9 @@ fn build_info( user_hash: &[u8; 16], role: u8, peer_id: u32, + peer_crypt: Option, ) -> Frame { - let mut p = BytesMut::with_capacity(INFO_LEN); + let mut p = BytesMut::with_capacity(INFO_EXT_LEN); match ip { // eD2k stores IPv4 as a u32; writing the octets in order is equivalent // to u32::from_le_bytes(octets) written little-endian. @@ -374,6 +644,9 @@ fn build_info( p.put_slice(user_hash); p.put_u8(role); p.put_u32_le(peer_id); + if let Some(c) = peer_crypt { + p.put_u8(c); + } Frame::new(OP_LOWID_HOLEPUNCH_INFO, p.to_vec()) } @@ -401,10 +674,164 @@ mod tests { use super::*; use std::net::Ipv4Addr; + const EXT: u32 = SRVFLG_HOLEPUNCH_EXT | SRVCAP_NATTRAVERSAL; + + /// A LowID at its own address with these login flags. + fn peer( + s: &ServerState, + hash: u8, + id: u32, + last_octet: u8, + flags: u32, + ) -> tokio::sync::mpsc::Receiver { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, last_octet)); + let rx = s.register_test_client_at([hash; 16], id, false, 4672, ip); + s.clients.get_mut(&[hash; 16]).unwrap().server_flags = flags; + rx + } + + fn ask(s: &Arc, from: u8, from_id: u32, last_octet: u8, target_id: u32) { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, last_octet)); + request(s, [from; 16], from_id, ip, 4662, 4672, target_id); + } + + fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + std::iter::from_fn(|| rx.try_recv().ok()).collect() + } + + fn opcodes(frames: &[Frame]) -> Vec<(u8, usize)> { + frames.iter().map(|f| (f.opcode, f.payload.len())).collect() + } + + async fn settle(ms: u64) { + tokio::time::sleep(Duration::from_millis(ms)).await; + } + + /// Issue #39, the extension: 0x37 wakes the target, nothing else goes out + /// until its 0x9F, then 0x61 (30 bytes) with that fresh port. + #[tokio::test] + async fn an_extension_pair_waits_for_the_targets_keepalive() { + let s = Arc::new(ServerState::for_test()); + let mut a = peer(&s, 0xA, 11, 1, EXT | 0x200); + let mut b = peer(&s, 0xB, 13, 2, EXT | 0x600); + ask(&s, 0xA, 11, 1, 13); + settle(50).await; + assert!(drain(&mut a).is_empty(), "the requester waits"); + let woke = drain(&mut b); + assert_eq!(opcodes(&woke), vec![(0x37, 27)]); + // B's fresh port, as the 0x9F handler records it. + s.natt_udp_ports.insert([0xB; 16], (50000, std::time::Instant::now())); + s.natt_keepalive_seen.insert([0xB; 16], true); + keepalive_arrived(&s, &[0xB; 16]); + settle(50).await; + let to_a = drain(&mut a); + assert_eq!(opcodes(&to_a), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); + assert_eq!(&to_a[0].payload[6..8], &50000u16.to_le_bytes()); + assert_eq!(to_a[0].payload[29], 0x03, "B supports and requests"); + let to_b = drain(&mut b); + assert_eq!(opcodes(&to_b), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); + assert_eq!(to_b[0].payload[29], 0x01, "A supports"); + assert_eq!(s.block_stats.get("holepunch_ext_fresh").map(|v| *v), Some(1)); + } + + /// A lost 0x9F costs the wait, never the punch. + #[tokio::test] + async fn without_the_keepalive_the_punch_goes_ahead_after_the_wait() { + let s = Arc::new(ServerState::for_test()); + let mut a = peer(&s, 0xA, 11, 1, EXT); + let mut b = peer(&s, 0xB, 13, 2, EXT); + ask(&s, 0xA, 11, 1, 13); + ask(&s, 0xA, 11, 1, 13); // coalesced: no second 0x37 + settle(100).await; + assert_eq!(opcodes(&drain(&mut b)), vec![(0x37, 11)]); + assert!(drain(&mut a).is_empty()); + tokio::time::sleep(EXT_WAIT).await; + settle(100).await; + let to_a = drain(&mut a); + assert_eq!(opcodes(&to_a), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); + assert_eq!(&to_a[0].payload[6..8], &4672u16.to_le_bytes(), "today's port"); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); + } + + /// Mixed pairs and legacy pairs: today's 29-byte 0x61 at once, no 0x37, + /// whichever side has the extension. + #[tokio::test] + async fn a_pair_without_the_extension_on_both_sides_is_unchanged() { + for (fa, fb) in [(EXT, SRVCAP_NATTRAVERSAL), (SRVCAP_NATTRAVERSAL, EXT), (0, 0), (EXT, 0)] { + let s = Arc::new(ServerState::for_test()); + let mut a = peer(&s, 0xA, 11, 1, fa); + let mut b = peer(&s, 0xB, 13, 2, fb); + // B has proven it reads 0x61, so no bridge either. + s.natt_keepalive_seen.insert([0xB; 16], false); + ask(&s, 0xA, 11, 1, 13); + assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)], "{fa:#x} {fb:#x}"); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)], "{fa:#x} {fb:#x}"); + } + } + + /// The extension off: an extension pair is a legacy pair. + #[tokio::test] + async fn with_the_extension_off_nothing_changes() { + let s = Arc::new(ServerState::for_test()); + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.network.holepunch_extension = false; + s.live_cfg.store(Arc::new(cfg)); + let mut a = peer(&s, 0xA, 11, 1, EXT); + let mut b = peer(&s, 0xB, 13, 2, EXT); + s.natt_keepalive_seen.insert([0xB; 16], false); + ask(&s, 0xA, 11, 1, 13); + assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + } + + /// The bridge: a target that set 0x1000 and never sent a 0x9F also gets + /// Lugdunum's 0x37; the requester's side is today's. + #[tokio::test] + async fn a_lugdunum_only_target_is_bridged() { + for requester_flags in [0, SRVCAP_NATTRAVERSAL, EXT] { + let s = Arc::new(ServerState::for_test()); + let mut a = peer(&s, 0xA, 11, 1, requester_flags); + let mut b = peer(&s, 0xB, 13, 2, SRVCAP_NATTRAVERSAL); + ask(&s, 0xA, 11, 1, 13); + assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + let to_b = drain(&mut b); + assert_eq!(opcodes(&to_b), vec![(OP_LOWID_HOLEPUNCH_INFO, 29), (0x37, 11)]); + // ip, the requester's port, its id, crypt 0. + assert_eq!(to_b[1].payload, vec![10, 0, 0, 1, 0x40, 0x12, 11, 0, 0, 0, 0]); + } + // Not to a stock client, nor with the callback off. + let s = Arc::new(ServerState::for_test()); + let _a = peer(&s, 0xA, 11, 1, 0); + let mut b = peer(&s, 0xB, 13, 2, 0); + ask(&s, 0xA, 11, 1, 13); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + let s = Arc::new(ServerState::for_test()); + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.network.lugdunum_natt = false; + s.live_cfg.store(Arc::new(cfg)); + let _a = peer(&s, 0xA, 11, 1, 0); + let mut b = peer(&s, 0xB, 13, 2, SRVCAP_NATTRAVERSAL); + ask(&s, 0xA, 11, 1, 13); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + } + + /// A bridged target without a UDP port still gets the 0x37: it dials + /// the requester, and needs no port of its own for that. + #[tokio::test] + async fn the_bridge_does_not_need_the_targets_port() { + let s = Arc::new(ServerState::for_test()); + let mut a = peer(&s, 0xA, 11, 1, 0); + let mut b = peer(&s, 0xB, 13, 2, SRVCAP_NATTRAVERSAL); + s.clients.get_mut(&[0xB; 16]).unwrap().udp_port = 0; + ask(&s, 0xA, 11, 1, 13); + assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_FAIL, 5)]); + assert_eq!(opcodes(&drain(&mut b)), vec![(0x37, 11)]); + } + #[test] fn info_payload_format() { let hash = [7u8; 16]; - let frame = build_info(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 4662, 4672, &hash, 0, 0x0102_0304); + let frame = build_info(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 4662, 4672, &hash, 0, 0x0102_0304, None); assert_eq!(frame.opcode, OP_LOWID_HOLEPUNCH_INFO); assert_eq!(frame.payload.len(), 29); assert_eq!(&frame.payload[0..4], &[1, 2, 3, 4]); // ip diff --git a/src/server/login.rs b/src/server/login.rs index 734a787..ed06233 100644 --- a/src/server/login.rs +++ b/src/server/login.rs @@ -200,7 +200,7 @@ pub(crate) fn reported_client_ip( /// IDCHANGE, SERVERSTATUS, SERVERMESSAGE, SERVERIDENT (only for some clients, /// see `ident_at_login`), plus optional extra welcome lines. pub fn build_welcome_batch(cfg: &Config, state: &ServerState, client: &ClientHandle) -> Vec { - build_welcome_batch_with(cfg, state, client, ident_at_login(client)) + build_welcome_batch_with(cfg, state, client, ident_at_login(client), 0) } /// `build_welcome_batch` with the SERVERIDENT decision made by the caller, @@ -210,6 +210,9 @@ pub fn build_welcome_batch_with( state: &ServerState, client: &ClientHandle, send_ident: bool, + // The source port of the client's TCP connection, 0 when unknown: the + // last field of a Lugdunum NAT-T client's OP_IDCHANGE. + tcp_src_port: u16, ) -> Vec { // Hot-reloadable view of the configuration. /api/config swaps `live_cfg` // atomically, so everything read through `live` below takes effect for the @@ -256,6 +259,15 @@ pub fn build_welcome_batch_with( let mut tcp_flags: u32 = 0x0000_05DD; // LowID hole punch is always on: say so (issue #35). tcp_flags |= crate::proto::opcodes::SRVFLG_LOWID_HOLEPUNCH; + // Lugdunum's NAT callback, beside it (issue #39). Lugdunum sets the + // same bit (its flags read 0x17F9). + if live.network.lugdunum_natt { + tcp_flags |= crate::server::lugdunum_natt::SRVCAP_NATTRAVERSAL; + } + // The hole-punch extension (issue #39), which builds on both. + if crate::server::holepunch::extension_enabled(&live) { + tcp_flags |= crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT; + } if live.network.ipv6_publish_sources { // ⚠ 0x4000 server→client. The client→server capability bit is // 0x1000 in CT_SERVER_FLAGS — a different word in a different @@ -290,6 +302,18 @@ pub fn build_welcome_batch_with( payload.put_u32_le(cfg.network.tcp_port as u32); // [8-11] aux/standard port payload.put_u32_le(reported_ip); // [12-15] the client's own address payload.put_u32_le(cfg.network.tcp_port as u32); // [16-19] obfuscation_tcp_port + // [20-23] Lugdunum's NAT callback (issue #39), only for a client that + // set 0x1000 at login, as Lugdunum sends it: the client's TCP port as + // we see it — the source port of its connection for a LowID, its own + // listening port for a HighID. eMule and aMule read IDCHANGE up to + // byte 20 and ignore the rest, so a client that set the bit for + // something else (an early IPv6 extension did) is not disturbed. + if live.network.lugdunum_natt + && client.server_flags & crate::server::lugdunum_natt::SRVCAP_NATTRAVERSAL != 0 + { + let port = if client.is_high_id || tcp_src_port == 0 { client.port } else { tcp_src_port }; + payload.put_u32_le(port as u32); + } frames.push(Frame::new(OP_IDCHANGE, payload.to_vec())); } // 2. SERVERSTATUS — current users, files @@ -592,10 +616,10 @@ pub(crate) use crate::proto::opcodes::CAPABLE_SUPPORTCRYPT as SRVCAP_SUPPORTCRYP /// The HighID for a verified address, or — if the address has none (IPv6, /// or an IPv4 ending in .0, issue #26) — a fresh LowID flagged as LowID. Never /// a LowID-range id flagged as HighID. -fn high_or_low_id(state: &ServerState, ip: IpAddr) -> (u32, bool) { +fn high_or_low_id(state: &ServerState, ip: IpAddr, client_flags: u32) -> (u32, bool) { match crate::server::highid_probe::high_id_from_ip(ip) { Some(id) => (id, true), - None => (state.allocate_low_id(), false), + None => (state.allocate_low_id_for(client_flags), false), } } @@ -619,7 +643,7 @@ pub async fn assign_client_id_for( // and this peer has none we know of. No probe, no HighID — its reachability // is published through the inline v6 source record instead. if peer_ip.is_ipv6() { - return (state.allocate_low_id(), false, peer_ip); + return (state.allocate_low_id_for(client_flags), false, peer_ip); } // A public address ending in .0 has no HighID: its id would fall in the @@ -628,7 +652,7 @@ pub async fn assign_client_id_for( && high_id_from_ip(peer_ip).is_none() { debug!(ip = %peer_ip, "highid: address has no HighID (ends in .0) → LowID"); - return (state.allocate_low_id(), false, peer_ip); + return (state.allocate_low_id_for(client_flags), false, peer_ip); } // admission.max_probe_jobs (issue #25): every outbound probe — this one, @@ -639,7 +663,7 @@ pub async fn assign_client_id_for( let Some(_probe_permit) = state.admission.probes.try_take() else { state.admission.note_probe_shed(); debug!(ip = %peer_ip, "highid: probe ceiling full → LowID"); - return (state.allocate_low_id(), false, peer_ip); + return (state.allocate_low_id_for(client_flags), false, peer_ip); }; if probe(peer_ip, client_port, probe_timeout).await { @@ -673,13 +697,13 @@ pub async fn assign_client_id_for( ip = %peer_ip, port = client_port, "highid: wrong-hash mark active → LowID" ); - return (state.allocate_low_id(), false, peer_ip); + return (state.allocate_low_id_for(client_flags), false, peer_ip); } } - let (id, high) = high_or_low_id(state, peer_ip); + let (id, high) = high_or_low_id(state, peer_ip, client_flags); return (id, high, peer_ip); } - let (id, high) = high_or_low_id(state, peer_ip); + let (id, high) = high_or_low_id(state, peer_ip, client_flags); // OBSERVE ONLY. The verdict above is already final and is returned // unchanged on the next line; this merely counts what the stricter, // Lugdunum-style check would have said. Detached, so the login does not @@ -790,7 +814,7 @@ pub async fn assign_client_id_for( lan_ip = %peer_ip, public_ip = %this_ip, port = client_port, "hairpin: client verified behind our own NAT → HighID" ); - let (id, high) = high_or_low_id(state, IpAddr::V4(this_ip)); + let (id, high) = high_or_low_id(state, IpAddr::V4(this_ip), client_flags); return (id, high, IpAddr::V4(this_ip)); } Ok(false) => { @@ -813,7 +837,7 @@ pub async fn assign_client_id_for( } } - (state.allocate_low_id(), false, peer_ip) + (state.allocate_low_id_for(client_flags), false, peer_ip) } /// The identity probe as the HighID check runs it: obfuscated first when the @@ -1657,6 +1681,74 @@ mod tests { assert_eq!(&ident.payload[16..20], &[85, 17, 116, 222]); } + /// Issue #39: Lugdunum's NAT callback at login. The server flag 0x1000 for + /// everyone; four more IDCHANGE bytes (the TCP port as seen) only for a + /// client that set 0x1000; odd LowIDs for those clients, even for others. + #[test] + fn lugdunum_natt_at_login() { + let mut cfg = crate::config::Config::minimal_test_config(); + cfg.server.this_ip = OUR_IP.to_string(); + let cfg = std::sync::Arc::new(cfg); + let state = ServerState::new( + std::sync::Arc::new(crate::filter::ContentFilter::new()), + std::sync::Arc::clone(&cfg), + ); + let mk = |flags: u32, high: bool| ClientHandle { + user_hash: [7; 16], + assigned_id: 43, + ip: IpAddr::V4(std::net::Ipv4Addr::new(93, 184, 216, 34)), + port: 4662, + udp_port: 0, + natt_capable: false, + nick: "t".into(), + server_flags: flags, + ipv6_capable: false, + ipv6: None, + is_high_id: high, + connected_at: Instant::now(), + country: "??".into(), + software: "test".into(), + software_detail: "test".into(), + login_fp: String::new(), + csam_attempts: 0, + soft_limit_warned: false, + offer_policy: None, + slot: Default::default(), + tx: None, + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), + }; + let idc = |c: &ClientHandle, src: u16| { + build_welcome_batch_with(&cfg, &state, c, false, src) + .into_iter() + .find(|f| f.opcode == OP_IDCHANGE) + .unwrap() + .payload + }; + // A stock client: 20 bytes, and the flag (harmless to it). + let p = idc(&mk(0x0319, false), 51000); + assert_eq!(p.len(), 20); + assert_ne!(u32::from_le_bytes(p[4..8].try_into().unwrap()) & 0x1000, 0); + // A Lugdunum NAT-T LowID: 24 bytes, the seen TCP source port last. + let p = idc(&mk(0x1319, false), 51000); + assert_eq!(p.len(), 24); + assert_eq!(u32::from_le_bytes(p[20..24].try_into().unwrap()), 51000); + // A HighID: its own port. + let p = idc(&mk(0x1319, true), 51000); + assert_eq!(u32::from_le_bytes(p[20..24].try_into().unwrap()), 4662); + // Parity of LowIDs. + for _ in 0..5 { + assert_eq!(state.allocate_low_id_for(0x1000) & 1, 1); + assert_eq!(state.allocate_low_id_for(0x0319) & 1, 0); + } + // Switched off: no flag, no extra bytes, ids as they come. + let mut off = (*cfg).clone(); + off.network.lugdunum_natt = false; + state.live_cfg.store(std::sync::Arc::new(off)); + let p = idc(&mk(0x1319, false), 51000); + assert_eq!(p.len(), 20); + assert_eq!(u32::from_le_bytes(p[4..8].try_into().unwrap()) & 0x1000, 0); + } + #[test] fn an_ipv6_peer_is_never_given_a_high_id() { // An eD2k client id IS a 32-bit IPv4 address, so there is no HighID an diff --git a/src/server/lugdunum_natt.rs b/src/server/lugdunum_natt.rs new file mode 100644 index 0000000..3ac43a5 --- /dev/null +++ b/src/server/lugdunum_natt.rs @@ -0,0 +1,424 @@ +//! Lugdunum's NAT callback, as the original eserver 17.x runs it (issue #39). +//! +//! Two server-coordinated NAT-T schemes exist. This server's own (0x60-0x62, +//! `server::holepunch`) and Lugdunum's, below. aMule and emule-qt speak both; +//! with this module a client written for Lugdunum works here unchanged. The +//! two share nothing on the wire and run side by side. +//! +//! Read from Lugdunum's `nat_callback()` and its login code: +//! +//! - **Capability.** The client sets 0x1000 (SRVCAP_NATTRAVERSAL) in +//! `CT_SERVER_FLAGS`. It gets an odd LowID, and `OP_IDCHANGE` with four +//! more bytes: the client's TCP port as the server sees it (the source port +//! of its connection for a LowID, its own port for a HighID). The server +//! sets 0x1000 in its `OP_IDCHANGE` TCP flags. +//! - **Request**, UDP `0xA6`, server-key obfuscated only: +//! ``. Both must be logged-in LowIDs, and the +//! requester must send from the address of its TCP session. The source port +//! of this datagram is the requester's UDP endpoint as the server sees it. +//! - **Forward to the target**: +//! ``, plus +//! the requester's user hash (16) with bit 0x80 in `crypt` when both sides +//! support the crypt layer and one of them asks for it. `crypt` is the +//! requester's `(CT_SERVER_FLAGS >> 9) & 7` (support, request, require). If +//! one side requires the crypt layer and they cannot both use it, nothing +//! is sent. It goes out as UDP `0xA7` to the target's own endpoint when the +//! target itself sent `0xA6` less than 20 s ago (its NAT mapping to us is +//! fresh), else as TCP `0x37` on its server connection. +//! - **The requester gets nothing back.** The target punches towards the +//! requester's endpoint; aMule also sends the reverse `0xA6` a few seconds +//! later, which brings the target's endpoint to the requester the same way. +//! +//! Two things here are stricter than Lugdunum, and neither costs a client +//! that speaks the protocol anything: +//! - The target must have set 0x1000 at login. Lugdunum forwards to any +//! LowID, so a stock client got an opcode it does not know. +//! - Requests are rate limited per requester (a burst of 10, then one a +//! second); aMule itself sends at most 20 a minute per server. + +use crate::proto::Frame; +use crate::state::{ClientHandle, ServerState}; +use std::net::{IpAddr, SocketAddr}; +use std::time::{Duration, Instant}; + +/// Client capability in `CT_SERVER_FLAGS`, and this server's flag in the +/// `OP_IDCHANGE` TCP flags: Lugdunum's NAT callback. (Not IPv6: some clients +/// once sent this bit for that; this server keys IPv6 on `CT_MOD_IP_V6`.) +pub const SRVCAP_NATTRAVERSAL: u32 = 0x1000; +/// Client to server, UDP: the request. +pub const OP_NAT_CALLBACK_REQUEST: u8 = 0xA6; +/// Server to target, UDP: the forward, when the target's endpoint is fresh. +pub const OP_NAT_CALLBACK_UDP: u8 = 0xA7; +/// Server to target, TCP: the forward otherwise. +pub const OP_NAT_CALLBACK_TCP: u8 = 0x37; + +/// Lugdunum answers by UDP only when the target sent `0xA6` within this long +/// (its test is "more than 19 s ago: use TCP"). +const UDP_FRESH: Duration = Duration::from_secs(20); +/// How long a requester's endpoint is kept, for the sweep. +pub const SEEN_TTL: Duration = Duration::from_secs(120); + +/// A client's last `0xA6`: where it came from, how to answer it, and its +/// request allowance. +pub struct Seen { + pub at: Instant, + pub addr: SocketAddr, + /// Key and formula the request was decoded with, to encode a `0xA7`. + pub obf: (u32, u8), + /// Our UDP port it arrived on: a reply must leave from it. + pub local_port: u16, + bucket: crate::admission::TokenBucket, +} + +/// Why a request was not forwarded. Counted, never answered: Lugdunum sends +/// the requester nothing either way. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refused { + Disabled, + NotObfuscated, + Short, + UnknownRequester, + RequesterHighId, + WrongAddress, + RateLimited, + SameClient, + UnknownTarget, + TargetHighId, + TargetNotCapable, + TargetGone, + CryptMismatch, +} + +impl Refused { + pub fn stat_key(self) -> &'static str { + match self { + Refused::Disabled => "natcb_disabled", + Refused::NotObfuscated => "natcb_not_obfuscated", + Refused::Short => "natcb_short", + Refused::UnknownRequester => "natcb_unknown_requester", + Refused::RequesterHighId => "natcb_requester_highid", + Refused::WrongAddress => "natcb_wrong_address", + Refused::RateLimited => "natcb_rate_limited", + Refused::SameClient => "natcb_same_client", + Refused::UnknownTarget => "natcb_unknown_target", + Refused::TargetHighId => "natcb_target_highid", + Refused::TargetNotCapable => "natcb_target_not_capable", + Refused::TargetGone => "natcb_target_gone", + Refused::CryptMismatch => "natcb_crypt_mismatch", + } + } +} + +/// What to send. +#[derive(Debug)] +pub enum Forward { + /// `0x37` to the client with this user hash. + Tcp { target: [u8; 16], frame: Frame }, + /// `[0xE3][0xA7]…` to `addr`, obfuscated with `obf`, from our `local_port`. + Udp { + addr: SocketAddr, + obf: (u32, u8), + local_port: u16, + packet: Vec, + }, +} + +fn v4(ip: IpAddr) -> Option { + match ip { + IpAddr::V4(a) => Some(a), + IpAddr::V6(a) => a.to_ipv4_mapped(), + } +} + +/// `(CT_SERVER_FLAGS >> 9) & 7`: bit 0 supports the crypt layer, bit 1 +/// requests it, bit 2 requires it. Lugdunum's `c_osetting`. +pub fn crypt_setting(server_flags: u32) -> u8 { + ((server_flags >> 9) & 7) as u8 +} + +/// The forward body for `req`, or None when one side requires the crypt +/// layer and the two cannot use it. +pub fn forward_body( + req: &ClientHandle, + req_addr: SocketAddr, + target_flags: u32, +) -> Option> { + let r = crypt_setting(req.server_flags); + let t = crypt_setting(target_flags); + let both = r & t & 1 != 0; + let asked = (r | t) & 2 != 0; + let with_hash = both && asked; + if !with_hash && (r | t) & 4 != 0 { + return None; + } + let ip = v4(req.ip)?; + let mut b = Vec::with_capacity(27); + b.extend_from_slice(&ip.octets()); + b.extend_from_slice(&req_addr.port().to_le_bytes()); + b.extend_from_slice(&req.assigned_id.to_le_bytes()); + if with_hash { + b.push(r | 0x80); + b.extend_from_slice(&req.user_hash); + } else { + b.push(r); + } + Some(b) +} + +/// Handle a `0xA6` from `peer`. `obf` is the key the datagram was decoded +/// with (None: it came in the clear), `local_port` our port it arrived on. +pub fn handle_request( + state: &ServerState, + payload: &[u8], + peer: SocketAddr, + obf: Option<(u32, u8)>, + local_port: u16, +) -> Result { + if !state.live_cfg.load().network.lugdunum_natt { + return Err(Refused::Disabled); + } + let Some(obf) = obf else { + return Err(Refused::NotObfuscated); + }; + if payload.len() < 8 { + return Err(Refused::Short); + } + let target_id = u32::from_le_bytes(payload[0..4].try_into().unwrap()); + let requester_id = u32::from_le_bytes(payload[4..8].try_into().unwrap()); + + let req = state + .client_by_assigned_id(requester_id) + .ok_or(Refused::UnknownRequester)?; + if req.is_high_id { + return Err(Refused::RequesterHighId); + } + if v4(req.ip).is_none() || v4(req.ip) != v4(peer.ip()) { + return Err(Refused::WrongAddress); + } + // Record the requester's endpoint (for a reverse request towards it) + // and charge its allowance. + { + let now = Instant::now(); + let mut e = state + .lugdunum_natt_seen + .entry(req.user_hash) + .or_insert_with(|| Seen { + at: now, + addr: peer, + obf, + local_port, + bucket: crate::admission::TokenBucket::new(1, 10, now), + }); + e.at = now; + e.addr = peer; + e.obf = obf; + e.local_port = local_port; + if !e.bucket.take(1, now) { + return Err(Refused::RateLimited); + } + } + if target_id == requester_id { + return Err(Refused::SameClient); + } + let target = state + .client_by_assigned_id(target_id) + .ok_or(Refused::UnknownTarget)?; + if target.is_high_id { + return Err(Refused::TargetHighId); + } + if target.server_flags & SRVCAP_NATTRAVERSAL == 0 { + return Err(Refused::TargetNotCapable); + } + if !target.is_alive() { + return Err(Refused::TargetGone); + } + let body = forward_body(&req, peer, target.server_flags).ok_or(Refused::CryptMismatch)?; + + // The target's own endpoint, if it asked for a callback itself just now. + let fresh = state + .lugdunum_natt_seen + .get(&target.user_hash) + .filter(|s| s.at.elapsed() < UDP_FRESH && v4(s.addr.ip()) == v4(target.ip)) + .map(|s| (s.addr, s.obf, s.local_port)); + Ok(match fresh { + Some((addr, obf, local_port)) => { + let mut packet = Vec::with_capacity(2 + body.len()); + packet.push(0xE3); + packet.push(OP_NAT_CALLBACK_UDP); + packet.extend_from_slice(&body); + Forward::Udp { + addr, + obf, + local_port, + packet, + } + } + None => Forward::Tcp { + target: target.user_hash, + frame: Frame::new(OP_NAT_CALLBACK_TCP, body), + }, + }) +} + +/// LowID parity, as Lugdunum hands them out: odd for a client that set +/// 0x1000, so a peer can tell from a source's id alone that the callback is +/// worth asking for. Lugdunum gives the others whatever comes next; here they +/// get even ids, so an odd id always means a capable client. +pub fn wants_odd_low_id(state: &ServerState, server_flags: u32) -> bool { + state.live_cfg.load().network.lugdunum_natt && server_flags & SRVCAP_NATTRAVERSAL != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn st() -> ServerState { + ServerState::for_test() + } + + /// Register a LowID client at `ip` with `flags`, returning its receiver. + fn client( + s: &ServerState, + hash: u8, + id: u32, + ip: [u8; 4], + flags: u32, + ) -> tokio::sync::mpsc::Receiver { + let rx = + s.register_test_client_at([hash; 16], id, false, 0, IpAddr::V4(Ipv4Addr::from(ip))); + s.clients.get_mut(&[hash; 16]).unwrap().server_flags = flags; + rx + } + + const NATT: u32 = SRVCAP_NATTRAVERSAL; + const KEY: Option<(u32, u8)> = Some((0x1234_5678, 1)); + + fn req(target: u32, requester: u32) -> Vec { + let mut p = target.to_le_bytes().to_vec(); + p.extend_from_slice(&requester.to_le_bytes()); + p + } + + #[test] + fn a_request_is_forwarded_to_the_target_by_tcp() { + let s = st(); + let _a = client(&s, 0xA, 11, [5, 6, 7, 8], NATT); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT); + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + let f = handle_request(&s, &req(13, 11), peer, KEY, 6086).unwrap(); + let Forward::Tcp { target, frame } = f else { + panic!("{f:?}") + }; + assert_eq!(target, [0xB; 16]); + assert_eq!(frame.opcode, 0x37); + // ip, port 40000 LE, requester id 11, crypt 0: Lugdunum's 11 bytes. + assert_eq!(frame.payload, vec![5, 6, 7, 8, 0x40, 0x9C, 11, 0, 0, 0, 0]); + } + + #[test] + fn the_hash_rides_along_when_both_use_the_crypt_layer() { + let s = st(); + // 0x200 supports, 0x400 requests. + let _a = client(&s, 0xA, 11, [5, 6, 7, 8], NATT | 0x600); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT | 0x200); + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + let Forward::Tcp { frame, .. } = handle_request(&s, &req(13, 11), peer, KEY, 6086).unwrap() + else { + panic!() + }; + assert_eq!(frame.payload.len(), 27); + assert_eq!(frame.payload[10], 0x03 | 0x80); + assert_eq!(&frame.payload[11..27], &[0xA; 16]); + } + + #[test] + fn a_required_crypt_layer_the_other_side_lacks_stops_it() { + let s = st(); + let _a = client(&s, 0xA, 11, [5, 6, 7, 8], NATT | 0xE00); // requires + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT); // no crypt + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + assert_eq!( + handle_request(&s, &req(13, 11), peer, KEY, 6086).unwrap_err(), + Refused::CryptMismatch + ); + } + + #[test] + fn the_rules_lugdunum_applies_and_ours() { + let s = st(); + let _a = client(&s, 0xA, 11, [5, 6, 7, 8], NATT); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT); + let _c = client(&s, 0xC, 14, [8, 8, 8, 8], 0); // stock client + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + assert_eq!( + handle_request(&s, &req(13, 11), peer, None, 6086).unwrap_err(), + Refused::NotObfuscated + ); + assert_eq!( + handle_request(&s, &[1, 2, 3], peer, KEY, 6086).unwrap_err(), + Refused::Short + ); + let elsewhere: SocketAddr = "1.1.1.1:40000".parse().unwrap(); + assert_eq!( + handle_request(&s, &req(13, 11), elsewhere, KEY, 6086).unwrap_err(), + Refused::WrongAddress + ); + assert_eq!( + handle_request(&s, &req(14, 11), peer, KEY, 6086).unwrap_err(), + Refused::TargetNotCapable + ); + assert_eq!( + handle_request(&s, &req(99, 11), peer, KEY, 6086).unwrap_err(), + Refused::UnknownTarget + ); + assert_eq!( + handle_request(&s, &req(11, 11), peer, KEY, 6086).unwrap_err(), + Refused::SameClient + ); + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.network.lugdunum_natt = false; + s.live_cfg.store(std::sync::Arc::new(cfg)); + assert_eq!( + handle_request(&s, &req(13, 11), peer, KEY, 6086).unwrap_err(), + Refused::Disabled + ); + } + + #[test] + fn a_target_that_asked_just_now_is_answered_by_udp() { + let s = st(); + let _a = client(&s, 0xA, 11, [5, 6, 7, 8], NATT); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT); + // B asks for A first (B's endpoint becomes fresh)… + let b_peer: SocketAddr = "9.9.9.9:50000".parse().unwrap(); + handle_request(&s, &req(11, 13), b_peer, Some((7, 1)), 6090).unwrap(); + // …then A's reverse request reaches B over UDP, from the port B used. + let a_peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + let f = handle_request(&s, &req(13, 11), a_peer, KEY, 6086).unwrap(); + let Forward::Udp { + addr, + obf, + local_port, + packet, + } = f + else { + panic!("{f:?}") + }; + assert_eq!((addr, obf, local_port), (b_peer, (7, 1), 6090)); + assert_eq!(&packet[..2], &[0xE3, 0xA7]); + assert_eq!(&packet[2..], &[5, 6, 7, 8, 0x40, 0x9C, 11, 0, 0, 0, 0]); + } + + #[test] + fn requests_are_rate_limited_per_requester() { + let s = st(); + let _a = client(&s, 0xA, 11, [5, 6, 7, 8], NATT); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT); + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + let ok = (0..30) + .filter(|_| handle_request(&s, &req(13, 11), peer, KEY, 6086).is_ok()) + .count(); + assert!((10..=11).contains(&ok), "{ok}"); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index af3e8e4..cbcb02e 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -9,6 +9,7 @@ pub mod highid_probe; pub mod holepunch; pub mod keepalive; pub mod login; +pub mod lugdunum_natt; pub mod obf_ping; pub mod obfuscated_conn; pub mod offer_pacing; diff --git a/src/server/udp.rs b/src/server/udp.rs index d6a6794..27524bd 100644 --- a/src/server/udp.rs +++ b/src/server/udp.rs @@ -742,6 +742,43 @@ impl UdpServer { send_reply(&self.socket, bytes, peer, obf).await } + /// Lugdunum's NAT callback (`server::lugdunum_natt`): forward a 0xA6 to + /// its target, by TCP 0x37 or UDP 0xA7. The requester is never answered, + /// as on Lugdunum; refusals are only counted. + async fn handle_nat_callback(&self, payload: &[u8], peer: SocketAddr, obf: Option<(u32, u8)>) { + use crate::server::lugdunum_natt::{handle_request, Forward}; + let local_port = self.socket.local_addr().map_or(0, |a| a.port()); + let key = match handle_request(&self.state, payload, peer, obf, local_port) { + Ok(Forward::Tcp { target, frame }) => { + if let Some(t) = self.state.clients.get(&target) { + let outcome = t.send_frame(frame); + self.state + .admission + .push + .note(crate::admission::PushKind::Holepunch, outcome); + } + "natcb_forwarded_tcp" + } + Ok(Forward::Udp { addr, obf, local_port, packet }) => { + // From the port the target sent its own request to: a client + // takes a server-way datagram only from the server's ports. + let sock = self + .state + .udp_sockets + .get(&local_port) + .map(|s| Arc::clone(&s)) + .unwrap_or_else(|| Arc::clone(&self.socket)); + let _ = send_reply(&sock, &packet, addr, Some(obf)).await; + "natcb_forwarded_udp" + } + Err(r) => { + debug!(ip = %peer.ip(), reason = r.stat_key(), "Lugdunum NAT callback not forwarded"); + r.stat_key() + } + }; + *self.state.block_stats.entry(key.to_string()).or_insert(0) += 1; + } + /// `obf`: how the request was obfuscated, or None if it arrived in the clear. /// Replies must mirror it — see `reply()`. async fn dispatch( @@ -854,7 +891,26 @@ impl UdpServer { // address: a user hash is no secret, and a keepalive // from elsewhere would send the client's peers there. let same_ip = canon_ip(h.ip) == canon_ip(peer.ip()); - if same_ip { + // Issue #39: on a server offering the extension, once + // a session has sent an obfuscated 0x9F its plain + // ones are ignored. Only the server key makes one, + // so a forged plain datagram cannot move its port. + let sent_obfuscated = self + .state + .natt_keepalive_seen + .get(&uh) + .is_some_and(|o| *o); + let plain_refused = obf.is_none() + && sent_obfuscated + && crate::server::holepunch::extension_enabled(&self.state.live_cfg.load()); + if plain_refused { + *self + .state + .block_stats + .entry("natt_keepalive_plain_ignored".to_string()) + .or_insert(0) += 1; + } + if same_ip && !plain_refused { // Issue #37: kept per client. The login port // (`udp_port`, 0xF9) stays as announced; it is // filled from here only if the client sent none, @@ -865,16 +921,24 @@ impl UdpServer { self.state .natt_udp_ports .insert(uh, (peer.port(), std::time::Instant::now())); + self.state + .natt_keepalive_seen + .insert(uh, sent_obfuscated || obf.is_some()); } // Count this UDP keepalive as client activity so the TCP // connection task's idle timer is reset. A NAT-T LowID // source is otherwise silent on TCP and would be wrongly // evicted after the idle timeout despite a live link. h.touch_activity(); - same_ip + same_ip && !plain_refused } else { false }; + // An extension coordination waiting for this client's + // fresh port can go ahead (issue #39). + if matched { + crate::server::holepunch::keepalive_arrived(&self.state, &uh); + } // DIAGNOSTIC: shows in the server log whether NAT-T keepalives // actually arrive and whether the userhash matches a connected // client. "matched=false" also when the keepalive came from @@ -900,6 +964,13 @@ impl UdpServer { } Ok(()) } + // Lugdunum's NAT callback request (issue #39). In eNode-go's + // dialect 0xA6 is server->client only, so nothing else arrives + // here with it. + crate::server::lugdunum_natt::OP_NAT_CALLBACK_REQUEST => { + self.handle_nat_callback(payload, peer, obf).await; + Ok(()) + } OP_GLOB_SERVSTATREQ => self.handle_servstat(payload, peer, obf).await, // 0x97 = GLOBSERVSTATRES — seed server responding to our keepalive ping 0x97 => self.handle_pingreply(payload, peer, obf.is_some()).await, diff --git a/src/state/mod.rs b/src/state/mod.rs index a85eac4..252deb0 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -629,6 +629,19 @@ pub struct ServerState { /// written only by 0x9F sent from the session's own IP, and preferred by /// the hole-punch coordinator. Swept with `observed_udp_ports`. pub natt_udp_ports: DashMap, + /// Each client's last Lugdunum NAT callback request (UDP 0xA6): its + /// endpoint, the key to answer it with, and its request allowance + /// (`server::lugdunum_natt`). Swept after `lugdunum_natt::SEEN_TTL`. + pub lugdunum_natt_seen: DashMap, + /// Clients that have sent a 0x9F from their session's address in this + /// session; the value says whether one of them came obfuscated (issue + /// #39). Having sent any 0x9F shows a client understands 0x61, so a + /// Lugdunum-only target is one without an entry. Cleared at login and + /// logout. + pub natt_keepalive_seen: DashMap, + /// Hole-punch extension coordinations waiting for this target's next + /// 0x9F (`server::holepunch`). The 0x9F handler fires and removes them. + pub natt_keepalive_waiters: DashMap>>, /// Map of local UDP port → bound UdpSocket. Lets any handler send from a /// specific port (e.g. send an obfuscated reply from our :4675 even when /// the request came in on our :4665). Lugdunum's FUN_0042c480 looks up @@ -1558,6 +1571,9 @@ impl ServerState { incoming_seed_challenges: DashMap::new(), observed_udp_ports: DashMap::new(), natt_udp_ports: DashMap::new(), + lugdunum_natt_seen: DashMap::new(), + natt_keepalive_seen: DashMap::new(), + natt_keepalive_waiters: DashMap::new(), our_sent_random_parts: DashMap::new(), udp_sockets: DashMap::new(), ip_filter: tokio::sync::RwLock::new(crate::filter::ipfilter::IpFilter::default()), @@ -1684,6 +1700,22 @@ impl ServerState { ) } + /// A LowID for a client that sent `server_flags` at login: odd when it + /// set Lugdunum's NAT callback bit (0x1000), even otherwise, while + /// `network.lugdunum_natt` is on (see `server::lugdunum_natt`). + pub fn allocate_low_id_for(&self, server_flags: u32) -> u32 { + if !self.live_cfg.load().network.lugdunum_natt { + return self.allocate_low_id(); + } + let odd = crate::server::lugdunum_natt::wants_odd_low_id(self, server_flags); + loop { + let id = self.allocate_low_id(); + if (id & 1 == 1) == odd { + return id; + } + } + } + pub fn allocate_low_id(&self) -> u32 { let id = self.next_low_id.fetch_add(1, Ordering::Relaxed); if id >= 0x00FF_FFFF { diff --git a/src/web.rs b/src/web.rs index b170841..d9f26af 100644 --- a/src/web.rs +++ b/src/web.rs @@ -727,6 +727,20 @@ struct StatusResp { ipv6_sources: u64, /// How many LowID↔LowID hole punches the server has coordinated since start. natt_coordinated: u64, + /// Connected clients that set Lugdunum's NAT callback bit (0x1000). + natcb_capable_clients: u64, + /// Lugdunum NAT callbacks forwarded (TCP 0x37, UDP 0xA7) and refused. + natcb_forwarded_tcp: u64, + natcb_forwarded_udp: u64, + natcb_refused: u64, + /// The hole-punch extension (issue #39): clients that announced it, and + /// coordinations started, released by the target's 0x9F, or timed out; + /// 0x37 bridges to Lugdunum-only targets. + hpext_clients: u64, + hpext_started: u64, + hpext_fresh: u64, + hpext_timeout: u64, + hp_bridged: u64, } async fn api_status( @@ -778,6 +792,34 @@ async fn api_status( .get("holepunch_coordinated") .map(|v| *v) .unwrap_or(0), + natcb_capable_clients: s + .server + .clients + .iter() + .filter(|c| c.server_flags & crate::server::lugdunum_natt::SRVCAP_NATTRAVERSAL != 0) + .count() as u64, + natcb_forwarded_tcp: s.server.block_stats.get("natcb_forwarded_tcp").map_or(0, |v| *v), + natcb_forwarded_udp: s.server.block_stats.get("natcb_forwarded_udp").map_or(0, |v| *v), + natcb_refused: s + .server + .block_stats + .iter() + .filter(|e| e.key().starts_with("natcb_") && !e.key().starts_with("natcb_forwarded")) + .map(|e| *e.value()) + .sum(), + hpext_clients: { + const BOTH: u32 = crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT + | crate::server::lugdunum_natt::SRVCAP_NATTRAVERSAL; + s.server + .clients + .iter() + .filter(|c| c.server_flags & BOTH == BOTH) + .count() as u64 + }, + hpext_started: s.server.block_stats.get("holepunch_ext_started").map_or(0, |v| *v), + hpext_fresh: s.server.block_stats.get("holepunch_ext_fresh").map_or(0, |v| *v), + hpext_timeout: s.server.block_stats.get("holepunch_ext_timeout").map_or(0, |v| *v), + hp_bridged: s.server.block_stats.get("holepunch_bridged").map_or(0, |v| *v), }) } @@ -3916,7 +3958,9 @@ async function refreshStatus() { - + + +
IPCountryQPMStddev (ms)SentFiles askedTCP client on this IPReasonFirst seenLast seen
ReasonFirst seenLast sanctioned
${b.tcp_client ? escapeHtml(b.tcp_client) : 'none'}${escapeHtml(b.reason)}${b.banned ? ' BANNED 24h' : ''}` + `${b.throttled ? ' THROTTLED 24h' : ''}` - + `${b.ipfilter_added ? ' IP FILTER' : ''}${fmtDur(b.first_seen_secs_ago)} ago${fmtDur(b.last_seen_secs_ago)} ago
NAT-T capable clients${fmt(st.natt_capable_clients)} connected clients with the NAT-traversal mod
IPv6-capable clients${fmt(st.ipv6_clients)} sessions that can parse an inline IPv6 source record
IPv6 publishers${fmt(st.ipv6_sources)} connected clients with a usable IPv6 address
NAT-T punches coordinated${fmt(st.natt_coordinated)} LowID↔LowID since start
NAT-T punches coordinated${fmt(st.natt_coordinated)} LowID↔LowID since start (0x60 → 0x61 to both)
Lugdunum NAT callbacks${fmt(st.natcb_forwarded_tcp + st.natcb_forwarded_udp)} forwarded (TCP 0x37 ${fmt(st.natcb_forwarded_tcp)} · UDP 0xA7 ${fmt(st.natcb_forwarded_udp)}) · ${fmt(st.natcb_refused)} refused · ${fmt(st.natcb_capable_clients)} capable clients 0xA6 requests from clients that set 0x1000 at login; reasons in the Blocks tab (natcb_*)
Hole punch extension${fmt(st.hpext_started)} started · ${fmt(st.hpext_fresh)} with a fresh 0x9F · ${fmt(st.hpext_timeout)} after the 3 s wait · ${fmt(st.hpext_clients)} clients · ${fmt(st.hp_bridged)} bridged 0x60 between two clients with 0x100000; bridged = 0x37 to a Lugdunum-only target
obf_decode_cache${fmt(st.obf_decode_cache_entries)} entries (capped at 8192)
seed_challenges${fmt(st.incoming_seed_challenges_entries)} entries (capped at 2048)
`; From e74aca7009cc1c235d039db43fa669bd88710cf3 Mon Sep 17 00:00:00 2001 From: andrey23127 Date: Fri, 30 Oct 2026 22:25:20 +0300 Subject: [PATCH 3/5] Release 0.9.86: cross-server NAT callback (#40), same-hash takeover (#41), FOUNDSOURCES_OBFU (#42), IPv6 dial-back (#43), fixes #44-#49, filter audit Two LowIDs on different servers reach each other through the target's own server, with no registry keyed by user hash. Agreed with aMule and emule-qt in #40. * Lugdunum's UDP 0xA6 <0>[] is accepted from a client not logged in here (requester id 0), server-key obfuscated only: the key is bound to the sender's IP, so a datagram that decodes proves it. * The target (logged-in LowID, set 0x1000) gets the usual 0x37 / 0xA7 with requester id 0 and no hash; nothing when one side requires the crypt layer. * A target with the extension (0x100000) answers with 0x9F; the requester then gets UDP 0xA7 , obfuscated, from the port it wrote to; after 3 s without 0x9F, the same with the port the server has. The hash is always there: the requester dials (no 0x61 follows a 0x37 with id 0). Resent at +1.2 s and +3 s. No other answer is ever sent. Requester ID 0 marks a foreign request on every path that wakes a target; same-server forwards never carry it. * Limits per source IP (burst 10 then 1/s; at most 60 target ids in 10 min) and per target (6 foreign wake-ups a minute); one answer in flight per requester and target, inside the hole-punch retry pool. * Announced with 0x100000 in the UDP flags of the status reply (plain and obfuscated, to clients and servers) and in ST_UDPFLAGS at login. * network.holepunch_foreign (default on, live; needs holepunch_extension). Status tab: foreign forwards and answers; Blocks tab: natcb_foreign_*. * README: wire contract and limits; UDP flag note; eNode-go's 0x200000 (NAT wake-up) in both flag tables, first free bit 0x400000. Same-hash logins (issue #41): a login whose user hash is already online replaces that session only from the same IPv4 / IPv6 prefix, from the IPv6 the IPv4 session advertised with the same listen port, or when the old session is gone. Otherwise the login is held while the old session is probed: 10 s TCP_USER_TIMEOUT and an OP_SERVERSTATUS on its socket; no ACK (a user who moved) aborts the old connection and the held login goes ahead in ~11 s, inside the client's 25 s login timeout; an ACK refuses the login with a server message, counted (login_hash_in_use). One probe per session at a time, then a 10 s pause; a contender refused after a probe is refused without one for 10 minutes. A shared hash (degenerate, or in the built-in list: DLP antiLeech's 30 community / corrupt / invalid hashes and mldonkey's 3649E68B070E85991648EF223A7F6FFD; or in limits.shared_user_hashes, added to it) keeps the old rule; contender counts are only reported. /api/takeover lists refused hashes with contenders and owner. checked before the HighID probe and again before the insert. limits.hash_takeover_from_any_address (default false, live) restores the old rule. NAT-T counters and caches: * /api/natt: all NAT-T counters and live gauges. New counters for 0x60 (by client family, repeats per pair in 10 min, first answer / 0x62 reason), 0x9F outcomes, extension timing / timeouts / late 0x9F, bridges, Lugdunum 0xA6 (non-capable targets by parity and requester family) and the cross-server path. Status "NAT-T clients" counts 0x9F senders instead of the 0xF9 port. * Obfuscated UDP decode cache aged (30 min, cap 262k) instead of cleared at 8192 every minute; a cold sender is tried as a client (one attempt) before the peer-server tables are scanned; hot / cold / crypt-ping / failed counters on Status. * GETSOURCES cache: TTL 120 s by default (limits.getsources_cache_secs, live; at 15 s half the misses were 30 s - 2 min old), expired entries swept every minute (key and insert time remembered 20 more minutes, outside the cap, so late requests still count), cap 200k, a full cache skips the new entry instead of clearing; expired misses by how long past the TTL they came, and skipped inserts, on Status. * Undecodable obfuscated UDP from an address with a session is counted; from a session with 0x1000 also listed in /api/natt "recent" (once a minute per address): a 0x9F under a stale server key otherwise looks like none was sent (#40). * 0x62 reason 5 when either session of a 0x60 is not over IPv4, instead of a 0x61 carrying 0.0.0.0; no extension wake-up for such a pair. * Counter client families include the mod name; /api/natt "recent" keeps the last 60 NAT-T requests in detail. README: aMule's OFFERFILES v1 (PR #1715) merged and on by default (#19). Issues #44-#49 (itlezy): * #44: the obfuscated identity probe sends the 12-byte opening eMule reads (methods supported + preferred + padding length), writes the HELLO only after the peer's answer is checked, and reads that answer's padding as a full byte (up to 254 to anyone but the server being logged into). * #45: a publisher with another size than the stored one is not merged (offer_size_mismatch); a sized GETSOURCES / UDP 0x94 record for a hash stored with another size gets no sources (getsources_size_mismatch_ {tcp,udp}_{larger,smaller}). The stored size gives way once the file has no sources. 0 matches anything, and so does a size cut to 32 bits against the full one above 4 GiB (the record takes the full size). A 64-bit FT_FILESIZE tag in OFFERFILES is read whole. /api/sizemismatch: the last 1000 disagreements (hash, sizes, publisher or requester address, publisher software, complete or not; no names), grouped by file. * #46: a replaced session's next frame closes it (replaced_session_frame_refused); silent ones still go after 10 min. Not for shared hashes: fencing those made their installs displace each other in a loop (11k shared-hash takeovers in 8 h instead of ~1k). * #49: up to 800 sources scanned (UDP 200), HighID first; stock LowID requesters get no LowID sources, NAT-T LowID requesters the NAT-T LowIDs, HighID requesters all; decided when served, the cache stays shared. * #48: lockfile back on Rust 1.75 (base64ct =1.6.0, zeroize <1.9, idna_adapter =1.2.0 -> ICU 1.5), resolver.incompatible-rust-versions = "fallback" in .cargo/config.toml. Filter audit: * /api/review?format=audit|auditcsv: false-positive audit that withholds every name with explicit words, a child's age, a word for a child or a second listed term, and all names of rules that are working markers. Per rule: hits, signals, file kinds, publishers, verdict; visible names; hashes withheld for explicit words only; L3 decoy and alias leads with clean names only. CSV with a suggestion per hash. * /api/indexcheck: the whole index against the current filter, hashes only, whitelisted marked; ?purge=1 removes the blocked files from the index. Issue #42: OP_GETSOURCES_OBFU (0x23) gets OP_FOUNDSOURCES_OBFU (0x44) with each source's crypt options and, when it supports the crypt layer, 0x80 and its user hash, as Lugdunum; 0x19 still gets 0x42; cached apart. Every 0x61 now carries the peer's crypt setting in byte 29 (30 bytes), not only between extension clients. Issue #43: a client's IPv6 (login tag, OP_IPV6_UPDATE or IPv6 session) is published only after the server dialled [address]:login port with the HighID identity probe and got the logged-in user hash back. Until then ST_IPV6_STATUS is 0x01; the verdict comes in a fresh OP_SERVERIDENT, 0x07 (published) or 0x05 (IPV6ST_PROBED = 0x04, not published). A new address in OP_IPV6_UPDATE is dialled again, the same keeps its verdict. Shared probe ceiling (retried a minute later when full), at most 4 dials per session. network.ipv6_probe (default on, live), in force with ipv6_publish_sources and an IPv6 route on the host, announced with 0x400000 in OP_IDCHANGE; otherwise addresses are published on trust. Status "IPv6 dial-back" with failures by stage (timeout / refused / unreachable / after connect), Blocks ipv6_probe_*. A connect error other than a reset is "connect unreachable" (HighID statistics too). Tests: 625 unit + 29 integration pass. Checked against a running server with an unlogged UDP requester: the status reply carries 0x100000; an extension target gets 0x37 (requester id 0, its UDP port) and, after its 0x9F, the requester gets 0xA7 from the obfuscation port with the target's fresh port and id; a Lugdunum-only target gets 0x37 and the requester nothing; a silent extension target is answered after 3 s; a plain request and an unknown target get nothing. Same-hash login, against a running server: a second login with the hash from 127.0.0.2 gets the server message and is closed while the first session stays up; a new login from the first session's address replaces it. Probe: with the old session's path dropped (iptables, no ACK and no RST), a login with its hash from another address was held and accepted after 10.7 s; a third login while that one lived was refused after the probe. Audit and index check against a running server with a test term: three clean names shown under a "likely false positive" rule, the explicit one withheld and listed by hash; a term added later finds the indexed file, purge removes it. 0x23 against a running server: 0x44 with crypt 0x83 and the publisher's hash for a source with 0x600; 0x19 still 0x42. --- .cargo/config.toml | 7 + CHANGELOG.md | 270 +++++++++++++++ Cargo.lock | 157 +++++---- Cargo.toml | 17 +- README.md | 145 +++++++- config/config.toml | 19 + src/config.rs | 91 ++++- src/filter/mod.rs | 18 +- src/main.rs | 56 ++- src/proto/crypt_stream.rs | 6 + src/proto/opcodes.rs | 15 + src/server/connection.rs | 404 ++++++++++++++++++++- src/server/get_sources.rs | 333 ++++++++++++++++-- src/server/highid_probe.rs | 158 +++++++-- src/server/holepunch.rs | 266 ++++++++++++-- src/server/ipv6_probe.rs | 304 ++++++++++++++++ src/server/login.rs | 47 ++- src/server/lugdunum_natt.rs | 488 +++++++++++++++++++++++--- src/server/mod.rs | 2 + src/server/offerfiles.rs | 5 + src/server/review_audit.rs | 357 +++++++++++++++++++ src/server/udp.rs | 255 +++++++++++++- src/state/file_id.rs | 149 ++++++++ src/state/mod.rs | 195 ++++++++++- src/state/smart_sources.rs | 282 +++++++++++++-- src/web.rs | 674 +++++++++++++++++++++++++++++++++++- tests/integration.rs | 69 ++++ 27 files changed, 4504 insertions(+), 285 deletions(-) create mode 100644 src/server/ipv6_probe.rs create mode 100644 src/server/review_audit.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index e569da4..0dd7ca5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -28,6 +28,13 @@ [env] JEMALLOC_SYS_WITH_MALLOC_CONF = "background_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:1000,thp:never" +# ── MSRV-aware updates (issue #48) ────────────────────────────────────────── +# Keep `cargo update` on dependency versions that build with the declared +# rust-version (1.75). Cargo 1.84+ reads this; older Cargo warns about an +# unused key and goes on. +[resolver] +incompatible-rust-versions = "fallback" + # ── Optional CPU optimization (uncomment ONE that matches YOUR build host) ─── # Commented out so the project builds portably everywhere. Enabling a specific # target-cpu produces a binary tuned to that microarchitecture — only run such a diff --git a/CHANGELOG.md b/CHANGELOG.md index aa5a973..5973571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,276 @@ Earlier releases are described in their release commit messages. +## 0.9.86 + +### Sources with crypt options (issue #42) + +`OP_GETSOURCES_OBFU` (0x23) is answered with `OP_FOUNDSOURCES_OBFU` (0x44), as +Lugdunum does: per source `[]`, the crypt byte +being the source's `(CT_SERVER_FLAGS >> 9) & 7` (supports / requests / +requires the crypt layer) with 0x80 and the hash when it supports the layer; +a source whose client has left gets 0. IPv6 sentinel records carry the same +fields before the address. It used to get the plain 0x42, so a client +requiring obfuscation dropped every source from this server and no client +could obfuscate its first connection to one — NAT-T dials included. 0x19 +still gets 0x42; the two replies are cached apart. + +Every `OP_LOWID_HOLEPUNCH_INFO` (0x61) now carries the peer's crypt setting +in byte 29, not only between extension clients. With the user hash it has +always had (bytes 8–23), the dialler can obfuscate from its first datagram, +so a target that requires the crypt layer accepts the stream (got3nks: aMule +dialled plain uTP and such targets refused it). Parsers read by length, so +the 30-byte frame is safe for 29-byte readers. + +Cross-server NAT callback (issue #40): two LowIDs on different servers reach +each other through the target's own server, with no registry. Same-hash +logins from another address no longer take a live session over (issue #41). +A filter audit that never shows a rightly blocked name, and an index check +against the current filter. +Two new keys, `network.holepunch_foreign` (on) and +`limits.hash_takeover_from_any_address` (off). + +- **Request.** Lugdunum's UDP `0xA6` `<0>[]` from a client + not logged in here, server-key obfuscated. The key is bound to the + sender's IP, so a datagram that decodes proves it; the source port is the + requester's endpoint. Requester id 0 marks it foreign. +- **Wake.** The target, a logged-in LowID that set `0x1000`, gets the usual + `0x37` (or `0xA7`) with requester id 0 and no hash. Nothing when one side + requires the crypt layer. +- **Answer.** For a target with the extension, the requester gets UDP `0xA7` + `` after the target's + `0x9F`, or after 3 s with the port the server has, resent at +1.2 s and + +3 s like `0x61`; the hash always, since + the requester dials and obfuscates its first QUIC Initial with it. The + target punches and is dialled; no `0x61` follows. Nothing else is ever + answered: no oracle for who is online. +- **Requester ID 0** marks a foreign request on every path that wakes a + target; a same-server forward never carries it (README). +- **Limits.** Per source IP: a burst of 10, then 1/s, and at most 60 + different target ids in 10 minutes. Per target: 6 foreign wake-ups a + minute. One answer in flight per requester and target. +- **Announced** with `0x100000` in the UDP flags of the status reply (plain + and obfuscated, to clients and servers) and in `ST_UDPFLAGS` at login. +- Status tab: foreign forwards and answers; Blocks tab: `natcb_foreign_*`. +- README: the wire contract and the limits; eNode-go's `0x200000` (NAT + wake-up, PR_NAT) added to both flag tables, first free bit now `0x400000`. + +### NAT-T counters, caches + +- Client families in the counters carry the mod ("eMule/Xtreme9.1"): eMule + mods all report as eMule. +- `/api/natt` also lists the last 60 NAT-T requests in detail: kind (0x60, + 0xA6, foreign 0xA6), both clients' software, login fingerprint, flags, + UDP port and whether they sent a 0x9F, the target id's parity, outcome. +- **`/api/natt`**: every NAT-T counter since start plus live gauges (clients + with 0x1000, with the extension, that sent 0x9F this session, obfuscated + or not; pending extension waits; foreign sources tracked…). New counters: + 0x60 requests by client family, repeats for the same pair within 10 + minutes (the closest the server gets to a failure rate), the first answer + per request (0x61 or each 0x62 reason — `holepunch_coordinated` also + counts the resends); every 0x9F by outcome (obfuscated, plain, plain + ignored, wrong IP, unknown hash); extension: time to the target's 0x9F in + buckets, timeouts split by whether the target ever sent a 0x9F, late + 0x9F; bridges by client family; Lugdunum 0xA6: requests, refusals for a + non-capable target split by even/odd LowID and by requester family, + forwards by requester family; cross-server: requests, target with or + without the extension. +- `/api/natt` "recent" also logs, once a minute per client, a 0x9F from + another address than the client's session, with both addresses (166 in + three hours from very few clients: the server can't use those ports). +- Status: "NAT-T clients" now counts clients that sent a 0x9F this session + (and obfuscated ones); the old figure counted the UDP port announced at + login, which stock clients send too. +- **Obfuscated UDP decode cache**: entries are aged out after 30 minutes + (cap 262k, ~10 MB) instead of the map being cleared at 8192 every minute, + which at 17k clients sent every obfuscated sender back to the cold path + once a minute. A cold sender is first tried as a client — this server's + key for its IP, direction 0x6B, one attempt — before the peer-server + tables are scanned; it used to be the ninth attempt. Status shows decodes + by the cached key, cold decodes, crypt pings answered (counted apart: they + are not obfuscated eD2k) and undecodable datagrams. Most cold senders are + clients of other servers sending a single global query, so cold decodes + stay common; they are now cheap. +- **GETSOURCES cache** (the Status "Cache hits"): TTL 5 → 15 s, cap 4096 → + 65536, and a full cache skips the new entry instead of clearing itself — + at several hundred requests a second the old cap was reached within + seconds and the cache wiped about as fast as it filled. Status shows + entries, misses on an expired entry with the entry's age (<30 s, 30–60 s, + 1–2 min, 2–5 min, >5 min: what a longer TTL would catch) and entries not + stored. Cap 200k. New key `limits.getsources_cache_secs` (1–600, live), + default 120 s: at 15 s about half of all misses (630k of 1.23M in three + hours) came 30 s to 2 minutes after the entry was built. A changed TTL + starts the cache statistics over; expired entries are swept every minute + instead of piling up to the cap. The histogram counts how long past the + TTL a request came (<1, 1–2, 2–5, 5–10, 10–20 min): by entry age, at a + TTL of 300 s every expired miss fell into ">5 min". A swept entry leaves + the map with its payload, but its key and insert time are remembered for + 20 more minutes (at most 400k, about 10 MB, outside the 200k cap), so a + late request still lands in the histogram; dropped outright, every + request later than one sweep read as a plain miss. "Entries" counts the + fresh ones. + +- **Undecodable UDP from a NAT-T client.** An obfuscated datagram no key + decodes, from an address with a session here, is counted + (`natt_udp_undecodable_from_session`); when the session set `0x1000`, also + `natt_undecodable_from_natt_client` and an entry in `/api/natt` "recent" + (once a minute per address). A keepalive obfuscated with a server key + fetched for another address is unreadable and otherwise looks exactly + like one never sent (#40). +- **`0x62` reason 5** for a `0x60` where either session is not over IPv4 + (an IPv6-only session is LowID too): the `0x61` would have carried + `0.0.0.0` as the peer's address. No extension wake-up for such a pair. + Counted as `holepunch_fail_no_ipv4`. + +### IPv6 dial-back (issue #43) + +A client's IPv6 is published as a source only after the server has dialled +`[address]:login port` with the HighID identity probe and got back the user +hash that logged in. Most dual-stack homes have a global address behind a +router that drops inbound IPv6; published on trust, it sent peers to an +address that never answered, and a client cannot test its own inbound IPv6. +Until the verdict the address is held (`ST_IPV6_STATUS` `0x01`); it comes in +a fresh `OP_SERVERIDENT`: `0x07` published, or `0x05` (`IPV6ST_PROBED` = +`0x04`) not published. The client stays able to receive IPv6 records. A new +address in `OP_IPV6_UPDATE` is dialled again, the same one keeps its verdict. +Shares the server-wide probe ceiling (a probe that finds it full is retried +a minute later), at most 4 dials per session since the client chooses where +they go. New key `network.ipv6_probe` (on, live); in force only with +`ipv6_publish_sources` and an IPv6 route on the host (checked at start and +every minute), and then announced with `0x400000` in `OP_IDCHANGE`. +Otherwise addresses are published on trust as before. Status tab: "IPv6 +dial-back", with the failures by stage (connect timeout, refused, ICMP +unreachable, after the connect); Blocks tab: `ipv6_probe_*`. A probe whose +connect fails other than by a reset now reads "connect unreachable" instead +of "connect refused", in the HighID statistics too. + +### Reported by itlezy (issues #44–#49) + +- **HighID identity probe, obfuscated (#44).** The opening is + `RC4()`, 12 bytes; it sent one method byte, so eMule and aMule read the + first byte of the HELLO as the padding length and lost the stream. The HELLO + now goes only after the peer's answer has been read and checked, and the + answer's padding length is read as the whole byte: a client pads its answer + to anyone but the server it is logging into with up to 254 bytes, which the + 4-bit mask cut short (the later re-checks and the IPv6 dial-back). Until now + an obfuscated probe most likely succeeded only through its plain fallback, + which a client requiring obfuscation refuses from any address but the + server's own. Live, the HighID check's "no answer" fell from 19% of + probes to under 5% (426 of 9.2k in eight hours). Tested against a peer that + negotiates as aMule does. +- **One file, one size (#45).** A publisher whose size differs from the one + stored for the hash is not merged into that file (`offer_size_mismatch`), + and a sized source request for a hash stored with another size gets no + sources (`getsources_size_mismatch_{tcp,udp}_{larger,smaller}`: which + side asked for more), over TCP and in UDP `0x94`. The first size stays + while the file has sources; once it has none, the next publisher's size + replaces it, so a wrong size cannot hold a hash. Size 0 matches anything, + and so does a size cut to 32 bits against the full one above 4 GiB (a + client without large-file support): that is the same file, and the record + takes the full size. A 64-bit `FT_FILESIZE` tag in `OP_OFFERFILES` is read + whole (it used to be dropped, leaving size 0). Live: under 1% of source + requests in eight hours asked with another size. **`/api/sizemismatch`** + keeps the last 1000 disagreements (hash, stored and other size, the + publisher's or requester's address, a publisher's software and whether its + copy is complete; never a name) and groups them by file: every other size + seen, with how many distinct addresses published or asked for it. No second record per (hash, size): the index stays one + record per hash. +- **A replaced session is fenced (#46).** Once a newer login of the same user + has taken over, the old socket's next frame closes it instead of being + handled under a user hash it no longer owns (`replaced_session_frame_refused`). + A silent one still goes after 10 minutes. Not for a shared hash (DLP's, + mldonkey's, the operator's): its sessions are different installs, and + fencing them sent each displaced one straight back to displace the other — + live, shared-hash takeovers went from 1.2k in ten hours to 11k in eight. +- **Sources a requester can reach (#49).** Up to 800 of a file's sources are + looked at (200 for UDP), HighID and departed ones first, LowID after, so the + HighID sources are no longer hidden behind a reply's worth of LowIDs (nine + sessions in ten are LowID). A stock LowID requester gets no LowID sources; a + LowID requester with NAT traversal (`0x1000`, `0x100000` or the announced + UDP port) gets the LowIDs that have it too; a HighID requester gets all of + them for callbacks. Decided when the reply is served, so the cached payload + still serves everyone. +- **Rust 1.75 (#48).** The lockfile had drifted onto releases that need Rust + 1.81–1.88 (`base64ct` 1.8, `zeroize` 1.9 and the ICU 2 line under `url`, + several of them edition 2024). Pinned in `Cargo.toml` (`base64ct` =1.6.0, + `zeroize` <1.9, `idna_adapter` =1.2.0, which selects ICU 1.5), the lockfile + regenerated with the MSRV-aware resolver, and `.cargo/config.toml` sets + `resolver.incompatible-rust-versions = "fallback"`. Every crate built on + Linux now declares a rust-version of 1.75 or lower; the ones that do not + are Windows / WASM only. +- **#47** (HighID only after the identity probe) is not taken: see the issue. + +### OFFERFILES v1 (issue #19) + +README: aMule's client side (PR #1715) is merged and on by default from its +next release; its 5 s wait for the login `OP_SERVERIDENT` and its re-offer +when the limits come late. + +### Filter audit + +- **`/api/review?format=audit` / `format=auditcsv`.** A false-positive audit + that never shows a name the filter blocked rightly. Each blocked name is + reduced to signals (explicit adult words, a child's age, a word for a + child, a second listed term); a name is shown only with none of them, in + a rule that is not a working marker. Per rule: hits, signal counts, file + kinds, publishers and a verdict; the visible names; hashes withheld for + explicit words only; L3 decoy candidates and alias leads with clean names + only. The CSV adds a suggestion per hash (`whitelist?`, `review`, `keep`, + `poison?`). +- **`/api/indexcheck`.** Every indexed file against the filter as it is now, + hashes only, with whitelisted ones marked; `?purge=1` takes the blocked + ones out of the index. Closes the gap where lists reloaded after a file was + indexed were never applied to it. + +### Same-hash logins (issue #41) + +A login with a user hash that is already online used to replace that +session from any address. User hashes are public (every hello and source +exchange carries them), so anyone could take a live session over: its +shared files, its LowID callbacks, its hole-punch port. It now replaces the +session only when it is plausibly the same machine: + +- the same IPv4, or the same IPv6 prefix (`admission.ipv6_source_prefix_bits`): + the NAT-dropped reconnect, as before; +- a dual-stack move: an IPv4 session's own advertised IPv6 (`CT_MOD_IP_V6`), + with the same listen port. Not the other way round; +- the old session is already gone. + +Before refusing, the server holds the new login and probes the old +session (ModderMule's idea, refined by got3nks): the old socket gets a 10 s +`TCP_USER_TIMEOUT` and an `OP_SERVERSTATUS`. When the user has moved, the +old path is dead, nothing acknowledges the write, the kernel aborts the old +connection after ~10 s and the held login goes ahead — inside the 25 s +eMule-family clients wait for a login answer, so a real move succeeds on +the first attempt. An acknowledged write means the owner is there: the +login gets a server message ("This user hash is already connected from +another address") and is closed. One probe per session at a time, then a +10 s pause. A contender refused after a probe is refused without one for +ten minutes: two installs sharing a user hash retry every few seconds, and +probing the owner each time kept its socket on a 10 s fuse (the first four +hours live: 5.5k refusals, 1.4k probes). **A shared hash keeps the +old rule** — the newest login takes the session — when it is degenerate (12+ +zero bytes, like the leecher mod's community hash +`00000000000E00000000000000006F00`; a random hash practically never is) or +on a built-in list — the 30 community, corrupt and invalid hashes of DLP's +antiLeech, plus mldonkey's `3649E68B070E85991648EF223A7F6FFD`, seen here from +many addresses in many countries at once (1.2k takeovers in ten hours once +listed, none refused) — or in the new `limits.shared_user_hashes` (live, added to the +built-in list). Such a hash +identifies no one; refusing all but one of its users locked them out (98% +of the refusals in the first hours came from three such hashes). Not by how +many addresses contest a hash: that is what a hijacker would produce +(got3nks) — `/api/takeover` only reports it (`looks_shared`). Counted as +`login_takeover_shared_hash`. Checked again just before the +session is stored. Counted in the Blocks tab (`login_hash_in_use`, +`login_takeover_probe`, `login_takeover_after_probe`, +`login_hash_in_use_known_contender`). **`/api/takeover`** lists the hashes +refused, worst first, with the contenders' addresses and the owner's +address and software: one or two contenders is a shared user hash, many is +a hash handed around. New key `limits.hash_takeover_from_any_address` +(off) restores the old rule. + ## 0.9.85 Lugdunum's NAT callback (issue #39), so a client written for the original diff --git a/Cargo.lock b/Cargo.lock index 327674f..db8164a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,9 +229,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bitflags" @@ -483,12 +483,13 @@ dependencies = [ [[package]] name = "ed2k-server" -version = "0.9.85" +version = "0.9.86" dependencies = [ "aho-corasick", "anyhow", "arc-swap", "axum", + "base64ct", "bytes", "chrono", "clap", @@ -500,6 +501,7 @@ dependencies = [ "hex", "hyper", "hyper-util", + "idna_adapter", "indexmap", "libc", "md-5", @@ -529,6 +531,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ureq", + "zeroize", ] [[package]] @@ -847,23 +850,21 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", - "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.3.0" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", @@ -872,62 +873,99 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ + "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_properties" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ "displaydoc", "icu_collections", - "icu_locale_core", + "icu_locid_transform", "icu_properties_data", "icu_provider", - "zerotrie", + "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" [[package]] name = "icu_provider" -version = "2.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", - "icu_locale_core", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", "writeable", "yoke", "zerofrom", - "zerotrie", "zerovec", ] +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "idna" version = "1.1.0" @@ -941,9 +979,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", @@ -1003,9 +1041,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "lock_api" @@ -1250,15 +1288,6 @@ dependencies = [ "spki", ] -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -1755,9 +1784,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tinystr" -version = "0.8.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", @@ -2011,6 +2040,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2357,11 +2392,17 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" -version = "0.6.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "yasna" @@ -2374,10 +2415,11 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ + "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -2385,9 +2427,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", @@ -2418,26 +2460,15 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.5" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerovec" -version = "0.11.8" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", @@ -2446,11 +2477,11 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index b17c9de..edde9a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ed2k-server" -version = "0.9.85" +version = "0.9.86" edition = "2021" description = "Modern eDonkey2000 / eMule index server (test/MVP build)" license = "MIT" @@ -102,6 +102,21 @@ version = "=2.6.0" [dependencies.powerfmt] version = "=0.2.0" +# Issue #48: newer releases of these need Rust 1.81-1.88 (several are +# edition 2024), and the lockfile had drifted onto them. base64ct comes in +# with the TLS stack, zeroize with the crypto crates, idna_adapter selects +# the ICU line under url/idna (1.2.0 = ICU 1.5, Rust 1.67; 1.2.1+ = ICU 2, +# Rust 1.82+). `.cargo/config.toml` also makes a newer Cargo's resolver +# honour rust-version on `cargo update`. +[dependencies.base64ct] +version = "=1.6.0" + +[dependencies.zeroize] +version = ">=1.8, <1.9" + +[dependencies.idna_adapter] +version = "=1.2.0" + # Stage 4 (memory): inline single-source storage in FileRecord. The `union` # feature lets SmallVec overlap its inline-array and heap-pointer storage, so # SmallVec<[Source;1]> stays at 32 bytes instead of being larger; sound on diff --git a/README.md b/README.md index a84be10..70015b4 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ stable at the scale of the largest real eD2k servers (tens of millions of files) - **Linux x86-64.** Developed and tested on **Debian 13 (codename trixie)**. Other modern distributions (Ubuntu, etc.) work the same way. - A Rust toolchain (`rustc` / `cargo`) ≥ 1.75 — install via [rustup](https://rustup.rs). + The committed `Cargo.lock` builds with 1.75 (`cargo build --release --locked`); + `.cargo/config.toml` keeps a newer Cargo's `cargo update` on versions that do. - Build tools: a C compiler and `make` (jemalloc-sys builds a small C library). On Debian/Ubuntu: `sudo apt install build-essential`. - RAM scales with index size. A small/medium server runs comfortably in a few @@ -336,11 +338,13 @@ a watched file) or needs a **restart**. | `highid_downgrade_on_wrong_hash` | **Verdict.** The same background check, acted on: a port that answers with a *different* client's user hash marks (IP, port, login hash), and that client's next logins get LowID while the mark lasts (see below). Default off | live | | `highid_wrong_hash_ttl_secs` | How long a wrong-hash mark lasts. Default 86400 (a day) | live | | `lugdunum_natt` | Lugdunum's NAT callback (see *NAT traversal*): flag `0x1000` in `OP_IDCHANGE`, odd LowIDs and a 24-byte `OP_IDCHANGE` for clients that set `0x1000`, UDP `0xA6` forwarded as TCP `0x37` / UDP `0xA7`. Default on | live | -| `holepunch_extension` | The hole-punch extension (see *NAT traversal*): flag `0x100000` in `OP_IDCHANGE`. Only for two clients that both announced `0x100000` with `0x1000`: a `0x60` wakes the target with `0x37`, waits up to 3 s for its fresh `0x9F`, then sends a 30-byte `0x61` with the peer's crypt setting. Also: plain `0x9F` ignored from a session that sent an obfuscated one. Needs `lugdunum_natt`. Default on | live | +| `holepunch_extension` | The hole-punch extension (see *NAT traversal*): flag `0x100000` in `OP_IDCHANGE`. Only for two clients that both announced `0x100000` with `0x1000`: a `0x60` wakes the target with `0x37`, waits up to 3 s for its fresh `0x9F`, then sends `0x61`. Also: plain `0x9F` ignored from a session that sent an obfuscated one. Needs `lugdunum_natt`. Default on | live | +| `holepunch_foreign` | Cross-server NAT callback (see *NAT traversal*): UDP `0xA6` with requester id 0 from a client logged in elsewhere wakes a target here with `0x37`; a target with the extension then gets the requester its endpoint by UDP `0xA7`. Announced with `0x100000` in the UDP flags of the status reply. Limited per source IP and per target. Needs `holepunch_extension`. Default on | live | | `ipv6_enabled` | Accept clients over IPv6 on the same TCP port, plus an IPv6 UDP socket on `tcp_port + 4`. Separate IPv6-only sockets, so the IPv4 path is unchanged; a host without IPv6 logs a warning and serves IPv4 only. Default off | restart | | `listen_ip6` | Bind address for the IPv6 sockets. Default `::` | restart | | `this_ip6` | The server's public IPv6, announced in `OP_SERVERIDENT` when `ipv6_publish_sources` is on. Empty = not announced | live | | `ipv6_publish_sources` | Publish IPv6 sources to clients that can parse them, and advertise the extension in `OP_IDCHANGE` and UDP pings. Independent of `ipv6_enabled`. Default off | live | +| `ipv6_probe` | Dial a client's IPv6 back before publishing it (issue #43; see *IPv6*). In force only with `ipv6_publish_sources` and an IPv6 route on this host; announced with `0x400000` in `OP_IDCHANGE`. Off, or without a route: addresses are published on trust. Default on | live | > **UDP ports are derived from `tcp_port`, not configured.** The eD2k protocol > fixes the whole UDP block relative to the TCP port, and seed servers compute a @@ -409,6 +413,9 @@ a watched file) or needs a **restart**. |---|---|---| | `max_clients` | Max concurrent logged-in clients, also advertised (`ST_MAXUSERS`). Reserved atomically before the login does any work, so concurrent logins cannot overshoot it. A login beyond it gets "Server full" and is closed; a client replacing its own stale session is always let in. `0` = no cap | live | | `max_clients_per_ip` | Max open TCP connections per client IP — per `admission.ipv6_source_prefix_bits` prefix for IPv6 — checked at accept before any work is done. Loopback is exempt; `0` = no cap. The Status tab shows the busiest IP — the address with the most logged-in clients, with its open connections — so you can check the value against real traffic (a provider NAT puts many users behind one address) | live | +| `getsources_cache_secs` | How long a TCP GETSOURCES answer is reused for the same file, seconds (1–600). The Status "Cache hits" row shows how long past the TTL requests came for an expired entry (up to 20 min), to choose it by. Default 120 | live | +| `hash_takeover_from_any_address` | A login whose user hash is already online replaces that session only when it is plausibly the same machine: the same IPv4 or the same IPv6 prefix (the NAT-dropped reconnect), or the IPv6 the IPv4 session advertised (`CT_MOD_IP_V6`) with the same listen port, or the old session is already gone. Otherwise the new login is held while the old session is probed: its socket gets a 10 s `TCP_USER_TIMEOUT` and an `OP_SERVERSTATUS`. A user who moved has a dead old path: nothing acknowledges the write, the kernel aborts the old connection, and the held login goes ahead (about 11 s, inside the 25 s clients wait for a login answer). An acknowledged write means the owner is still there: the login gets a server message and is closed (Blocks tab: "Hash online elsewhere"). One probe per session at a time, then a 10 s pause; a contender refused after a probe is refused without one for ten minutes. A hash that is degenerate (12+ zero bytes) or listed in `shared_user_hashes` is shared, identifies no one, and keeps the old rule. `/api/takeover` lists the refused hashes with their contenders. User hashes are public, so without this anyone could take a live session over (issue #41). `true` restores the old rule. Default `false` | live | +| `shared_user_hashes` | User hashes many installs share (hex), in addition to a built-in list: the 30 community, corrupt and invalid hashes of DLP's antiLeech, and mldonkey's `3649E68B070E85991648EF223A7F6FFD`. For these the newest login takes the session, as before 0.9.86. Add the ones `/api/takeover` marks `looks_shared` after a look (an attacker can produce many contenders too, so that is never applied by itself). Default empty | live | | `soft_limit_files`, `hard_limit_files` | Advertised to clients (`ST_SOFTFILES` / `ST_HARDFILES`) and enforced with Lugdunum semantics. `soft_limit_files` is the per-client indexing budget: new files beyond it are not indexed and the client gets one server message per connection; re-offers of files it already sources and filtered files use no budget; the session stays up. `hard_limit_files` is a per-packet bound: an `OFFERFILES` declaring that many records or more is rejected and the connection closed — keep it well above 200 (the eMule batch size). `0` disables either | live | | `max_string_size` | Max bytes STORED for a file name or nick (cut at a character boundary). The content filter always sees the full name first. `0` = no cap | live | | `ping_delay_seconds` | Server keep-alive ping interval | restart | @@ -690,6 +697,35 @@ contain a marker word, or historical texts whose title does — so an override t left the term layers running did nothing for the one class of mistake that happens in practice. +### Auditing the filter: `/api/review?format=audit` and `/api/indexcheck` + +Two admin endpoints for keeping the lists right, built so that the reader +never has to go through the names the filter blocked rightly. + +**`/api/review?format=audit`** (and `format=auditcsv`) looks for false +positives in the blocks of the window (same `hours`, `all`, `peek` parameters +as the plain review). Every blocked name is reduced to signals — explicit +adult words, a child's age, a word for a child, a second listed term — and a +name is shown only when it has none of them and its rule is not a working +marker (a quarter or more of its hits point at a child). The report has a +table per rule (hits, signal counts, audio/doc/video/image split, +publishers, verdict: likely false positive / mixed / marker), the visible +names under each rule, the hashes of names withheld for explicit words only +(legal adult titles the term fires on, to look at by hash), and, as before, +L3 hits with a decoy signature and indexed files under unrelated names — those +with clean names only. The CSV has one line per hash: +`hash;layer;reason;size;kind;explicit;age;minor;marker2;sizeflag;names;unrelated;publishers;suggest;name`, +`suggest` being `whitelist?`, `review`, `keep` or (L3) `poison?`, and `name` +empty when withheld. + +**`/api/indexcheck`** runs every indexed file through the filter as it is now. +Lists reloaded after a file was indexed were never applied to it; this finds +those files: `hash;layer;reason;sources;size;whitelisted`, no names. A file the +whitelist lets through is listed with `whitelisted=1`. `?purge=1` takes the +blocked, non-whitelisted ones out of the index (counted as +`indexcheck_purged`). One run at a time; the walk copies names a few thousand +at a time and filters them outside the index locks. + ### Layer 2: age plus context Layer 2's word lists live in `layer2_terms_file` rather than in the binary, and @@ -831,14 +867,16 @@ else the port you announced (`0xF9`, or the one in your `0x60`). | Opcode | Value | Direction | Payload | |---|---|---|---| | `OP_LOWID_HOLEPUNCH_REQUEST` | `0x60` | client → server (TCP) | `` | -| `OP_LOWID_HOLEPUNCH_INFO` | `0x61` | server → both (TCP) | `` | +| `OP_LOWID_HOLEPUNCH_INFO` | `0x61` | server → both (TCP) | `` | | `OP_LOWID_HOLEPUNCH_FAIL` | `0x62` | server → client (TCP) | `` | | `OP_SERVER_NATT_KEEPALIVE` | `0x9F` | client → server (UDP) | `` | | `OP_IDCHANGE` TCP flags bit | `0x20000` | server → client (TCP) | set when the server accepts `0x60` | To download from a LowID source **B**, send `0x60` with B's assigned id and your UDP port. The server replies `0x61` to **both** you and B — each gets the other's address -plus a role byte — or `0x62` if B is HighID, gone, or has a dead session. On `0x61` +plus a role byte — or `0x62` if B is HighID, gone, or has a dead session +(reasons 1–3), has no UDP port (4), or one of the two sessions is not over +IPv4 (5: there is no IPv4 endpoint to hand out). On `0x61` both sides punch and raise the tunnel; on `0x62` retry or fall back. The `role` byte is advisory: when both sides initiate, as they should, order does not matter. @@ -847,8 +885,11 @@ the `target_id` of its `0x60`, so it can match a `0x61` to one of several requests in flight; the target gets the requester's ID. It was added in 0.9.84; earlier servers send 25 bytes. **Read it only when the payload is at least 29 bytes**, and accept longer payloads: fields may be appended later, never -inserted. Between two clients using the extension (below) it is 30 bytes: -`peer_crypt(1)` is appended. +inserted. Since 0.9.86 every `0x61` is 30 bytes (issue #42): `peer_crypt(1)` +is appended, the peer's `(CT_SERVER_FLAGS >> 9) & 7` (supports / requests / +requires the crypt layer). With the user hash at bytes 8–23 it lets the +dialler obfuscate from its first datagram, so a peer that requires the crypt +layer accepts the stream. Read it only from 30 bytes or more. (`0x60`–`0x62` are unused in the server TCP opcode space, which runs `0x01`–`0x44`, and `0x9F` was free in the server UDP namespace — no conflict on the server link.) @@ -927,7 +968,7 @@ unchanged. Counted on the Status tab as "bridged". emule-qt. It applies only when the server sets `0x100000` in the `OP_IDCHANGE` flags and **both** clients set `0x100000` together with `0x1000` in `CT_SERVER_FLAGS` at login. Every other pair gets exactly the punch above: -`0x61` at once, 29 bytes, no `0x37`. Off with +`0x61` at once, no `0x37`. Off with `network.holepunch_extension = false` (it needs `lugdunum_natt`). 1. The requester sends a `0x9F` (server-key obfuscated when it has the key) @@ -936,7 +977,7 @@ flags and **both** clients set `0x100000` together with `0x1000` in carrying the requester's best port so far. 3. The target sends `0x9F` at 0, 0.5 and 1 s and starts punching towards that endpoint. It dials only as the role in `0x61` says. -4. On the target's first `0x9F` the server sends `0x61` to both, 30 bytes, +4. On the target's first `0x9F` the server sends `0x61` to both (30 bytes, as every `0x61`), with `peer_crypt` = the peer's `(CT_SERVER_FLAGS >> 9) & 7` (support, request, require). Resends follow at +1.2 s and +3 s as usual. 5. With no `0x9F` from the target within 3 s, the server sends the same @@ -951,6 +992,53 @@ that client's port. Clients without the extension keep working as before. The Status tab shows coordinations started, released by a fresh `0x9F`, and timed out. +### Cross-server NAT callback (issue #40) + +Two LowIDs on **different** servers, without a registry: the requester asks +the target's own server, whose address and the target's LowID it already has +from source exchange (every version sends a source's server next to its +LowID). Agreed with aMule and emule-qt. Off with +`network.holepunch_foreign = false`. + +| Step | Wire | +|---|---| +| Is it offered? | `0x100000` in the UDP flags of the status reply (`0x97`, plain and obfuscated, to clients and servers) and in `ST_UDPFLAGS` at login. Lugdunum 17.15 ignores the bit | +| Key | the status ping gives the requester this server's UDP key, `IPObfuscate(secret, requester IP)`, valid only for that address | +| Request, requester → target's server, UDP | `0xA6` `<0 4>[]`, server-key obfuscated, to the obfuscation UDP port. Requester id `0` marks it foreign. `crypt` (optional) is the requester's `(CT_SERVER_FLAGS >> 9) & 7`; absent means 0. The source port is the requester's endpoint | +| Wake, server → target | the usual `0x37` (or `0xA7` when the target asked for a callback itself within 20 s): `<0>`, never with a hash, since the server doesn't know the requester's. No `0x61` follows a `0x37` with requester id 0: the target punches towards the requester's endpoint and waits to be dialled | +| Answer, server → requester, UDP | only when the target has the extension: on its first `0x9F`, or after 3 s, `0xA7` ``, obfuscated with the requester's key, from the port the request arrived on, and resent at +1.2 s and +3 s like `0x61` (the requester ignores copies after the first). `crypt` is the target's setting, with `0x80` when both use the crypt layer and one asks for it; the hash is always there. The port is the target's fresh `0x9F` port, else the one the server would give today. **The requester dials**: it is the side that has the other's hash, which obfuscates the first QUIC Initial; both sides punch, so the path opens either way | + +**Requester ID 0 means foreign, everywhere.** On a server that sets +`0x100000`, every forward that wakes a target (`0x37` or `0xA7`) carries +requester ID 0 exactly when the request came from a client logged in +elsewhere. A same-server forward — Lugdunum's `0xA6` path, the extension's +wake-up, the bridge — always carries the requester's own ID, which is never +0 here (LowIDs start at 1). A target can rely on "ID 0 on a `0x100000` +server" alone: punch towards the requester's endpoint, send its `0x9F` +burst, and wait to be dialled. + +The target must be a logged-in LowID that set `0x1000`. Nothing is sent when +one side requires the crypt layer (the target can't get the requester's hash +here). Every other case gets no answer at all: an unknown, HighID, gone or +incapable target, a plain request, a limit reached. So the path is no oracle +for who is online. Without a session to kick, the limits sit on the source IP +and on the target: + +- per source IP, a burst of 10 requests, then one a second; +- per source IP, at most 60 different target ids in 10 minutes, counted + whether they exist or not, which stops a sweep over LowIDs; +- per target, at most 6 foreign wake-ups a minute; +- one answer in flight per requester address and target, inside the shared + hole-punch retry pool. + +An endpoint goes only to someone who names the target's current server and +LowID, as on one server. Clients should use a source's server only when it is +already in their own server list (an address learnt from a peer could +otherwise aim pings at anyone), and should not sweep other servers. A server +without the bit drops such a request; Lugdunum 17.15 ignores it too. Refusals +are counted in the Blocks tab (`natcb_foreign_*` and the `natcb_*` target +reasons); the Status tab shows forwards and answers. + **5. Keep your LowID source alive.** A share-only client is TCP-silent for hours. This server keeps such a source by **(a)** counting your `0x9F` UDP keepalive as activity against the session idle timer (idle backstop is 900 s, refreshed by any @@ -966,6 +1054,17 @@ wrong and a live share-only source is silently evicted after a few minutes — t symptom is identical to a successful connection, which makes it easy to misdiagnose. +### Watching NAT-T: `/api/natt` + +All NAT-T counters since start and the live gauges, as JSON: 0x60 requests +(by client family), repeats for the same pair within ten minutes, the first +answer to each (0x61 or a 0x62 reason), every 0x9F by outcome, the +extension's timing and timeouts, bridges, Lugdunum 0xA6 requests and +refusals (non-capable targets by even/odd LowID and by requester family), +and the cross-server path. `recent` holds the last 60 requests in detail +(both clients' software, login fingerprint, flags, UDP port, 0x9F seen, +target id parity, outcome). The Status tab shows the headline figures. + ### `OP_IDCHANGE` TCP flag bits in use The second word of `OP_IDCHANGE` (server → client) is shared by every server @@ -990,8 +1089,16 @@ be taken (corrections and new rows welcome, as for the `0xB2` table): | `0x40000` | NAT rendezvous (`FlagNatRendezvous`, since 2026-10-20) | eNode-go | no | | `0x80000` | server search (`FlagServerSearch`, reserved, not sent) | eNode-go | no | | `0x100000` | hole-punch extension (issue #39) | this server | with `holepunch_extension` | +| `0x200000` | NAT wake-up over TCP (`SRV_TCPFLG_NAT_WAKE`, PR_NAT) | eNode-go | no | +| `0x400000` | the client's IPv6 is dialled back before it is published (issue #43) | this server | with `ipv6_probe`, `ipv6_publish_sources` and an IPv6 route | + +In the UDP flags word of the status reply, this server sets `0x100000` when +it takes cross-server `0xA6` (issue #40). Lugdunum's own UDP flags have +`0x1000`; eNode-go uses `0x10000` and `0x200000` there and keeps `0x80000` +reserved. -The first bit free everywhere is `0x200000`. +The first bit free in `OP_IDCHANGE` is `0x800000`; in the client's +`CT_SERVER_FLAGS` and the UDP flags, `0x400000`. The client's `CT_SERVER_FLAGS` at login (client → server) is a different word with its own bits: @@ -1003,6 +1110,7 @@ with its own bits: | `0x1000` | NAT callback; odd LowID, 24-byte `OP_IDCHANGE` | Lugdunum | | `0x10000` | asks for meta-search rows | eNode-go | | `0x100000` | hole-punch extension, always with `0x1000` | this server | +| `0x200000` | "wake me", no standing PR_NAT registration (`SRVCAP_NAT_WAKE`) | eNode-go | --- @@ -1031,9 +1139,10 @@ Every element is ignored by a client that does not know it. | client → server | login tag `CT_MOD_IP_V6` | `0xAE`, 16 raw bytes | the client's public IPv6 | | server → client | `OP_IDCHANGE` flags / UDP ping flags bit | `0x4000` | server speaks the extension | | server → client | `OP_IDCHANGE` flags bit | `0x8000` | server accepts `OP_IPV6_UPDATE` | +| server → client | `OP_IDCHANGE` flags bit | `0x400000` | server dials the client's IPv6 back before publishing it (issue #43) | | client → server | `OP_IPV6_UPDATE` | `0x63`, 16 raw bytes | the client's new IPv6, or zeros to withdraw it; only to a server that set `0x8000` | | server → client | `OP_SERVERIDENT` tag `ST_IPV6` | `0xAE`, 16 raw bytes | the server's IPv6 (`this_ip6`) | -| server → client | `OP_SERVERIDENT` tag `ST_IPV6_STATUS` | `0xAB`, u8 | `0x01` address held, `0x02` treated as reachable | +| server → client | `OP_SERVERIDENT` tag `ST_IPV6_STATUS` | `0xAB`, u8 | `0x01` address held, `0x02` reachable (published), `0x04` dialled back: `0x02` is then the result | | server → client | source record in `OP_FOUNDSOURCES` / UDP answer | id `0xFFFFFFFF`, port, then 16 bytes | an IPv6 source | The client announces the extension with the `CT_MOD_IP_V6` tag alone. An @@ -1059,6 +1168,18 @@ Rules the server applies: published, any address makes the client able to receive IPv6 records, zeros withdraw both. A session that arrived over IPv6 keeps its observed address and ignores the update (issue #34). +- **Dial-back (issue #43).** With `0x400000` set, a claimed address (login + tag, `OP_IPV6_UPDATE`, or the address of an IPv6 session) is not published + until the server has dialled `[address]:login port` with the HighID + identity probe and got back the user hash that logged in. Until then + `ST_IPV6_STATUS` is `0x01`. The verdict comes in a fresh `OP_SERVERIDENT`: + `0x07` (published) or `0x05` (not published; most often a router that + drops inbound IPv6). The client stays able to receive IPv6 records either + way. A new address in `OP_IPV6_UPDATE` is dialled again; the same address + keeps its verdict. The probe takes slots from the server-wide probe + ceiling; a login that finds it full is retried a minute later. At most 4 + dials per session, since each goes where the client points it. Status tab: + "IPv6 dial-back"; Blocks tab: `ipv6_probe_*`. - Answers carrying IPv6 records bypass the source cache, which is shared with clients that cannot parse them. @@ -1083,7 +1204,11 @@ hours to be fully published. With `limits.offerfiles_v1 = true` the server tells a client how fast it may go and holds it to that; at the defaults (200 files every 500 ms) the same library takes under three minutes. Agreed in [issue #19](https://github.com/andrey23127/ed2k-server/issues/19); the client -side is aMule PR #1715 (experimental, opt-in). Only a client that asks for v1 +side is aMule PR #1715, merged and on by default from aMule's next +release (checked by got3nks against 0.9.85: all 20 interop scenarios pass). +aMule waits up to 5 s after `OP_IDCHANGE` for the login `OP_SERVERIDENT` +before it falls back to the standard pace, and if the limits arrive later +than that it re-offers the connection's files within them. Only a client that asks for v1 at login is answered and held to it; every other client publishes exactly as with the option off. diff --git a/config/config.toml b/config/config.toml index 7e962a2..80439ef 100644 --- a/config/config.toml +++ b/config/config.toml @@ -114,6 +114,11 @@ highid_wrong_hash_ttl_secs = 86400 # 24 h # and waits up to 3 s for its fresh 0x9F before sending 0x61. Everyone else sees # no change. Needs lugdunum_natt. # holepunch_extension = true +# Cross-server NAT callback (issue #40): 0xA6 with requester id 0 from a client +# logged in elsewhere wakes a target here with 0x37; a target with the extension +# then gets the requester its endpoint by UDP 0xA7. Limited per source IP and per +# target. Needs holepunch_extension. +# holepunch_foreign = true # ── IPv6 ──────────────────────────────────────────────────────────────────── # Accept clients over IPv6: separate IPv6-only sockets, TCP on tcp_port and UDP @@ -130,12 +135,26 @@ this_ip6 = "" # e.g. "2001:db8:1234::2" # to peers that can parse it (eMuleQt, eNode-go and compatible mods). Clients # that do not know the extension never see it. Independent of ipv6_enabled. ipv6_publish_sources = true +# Dial a client's IPv6 back (login port, identity probe with its user hash) +# before publishing it. Only in force when this host has an IPv6 route. +# ipv6_probe = true [limits] max_clients = 5000 soft_limit_files = 5000 hard_limit_files = 7500 max_clients_per_ip = 10 +# How long a TCP GETSOURCES answer is reused for the same file, seconds (1-600). +# Status "Cache hits" shows the age of the entries requests missed. +# getsources_cache_secs = 120 +# A login whose user hash is already online takes that session over only from +# the same address (IPv6: same prefix), or from the IPv6 the IPv4 session +# advertised; otherwise it is refused (issue #41). true = any address may. +# hash_takeover_from_any_address = false +# User hashes shared by many installs keep the old rule (newest login wins). +# DLP antiLeech's community hashes are built in; list more here, e.g. ones +# /api/takeover marks "looks_shared": +# shared_user_hashes = ["3649E68B070E85991648EF223A7F6FFD"] max_string_size = 250 ping_delay_seconds = 300 diff --git a/src/config.rs b/src/config.rs index c28f86d..d8b5e5d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,47 @@ use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::path::Path; +/// User hashes shared by many clients: DLP antiLeech's community / corrupt / +/// invalid hashes (persmule/amule-dlp.antiLeech, antiLeech.cpp), and one +/// observed here (the last). +pub const DLP_SHARED_USER_HASHES: &[&str] = &[ + "00000000000E00000000000000006F00", + "0B76CC42CB0E81B0DC6120D2BCB36FF9", + "154CE646120E96CC798C439A20D26F8D", + "36725093E00E9350F7680C871E946FD1", + "3F44A7996F0E17D1F4B319EB58B26F64", + "455361F9D95C3CD7E6BF2192D1CB3D02", + "559ACC89D80E90C50A7A0CD3224F6F57", + "596B305E050EA842CE38DF3811216F3F", + "5E02F74DBA0E8A19DBF6733F0AE66F4A", + "65C3B2E8940E582630A7F58AF9F26F9E", + "66B002DADE0E6DBEDF4FCCAA380E6FD4", + "6AE1D2DF4B0E8707B6F6BC29E8746F0F", + "769D36987E0E313A1501967D0F146F7A", + "888F4742450EF75F9DD8B7E53FA06FF0", + "8A537F20B80EF9AF02E59E6C087C6F6B", + "9BA09B83DC0EE78BE20280C387936F00", + "A2221641460E961C8B7FF21A53FB6F6C", + "AAEE84C0C30E247CBB99B459255D6F99", + "B1798B2F620E0B676452C6E2EF706F13", + "B6491292AE0E07AC8C6045CAC2DD6F9F", + "C1533316C00E3E0D0218843A05E46FAC", + "C8B5F41441C615FBABAD9A7E55294D01", + "C92859E4860EA0F15F7837750C886FB6", + "C9E61DEEF30E0360E2741C9CF1396F94", + "CB42F563EE0EA7907395420CAC146FF5", + "D0D897BD360EEFF329903E04990B6F86", + "DA1CEEE05B0E5319B3B48CAED24C6F4A", + "EAA383FD9E0E68538C7AC8AD15526F7A", + "FE000000000E00000000000000006F00", + "FE10F3C0610E0A925B85204CE8456F42", + // Not from DLP: mldonkey 3.1.x, seen on ed2k-rust from many addresses in + // many countries at once, its owner changing all the time. + "3649E68B070E85991648EF223A7F6FFD", +]; + #[derive(Debug, Deserialize, Clone)] + pub struct Config { pub server: ServerConfig, pub network: NetworkConfig, @@ -945,13 +985,23 @@ pub struct NetworkConfig { /// The hole-punch extension (issue #39): flag 0x100000 in `OP_IDCHANGE`. /// Only for a pair of clients that both announced 0x100000 and 0x1000 at /// login: a 0x60 wakes the target with 0x37, the server waits up to 3 s - /// for the target's fresh 0x9F before sending 0x61 (30 bytes, with the + /// for the target's fresh 0x9F before sending 0x61 (30 bytes, as every 0x61; the /// peer's crypt setting), and a session that sent an obfuscated 0x9F has /// its plain ones ignored. Every other client sees no change. Needs /// `lugdunum_natt`. Default on. #[serde(default = "default_true")] pub holepunch_extension: bool, + /// Cross-server NAT callback (issue #40): accept Lugdunum's UDP `0xA6` + /// from a client that is not logged in here (requester id 0), for a + /// target that is. The target gets the usual `0x37`; a target with the + /// extension answers with `0x9F` and the requester then gets the target's + /// endpoint by UDP `0xA7`. Announced with 0x100000 in the UDP flags of the + /// status reply, to clients and servers alike. Limited per source IP and + /// per target. Needs `holepunch_extension`. Default on. + #[serde(default = "default_true")] + pub holepunch_foreign: bool, + /// Accept client connections over IPv6, on the same TCP port. /// /// A separate listener rather than a dual-stack socket: the IPv4 path stays @@ -978,6 +1028,18 @@ pub struct NetworkConfig { /// watching how it behaves. #[serde(default)] pub ipv6_publish_sources: bool, + + /// Dial a client's IPv6 back before publishing it (issue #43): the + /// identity probe to `[v6]:login port` must answer with the user hash that + /// logged in. Until it does the address is not published, and the verdict + /// goes to the client in `ST_IPV6_STATUS` with `IPV6ST_PROBED`. Takes + /// probe slots from the same server-wide ceiling as the HighID probe; a + /// login that finds it full is left unpublished. Only in force with + /// `ipv6_publish_sources` and an IPv6 route on this host; announced with + /// 0x400000 in `OP_IDCHANGE`. Off: the address is published on trust, as + /// before. Default on. + #[serde(default = "default_true")] + pub ipv6_probe: bool, } fn default_listen_ip6() -> String { @@ -1010,12 +1072,35 @@ pub struct LimitsConfig { /// exempt, `0` = no cap. Live. #[serde(default = "default_per_ip")] pub max_clients_per_ip: u32, + /// A login with a user hash that is already online replaces that session + /// only from the same source (the same IPv4, or the same IPv6 prefix of + /// `admission.ipv6_source_prefix_bits`), from the IPv6 the IPv4 session + /// advertised with the same listen port, or when the old session is + /// already gone (issue #41). Any other is refused: user hashes are public. + /// `true` restores the old rule, where any address takes the session + /// over. Live. + #[serde(default)] + pub hash_takeover_from_any_address: bool, + /// User hashes known to be shared by many installs (issue #41): for + /// these the newest login takes the session, as before 0.9.86, since they + /// identify no one. Hex, case-insensitive, IN ADDITION to the built-in + /// list (`DLP_SHARED_USER_HASHES`: the community, corrupt and invalid + /// user hashes of DLP's antiLeech) — add ones `/api/takeover` turns up. + /// Degenerate hashes (12+ zero bytes) are shared without being listed. + /// Live. + #[serde(default)] + pub shared_user_hashes: Vec, /// Max bytes stored for a file name or nick, cut at a character boundary /// AFTER the content filter has seen the whole name. `0` = no cap. Live. #[serde(default = "default_max_string")] pub max_string_size: u32, #[serde(default = "default_ping_delay")] pub ping_delay_seconds: u64, + /// How long a TCP GETSOURCES answer is reused for the same file, seconds + /// (1-600). The Status "Cache hits" row shows how old the entries were + /// when a request missed them, to choose it by. Default 120. Live. + #[serde(default = "default_getsources_cache_secs")] + pub getsources_cache_secs: u64, /// Maximum file records returned for one search, across all pages. /// @@ -1314,6 +1399,10 @@ fn default_per_ip() -> u32 { fn default_max_string() -> u32 { 250 } +fn default_getsources_cache_secs() -> u64 { + crate::state::smart_sources::DEFAULT_TTL_SECS +} + fn default_ping_delay() -> u64 { 300 } diff --git a/src/filter/mod.rs b/src/filter/mod.rs index ad1c1e0..73e44b7 100644 --- a/src/filter/mod.rs +++ b/src/filter/mod.rs @@ -497,6 +497,22 @@ impl ContentFilter { } pub fn check(&self, file_hash: &[u8; 16], filename: &str) -> FilterResult { + self.check_inner(file_hash, filename, true) + } + + /// Is this hash on the false-positive whitelist? + pub fn is_whitelisted(&self, file_hash: &[u8; 16]) -> bool { + self.hash_whitelist.load().contains(file_hash) + } + + /// What every layer except the whitelist says. For reports that must show + /// a whitelisted file next to the layer it would otherwise trip + /// (`/api/indexcheck`); never for a publish decision. + pub fn check_ignoring_whitelist(&self, file_hash: &[u8; 16], filename: &str) -> FilterResult { + self.check_inner(file_hash, filename, false) + } + + fn check_inner(&self, file_hash: &[u8; 16], filename: &str, whitelist: bool) -> FilterResult { // Whitelist first, and it now overrides EVERY layer rather than only the // hash lists. // @@ -512,7 +528,7 @@ impl ContentFilter { // // An operator putting a hash here has looked at the file and ruled it // legal. That ruling now stands against every layer. - if self.hash_whitelist.load().contains(file_hash) { + if whitelist && self.hash_whitelist.load().contains(file_hash) { return FilterResult::Allow; } diff --git a/src/main.rs b/src/main.rs index f9770c5..c9ce1b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -484,6 +484,10 @@ async fn async_main(args: Args, cfg: Config) -> Result<()> { let cfg = Arc::new(cfg); let state = Arc::new(ServerState::new(Arc::new(filter), Arc::clone(&cfg))); + // The IPv6 dial-back (#43) needs a route to dial with. + let v6_route = ed2k_server::server::ipv6_probe::detect_route(); + state.ipv6_route.store(v6_route, std::sync::atomic::Ordering::Relaxed); + tracing::info!(ipv6_route = v6_route, "IPv6 dial-back route"); let _ = state.bot_log_path.set(ed2k_server::server::bot_detector::ledger_path( &cfg.storage.bot_log_path, &args.config, @@ -908,6 +912,32 @@ async fn async_main(args: Args, cfg: Config) -> Result<()> { v.retain(|s| !s.is_closed()); !v.is_empty() }); + state_clean + .natcb_foreign_sources + .retain(|_, s| !s.idle()); + state_clean.smart_sources.sweep_expired(); + ed2k_server::server::ipv6_probe::sweep(&state_clean); + state_clean + .undecodable_from_session_seen + .retain(|_, t| t.elapsed() < std::time::Duration::from_secs(3600)); + state_clean + .wrong_ip_9f_seen + .retain(|_, t| t.elapsed() < std::time::Duration::from_secs(3600)); + state_clean + .takeover_lost + .retain(|_, e| e.0.elapsed() < std::time::Duration::from_secs(3600)); + state_clean + .holepunch_recent + .retain(|_, t| t.elapsed() < std::time::Duration::from_secs(600)); + state_clean + .ext_timed_out + .retain(|_, t| t.elapsed() < std::time::Duration::from_secs(60)); + state_clean + .takeover_probes + .retain(|_, t| t.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(60))); + state_clean + .natcb_foreign_wakes + .retain(|_, w| w.back().is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(60))); state_clean .lugdunum_natt_seen .retain(|_, s| s.at.elapsed() < ed2k_server::server::lugdunum_natt::SEEN_TTL); @@ -985,12 +1015,28 @@ async fn async_main(args: Args, cfg: Config) -> Result<()> { // re-derives the key on the next packet; incoming_seed_challenges // re-handshakes on the next 60s probe. Real seeds (a few dozen) // never reach the cap, so only scanner/churn noise is swept. - const OBF_CACHE_CAP: usize = 8192; + // obf_decode_cache: aged, not cleared wholesale. An entry is + // ~40 bytes, so the cap is ~10 MB; a sender seen within the + // TTL keeps its entry, so a client pays the cold path once + // per half hour instead of once per sweep. + const OBF_CACHE_CAP: usize = 262_144; + const OBF_CACHE_TTL_SECS: u32 = 1800; const SEED_CHAL_CAP: usize = 2048; - if state_clean.obf_decode_cache.len() > OBF_CACHE_CAP { - let n = state_clean.obf_decode_cache.len(); - state_clean.obf_decode_cache.clear(); - info!(cleared = n, "obf_decode_cache over cap, swept"); + { + let now = state_clean.file_slab.now_secs(); + state_clean + .obf_decode_cache + .retain(|_, e| now.saturating_sub(e.2) < OBF_CACHE_TTL_SECS); + if state_clean.obf_decode_cache.len() > OBF_CACHE_CAP { + state_clean + .obf_decode_cache + .retain(|_, e| now.saturating_sub(e.2) < 300); + } + if state_clean.obf_decode_cache.len() > OBF_CACHE_CAP { + let n = state_clean.obf_decode_cache.len(); + state_clean.obf_decode_cache.clear(); + info!(cleared = n, "obf_decode_cache over cap, cleared"); + } } if state_clean.incoming_seed_challenges.len() > SEED_CHAL_CAP { let n = state_clean.incoming_seed_challenges.len(); diff --git a/src/proto/crypt_stream.rs b/src/proto/crypt_stream.rs index 0350409..bd7fafd 100644 --- a/src/proto/crypt_stream.rs +++ b/src/proto/crypt_stream.rs @@ -50,6 +50,12 @@ const MAX_ENCRYPT_CHUNK: usize = 64 * 1024; const PENDING_KEEP: usize = 512; impl CryptStream { + /// The socket underneath, for socket options (the takeover probe sets + /// `TCP_USER_TIMEOUT` on it, issue #41). + pub fn tcp(&self) -> &TcpStream { + &self.inner + } + pub fn plain(stream: TcpStream) -> Self { Self::new(stream, None, None, vec![]) } diff --git a/src/proto/opcodes.rs b/src/proto/opcodes.rs index 498ee70..9094d9b 100644 --- a/src/proto/opcodes.rs +++ b/src/proto/opcodes.rs @@ -33,6 +33,10 @@ pub const OP_GETSOURCES_OBFU: u8 = 0x23; // have to parse it out of the login tags, which not all clients send reliably). // payload: target_id(4) + requester_udp_port(2) pub const OP_LOWID_HOLEPUNCH_REQUEST: u8 = 0x60; +/// Internal, never on the wire: a frame with this opcode on a session's push +/// channel asks its task to probe the connection (issue #41) — a short +/// `TCP_USER_TIMEOUT`, then the payload sent as `OP_SERVERSTATUS`. +pub const INTERNAL_PROBE: u8 = 0x00; // Server→both clients: each side is told the other's address so they can punch. // payload: peer_ip(4 LE) + peer_tcp_port(2 LE) + peer_udp_port(2 LE) // + peer_user_hash(16) + role(1) role: 0 = you initiate, 1 = you wait @@ -227,8 +231,19 @@ pub const SRVFLG_LOWID_HOLEPUNCH: u32 = 0x0002_0000; /// 0x61, 30 bytes with the peer's crypt setting appended. 0x40000 and 0x80000 /// belong to eNode-go (`FlagNatRendezvous`, `FlagServerSearch`); 0x100000 is /// the first bit nobody uses, on the server side and the client side. +/// +/// The same value in the UDP flags of the status reply (and `ST_UDPFLAGS` at +/// login) says the server also takes `0xA6` from clients logged in elsewhere +/// (issue #40), in every status reply, to clients and servers. pub const SRVFLG_HOLEPUNCH_EXT: u32 = 0x0010_0000; +/// `OP_IDCHANGE`: the server dials a client's IPv6 back before publishing it +/// (issue #43), so `ST_IPV6_STATUS` carries `IPV6ST_PROBED` and may arrive +/// again in a later `OP_SERVERIDENT` once the verdict is in. Set only while +/// the server has an IPv6 route to dial with. 0x200000 is eNode-go's +/// (NAT wake); 0x400000 is the next free bit. +pub const SRVFLG_IPV6_PROBE: u32 = 0x0040_0000; + // OFFERFILES self-source markers - SPEC.md §3.3 pub const SELF_COMPLETE_ID: u32 = 0xFBFB_FBFB; pub const SELF_COMPLETE_PORT: u16 = 0xFBFB; diff --git a/src/server/connection.rs b/src/server/connection.rs index b61127e..a33897c 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -48,6 +48,160 @@ impl std::error::Error for SessionRefused {} /// A reservation, not a count-then-insert (issue #25): the slot is taken with /// one compare-and-swap before the login does any work, so concurrent logins /// cannot all see room. The connection task holds it until it ends. +/// May a login from `ip` with listen port `listen_port` replace `prev`, the +/// live session of the same user hash (issue #41)? User hashes are public: +/// every hello and every source exchange carries them, so a hash alone does +/// not say who is asking. Yes when: +/// 1. the old session is already gone (its connection task has ended); +/// 2. the same source: the same IPv4, or the same IPv6 prefix — the +/// NAT-dropped reconnect this replacement exists for; +/// 3. a dual-stack move: the old session is IPv4, the new one comes from +/// exactly the IPv6 the old one advertised (`CT_MOD_IP_V6`) and has the +/// same listen port. Only that host can connect from that address. Not +/// the other way: that would trust an address the newcomer claims. +pub(crate) fn may_replace_session( + state: &ServerState, + prev: &crate::state::ClientHandle, + ip: std::net::IpAddr, + listen_port: u16, +) -> bool { + use std::net::IpAddr; + if !prev.is_alive() || state.live_cfg.load().limits.hash_takeover_from_any_address { + return true; + } + // A hash many people use identifies no one: there is no owner to protect, + // and refusing them all but one locks the rest out of the server. + if is_shared_hash(state, &prev.user_hash) { + return true; + } + let canon = |ip: IpAddr| match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4), + v4 => v4, + }; + let (old, new) = (canon(prev.ip), canon(ip)); + if state.source_key(old) == state.source_key(new) { + return true; + } + matches!((old, new), (IpAddr::V4(_), IpAddr::V6(v6)) + if prev.ipv6 == Some(v6) && prev.port == listen_port) +} + +/// The takeover probe (issue #41, ModderMule's idea as got3nks refined it). +/// A contested login makes the old session's task set `TCP_USER_TIMEOUT` to +/// `PROBE_USER_TIMEOUT` and write it an `OP_SERVERSTATUS`. When the user has +/// moved, the old path is dead and nothing answers: the kernel aborts the +/// connection once the write has gone unacknowledged that long, the old +/// session ends, and the new login, held meanwhile, goes ahead. An ACK means +/// the old session is alive: the login is refused, and the timeout goes back +/// to `SESSION_TCP_USER_TIMEOUT`. The wait stays inside the 25 s eMule-family +/// clients allow for the login answer, so a real move succeeds on the first +/// attempt instead of after about five minutes. +const PROBE_USER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +/// The abort fires at the first retransmission after the timeout: up to a +/// few seconds past it with exponential backoff. +const TAKEOVER_PROBE_WAIT: std::time::Duration = std::time::Duration::from_secs(16); +/// A contender refused after a probe is refused without one this long. +const TAKEOVER_CONTENDER_MEMORY: std::time::Duration = std::time::Duration::from_secs(600); +/// After a probe found the session alive, the next one waits this long. +const TAKEOVER_PROBE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(10); +/// The usual limit on unacknowledged data for client sockets; the same value +/// as `TCP_USER_TIMEOUT` set at accept in main.rs. +const SESSION_TCP_USER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// Is the old session gone? Probes it when no probe is running or cooling +/// down; one probe per session at a time, so contested logins cannot be used +/// to keep writing to a live owner. A login that arrives while a probe runs +/// waits for that probe's outcome. +async fn old_session_gone(state: &ServerState, prev: &ClientHandle) -> bool { + if !prev.is_alive() { + return true; + } + let now = std::time::Instant::now(); + let started = { + let mut e = state.takeover_probes.entry(prev.user_hash).or_insert(None); + match *e { + // Running: wait for it. + Some(t) if now.duration_since(t) < TAKEOVER_PROBE_WAIT => t, + // Just found the session alive. + Some(t) if now.duration_since(t) < TAKEOVER_PROBE_WAIT + TAKEOVER_PROBE_COOLDOWN => { + return !prev.is_alive(); + } + _ => { + *e = Some(now); + let mut p = BytesMut::with_capacity(8); + p.put_u32_le(state.client_count() as u32); + p.put_u32_le(state.file_count() as u32); + let _ = prev.send_frame(Frame::new(INTERNAL_PROBE, p.to_vec())); + crate::server::holepunch::count(state, "login_takeover_probe"); + now + } + } + }; + let deadline = tokio::time::Instant::from_std(started + TAKEOVER_PROBE_WAIT); + while tokio::time::Instant::now() < deadline { + if !prev.is_alive() { + crate::server::holepunch::count(state, "login_takeover_after_probe"); + // That session is gone; the next contest is about a new one. + state.takeover_probes.remove(&prev.user_hash); + return true; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + !prev.is_alive() +} + +/// Is this user hash used by many installs at once, so that it identifies no +/// one (issue #41)? Either it is degenerate — 12 or more zero bytes, as the +/// "corrupt" community hash `00000000000E00000000000000006F00` of a leecher +/// mod is; a random hash practically never is — or the operator lists it in +/// `limits.shared_user_hashes` or on the built-in list of DLP's community +/// hashes. For such a +/// hash the old rule applies: the newest login takes the session. +/// +/// Not by how many addresses contest it: that is exactly what a hijacker +/// produces — three logins from three proxies would turn any user's hash +/// into a shared one (got3nks). The address count is only reported, in +/// `/api/takeover`. +pub(crate) fn is_shared_hash(state: &ServerState, h: &crate::state::UserHash) -> bool { + if h.iter().filter(|b| **b == 0).count() >= 12 { + return true; + } + let hex = hex::encode(h); + crate::config::DLP_SHARED_USER_HASHES + .iter() + .any(|s| s.eq_ignore_ascii_case(&hex)) + || state + .live_cfg + .load() + .limits + .shared_user_hashes + .iter() + .any(|s| s.trim().eq_ignore_ascii_case(&hex)) +} + +/// Refuse a login whose user hash is online from elsewhere (issue #41). +async fn refuse_hash_in_use( + framed: &mut Framed, + state: &ServerState, + peer: SocketAddr, + user_hash: &crate::state::UserHash, +) -> anyhow::Error { + *state + .block_stats + .entry("login_hash_in_use".to_string()) + .or_insert(0) += 1; + let text = b"This user hash is already connected from another address"; + let mut payload = Vec::with_capacity(2 + text.len()); + payload.extend_from_slice(&(text.len() as u16).to_le_bytes()); + payload.extend_from_slice(text); + let _ = framed.send(Frame::new(OP_SERVERMESSAGE, payload)).await; + if let Some(sup) = crate::health::throttle().allow(peer.ip(), "login_hash_in_use", crate::health::SUPPRESS_WINDOW) { + info!(ip = %peer.ip(), user_hash = hex::encode(user_hash), suppressed = sup, + "login refused — user hash online from another address"); + } + SessionRefused("user hash online from another address").into() +} + pub(crate) fn reserve_client_slot( state: &ServerState, user_hash: &crate::state::UserHash, @@ -224,12 +378,17 @@ pub async fn handle_admitted_connection( // per-IP slot. When the peer abandons such a socket without closing it // (seen live: one address kept 100 of them open, silent for 3-5 hours, // each until the 6 h backstop), only a short silence limit frees it. It - // is not closed outright: two installs sharing one user hash would then - // knock each other off in a loop, and a copy that still talks is kept. + // is not closed the moment it is replaced: two installs sharing one user + // hash would then knock each other off as fast as they reconnect. But it + // is fenced: its first frame closes it (issue #46), unless the hash is a + // shared one. let replaced_idle = std::time::Duration::from_secs(10 * 60); // How often a logged-in session re-checks whether it has been replaced. let replaced_recheck = std::time::Duration::from_secs(60); let mut last_activity = tokio::time::Instant::now(); + // While a takeover probe runs (issue #41): when to put the socket's + // TCP_USER_TIMEOUT back. + let mut probe_restore: Option = None; // A banned bot gets no session for its 24 hours, over TCP as over UDP. if state.is_bot_banned(peer.ip()) { crate::server::bot_detector::note_banned(&state, peer.ip(), &[]); @@ -265,7 +424,8 @@ pub async fn handle_admitted_connection( let udp_idle = std::time::Duration::from_millis(c.idle_ms()); now + logged_in_backstop.saturating_sub(tcp_idle.min(udp_idle)) }; - (deadline, deadline.min(now + replaced_recheck)) + let wake = deadline.min(now + replaced_recheck); + (deadline, probe_restore.map_or(wake, |t| wake.min(t))) } else { // Not logged in: tight timeout guards against half-open / scanner // sockets that complete the crypto handshake but never LOGINREQUEST. @@ -274,6 +434,13 @@ pub async fn handle_admitted_connection( tokio::select! { _ = tokio::time::sleep_until(wake) => { + if probe_restore.is_some_and(|t| tokio::time::Instant::now() >= t) { + // The probe got its ACK in time: back to the usual limit. + probe_restore = None; + let _ = socket2::SockRef::from(framed.get_ref().tcp()) + .set_tcp_user_timeout(Some(SESSION_TCP_USER_TIMEOUT)); + continue; + } if tokio::time::Instant::now() < deadline { // A re-check tick, not a timeout. continue; @@ -307,6 +474,36 @@ pub async fn handle_admitted_connection( if let Some(c) = client.as_ref() { c.touch_activity(); } match result { Some(Ok(frame)) => { + // A replaced session holds no authority (issue #46): + // the user's state belongs to the new login, and a + // frame from here would publish, search or relay + // under an identity this socket no longer owns. Its + // first frame closes it, so a client still using it + // notices and logs in again; a silent one goes after + // `replaced_idle`, as before. + // + // Not for a shared hash (DLP's list, mldonkey's, the + // operator's): it identifies no one, its sessions + // are different installs, and closing the displaced + // one only sent it straight back to displace the + // other. Live, fencing them turned 1.2k shared-hash + // takeovers in ten hours into 11k in eight. + if let Some(c) = client.as_ref() { + let current = state + .clients + .get(&c.user_hash) + .is_some_and(|e| e.same_session(c)); + if !current && !is_shared_hash(&state, &c.user_hash) { + debug!(ip = %peer.ip(), + opcode = format_args!("0x{:02X}", frame.opcode), + "frame on a replaced session — closing it"); + *state + .block_stats + .entry("replaced_session_frame_refused".to_string()) + .or_insert(0) += 1; + break; + } + } // Boxed: the dispatch future (login, search, offer // handling and every await inside them) is several KB. // Inline it was part of this connection's state for the @@ -378,7 +575,16 @@ pub async fn handle_admitted_connection( break; } } - Some(frame) = rx.recv() => { + Some(mut frame) = rx.recv() => { + if frame.opcode == INTERNAL_PROBE { + // Issue #41: data unacknowledged for PROBE_USER_TIMEOUT + // now aborts the connection — the read below then ends + // the session, and the login waiting on it goes ahead. + let _ = socket2::SockRef::from(framed.get_ref().tcp()) + .set_tcp_user_timeout(Some(PROBE_USER_TIMEOUT)); + probe_restore = Some(tokio::time::Instant::now() + TAKEOVER_PROBE_WAIT); + frame = Frame::new(OP_SERVERSTATUS, std::mem::take(&mut frame.payload)); + } if let Err(e) = framed.send(frame).await { debug!(ip = %peer.ip(), error = %e, "send error on pushed frame"); break; @@ -463,6 +669,7 @@ pub(crate) fn end_session(state: &ServerState, c: &ClientHandle) { // stale entry here survives indefinitely because nothing else // references it. state.client_ipv6.remove(&c.user_hash); + state.ipv6_probe.remove(&c.user_hash); // Its keepalive port goes too: a reconnect from another address // must not be handed out with the old mapping (issue #37). state.natt_udp_ports.remove(&c.user_hash); @@ -610,6 +817,41 @@ async fn dispatch( return Err(SessionRefused("banned CSAM publisher").into()); } } + // Issue #41: the hash is online from elsewhere. Checked before the + // slot and the HighID probe, and again just before the insert. + let online = state.clients.get(&req.user_hash).map(|c| c.clone()); + if let Some(prev) = online { + if prev.is_alive() && is_shared_hash(state, &prev.user_hash) { + crate::server::holepunch::count(state, "login_takeover_shared_hash"); + } + if !may_replace_session(state, &prev, peer.ip(), req.port) { + // A contender this hash's owner already outlasted: two + // installs sharing one user hash retry every few seconds, + // and probing the owner for each would keep its socket + // on a 10 s fuse. Refused without a probe for a while. + let key = (req.user_hash, state.source_key(peer.ip())); + let known = state + .takeover_lost + .get(&key) + .is_some_and(|e| e.0.elapsed() < TAKEOVER_CONTENDER_MEMORY); + let gone = !known && old_session_gone(state, &prev).await; + if !gone { + if known { + crate::server::holepunch::count(state, "login_hash_in_use_known_contender"); + } + let mut e = state + .takeover_lost + .entry(key) + .or_insert((std::time::Instant::now(), 0)); + if !known { + e.0 = std::time::Instant::now(); + } + e.1 = e.1.saturating_add(1); + drop(e); + return Err(refuse_hash_in_use(framed, state, peer, &req.user_hash).await); + } + } + } // limits.max_clients (live; 0 = no cap). Reserved before // handle_login, which runs the HighID probe — a full server should // not spend a connection on a client it is about to turn away. A @@ -638,7 +880,16 @@ async fn dispatch( }; // OFFERFILES v1 only for a client that asked for it (issue #19). let wants_v1 = crate::server::offer_pacing::client_requests_v1(&req.tags); + let listen_port = req.port; let mut new_client = handle_login(cfg, state, peer.ip(), req).await; + // The HighID probe took time: another login with this hash may + // have come in meanwhile. From here to the insert nothing waits. + let online = state.clients.get(&new_client.user_hash).map(|c| c.clone()); + if let Some(prev) = online { + if !may_replace_session(state, &prev, peer.ip(), listen_port) { + return Err(refuse_hash_in_use(framed, state, peer, &new_client.user_hash).await); + } + } // OFFERFILES v1 snapshot (issue #19): taken once, here, and both // advertised from and enforced from for the life of the connection. // @@ -673,12 +924,21 @@ async fn dispatch( // client map, so the two cannot get out of step: a client with no // usable v6 must have NO entry rather than a stale one from a // previous session at a different address. - match new_client.ipv6 { - Some(a) => { + // With the dial-back on (#43) the address waits for its probe, + // which starts after the welcome batch. + let probe_v6 = new_client.ipv6.filter(|_| crate::server::ipv6_probe::active(state)); + match (new_client.ipv6, probe_v6) { + (Some(a), Some(_)) => { + state.ipv6_probe.remove(&new_client.user_hash); + let _ = crate::server::ipv6_probe::mark_pending(state, new_client.user_hash, a); + } + (Some(a), None) => { state.client_ipv6.insert(new_client.user_hash, a); + state.ipv6_probe.remove(&new_client.user_hash); } - None => { + (None, _) => { state.client_ipv6.remove(&new_client.user_hash); + state.ipv6_probe.remove(&new_client.user_hash); } } // A new session proves its 0x9F handling afresh (issue #39). @@ -729,6 +989,15 @@ async fn dispatch( for f in crate::server::login::build_welcome_batch_with(cfg, state, &new_client, ident, peer.port()) { framed.send(f).await?; } + if let Some(a) = probe_v6 { + crate::server::ipv6_probe::start( + state_arc, + new_client.user_hash, + a, + new_client.port, + new_client.server_flags, + ); + } } OP_OFFERFILES => { @@ -925,7 +1194,8 @@ async fn dispatch( let Some(c) = client.as_ref() else { return Err(anyhow::anyhow!("GETSOURCES before login")); }; - if let Ok(req) = GetSourcesRequest::parse(&frame.payload) { + if let Ok(mut req) = GetSourcesRequest::parse(&frame.payload) { + req.obfu = frame.opcode == OP_GETSOURCES_OBFU; state.note_activity( peer.ip(), crate::server::activity::Kind::TcpSources, @@ -978,18 +1248,30 @@ async fn dispatch( if matches!(outcome, Published(_) | Unusable | Withdrawn) { // Side table first, then the client map, as at login: the // source encoder reads the table by user hash. - match c.ipv6 { - Some(a) => { + let mut probe = c.ipv6.filter(|_| crate::server::ipv6_probe::active(state)); + match (c.ipv6, probe) { + // A new address is dialled back like the login one (#43). + (Some(a), Some(_)) => { + if !crate::server::ipv6_probe::mark_pending(state, c.user_hash, a) { + // Same address: its verdict stands, no new dial. + probe = None; + } + } + (Some(a), None) => { state.client_ipv6.insert(c.user_hash, a); } - None => { + (None, _) => { state.client_ipv6.remove(&c.user_hash); + state.ipv6_probe.remove(&c.user_hash); } } if let Some(mut h) = state.clients.get_mut(&c.user_hash) { h.ipv6 = c.ipv6; h.ipv6_capable = c.ipv6_capable; } + if let Some(a) = probe { + crate::server::ipv6_probe::start(state_arc, c.user_hash, a, c.port, c.server_flags); + } } debug!(ip = %peer.ip(), ?outcome, "IPv6 update"); } @@ -1284,3 +1566,103 @@ mod task_size_tests { assert!(size <= 2048, "connection future grew to {size} bytes"); } } + +/// Issue #41: who may take over a live session of the same user hash. +#[cfg(test)] +mod takeover_tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + fn live(st: &ServerState, ip: IpAddr, v6: Option) -> (ClientHandle, mpsc::Receiver) { + let rx = st.register_test_client_at([7; 16], 1, false, 0, ip); + { + let mut c = st.clients.get_mut(&[7; 16]).unwrap(); + c.ipv6 = v6; + c.port = 4662; + } + (st.clients.get(&[7; 16]).unwrap().clone(), rx) + } + + fn v4(a: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(5, 6, 7, a)) + } + + #[test] + fn only_the_same_source_takes_a_live_session_over() { + let st = ServerState::for_test(); + let (prev, _rx) = live(&st, v4(8), None); + assert!(may_replace_session(&st, &prev, v4(8), 4662), "a reconnect from the same IPv4"); + assert!(may_replace_session(&st, &prev, v4(8), 1234), "the port doesn't matter there"); + let mapped: IpAddr = "::ffff:5.6.7.8".parse().unwrap(); + assert!(may_replace_session(&st, &prev, mapped, 4662), "the same IPv4, v4-mapped"); + assert!(!may_replace_session(&st, &prev, v4(9), 4662), "another address"); + } + + #[test] + fn ipv6_compares_by_prefix() { + let st = ServerState::for_test(); + let a: IpAddr = "2001:db8:1:2::10".parse().unwrap(); + let (prev, _rx) = live(&st, a, None); + let same_64: IpAddr = "2001:db8:1:2::99".parse().unwrap(); + let other_64: IpAddr = "2001:db8:1:3::10".parse().unwrap(); + assert!(may_replace_session(&st, &prev, same_64, 4662)); + assert!(!may_replace_session(&st, &prev, other_64, 4662)); + } + + #[test] + fn a_dual_stack_move_goes_one_way_only() { + let st = ServerState::for_test(); + let six: Ipv6Addr = "2001:db8::5".parse().unwrap(); + let (prev, _rx) = live(&st, v4(8), Some(six)); + assert!(may_replace_session(&st, &prev, IpAddr::V6(six), 4662)); + assert!(!may_replace_session(&st, &prev, IpAddr::V6(six), 4663), "another listen port"); + let other: IpAddr = "2001:db8::6".parse().unwrap(); + assert!(!may_replace_session(&st, &prev, other, 4662), "another IPv6"); + // An IPv6 session is not taken over from IPv4, whatever it claims. + let st = ServerState::for_test(); + let (prev, _rx) = live(&st, IpAddr::V6(six), Some(six)); + assert!(!may_replace_session(&st, &prev, v4(8), 4662)); + } + + #[test] + fn a_session_that_is_gone_or_the_old_rule_lets_anyone_in() { + let st = ServerState::for_test(); + let (prev, rx) = live(&st, v4(8), None); + assert!(!may_replace_session(&st, &prev, v4(9), 4662)); + drop(rx); + assert!(may_replace_session(&st, &prev, v4(9), 4662), "its connection task has ended"); + let st = ServerState::for_test(); + let (prev, _rx) = live(&st, v4(8), None); + let mut cfg = (**st.live_cfg.load()).clone(); + cfg.limits.hash_takeover_from_any_address = true; + st.live_cfg.store(Arc::new(cfg)); + assert!(may_replace_session(&st, &prev, v4(9), 4662)); + } + + /// A shared hash (degenerate, or listed) is nobody's: the newest login + /// takes it. Contenders from many addresses do not make one shared. + #[test] + fn a_shared_hash_falls_back_to_the_old_rule() { + let st = ServerState::for_test(); + let (prev, _rx) = live(&st, v4(8), None); + assert!(!may_replace_session(&st, &prev, v4(9), 4662)); + for a in [20u8, 21, 22, 23] { + st.takeover_lost.insert(([7; 16], v4(a)), (std::time::Instant::now(), 1)); + } + assert!(!is_shared_hash(&st, &[7; 16]), "an attacker's three proxies prove nothing"); + assert!(!may_replace_session(&st, &prev, v4(9), 4662)); + let mut cfg = (**st.live_cfg.load()).clone(); + cfg.limits.shared_user_hashes.push(hex::encode([7u8; 16]).to_uppercase()); + st.live_cfg.store(Arc::new(cfg)); + assert!(is_shared_hash(&st, &[7; 16])); + assert!(may_replace_session(&st, &prev, v4(9), 4662)); + // A degenerate hash is shared without a list. + let mut zero = [0u8; 16]; + zero[5] = 0x0e; + zero[14] = 0x6f; + assert!(is_shared_hash(&st, &zero)); + // The default list holds DLP's community hashes. + let cb: [u8; 16] = hex::decode("cb42f563ee0ea7907395420cac146ff5").unwrap().try_into().unwrap(); + assert!(is_shared_hash(&ServerState::for_test(), &cb)); + } +} \ No newline at end of file diff --git a/src/server/get_sources.rs b/src/server/get_sources.rs index 04c3152..a4221c9 100644 --- a/src/server/get_sources.rs +++ b/src/server/get_sources.rs @@ -13,6 +13,31 @@ use tracing::debug; /// Maximum sources returned per response (SPEC.md §6.2.6 SmartSources tiers). const MAX_SOURCES_PER_REPLY: usize = 200; +/// How many of a file's sources are looked at to fill a reply (issue #49). +/// Sources are stored in publish order and most of them are LowID (nine in +/// ten on ed2k-rust), so the first `MAX_SOURCES_PER_REPLY` alone could hide +/// every HighID behind them. Four replies' worth bounds the lookups. +const SOURCE_SCAN: usize = 4 * MAX_SOURCES_PER_REPLY; + +/// Can this requester do anything with a LowID source? A HighID one by +/// callback; a LowID one only through NAT traversal: the hole punch +/// (`natt_capable`, `0x100000`) or Lugdunum's NAT callback (`0x1000`). A +/// stock LowID client cannot, and drops them itself. +pub(crate) fn reaches_lowid(c: &ClientHandle) -> bool { + c.is_high_id || c.natt_capable || c.server_flags & (0x1000 | 0x0010_0000) != 0 +} + +/// Can a LowID source be reached by NAT traversal? The same bits, on the +/// source's side. +fn lowid_natt(c: &ClientHandle) -> bool { + c.natt_capable || c.server_flags & (0x1000 | 0x0010_0000) != 0 +} + +/// Owner keys carry one flag in their lowest bit: the record is a LowID +/// source reachable by NAT traversal. A LowID record without it is served +/// only to HighID requesters. +const OWNER_NATT: u64 = 1; + /// ClientID marking an inline IPv6 source. `255.255.255.255` is never a real /// client, so the value is unambiguous. Agreed with eMuleQt and eNode-go. pub const IPV6_SOURCE_SENTINEL: u32 = 0xFFFF_FFFF; @@ -22,6 +47,10 @@ pub struct GetSourcesRequest { pub file_hash: [u8; 16], /// File size, encoded as v2 (4 bytes) or v2-large (4 + 4 bytes) extension. pub size: Option, + /// Asked as OP_GETSOURCES_OBFU (0x23): answer OP_FOUNDSOURCES_OBFU (0x44), + /// each source with its crypt options and, when it supports the crypt + /// layer, its user hash (issue #42). + pub obfu: bool, } impl GetSourcesRequest { @@ -66,6 +95,7 @@ impl GetSourcesRequest { Ok(Self { file_hash: hash, size, + obfu: false, }) } } @@ -82,6 +112,31 @@ impl GetSourcesRequest { /// /// Clients expect a reply to every request, so a listed file answers "nobody /// has it" rather than staying silent — silence just makes the client re-ask. +/// The reply opcode for a request. +fn reply_opcode(req: &GetSourcesRequest) -> u8 { + if req.obfu { + OP_FOUNDSOURCES_OBFU + } else { + OP_FOUNDSOURCES + } +} + +/// A source's crypt options in FOUNDSOURCES_OBFU: bit 0 supports, bit 1 +/// requests, bit 2 requires the crypt layer (`(CT_SERVER_FLAGS >> 9) & 7`), +/// and 0x80 when its user hash follows — set whenever it supports the layer, +/// as Lugdunum 17.15 does. A source whose client has gone is sent as 0. +fn source_crypt(state: &ServerState, user_hash: &[u8; 16]) -> u8 { + let c = state + .clients + .get(user_hash) + .map_or(0, |h| crate::server::lugdunum_natt::crypt_setting(h.server_flags)); + if c & 1 != 0 { + c | 0x80 + } else { + c + } +} + fn encode_empty_sources(file_hash: &[u8; 16]) -> Vec { let mut payload = BytesMut::with_capacity(17); payload.put_slice(file_hash); @@ -112,7 +167,11 @@ pub fn handle_get_sources( file_hash = hex::encode(req.file_hash), "getsources refused: file is withheld by the content filter" ); - return Frame::new(OP_FOUNDSOURCES, encode_empty_sources(&req.file_hash)); + return Frame::new(reply_opcode(&req), encode_empty_sources(&req.file_hash)); + } + // A sized request for a hash stored with another size (issue #45). + if state.size_mismatch(&req.file_hash, req.size, false, requester.ip) { + return Frame::new(reply_opcode(&req), encode_empty_sources(&req.file_hash)); } // Whether this requester gets inline IPv6 records. Decided once, and it @@ -127,6 +186,9 @@ pub fn handle_get_sources( // callback, so for it the IPv6 path is the only one and it keeps it. A // session that arrived over IPv6 is always LowID (an eD2k id is an IPv4 // address), so it keeps the sentinel too. + state + .smart_sources + .set_ttl_secs(state.live_cfg.load().limits.getsources_cache_secs); let v6_capable = state.live_cfg.load().network.ipv6_publish_sources && requester.ipv6_capable && !requester.is_high_id; @@ -145,16 +207,19 @@ pub fn handle_get_sources( // sessions are a small minority; a second keyspace would halve the hit // rate for the common case to serve the rare one. let me = owner_key(&requester.user_hash); + let reach = Reach::of(requester); if !v6_capable { if let Some(payload) = state .smart_sources - .get_with(&req.file_hash, |p, owners| without_owner(p, owners, me)) + .get_kind_with(&req.file_hash, req.obfu, |p, owners| { + without_owner(p, owners, me, req.obfu, reach) + }) { debug!( file_hash = hex::encode(req.file_hash), "getsources answered from SmartSources cache" ); - return Frame::new(OP_FOUNDSOURCES, payload); + return Frame::new(reply_opcode(&req), payload); } } @@ -173,16 +238,35 @@ pub fn handle_get_sources( // on a cache hit above). One extra is taken so that removing // it still leaves a full reply. // - // LowID sources are returned to LowID requesters on purpose. - // Stock eMule cannot use them and drops them itself, but the - // NAT-traversal mod reaches exactly these through the server's - // hole-punch rendezvous, and an IPv6-capable pair can connect - // over v6. Filtering here would cut those paths for no gain. - .take(MAX_SOURCES_PER_REPLY + 1) + // + // More than a reply's worth is read: the HighID sources go + // first (below), and they may sit behind hundreds of LowIDs. + .take(SOURCE_SCAN) .copied() .collect::>() }) .unwrap_or_default(); + // HighID (and departed) sources first, LowID after, each in publish + // order (issue #49). A LowID source is no use to a stock LowID requester, + // and a reply cut at the cap used to be mostly LowIDs, hiding the HighID + // sources behind them. Which LowIDs a requester gets is decided when the + // reply is served (`without_owner`): a cached payload is shared by every + // kind of requester. + let sources: Vec<_> = { + let (mut direct, mut low) = (Vec::new(), Vec::new()); + for s in sources { + let live_low = state.clients.get(&s.user_hash).is_some_and(|h| !h.is_high_id); + if live_low { + low.push(s); + } else { + direct.push(s); + } + } + direct.extend(low); + direct + }; + // This requester's own reach, for the per-requester (IPv6) path. + let requester_reaches_lowid = reaches_lowid(requester); debug!( file_hash = hex::encode(req.file_hash), @@ -208,6 +292,10 @@ pub fn handle_get_sources( if emitted as usize == MAX_SOURCES_PER_REPLY { break; } + } else if emitted as usize == MAX_SOURCES_PER_REPLY + 1 { + // A shared payload keeps one more, so that removing the + // requester's own record still leaves a full reply. + break; } // Encode the source ID the way eD2k clients expect: // * HighID source -> its real IPv4 (client connects directly) @@ -255,6 +343,17 @@ pub fn handle_get_sources( None }; + let handle = state.clients.get(&s.user_hash).map(|h| (h.is_high_id, lowid_natt(&h))); + let natt_low = matches!(handle, Some((false, true))); + // The IPv6 path is built for one requester: a LowID source it can + // reach neither by IPv6 nor by NAT traversal is left out here. + if v6_capable + && v6.is_none() + && matches!(handle, Some((false, _))) + && !(requester_reaches_lowid && natt_low) + { + continue; + } let id = match (v6, state.clients.get(&s.user_hash)) { (Some(_), _) => IPV6_SOURCE_SENTINEL, (None, Some(handle)) if !handle.is_high_id => handle.assigned_id, @@ -275,6 +374,13 @@ pub fn handle_get_sources( }; entries.put_u32_le(id); entries.put_u16_le(s.port()); + if req.obfu { + let c = source_crypt(state, &s.user_hash); + entries.put_u8(c); + if c & 0x80 != 0 { + entries.put_slice(&s.user_hash); + } + } if let Some(a) = v6 { // ALWAYS LAST, after any obfuscation fields — both existing // implementations read it there, and the position is the only thing @@ -282,7 +388,7 @@ pub fn handle_get_sources( entries.put_slice(&a.octets()); } emitted += 1; - owners.push(owner_key(&s.user_hash)); + owners.push(owner_key(&s.user_hash) | if natt_low { OWNER_NATT } else { 0 }); } let mut payload = BytesMut::new(); @@ -294,48 +400,103 @@ pub fn handle_get_sources( if v6_capable { // Already this requester's own reply (see the loop). Not cached, for // the reason at the read side. - return Frame::new(OP_FOUNDSOURCES, payload_vec); + return Frame::new(reply_opcode(&req), payload_vec); } // Cache the payload with EVERY source, and serve this requester the same // cut every later requester gets. It used to be cached with the first // requester already removed: for the TTL that source was hidden from // everyone else, and every later requester was handed its own address. - let reply = without_owner(&payload_vec, &owners, me); - state.smart_sources.put(req.file_hash, payload_vec, owners); - Frame::new(OP_FOUNDSOURCES, reply) + let reply = without_owner(&payload_vec, &owners, me, req.obfu, reach); + state.smart_sources.put_kind(req.file_hash, req.obfu, payload_vec, owners); + Frame::new(reply_opcode(&req), reply) } /// The key a cached record's owner is stored under: the first 8 bytes of the /// user hash. A collision would only hide one source from one requester. fn owner_key(user_hash: &[u8; 16]) -> u64 { - u64::from_le_bytes(user_hash[..8].try_into().unwrap()) + u64::from_le_bytes(user_hash[..8].try_into().unwrap()) & !OWNER_NATT } /// Cut a requester-specific reply from a shared classic payload: drop the /// records owned by `me` and keep at most `MAX_SOURCES_PER_REPLY`, fixing the /// count byte to match. /// -/// Classic records only (6 bytes: id + port) — the only kind ever cached. A -/// payload whose length does not match its owner list is returned unchanged -/// apart from the cap, rather than being cut at the wrong offsets. -fn without_owner(payload: &[u8], owners: &[u64], me: u64) -> Vec { +/// Records without IPv6 only — the only kind ever cached: 6 bytes (id + +/// port), or with `obfu` 7, or 23 when the crypt byte has 0x80 and the hash +/// follows. A payload whose records do not add up to its owner list is +/// returned unchanged, rather than being cut at the wrong offsets. +/// Which LowID records a requester is served. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Reach { + /// A HighID requester: every LowID, by callback. + All, + /// A LowID requester with NAT traversal: LowIDs that have it too. + Natt, + /// A stock LowID requester: none. + None, +} + +impl Reach { + pub(crate) fn of(c: &ClientHandle) -> Self { + if c.is_high_id { + Reach::All + } else if reaches_lowid(c) { + Reach::Natt + } else { + Reach::None + } + } +} + +fn without_owner(payload: &[u8], owners: &[u64], me: u64, obfu: bool, reach: Reach) -> Vec { const HDR: usize = 17; - const REC: usize = 6; - if payload.len() < HDR || payload.len() != HDR + owners.len() * REC { + if payload.len() < HDR { + return payload.to_vec(); + } + // Record boundaries. + let mut bounds = Vec::with_capacity(owners.len()); + let mut at = HDR; + for _ in owners { + let mut len = 6; + if obfu { + let Some(&c) = payload.get(at + 6) else { + return payload.to_vec(); + }; + len = if c & 0x80 != 0 { 23 } else { 7 }; + } + if at + len > payload.len() { + return payload.to_vec(); + } + bounds.push((at, at + len)); + at += len; + } + if at != payload.len() { return payload.to_vec(); } let mut out = Vec::with_capacity(payload.len()); out.extend_from_slice(&payload[..HDR]); let mut n: usize = 0; - for (i, &owner) in owners.iter().enumerate() { - if owner == me { + for (&owner, &(a, b)) in owners.iter().zip(&bounds) { + if owner & !OWNER_NATT == me { continue; } + // A LowID record: an id below 2^24 (eMule's IsLowID). + let id = u32::from_le_bytes([payload[a], payload[a + 1], payload[a + 2], payload[a + 3]]); + if id < 0x0100_0000 { + let keep = match reach { + Reach::All => true, + Reach::Natt => owner & OWNER_NATT != 0, + Reach::None => false, + }; + if !keep { + continue; + } + } if n == MAX_SOURCES_PER_REPLY { break; } - out.extend_from_slice(&payload[HDR + i * REC..HDR + (i + 1) * REC]); + out.extend_from_slice(&payload[a..b]); n += 1; } out[16] = n as u8; @@ -496,9 +657,10 @@ mod tests { state.register_test_client(low_uh, 42, /*high_id*/ false, 5001); state.register_test_client(high_uh, 0x0102_0304, /*high_id*/ true, 0); - // Requester: some third client asking for the file. + // Requester: some third client asking for the file, HighID (a + // stock LowID one is not given LowID sources, issue #49). let req_uh = [0xCCu8; 16]; - state.register_test_client(req_uh, 7, false, 0); + state.register_test_client(req_uh, 0x0506_0708, true, 0); let requester = state.clients.get(&req_uh).unwrap().clone(); let frame = handle_get_sources( @@ -507,6 +669,7 @@ mod tests { GetSourcesRequest { file_hash, size: None, + obfu: false, }, ); @@ -563,7 +726,7 @@ mod tests { state.register_test_client(uh, if high { 0x0102_0304 } else { 77 }, high, 0); let mut requester = state.clients.get(&uh).unwrap().clone(); requester.ipv6_capable = true; - handle_get_sources(&state, &requester, GetSourcesRequest { file_hash, size: None }) + handle_get_sources(&state, &requester, GetSourcesRequest { file_hash, size: None, obfu: false }) }; // HighID, sentinel-capable: the classic record with the low id. let p = ask([0xC1; 16], true).payload; @@ -604,7 +767,7 @@ mod tests { } let ask = |uh: &[u8; 16]| { let requester = state.clients.get(uh).unwrap().clone(); - handle_get_sources(&state, &requester, GetSourcesRequest { file_hash, size: None }) + handle_get_sources(&state, &requester, GetSourcesRequest { file_hash, size: None, obfu: false }) }; // First requester builds the cache entry. assert_eq!(ports_of(&ask(&users[0])), vec![4002, 4003]); @@ -627,15 +790,123 @@ mod tests { let mut owners = Vec::new(); for i in 0..=MAX_SOURCES_PER_REPLY as u64 { payload.extend_from_slice(&[1, 2, 3, 4, (i & 0xFF) as u8, (i >> 8) as u8]); - owners.push(i); + owners.push(i << 1); } payload[16] = owners.len() as u8; // The requester is in the list: the rest exactly fills the cap. - let out = without_owner(&payload, &owners, 5); + let out = without_owner(&payload, &owners, 4, false, Reach::All); assert_eq!(out[16] as usize, MAX_SOURCES_PER_REPLY); assert_eq!(out.len(), 17 + MAX_SOURCES_PER_REPLY * 6); // Not in the list: still capped. - let out = without_owner(&payload, &owners, 99_999); + let out = without_owner(&payload, &owners, 99_998, false, Reach::All); assert_eq!(out[16] as usize, MAX_SOURCES_PER_REPLY); } + + /// Issue #42: 0x23 gets 0x44, each source with its crypt options and, if + /// it supports the crypt layer, its hash; the classic answer is unchanged + /// and the two are cached apart. + #[test] + fn getsources_obfu_carries_crypt_options_and_hashes() { + use std::net::{IpAddr, Ipv4Addr}; + let state = ServerState::for_test(); + let file_hash = [0x55u8; 16]; + let (a, b, c) = ([0xA1u8; 16], [0xB2u8; 16], [0xC3u8; 16]); + for (i, h) in [a, b, c].iter().enumerate() { + let ip = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 20 + i as u8)); + state.add_file_with_source(file_hash, 1000, "f".into(), (*h, ip, 4100 + i as u16, true)); + } + state.register_test_client(a, 0x0102_0304, true, 0); + state.register_test_client(b, 77, false, 0); + state.register_test_client(c, 0x0102_0305, true, 0); + // a supports and requests the crypt layer, b supports it, c has none. + state.clients.get_mut(&a).unwrap().server_flags = 0x600; + state.clients.get_mut(&b).unwrap().server_flags = 0x200; + let ask = |who: &[u8; 16], obfu: bool| { + let r = state.clients.get(who).unwrap().clone(); + handle_get_sources(&state, &r, GetSourcesRequest { file_hash, size: None, obfu }) + }; + let outsider = [0xEEu8; 16]; + state.register_test_client(outsider, 0x0909_0909, true, 0); + let f = ask(&outsider, true); + assert_eq!(f.opcode, OP_FOUNDSOURCES_OBFU); + let p = &f.payload; + assert_eq!(p[16], 3); + // HighIDs first (issue #49). a: id, port, 0x83, hash + assert_eq!(p[17 + 6], 0x83); + assert_eq!(&p[17 + 7..17 + 23], &a); + // c: no crypt layer, no hash + let rc = 17 + 23; + assert_eq!(p[rc + 6], 0x00); + // b: LowID 77, 0x81, hash + let rb = rc + 7; + assert_eq!(&p[rb..rb + 4], &77u32.to_le_bytes()); + assert_eq!(p[rb + 6], 0x81); + assert_eq!(&p[rb + 7..rb + 23], &b); + assert_eq!(p.len(), rb + 23); + // From the cache, b's own record is cut out at the right offsets. + let f = ask(&b, true); + assert_eq!(f.payload[16], 2); + assert_eq!(f.payload.len(), 17 + 23 + 7); + assert_eq!(f.payload[17 + 23 + 6], 0x00); + // The classic reply for the same file is unchanged. + let f = ask(&outsider, false); + assert_eq!(f.opcode, OP_FOUNDSOURCES); + assert_eq!(f.payload.len(), 17 + 3 * 6); + } + + /// Issue #49: HighID sources are not hidden behind a reply's worth of + /// LowIDs, and a stock LowID requester gets no LowID source, while a NAT-T + /// one gets those that can be punched to. + #[test] + fn lowid_sources_go_only_where_they_can_be_reached() { + use std::net::{IpAddr, Ipv4Addr}; + let state = ServerState::for_test(); + let file_hash = [0x49u8; 16]; + let n = MAX_SOURCES_PER_REPLY + 20; + for i in 0..n { + let mut h = [0u8; 16]; + h[0] = 0x10; + h[1] = (i & 0xFF) as u8; + h[2] = (i >> 8) as u8; + let ip = IpAddr::V4(Ipv4Addr::new(203, 0, (i >> 8) as u8, (i & 0xFF) as u8 + 1)); + state.add_file_with_source(file_hash, 1000, "f".into(), (h, ip, 5000, true)); + state.register_test_client(h, 100 + i as u32, false, 0); + if i % 2 == 0 { + // Every other LowID source can be punched to. + state.clients.get_mut(&h).unwrap().server_flags = 0x1000; + } + } + let high = [0x20u8; 16]; + state.add_file_with_source( + file_hash, + 1000, + "f".into(), + (high, IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 4662, true), + ); + state.register_test_client(high, u32::from_le_bytes([198, 51, 100, 9]), true, 0); + let ask = |uh: [u8; 16], id: u32, is_high: bool, flags: u32| { + state.register_test_client(uh, id, is_high, 0); + state.clients.get_mut(&uh).unwrap().server_flags = flags; + let r = state.clients.get(&uh).unwrap().clone(); + handle_get_sources(&state, &r, GetSourcesRequest { file_hash, size: None, obfu: false }) + }; + let ids = |f: &Frame| -> Vec { + let p = &f.payload; + (0..p[16] as usize) + .map(|i| u32::from_le_bytes([p[17 + i * 6], p[18 + i * 6], p[19 + i * 6], p[20 + i * 6]])) + .collect() + }; + // Stock LowID: only the HighID, which came after 220 LowIDs. + let got = ids(&ask([0xA0; 16], 9001, false, 0x119)); + assert_eq!(got, vec![u32::from_le_bytes([198, 51, 100, 9])]); + // NAT-T LowID (from the cache): the HighID and the punchable LowIDs. + let got = ids(&ask([0xA1; 16], 9002, false, 0x1119)); + assert_eq!(got[0], u32::from_le_bytes([198, 51, 100, 9])); + assert!(got.len() > 1); + assert!(got[1..].iter().all(|id| (id - 100) % 2 == 0), "only punchable LowIDs"); + // HighID: everything, capped. + let got = ids(&ask([0xA2; 16], 0x0A0B_0C0D, true, 0x119)); + assert_eq!(got.len(), MAX_SOURCES_PER_REPLY); + assert_eq!(got[0], u32::from_le_bytes([198, 51, 100, 9])); + } } diff --git a/src/server/highid_probe.rs b/src/server/highid_probe.rs index e4f0b7e..34acf78 100644 --- a/src/server/highid_probe.rs +++ b/src/server/highid_probe.rs @@ -116,15 +116,20 @@ impl ClientCrypt { marker = crate::proto::obfuscation::random_bytes(1)[0]; } - let mut out = Vec::with_capacity(11); + let mut out = Vec::with_capacity(12); out.push(marker); out.extend_from_slice(&keypart.to_le_bytes()); - // Encrypted from here on. No padding: the length byte is honoured by - // every implementation and zero keeps the exchange one packet shorter. - let mut body = Vec::with_capacity(6); + // Encrypted from here on: , as eMule and aMule read it + // (EncryptedStreamSocket, ONS_BASIC_CLIENTA_METHODTAGSPADLEN reads + // three bytes). This sent one method byte only, so the peer took the + // first byte of the HELLO as the padding length (issue #44). No + // padding: zero keeps the exchange one packet shorter. + let mut body = Vec::with_capacity(7); body.extend_from_slice(&MAGIC_SYNC.to_le_bytes()); - body.push(0); // encryption method: RC4 + body.push(0); // methods supported: obfuscation (RC4) + body.push(0); // method preferred: the same body.push(0); // padding length send.apply(&mut body); out.extend_from_slice(&body); @@ -270,7 +275,14 @@ pub async fn probe_identity_hash( %addr, elapsed_us = started.elapsed().as_micros(), error = %e, "probe_identity: connect failed" ); - return Err("connect refused"); + // A reset means the host is there and the port closed (or a + // firewall that rejects); anything else (an ICMP unreachable, no + // route) means the packets did not get there. + return Err(if e.kind() == std::io::ErrorKind::ConnectionRefused { + "connect refused" + } else { + "connect unreachable" + }); } Err(_) => { debug!( @@ -318,19 +330,6 @@ pub async fn probe_identity_hash( None }; - let mut hello = build_hello(our_user_hash, our_id, our_port); - if let Some(c) = crypt.as_mut() { - c.send.apply(&mut hello); - } - match tokio::time::timeout(deadline, stream.write_all(&hello)).await { - Ok(Ok(())) => {} - Ok(Err(e)) => { - debug!(%addr, error = %e, "probe_identity: write failed"); - return Err("write failed"); - } - Err(_) => return Err("write timeout"), - } - // The peer answers the handshake before anything else, with its own magic // value under ITS send key — which is our recv key. Getting this right also // proves the peer knows its own user hash, since the key is derived from it. @@ -360,9 +359,12 @@ pub async fn probe_identity_hash( if head[4] != 0 { return Err("peer chose an encryption method we do not implement"); } - // Masked to four bits, as Lugdunum does: the field is a byte but no - // implementation sends more than 15 bytes of padding here. - let pad = (head[5] & 0x0F) as usize; + // The whole byte. A client pads its answer to the server it is + // logging into with under 16 bytes (aMule: `AwaitingTestFromIP`), + // which is why masking to four bits, as Lugdunum does, seemed right; + // to anyone else, a later re-check or the IPv6 dial-back, it pads + // up to its CryptTCPPaddingLength, 254 by default (issue #44). + let pad = head[5] as usize; if pad > 0 { let mut sink = vec![0u8; pad]; match tokio::time::timeout(deadline, stream.read_exact(&mut sink)).await { @@ -374,6 +376,22 @@ pub async fn probe_identity_hash( debug!(%addr, pad, "probe_identity: obfuscated handshake accepted"); } + // The HELLO only once the handshake is complete: a peer still + // negotiating treats bytes beyond the step it is on as an error, and on + // a fast path the two writes arrive as one segment (issue #44). + let mut hello = build_hello(our_user_hash, our_id, our_port); + if let Some(c) = crypt.as_mut() { + c.send.apply(&mut hello); + } + match tokio::time::timeout(deadline, stream.write_all(&hello)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + debug!(%addr, error = %e, "probe_identity: write failed"); + return Err("write failed"); + } + Err(_) => return Err("write timeout"), + } + // READ FRAMES UNTIL THE RIGHT ONE, rather than assuming the first is it. // // This is what the first version got wrong. A stock eMule answering an @@ -677,14 +695,14 @@ mod tests { } #[test] - fn the_obfuscated_opening_has_the_shape_lugdunum_sends() { + fn the_obfuscated_opening_has_the_shape_emule_reads() { // Layout, initiator side: // [1] semi-random marker, in the clear // [4] key part, in the clear - // [6] RC4( magic 0x835E6FC4 | method 0 | padding length 0 ) + // [7] RC4( magic 0x835E6FC4 | supported 0 | preferred 0 | padding 0 ) let peer = [0x5Au8; 16]; let (_state, opening) = ClientCrypt::start(&peer); - assert_eq!(opening.len(), 11, "1 + 4 + 6"); + assert_eq!(opening.len(), 12, "1 + 4 + 7"); assert!( !crate::proto::obfuscation::FORBIDDEN_MARKERS.contains(&opening[0]), "the clear marker must not look like a protocol byte" @@ -722,8 +740,94 @@ mod tests { MAGIC_SYNC, "the peer must recover the magic value" ); - assert_eq!(body[4], 0, "encryption method RC4"); - assert_eq!(body[5] & 0x0F, 0, "no padding"); + assert_eq!(body.len(), 7); + assert_eq!(body[4], 0, "methods supported: RC4"); + assert_eq!(body[5], 0, "method preferred: RC4"); + assert_eq!(body[6], 0, "no padding"); + } + + /// A peer that negotiates the way aMule's receiving side does + /// (EncryptedStreamSocket::Negotiate): one step at a time, bytes beyond + /// the step it is on before it has answered are an error, and it pads its + /// answer with more than 15 bytes, as it does to anyone but the server it + /// is logging into (issue #44). + #[tokio::test] + async fn the_obfuscated_probe_negotiates_before_its_hello() { + use crate::proto::obfuscation::{Rc4, MAGIC_SYNC, MAGIC_VALUE_REQUESTER, MAGIC_VALUE_SERVER}; + use md5::{Digest, Md5}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let peer_hash = [0x3Cu8; 16]; + let ours = server_pseudo_user_hash(&[1u8; 16]); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let peer = tokio::spawn(async move { + let (mut s, _) = listener.accept().await.unwrap(); + let mut opening = [0u8; 12]; + s.read_exact(&mut opening).await.unwrap(); + // Nothing more may be waiting: the HELLO must follow our answer. + tokio::time::sleep(Duration::from_millis(100)).await; + let mut extra = [0u8; 1]; + let early = tokio::time::timeout(Duration::from_millis(10), s.read(&mut extra)).await; + assert!(early.is_err(), "data beyond the negotiation before the answer"); + let keypart = &opening[1..5]; + let key = |magic: u8| { + let mut h = Md5::new(); + h.update(peer_hash); + h.update([magic]); + h.update(keypart); + Rc4::new(&h.finalize(), true) + }; + let mut recv = key(MAGIC_VALUE_REQUESTER); + let mut send = key(MAGIC_VALUE_SERVER); + let mut body = opening[5..].to_vec(); + recv.apply(&mut body); + assert_eq!(u32::from_le_bytes([body[0], body[1], body[2], body[3]]), MAGIC_SYNC); + assert_eq!(body[6], 0, "padding length is the third byte"); + // Answer with 200 bytes of padding. + let mut answer = MAGIC_SYNC.to_le_bytes().to_vec(); + answer.push(0); + answer.push(200); + answer.extend(std::iter::repeat(0x55).take(200)); + send.apply(&mut answer); + s.write_all(&answer).await.unwrap(); + // Now the HELLO, decrypted from its first byte. + let mut head = [0u8; 6]; + s.read_exact(&mut head).await.unwrap(); + recv.apply(&mut head); + assert_eq!((head[0], head[5]), (PROTO_EDONKEY, OP_HELLO)); + let len = u32::from_le_bytes([head[1], head[2], head[3], head[4]]) as usize; + let mut rest = vec![0u8; len - 1]; + s.read_exact(&mut rest).await.unwrap(); + recv.apply(&mut rest); + assert_eq!(&rest[1..17], &ours[..], "the HELLO carries the server's hash"); + let mut reply = Vec::new(); + reply.extend_from_slice(&peer_hash); + reply.extend_from_slice(&0x0100_007Fu32.to_le_bytes()); + reply.extend_from_slice(&4662u16.to_le_bytes()); + reply.extend_from_slice(&0u32.to_le_bytes()); + reply.extend_from_slice(&[0u8; 6]); + let mut frame = vec![PROTO_EDONKEY]; + frame.extend_from_slice(&((reply.len() + 1) as u32).to_le_bytes()); + frame.push(OP_HELLOANSWER); + frame.extend_from_slice(&reply); + send.apply(&mut frame); + s.write_all(&frame).await.unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + }); + let got = probe_identity_hash( + "127.0.0.1".parse().unwrap(), + port, + &peer_hash, + &ours, + 0x0100_007F, + 6082, + 3000, + true, + ) + .await; + peer.await.unwrap(); + assert_eq!(got, Ok(peer_hash)); } #[test] diff --git a/src/server/holepunch.rs b/src/server/holepunch.rs index 45753c5..eb9f583 100644 --- a/src/server/holepunch.rs +++ b/src/server/holepunch.rs @@ -43,7 +43,8 @@ //! the peer's crypt setting appended), then resends at +1.2 s and +3 s; //! 5. with no 0x9F within 3 s, it sends the same 0x61 with the ports it //! would have used anyway. A lost 0x9F costs 3 s, never the punch. -//! Any other pair gets exactly today's punch: 29 bytes, at once, no 0x37. +//! Any other pair gets the punch at once, no 0x37. Every 0x61 is 30 bytes +//! since issue #42: the peer's crypt setting is byte 29 for every pair. //! //! BRIDGE (issue #39). A client that implements only Lugdunum's callback //! (NeoMule's "CanLow2Low") ignores 0x61. A 0x60 whose target set 0x1000, @@ -55,7 +56,7 @@ use crate::proto::{opcodes::*, Frame}; use crate::server::lugdunum_natt::{self, SRVCAP_NATTRAVERSAL}; use crate::state::{ClientHandle, ServerState}; use bytes::{BufMut, BytesMut}; -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use tracing::{debug, warn}; @@ -85,7 +86,7 @@ const OBSERVED_UDP_FRESH: Duration = Duration::from_secs(600); /// when no other client is connected from that address, since it is /// shared by every client there; /// 3. the port the client announced (0xF9 at login, or in its 0x60). -fn observed_udp_port(state: &ServerState, user_hash: &[u8; 16], ip: IpAddr, announced: u16) -> u16 { +pub(crate) fn observed_udp_port(state: &ServerState, user_hash: &[u8; 16], ip: IpAddr, announced: u16) -> u16 { if let Some(e) = state.natt_udp_ports.get(user_hash) { let (port, seen) = *e; if port != 0 && seen.elapsed() < OBSERVED_UDP_FRESH { @@ -131,6 +132,18 @@ pub const FAIL_TARGET_NOT_CONNECTED: u8 = 1; pub const FAIL_TARGET_IS_HIGHID: u8 = 2; pub const FAIL_BAD_REQUEST: u8 = 3; pub const FAIL_TARGET_NO_UDP: u8 = 4; +/// One of the two sessions is not over IPv4, so the server has no IPv4 +/// endpoint to hand the other side: a 0x61 would carry 0.0.0.0. The punch is +/// UDP over IPv4 only. +pub const FAIL_NO_IPV4: u8 = 5; + +/// The IPv4 of a session address, an IPv4-mapped IPv6 included. +fn session_v4(ip: IpAddr) -> Option { + match ip { + IpAddr::V4(v4) => Some(v4), + IpAddr::V6(v6) => v6.to_ipv4_mapped(), + } +} /// Handle OP_LOWID_HOLEPUNCH_REQUEST from a logged-in client. /// @@ -231,6 +244,13 @@ pub fn handle_holepunch_request( return Some(FAIL_TARGET_IS_HIGHID); } + // Both ends need an IPv4 the other can be told (an IPv6-only session has + // a LowID too, being unreachable on IPv4). + if session_v4(requester_ip).is_none() || session_v4(t_ip).is_none() { + send_fail(state, requester_user_hash, target_id, FAIL_NO_IPV4); + return Some(FAIL_NO_IPV4); + } + // The target must have announced a UDP port (i.e. it is also a modified // client). A stock LowID client cannot participate as a target. if t_udp == 0 { @@ -255,21 +275,18 @@ pub fn handle_holepunch_request( let requester_udp_eff = observed_udp_port(state, requester_user_hash, requester_ip, requester_udp_port); - // A pair using the extension also gets each other's crypt setting - // (issue #39); everyone else keeps the 29 bytes. + // Each side also gets the other's crypt setting, byte 29 (issue #39 for + // extension pairs, issue #42 for everyone): with the hash at bytes 8-23 + // it lets the dialler obfuscate from its first datagram, so a target + // that requires the crypt layer accepts the stream. Parsers read fields + // by length and ignore what follows, so the 30-byte frame is safe for + // clients that know only 29. let r_flags = state .clients .get(requester_user_hash) .map_or(0, |c| c.server_flags); - let ext = has_extension(state, r_flags) && has_extension(state, t_flags); - let (crypt_of_target, crypt_of_requester) = if ext { - ( - Some(lugdunum_natt::crypt_setting(t_flags)), - Some(lugdunum_natt::crypt_setting(r_flags)), - ) - } else { - (None, None) - }; + let crypt_of_target = Some(lugdunum_natt::crypt_setting(t_flags)); + let crypt_of_requester = Some(lugdunum_natt::crypt_setting(r_flags)); // Each side also gets the server ID of the peer described (issue #38): // the requester its 0x60 target id, so it can tell which of several @@ -431,10 +448,112 @@ fn requester_alive(state: &ServerState, user_hash: &[u8; 16]) -> bool { .unwrap_or(false) } -fn count(state: &ServerState, key: &str) { +pub(crate) fn count(state: &ServerState, key: &str) { *state.block_stats.entry(key.to_string()).or_insert(0) += 1; } +/// A client family for a counter key: "aMule", "eMule", "emule-qt"…, cut to +/// a short ASCII token so a client cannot mint counters with its own text. +pub(crate) fn sw_family(state: &ServerState, user_hash: &[u8; 16]) -> String { + let (sw, detail) = state + .clients + .get(user_hash) + .map(|c| (c.software.clone(), c.software_detail.clone())) + .unwrap_or_default(); + let clean = |s: &str, n: usize| -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '.')) + .take(n) + .collect() + }; + let family = clean(&sw, 16); + let family = if family.is_empty() { "unknown".to_string() } else { family }; + // The mod, from "eMule v0.50a [Xtreme 9.1]": eMule mods report as eMule. + match detail.split_once('[').and_then(|(_, m)| m.split_once(']')).map(|(m, _)| clean(m, 20)) { + Some(m) if !m.is_empty() => format!("{family}/{m}"), + _ => family, + } +} + +/// How many detailed NAT-T events /api/natt keeps. +pub const NATT_RECENT_MAX: usize = 60; + +/// Record one NAT-T request in detail for /api/natt: the two clients' +/// software, login fingerprint and flags, the target's id parity and UDP +/// port, and the outcome. Counters say how often; this says who. +pub(crate) fn note_event( + state: &ServerState, + kind: &str, + requester: Option<&ClientHandle>, + target: Option<&ClientHandle>, + target_id: u32, + outcome: &str, +) { + note_event_from(state, kind, requester, target, target_id, outcome, None) +} + +/// `note_event` with the requester's datagram source, for a requester that +/// is not logged in here (a foreign 0xA6). +pub(crate) fn note_event_from( + state: &ServerState, + kind: &str, + requester: Option<&ClientHandle>, + target: Option<&ClientHandle>, + target_id: u32, + outcome: &str, + from: Option, +) { + let who = |c: Option<&ClientHandle>| { + c.map(|c| { + serde_json::json!({ + "software": c.software_detail, + "login_fp": c.login_fp, + "flags": format!("0x{:x}", c.server_flags), + "high_id": c.is_high_id, + "udp_port": c.udp_port, + "session_ip": c.ip.to_string(), + "sent_9f": state.natt_keepalive_seen.contains_key(&c.user_hash), + }) + }) + }; + let ev = serde_json::json!({ + "secs_ago_base": state.file_slab.now_secs(), + "kind": kind, + "target_id": target_id, + "target_id_odd": target_id & 1 == 1, + "requester": who(requester), + "target": who(target), + "outcome": outcome, + "from": from.map(|a| a.to_string()), + }); + if let Ok(mut q) = state.natt_recent.lock() { + if q.len() >= NATT_RECENT_MAX { + q.pop_front(); + } + q.push_back(ev); + } +} + +/// The FAIL reason as a counter suffix. +fn fail_name(reason: u8) -> &'static str { + match reason { + FAIL_TARGET_NOT_CONNECTED => "holepunch_fail_not_connected", + FAIL_TARGET_IS_HIGHID => "holepunch_fail_highid", + FAIL_BAD_REQUEST => "holepunch_fail_bad_request", + FAIL_TARGET_NO_UDP => "holepunch_fail_no_udp", + FAIL_NO_IPV4 => "holepunch_fail_no_ipv4", + _ => "holepunch_fail_other", + } +} + +/// How long a repeated 0x60 for the same pair counts as a repeat: a client +/// asks again when the first punch did not give it a connection, so this is +/// the closest the server gets to a failure rate. +const REPEAT_WINDOW: std::time::Duration = std::time::Duration::from_secs(600); +/// A target's 0x9F this soon after its extension wait timed out counts as +/// late (the client answers, just slower than the wait). +pub const LATE_9F_WINDOW: std::time::Duration = std::time::Duration::from_secs(15); + /// The whole handling of an OP_LOWID_HOLEPUNCH_REQUEST: the extension when /// both peers use it, otherwise today's punch with its resends, plus the /// bridge to a Lugdunum-only target. @@ -448,11 +567,25 @@ pub fn request( requester_udp_port: u16, target_id: u32, ) { + // Counters (NAT-T analysis): requests, by client family, and repeats. + count(state, "holepunch_requests"); + count(state, &format!("hp_req_sw:{}", sw_family(state, &requester_user_hash))); + { + let now = std::time::Instant::now(); + let key = (requester_user_hash, target_id); + if let Some(prev) = state.holepunch_recent.insert(key, now) { + if now.duration_since(prev) < REPEAT_WINDOW { + count(state, "holepunch_repeat_10min"); + } + } + } // A target the punch can be coordinated with at all; the checks and // their FAIL answers stay in `handle_holepunch_request`. let target = state .client_by_assigned_id(target_id) - .filter(|t| t.user_hash != requester_user_hash && !t.is_high_id && t.is_alive()); + .filter(|t| t.user_hash != requester_user_hash && !t.is_high_id && t.is_alive()) + // No wake-up for a pair that cannot be given IPv4 endpoints. + .filter(|t| session_v4(t.ip).is_some() && session_v4(requester_ip).is_some()); if let Some(t) = &target { let r_flags = state .clients @@ -478,7 +611,7 @@ pub fn request( } } } - handle_holepunch_request( + let failed = handle_holepunch_request( state, &requester_user_hash, requester_id, @@ -487,6 +620,12 @@ pub fn request( requester_udp_port, target_id, ); + count(state, failed.map_or("holepunch_first_info", fail_name)); + { + let req = state.clients.get(&requester_user_hash).map(|c| c.clone()); + let tgt = target.clone().or_else(|| state.client_by_assigned_id(target_id)); + note_event(state, "0x60", req.as_ref(), tgt.as_ref(), target_id, failed.map_or("holepunch_first_info", fail_name)); + } if let Some(t) = &target { bridge(state, &requester_user_hash, requester_ip, t); } @@ -554,14 +693,42 @@ fn start_extension( .note(crate::admission::PushKind::Holepunch, outcome); count(state, "holepunch_ext_started"); let state = Arc::clone(state); + let target_hash = target.user_hash; tokio::spawn(async move { let _guards = guards; + let started = std::time::Instant::now(); let fresh = matches!(tokio::time::timeout(EXT_WAIT, rx).await, Ok(Ok(()))); count(&state, if fresh { "holepunch_ext_fresh" } else { "holepunch_ext_timeout" }); + if fresh { + // How soon the target answered, in buckets. + let ms = started.elapsed().as_millis(); + count( + &state, + match ms { + 0..=499 => "holepunch_ext_9f_under_0.5s", + 500..=999 => "holepunch_ext_9f_0.5_1s", + 1000..=1999 => "holepunch_ext_9f_1_2s", + _ => "holepunch_ext_9f_2_3s", + }, + ); + } else { + // Lost, or a client that does not answer 0x37 with 0x9F at all? + // Whether it ever sent one this session tells the two apart; a + // 0x9F soon after counts as late (see `keepalive_arrived`). + count( + &state, + if state.natt_keepalive_seen.contains_key(&target_hash) { + "holepunch_ext_timeout_target_sent_9f_before" + } else { + "holepunch_ext_timeout_target_never_sent_9f" + }, + ); + state.ext_timed_out.insert(target_hash, std::time::Instant::now()); + } if !requester_alive(&state, &requester_user_hash) { return; } - let _ = handle_holepunch_request( + let failed = handle_holepunch_request( &state, &requester_user_hash, requester_id, @@ -570,6 +737,17 @@ fn start_extension( requester_udp_port, target_id, ); + count(&state, failed.map_or("holepunch_first_info", fail_name)); + { + let req = state.clients.get(&requester_user_hash).map(|c| c.clone()); + let tgt = state.clients.get(&target_hash).map(|c| c.clone()); + let outcome = format!( + "extension, {}: {}", + if fresh { "fresh 0x9F" } else { "after the wait" }, + failed.map_or("holepunch_first_info", fail_name) + ); + note_event(&state, "0x60", req.as_ref(), tgt.as_ref(), target_id, &outcome); + } retry_loop( &state, requester_user_hash, @@ -587,6 +765,11 @@ fn start_extension( /// A 0x9F from `user_hash`'s own address was accepted: release the /// extension coordinations waiting for it. pub fn keepalive_arrived(state: &ServerState, user_hash: &[u8; 16]) { + if let Some((_, at)) = state.ext_timed_out.remove(user_hash) { + if at.elapsed() < LATE_9F_WINDOW { + count(state, "holepunch_ext_late_9f"); + } + } if let Some((_, waiters)) = state.natt_keepalive_waiters.remove(user_hash) { for w in waiters { let _ = w.send(()); @@ -614,9 +797,10 @@ fn bridge(state: &ServerState, requester_user_hash: &[u8; 16], requester_ip: IpA .push .note(crate::admission::PushKind::Holepunch, outcome); count(state, "holepunch_bridged"); + count(state, &format!("hp_bridged_sw:{}", sw_family(state, &target.user_hash))); } -/// OP_LOWID_HOLEPUNCH_INFO payload, 29 bytes: +/// OP_LOWID_HOLEPUNCH_INFO payload, 30 bytes (29 + peer_crypt): /// peer_ip(4 LE) + peer_tcp_port(2 LE) + peer_udp_port(2 LE) /// + peer_user_hash(16) + role(1) + peer_id(4 LE) /// `peer_id` (issue #38) is the server ID of the peer described; a client @@ -636,8 +820,7 @@ fn build_info( match ip { // eD2k stores IPv4 as a u32; writing the octets in order is equivalent // to u32::from_le_bytes(octets) written little-endian. - IpAddr::V4(v4) => p.put_u32_le(u32::from_le_bytes(v4.octets())), - _ => p.put_u32_le(0), + ip => p.put_u32_le(session_v4(ip).map_or(0, |v4| u32::from_le_bytes(v4.octets()))), } p.put_u16_le(tcp_port); p.put_u16_le(udp_port); @@ -753,8 +936,8 @@ mod tests { assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); } - /// Mixed pairs and legacy pairs: today's 29-byte 0x61 at once, no 0x37, - /// whichever side has the extension. + /// Mixed pairs and legacy pairs: 0x61 at once (30 bytes since #42), no + /// 0x37, whichever side has the extension. #[tokio::test] async fn a_pair_without_the_extension_on_both_sides_is_unchanged() { for (fa, fb) in [(EXT, SRVCAP_NATTRAVERSAL), (SRVCAP_NATTRAVERSAL, EXT), (0, 0), (EXT, 0)] { @@ -764,8 +947,8 @@ mod tests { // B has proven it reads 0x61, so no bridge either. s.natt_keepalive_seen.insert([0xB; 16], false); ask(&s, 0xA, 11, 1, 13); - assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)], "{fa:#x} {fb:#x}"); - assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)], "{fa:#x} {fb:#x}"); + assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)], "{fa:#x} {fb:#x}"); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)], "{fa:#x} {fb:#x}"); } } @@ -780,8 +963,8 @@ mod tests { let mut b = peer(&s, 0xB, 13, 2, EXT); s.natt_keepalive_seen.insert([0xB; 16], false); ask(&s, 0xA, 11, 1, 13); - assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); - assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); } /// The bridge: a target that set 0x1000 and never sent a 0x9F also gets @@ -793,9 +976,9 @@ mod tests { let mut a = peer(&s, 0xA, 11, 1, requester_flags); let mut b = peer(&s, 0xB, 13, 2, SRVCAP_NATTRAVERSAL); ask(&s, 0xA, 11, 1, 13); - assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + assert_eq!(opcodes(&drain(&mut a)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); let to_b = drain(&mut b); - assert_eq!(opcodes(&to_b), vec![(OP_LOWID_HOLEPUNCH_INFO, 29), (0x37, 11)]); + assert_eq!(opcodes(&to_b), vec![(OP_LOWID_HOLEPUNCH_INFO, 30), (0x37, 11)]); // ip, the requester's port, its id, crypt 0. assert_eq!(to_b[1].payload, vec![10, 0, 0, 1, 0x40, 0x12, 11, 0, 0, 0, 0]); } @@ -804,7 +987,7 @@ mod tests { let _a = peer(&s, 0xA, 11, 1, 0); let mut b = peer(&s, 0xB, 13, 2, 0); ask(&s, 0xA, 11, 1, 13); - assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); let s = Arc::new(ServerState::for_test()); let mut cfg = (**s.live_cfg.load()).clone(); cfg.network.lugdunum_natt = false; @@ -812,7 +995,7 @@ mod tests { let _a = peer(&s, 0xA, 11, 1, 0); let mut b = peer(&s, 0xB, 13, 2, SRVCAP_NATTRAVERSAL); ask(&s, 0xA, 11, 1, 13); - assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 29)]); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)]); } /// A bridged target without a UDP port still gets the 0x37: it dials @@ -881,6 +1064,27 @@ mod tests { assert_eq!((tcp, udp, hash, id), (24830, 40002, b, 200), "A is told B's port and id"); } + #[test] + fn a_pair_without_ipv4_on_both_sides_is_refused() { + let state = ServerState::for_test(); + let v6: IpAddr = "2001:db8::5".parse().unwrap(); + let v4 = IpAddr::V4(Ipv4Addr::new(5, 6, 7, 9)); + let (a, b) = ([0x51u8; 16], [0x52u8; 16]); + let _rx_a = state.register_test_client_at(a, 101, false, 4672, v4); + let _rx_b = state.register_test_client_at(b, 201, false, 4672, v6); + assert_eq!( + handle_holepunch_request(&state, &b, 201, v6, 4662, 4672, 101), + Some(FAIL_NO_IPV4), + "an IPv6 requester is not handed 0.0.0.0" + ); + assert_eq!( + handle_holepunch_request(&state, &a, 101, v4, 4662, 4672, 201), + Some(FAIL_NO_IPV4), + "nor is an IPv4 requester told an IPv6 target's 0.0.0.0" + ); + assert_eq!(session_v4("::ffff:1.2.3.4".parse().unwrap()), Some(Ipv4Addr::new(1, 2, 3, 4))); + } + #[test] fn the_per_ip_port_is_used_only_for_a_client_alone_on_its_ip() { let state = ServerState::for_test(); diff --git a/src/server/ipv6_probe.rs b/src/server/ipv6_probe.rs new file mode 100644 index 0000000..f2d6c1e --- /dev/null +++ b/src/server/ipv6_probe.rs @@ -0,0 +1,304 @@ +//! Dial-back of a client's IPv6 before it is published as a source (issue #43). +//! +//! A client tells the server its IPv6 in `CT_MOD_IP_V6` (or in +//! `OP_IPV6_UPDATE`), nearly always over an IPv4 session, so nothing has +//! checked the address. Most dual-stack homes have a global address behind a +//! router that drops unsolicited inbound IPv6: published as-is, it sends +//! requesters to an address that never answers, and a client cannot test its +//! own inbound IPv6 before peers dial it. +//! +//! So the server dials `[v6]:login port` with the identity probe the HighID +//! check uses: the answer must carry the user hash that logged in, since the +//! address is only the client's word. Until it succeeds the client is handled +//! as one without IPv6 — not published, though it stays sentinel-capable. The +//! verdict goes back in `ST_IPV6_STATUS`: `HAVE|REACHABLE|PROBED` on success, +//! `HAVE|PROBED` on failure, in a fresh `OP_SERVERIDENT`. +//! +//! Active only when the server publishes IPv6 sources, `network.ipv6_probe` +//! is on and the server has an IPv6 route to dial with; it then sets +//! `SRVFLG_IPV6_PROBE` (0x400000) in `OP_IDCHANGE`. Otherwise everything +//! works as before: the address is published on trust, status `HAVE|REACHABLE`. + +use crate::proto::Frame; +use crate::state::{ServerState, UserHash}; +use std::net::{IpAddr, Ipv6Addr}; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Instant; + +/// One client's probe: the address it is about, and the verdict (None while +/// it runs, or when it could not run). +#[derive(Debug, Clone, Copy)] +pub struct Probe { + pub addr: Ipv6Addr, + pub verdict: Option, + pub at: Instant, + /// Dials made for this session. The address is the client's word, so + /// each dial goes wherever the client points it; this caps how often. + pub dials: u8, +} + +/// Dials per session. The login address and a few `OP_IPV6_UPDATE` changes; +/// past that a new address stays unpublished. +pub const MAX_DIALS: u8 = 4; + +/// Is the dial-back in force? +pub fn active(state: &ServerState) -> bool { + let live = state.live_cfg.load(); + live.network.ipv6_probe + && live.network.ipv6_publish_sources + && state.ipv6_route.load(Ordering::Relaxed) +} + +/// Does this host have a route to the IPv6 internet? A UDP "connect" picks a +/// source address and route without sending anything. +pub fn detect_route() -> bool { + std::net::UdpSocket::bind("[::]:0") + .and_then(|s| s.connect("[2001:4860:4860::8888]:53").map(|_| s)) + .and_then(|s| s.local_addr()) + .map(|a| matches!(a.ip(), IpAddr::V6(v6) if !v6.is_loopback() && !v6.is_unspecified())) + .unwrap_or(false) +} + +/// The verdict for `addr`, when the last probe was about that address. +pub fn verdict(state: &ServerState, user_hash: &UserHash, addr: Ipv6Addr) -> Option { + state + .ipv6_probe + .get(user_hash) + .filter(|p| p.addr == addr) + .and_then(|p| p.verdict) +} + +/// `ST_IPV6_STATUS` bits for a client holding `addr`. +pub fn status_bits(state: &ServerState, user_hash: &UserHash, addr: Option) -> u8 { + use crate::server::login::{ + IPV6ST_HAVE as HAVE, IPV6ST_PROBED as PROBED, IPV6ST_REACHABLE as REACHABLE, + }; + let Some(a) = addr else { return 0 }; + if !active(state) { + // On trust, as before the probe existed. + return HAVE | REACHABLE; + } + match verdict(state, user_hash, a) { + Some(true) => HAVE | REACHABLE | PROBED, + Some(false) => HAVE | PROBED, + // Running: no claim yet. + None => HAVE, + } +} + +/// Hold `addr` back until its probe has run: not published, status `HAVE`. +/// Called before the welcome batch at login, so its `OP_SERVERIDENT` cannot +/// claim more than that. Returns whether a probe should start: not when this +/// address already has its verdict (re-applied) or a probe running. +pub fn mark_pending(state: &ServerState, user_hash: UserHash, addr: Ipv6Addr) -> bool { + let prev = state.ipv6_probe.get(&user_hash).map(|p| *p); + if let Some(p) = prev.filter(|p| p.addr == addr) { + match p.verdict { + Some(true) => { + state.client_ipv6.insert(user_hash, addr); + } + _ => { + state.client_ipv6.remove(&user_hash); + } + } + return false; + } + state.client_ipv6.remove(&user_hash); + state.ipv6_probe.insert( + user_hash, + Probe { + addr, + verdict: None, + at: Instant::now(), + dials: prev.map_or(0, |p| p.dials), + }, + ); + true +} + +/// Dial `[addr]:port` and expect `user_hash`; `mark_pending` first. On the +/// verdict, publish (or not) and send the session a fresh `OP_SERVERIDENT`. +/// Left unpublished when the server-wide probe ceiling is full or the +/// session has used its dials. +pub fn start( + state: &Arc, + user_hash: UserHash, + addr: Ipv6Addr, + port: u16, + client_flags: u32, +) { + { + let Some(mut p) = state.ipv6_probe.get_mut(&user_hash) else { + return; + }; + if p.addr != addr || p.verdict.is_some() { + return; + } + if p.dials >= MAX_DIALS { + drop(p); + crate::server::holepunch::count(state, "ipv6_probe_skipped_limit"); + return; + } + p.dials += 1; + } + let Some(permit) = state.admission.probes.try_take() else { + crate::server::holepunch::count(state, "ipv6_probe_skipped_busy"); + return; + }; + crate::server::holepunch::count(state, "ipv6_probe_started"); + let live = state.live_cfg.load_full(); + let ident = crate::server::login::HighIdProbeIdent::from_config(&live); + let timeout_ms = live.network.login_timeout_ms; + let supports_crypt = client_flags & crate::server::login::SRVCAP_SUPPORTCRYPT != 0; + let state = Arc::clone(state); + tokio::spawn(async move { + let _permit = permit; + let answer = crate::server::login::highid_identity_answer( + IpAddr::V6(addr), + port, + user_hash, + ident, + timeout_ms, + supports_crypt, + ) + .await; + let ok = matches!(&answer, Ok(h) if crate::server::highid_probe::same_client_hash(h, &user_hash)); + crate::server::holepunch::count( + &state, + match &answer { + Ok(_) if ok => "ipv6_probe_ok", + Ok(_) => "ipv6_probe_wrong_hash", + Err(_) => "ipv6_probe_no_answer", + }, + ); + // Why there was no answer: dropped on the way (a firewall that + // discards: timeout), turned away (refused: the host is reachable + // but nothing takes the port on IPv6, or a rejecting firewall), no + // route (unreachable), or something answered but not as a client. + if let Err(e) = &answer { + crate::server::holepunch::count(&state, fail_key(e)); + } + conclude(&state, user_hash, addr, ok); + }); +} + +/// Each minute: recheck the route, drop entries of sessions gone without +/// the logout path, and retry a probe that never ran (the ceiling was full) +/// once it has waited a minute, within the session's dials. +pub fn sweep(state: &Arc) { + state.ipv6_route.store(detect_route(), Ordering::Relaxed); + state + .ipv6_probe + .retain(|h, _| state.clients.contains_key(h)); + if !active(state) { + return; + } + let stalled: Vec<(UserHash, Ipv6Addr)> = state + .ipv6_probe + .iter() + .filter(|p| p.verdict.is_none() && p.dials < MAX_DIALS && p.at.elapsed().as_secs() >= 60) + .map(|p| (*p.key(), p.addr)) + .collect(); + for (h, a) in stalled { + let Some((port, flags, cur)) = state + .clients + .get(&h) + .map(|c| (c.port, c.server_flags, c.ipv6)) + else { + continue; + }; + if cur == Some(a) { + if let Some(mut p) = state.ipv6_probe.get_mut(&h) { + p.at = Instant::now(); + } + start(state, h, a, port, flags); + } + } +} + +/// The counter for a probe that got no answer, by stage. +fn fail_key(e: &str) -> &'static str { + match e { + "connect timeout" => "ipv6_probe_fail_timeout", + "connect refused" => "ipv6_probe_fail_refused", + "connect unreachable" => "ipv6_probe_fail_unreachable", + _ => "ipv6_probe_fail_after_connect", + } +} + +/// Record the verdict and act on it, if the session still holds `addr`. +pub fn conclude(state: &ServerState, user_hash: UserHash, addr: Ipv6Addr, ok: bool) { + let Some(client) = state.clients.get(&user_hash).map(|c| c.clone()) else { + state.ipv6_probe.remove(&user_hash); + return; + }; + if client.ipv6 != Some(addr) { + // Superseded by an OP_IPV6_UPDATE; its own probe decides. + return; + } + match state.ipv6_probe.get_mut(&user_hash) { + Some(mut p) if p.addr == addr => { + p.verdict = Some(ok); + p.at = Instant::now(); + } + // The session ended and another began meanwhile. + _ => return, + } + if ok { + state.client_ipv6.insert(user_hash, addr); + } else { + state.client_ipv6.remove(&user_hash); + } + let live = state.live_cfg.load(); + let ident: Frame = crate::server::login::build_server_ident(&live, state, &client); + let outcome = client.send_frame(ident); + state + .admission + .push + .note(crate::admission::PushKind::Holepunch, outcome); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn on(s: &ServerState) { + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.network.ipv6_publish_sources = true; + cfg.network.ipv6_probe = true; + s.live_cfg.store(Arc::new(cfg)); + s.ipv6_route.store(true, Ordering::Relaxed); + } + + #[test] + fn the_status_follows_the_verdict() { + let s = ServerState::for_test(); + let a: Ipv6Addr = "2001:db8::7".parse().unwrap(); + let h = [7u8; 16]; + // Off: on trust, as before. + assert_eq!(status_bits(&s, &h, Some(a)), 0x03); + assert_eq!(status_bits(&s, &h, None), 0); + on(&s); + assert_eq!(status_bits(&s, &h, Some(a)), 0x01, "running: HAVE only"); + let mut rx = s.register_test_client_at(h, 5, false, 0, "10.0.0.5".parse().unwrap()); + s.clients.get_mut(&h).unwrap().ipv6 = Some(a); + assert!(mark_pending(&s, h, a)); + assert!(!mark_pending(&s, h, a), "already running"); + conclude(&s, h, a, true); + assert_eq!(status_bits(&s, &h, Some(a)), 0x07); + assert_eq!(s.client_ipv6.get(&h).map(|e| *e), Some(a), "published"); + assert!(rx.try_recv().is_ok(), "a fresh SERVERIDENT went out"); + conclude(&s, h, a, false); + assert_eq!(status_bits(&s, &h, Some(a)), 0x05); + assert!(s.client_ipv6.get(&h).is_none(), "withdrawn"); + // The same address again keeps its verdict, no new dial. + assert!(!mark_pending(&s, h, a)); + assert_eq!(status_bits(&s, &h, Some(a)), 0x05); + // A verdict about another address does not count. + let b: Ipv6Addr = "2001:db8::8".parse().unwrap(); + assert_eq!(status_bits(&s, &h, Some(b)), 0x01); + assert!(mark_pending(&s, h, b), "a new address is dialled"); + assert!(s.client_ipv6.get(&h).is_none()); + } +} diff --git a/src/server/login.rs b/src/server/login.rs index ed06233..468c836 100644 --- a/src/server/login.rs +++ b/src/server/login.rs @@ -281,6 +281,10 @@ pub fn build_welcome_batch_with( tcp_flags |= SRV_TCPFLG_IPV6; // And the in-session update opcode (issue #34), on its own bit. tcp_flags |= crate::proto::opcodes::SRVFLG_IPV6_UPDATE; + // And the dial-back of the client's IPv6 (issue #43). + if crate::server::ipv6_probe::active(state) { + tcp_flags |= crate::proto::opcodes::SRVFLG_IPV6_PROBE; + } } let mut payload = BytesMut::with_capacity(20); @@ -427,7 +431,19 @@ pub fn build_server_ident(cfg: &Config, state: &ServerState, client: &ClientHand // GetObfuscationPortTCP() != 0 && ((UDPFlags & 0x400) || (TCPFlags & 0x400)) // Single source of truth, shared with GLOBSERVSTATRES — see the // per-bit table on SERVER_UDP_FLAGS. - Tag::byte(ST_UDPFLAGS, TagValue::U32(SERVER_UDP_FLAGS)), + Tag::byte( + ST_UDPFLAGS, + TagValue::U32( + SERVER_UDP_FLAGS + | if crate::server::lugdunum_natt::foreign_enabled(&live) { + // Cross-server NAT callback (issue #40); a login is + // always a client's. + crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT + } else { + 0 + }, + ), + ), // ST_TCPPORTOBFUSCATION / ST_UDPPORTOBFUSCATION — eMule casts to uint16, // but values are stored in tags as u32 (eMule code: m_nObfuscationPortTCP = (uint16)tag->GetInt()). // Non-zero TCP obf port is required for SupportsObfuscationTCP() to return true. @@ -468,13 +484,13 @@ pub fn build_server_ident(cfg: &Config, state: &ServerState, client: &ClientHand // Bits are only ever set to "yes". The tag is omitted entirely when // there is no verdict, so a client that sees it can trust every bit // — an unset bit means no, never unknown. - let mut status: u8 = 0; - if client.ipv6.is_some() { - status |= IPV6ST_HAVE | IPV6ST_REACHABLE; - } - // IPV6ST_PROBED stays unset: we accept the address on trust (from - // the socket, or from the login tag) and never dial it back. Setting - // it would claim a check we do not perform. + // With the dial-back on (#43), REACHABLE is a probed fact: a running + // probe gives HAVE alone, its verdict HAVE|PROBED or + // HAVE|REACHABLE|PROBED. With it off the address is taken on trust, + // HAVE|REACHABLE, and PROBED stays unset rather than claim a check + // that was not made. + let status: u8 = + crate::server::ipv6_probe::status_bits(state, &client.user_hash, client.ipv6); if status != 0 { tags.push(Tag::byte(ST_IPV6_STATUS, TagValue::U8(status))); } @@ -524,10 +540,12 @@ const ST_IPV6: u8 = 0xAE; /// `ST_IPV6_STATUS` — the server's verdict on the client's advertised IPv6. const ST_IPV6_STATUS: u8 = 0xAB; /// The server holds a public IPv6 for this session. -const IPV6ST_HAVE: u8 = 0x01; +pub(crate) const IPV6ST_HAVE: u8 = 0x01; /// That address is treated as reachable and the client is published as a v6 /// source. -const IPV6ST_REACHABLE: u8 = 0x02; +pub(crate) const IPV6ST_REACHABLE: u8 = 0x02; +/// The server dialled the address back (#43); REACHABLE then gives the result. +pub(crate) const IPV6ST_PROBED: u8 = 0x04; /// `OP_IDCHANGE` / `OP_GLOBSERVSTATRES` flag bit: this server speaks the IPv6 /// extension. See the warning at `SRVCAP_IPV6` about the direction asymmetry. @@ -848,7 +866,7 @@ pub async fn assign_client_id_for( /// observe numbers are only worth something if they describe exactly what the /// verdict does. The hairpin code keeps its own copy on purpose (see /// `spawn_highid_observe`); if its fallback list ever changes, change this too. -async fn highid_identity_answer( +pub(crate) async fn highid_identity_answer( ip: IpAddr, port: u16, user_hash: [u8; 16], @@ -891,14 +909,14 @@ async fn highid_identity_answer( /// What the server presents of itself in the identity probe's OP_HELLO. #[derive(Clone, Copy)] -struct HighIdProbeIdent { +pub(crate) struct HighIdProbeIdent { ours: [u8; 16], our_id: u32, our_port: u16, } impl HighIdProbeIdent { - fn from_config(live: &Config) -> Self { + pub(crate) fn from_config(live: &Config) -> Self { use crate::server::highid_probe::{high_id_from_ip, server_pseudo_user_hash}; Self { // Never the peer's own hash — a client handed its own hash thinks @@ -1834,8 +1852,7 @@ mod tests { assert_eq!(IPV6ST_HAVE, 0x01); assert_eq!(IPV6ST_REACHABLE, 0x02); assert_eq!(IPV6ST_HAVE | IPV6ST_REACHABLE, 0x03); - // 0x04 (PROBED) is deliberately not defined here. - assert_eq!((IPV6ST_HAVE | IPV6ST_REACHABLE) & 0x04, 0); + assert_eq!(IPV6ST_PROBED, 0x04); } #[test] diff --git a/src/server/lugdunum_natt.rs b/src/server/lugdunum_natt.rs index 3ac43a5..393b572 100644 --- a/src/server/lugdunum_natt.rs +++ b/src/server/lugdunum_natt.rs @@ -35,10 +35,28 @@ //! LowID, so a stock client got an opcode it does not know. //! - Requests are rate limited per requester (a burst of 10, then one a //! second); aMule itself sends at most 20 a minute per server. +//! +//! **Cross-server (issue #40).** A client logged in to another server can +//! reach a target logged in here: it learns the target's server and LowID +//! from source exchange, takes this server's UDP key from the status ping +//! and sends `0xA6` `<0>[]`, obfuscated. Requester id 0 +//! marks it foreign. The key is `IPObfuscate(secret, sender IP)`, so a +//! datagram that decodes proves the sender's IP; its source port is the +//! requester's endpoint. The target gets the usual `0x37` with requester id +//! 0, and no hash (the server does not know the requester's). A target with +//! the extension (0x100000) answers with `0x9F`; the requester then gets +//! `0xA7` `` by UDP, +//! obfuscated, from the port it wrote to; without that `0x9F` within 3 s, +//! the same with the port the server has. Nothing else is ever answered, so +//! an unknown or unsuitable target is indistinguishable from a lost +//! datagram. Without a session to kick, the limits sit on the source IP: a +//! burst of 10 then one a second, at most 60 different targets in 10 +//! minutes, and at most 6 foreign wake-ups a minute per target. use crate::proto::Frame; use crate::state::{ClientHandle, ServerState}; -use std::net::{IpAddr, SocketAddr}; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::{Duration, Instant}; /// Client capability in `CT_SERVER_FLAGS`, and this server's flag in the @@ -58,6 +76,54 @@ const UDP_FRESH: Duration = Duration::from_secs(20); /// How long a requester's endpoint is kept, for the sweep. pub const SEEN_TTL: Duration = Duration::from_secs(120); +/// Cross-server requests (issue #40): how many different targets one source +/// IP may name in `FOREIGN_TARGET_WINDOW`. A downloader has a handful of LowID +/// sources on one server; a sweep over LowIDs hits this within a minute. +pub const FOREIGN_MAX_TARGETS: usize = 60; +pub const FOREIGN_TARGET_WINDOW: Duration = Duration::from_secs(600); +/// Foreign wake-ups one target may get a minute. +pub const FOREIGN_WAKES_PER_MIN: usize = 6; + +/// One foreign requester (by source IP): its allowance and the targets it +/// named recently. +pub struct ForeignSource { + bucket: crate::admission::TokenBucket, + targets: HashMap, + last: Instant, +} + +impl ForeignSource { + /// Nothing left to remember: the sweep may drop it. + pub fn idle(&self) -> bool { + self.last.elapsed() >= FOREIGN_TARGET_WINDOW + } +} + +/// The answer a foreign requester is owed once the target has sent its +/// `0x9F` (or the wait is over). The caller waits on `rx`, then sends +/// `answer_packet` to `to`. +pub struct ForeignAnswer { + pub rx: tokio::sync::oneshot::Receiver<()>, + pub target: [u8; 16], + pub to: SocketAddr, + pub obf: (u32, u8), + pub local_port: u16, + pub requester_crypt: u8, + /// One wait per (requester address, target), within the shared retry + /// pool; released when the answer is sent. + pub guards: (crate::admission::RetryKey, tokio::sync::OwnedSemaphorePermit), +} + +impl std::fmt::Debug for ForeignAnswer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ForeignAnswer") + .field("to", &self.to) + .field("local_port", &self.local_port) + .field("requester_crypt", &self.requester_crypt) + .finish() + } +} + /// A client's last `0xA6`: where it came from, how to answer it, and its /// request allowance. pub struct Seen { @@ -87,6 +153,10 @@ pub enum Refused { TargetNotCapable, TargetGone, CryptMismatch, + ForeignDisabled, + ForeignRateLimited, + ForeignTooManyTargets, + ForeignTargetLimited, } impl Refused { @@ -105,6 +175,10 @@ impl Refused { Refused::TargetNotCapable => "natcb_target_not_capable", Refused::TargetGone => "natcb_target_gone", Refused::CryptMismatch => "natcb_crypt_mismatch", + Refused::ForeignDisabled => "natcb_foreign_disabled", + Refused::ForeignRateLimited => "natcb_foreign_rate_limited", + Refused::ForeignTooManyTargets => "natcb_foreign_too_many_targets", + Refused::ForeignTargetLimited => "natcb_foreign_target_limited", } } } @@ -143,28 +217,253 @@ pub fn forward_body( req_addr: SocketAddr, target_flags: u32, ) -> Option> { - let r = crypt_setting(req.server_flags); - let t = crypt_setting(target_flags); - let both = r & t & 1 != 0; - let asked = (r | t) & 2 != 0; - let with_hash = both && asked; - if !with_hash && (r | t) & 4 != 0 { + // ID 0 marks a foreign request (issue #40); a same-server forward never + // carries it. LowIDs start at 1, so this is a guard, not a case. + if req.assigned_id == 0 { return None; } let ip = v4(req.ip)?; + body( + ip, + req_addr.port(), + req.assigned_id, + crypt_setting(req.server_flags), + Some(&req.user_hash), + crypt_setting(target_flags), + ) +} + +/// `[]` describing the peer `ip`, whose crypt +/// setting is `own`, for a reader whose setting is `other`. The hash rides +/// along (and 0x80 in `crypt`) when both support the crypt layer and one +/// asks for it, if it is known; None when one side requires the layer and +/// the pair cannot use it. +fn body(ip: Ipv4Addr, port: u16, id: u32, own: u8, hash: Option<&[u8; 16]>, other: u8) -> Option> { + let both = own & other & 1 != 0; + let asked = (own | other) & 2 != 0; + let with_hash = both && asked && hash.is_some(); + if !with_hash && (own | other) & 4 != 0 { + return None; + } let mut b = Vec::with_capacity(27); b.extend_from_slice(&ip.octets()); - b.extend_from_slice(&req_addr.port().to_le_bytes()); - b.extend_from_slice(&req.assigned_id.to_le_bytes()); - if with_hash { - b.push(r | 0x80); - b.extend_from_slice(&req.user_hash); - } else { - b.push(r); + b.extend_from_slice(&port.to_le_bytes()); + b.extend_from_slice(&id.to_le_bytes()); + match hash.filter(|_| with_hash) { + Some(h) => { + b.push(own | 0x80); + b.extend_from_slice(h); + } + None => b.push(own), } Some(b) } +/// The cross-server path is offered: it builds on the extension. +pub fn foreign_enabled(cfg: &crate::config::Config) -> bool { + cfg.network.holepunch_foreign && crate::server::holepunch::extension_enabled(cfg) +} + +/// A `0xA6` of either kind: requester id 0 is a client logged in elsewhere +/// (issue #40), anything else one logged in here. +pub fn handle( + state: &ServerState, + payload: &[u8], + peer: SocketAddr, + obf: Option<(u32, u8)>, + local_port: u16, +) -> Result<(Forward, Option), Refused> { + if payload.len() >= 8 && payload[4..8] == [0; 4] { + crate::server::holepunch::count(state, "foreign_natcb_requests"); + handle_foreign(state, payload, peer, obf, local_port) + } else { + crate::server::holepunch::count(state, "natcbx_requests"); + handle_request(state, payload, peer, obf, local_port).map(|f| (f, None)) + } +} + +/// The forward of `body` to `target`: by UDP when the target asked for a +/// callback itself just now, else by TCP. +fn deliver(state: &ServerState, target: &ClientHandle, body: Vec) -> Forward { + let fresh = state + .lugdunum_natt_seen + .get(&target.user_hash) + .filter(|s| s.at.elapsed() < UDP_FRESH && v4(s.addr.ip()) == v4(target.ip)) + .map(|s| (s.addr, s.obf, s.local_port)); + match fresh { + Some((addr, obf, local_port)) => { + let mut packet = Vec::with_capacity(2 + body.len()); + packet.push(0xE3); + packet.push(OP_NAT_CALLBACK_UDP); + packet.extend_from_slice(&body); + Forward::Udp { + addr, + obf, + local_port, + packet, + } + } + None => Forward::Tcp { + target: target.user_hash, + frame: Frame::new(OP_NAT_CALLBACK_TCP, body), + }, + } +} + +/// A `0xA6` from a client not logged in here (requester id 0, issue #40). +pub fn handle_foreign( + state: &ServerState, + payload: &[u8], + peer: SocketAddr, + obf: Option<(u32, u8)>, + local_port: u16, +) -> Result<(Forward, Option), Refused> { + let cfg = state.live_cfg.load(); + if !cfg.network.lugdunum_natt { + return Err(Refused::Disabled); + } + if !foreign_enabled(&cfg) { + return Err(Refused::ForeignDisabled); + } + let Some(obf) = obf else { + return Err(Refused::NotObfuscated); + }; + if payload.len() < 8 { + return Err(Refused::Short); + } + let target_id = u32::from_le_bytes(payload[0..4].try_into().unwrap()); + let requester_crypt = payload.get(8).map_or(0, |b| b & 7); + let ip = v4(peer.ip()).ok_or(Refused::WrongAddress)?; + let now = Instant::now(); + { + let mut src = state + .natcb_foreign_sources + .entry(IpAddr::V4(ip)) + .or_insert_with(|| ForeignSource { + bucket: crate::admission::TokenBucket::new(1, 10, now), + targets: HashMap::new(), + last: now, + }); + src.last = now; + if !src.bucket.take(1, now) { + return Err(Refused::ForeignRateLimited); + } + // Every target id named counts, known or not: a sweep finds out + // nothing either way, and is stopped all the same. + src.targets.retain(|_, t| t.elapsed() < FOREIGN_TARGET_WINDOW); + if !src.targets.contains_key(&target_id) && src.targets.len() >= FOREIGN_MAX_TARGETS { + return Err(Refused::ForeignTooManyTargets); + } + src.targets.insert(target_id, now); + } + let target = state + .client_by_assigned_id(target_id) + .ok_or(Refused::UnknownTarget)?; + if target.is_high_id { + return Err(Refused::TargetHighId); + } + if target.server_flags & SRVCAP_NATTRAVERSAL == 0 { + return Err(Refused::TargetNotCapable); + } + if !target.is_alive() { + return Err(Refused::TargetGone); + } + let body = body( + ip, + peer.port(), + 0, + requester_crypt, + None, + crypt_setting(target.server_flags), + ) + .ok_or(Refused::CryptMismatch)?; + { + let mut w = state.natcb_foreign_wakes.entry(target.user_hash).or_default(); + while w.front().is_some_and(|t| t.elapsed() >= Duration::from_secs(60)) { + w.pop_front(); + } + if w.len() >= FOREIGN_WAKES_PER_MIN { + return Err(Refused::ForeignTargetLimited); + } + w.push_back(now); + } + // The requester's answer, when the target can give one: a target with + // the extension sends 0x9F on the 0x37. Listen before it is woken. + crate::server::holepunch::count( + state, + if crate::server::holepunch::has_extension(state, target.server_flags) { + "natcbx_foreign_target_extension" + } else { + "natcbx_foreign_target_lugdunum_only" + }, + ); + let answer = if crate::server::holepunch::has_extension(state, target.server_flags) { + let mut pseudo = [0xFFu8; 16]; + pseudo[..4].copy_from_slice(&ip.octets()); + pseudo[4..6].copy_from_slice(&peer.port().to_le_bytes()); + match state.admission.try_retry(pseudo, target_id) { + Ok(guards) => { + let (tx, rx) = tokio::sync::oneshot::channel(); + state + .natt_keepalive_waiters + .entry(target.user_hash) + .or_default() + .push(tx); + Some(ForeignAnswer { + rx, + target: target.user_hash, + to: peer, + obf, + local_port, + requester_crypt, + guards, + }) + } + // One already waits for this pair, or the pool is full: the + // target is still woken; the answer comes from the one waiting. + Err(_) => None, + } + } else { + None + }; + Ok((deliver(state, &target, body), answer)) +} + +/// `[0xE3][0xA7]` for a +/// foreign requester, with the best port the server has for the target now. +/// Always 27 bytes of body: the requester dials and needs the hash. None +/// when the target has gone or has no UDP port. +pub fn answer_packet(state: &ServerState, target: &[u8; 16], requester_crypt: u8) -> Option> { + let t = state.clients.get(target).map(|c| c.clone())?; + if !t.is_alive() || t.is_high_id { + return None; + } + let ip = v4(t.ip)?; + let port = crate::server::holepunch::observed_udp_port(state, &t.user_hash, t.ip, t.udp_port); + if port == 0 { + return None; + } + let mut b = body( + ip, + port, + t.assigned_id, + crypt_setting(t.server_flags), + Some(&t.user_hash), + requester_crypt, + )?; + // The requester dials (issue #40), and its first QUIC Initial is + // obfuscated with the target's hash: it always gets the hash here. 0x80 + // in `crypt` keeps Lugdunum's meaning, "use the crypt layer". + if b.len() == 11 { + b.extend_from_slice(&t.user_hash); + } + let mut packet = Vec::with_capacity(2 + b.len()); + packet.push(0xE3); + packet.push(OP_NAT_CALLBACK_UDP); + packet.extend_from_slice(&b); + Some(packet) +} + /// Handle a `0xA6` from `peer`. `obf` is the key the datagram was decoded /// with (None: it came in the clear), `local_port` our port it arrived on. pub fn handle_request( @@ -227,37 +526,31 @@ pub fn handle_request( return Err(Refused::TargetHighId); } if target.server_flags & SRVCAP_NATTRAVERSAL == 0 { + // NAT-T analysis: requesters that ignore the parity rule ask for even + // LowIDs, which never set 0x1000 here. + crate::server::holepunch::count( + state, + if target_id & 1 == 0 { + "natcbx_not_capable_even_target" + } else { + "natcbx_not_capable_odd_target" + }, + ); + crate::server::holepunch::count( + state, + &format!("natcbx_not_capable_from_sw:{}", crate::server::holepunch::sw_family(state, &req.user_hash)), + ); return Err(Refused::TargetNotCapable); } if !target.is_alive() { return Err(Refused::TargetGone); } let body = forward_body(&req, peer, target.server_flags).ok_or(Refused::CryptMismatch)?; - - // The target's own endpoint, if it asked for a callback itself just now. - let fresh = state - .lugdunum_natt_seen - .get(&target.user_hash) - .filter(|s| s.at.elapsed() < UDP_FRESH && v4(s.addr.ip()) == v4(target.ip)) - .map(|s| (s.addr, s.obf, s.local_port)); - Ok(match fresh { - Some((addr, obf, local_port)) => { - let mut packet = Vec::with_capacity(2 + body.len()); - packet.push(0xE3); - packet.push(OP_NAT_CALLBACK_UDP); - packet.extend_from_slice(&body); - Forward::Udp { - addr, - obf, - local_port, - packet, - } - } - None => Forward::Tcp { - target: target.user_hash, - frame: Frame::new(OP_NAT_CALLBACK_TCP, body), - }, - }) + crate::server::holepunch::count( + state, + &format!("natcbx_forward_from_sw:{}", crate::server::holepunch::sw_family(state, &req.user_hash)), + ); + Ok(deliver(state, &target, body)) } /// LowID parity, as Lugdunum hands them out: odd for a client that set @@ -410,6 +703,123 @@ mod tests { assert_eq!(&packet[2..], &[5, 6, 7, 8, 0x40, 0x9C, 11, 0, 0, 0, 0]); } + fn foreign(target: u32) -> Vec { + req(target, 0) + } + + const EXT: u32 = crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT | NATT; + + /// Issue #40: a client logged in elsewhere wakes a target here; the + /// target gets Lugdunum's 0x37 with requester id 0 and no hash. + #[test] + fn a_foreign_request_wakes_the_target() { + let s = st(); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT | 0x600); + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + let mut p = foreign(13); + p.push(0x03); // the requester supports and requests the crypt layer + let (f, answer) = handle(&s, &p, peer, KEY, 6086).unwrap(); + let Forward::Tcp { target, frame } = f else { panic!("{f:?}") }; + assert_eq!(target, [0xB; 16]); + assert_eq!(frame.opcode, 0x37); + assert_eq!(frame.payload, vec![5, 6, 7, 8, 0x40, 0x9C, 0, 0, 0, 0, 0x03]); + assert!(answer.is_none(), "a Lugdunum-only target gives no answer"); + // Plain, or with the feature off: nothing. + assert_eq!(handle(&s, &foreign(13), peer, None, 6086).unwrap_err(), Refused::NotObfuscated); + let mut cfg = (**s.live_cfg.load()).clone(); + cfg.network.holepunch_foreign = false; + s.live_cfg.store(std::sync::Arc::new(cfg)); + assert_eq!(handle(&s, &foreign(13), peer, KEY, 6086).unwrap_err(), Refused::ForeignDisabled); + } + + /// A target with the extension owes the requester an answer: its + /// endpoint, id, crypt setting and hash (both use the crypt layer). + #[tokio::test] + async fn a_foreign_request_to_an_extension_target_is_answered() { + let s = st(); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], EXT | 0x200); + s.natt_udp_ports.insert([0xB; 16], (50000, Instant::now())); + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + let mut p = foreign(13); + p.push(0x03); + let (_, answer) = handle(&s, &p, peer, KEY, 6086).unwrap(); + let a = answer.expect("an answer is owed"); + assert_eq!((a.to, a.obf, a.local_port), (peer, KEY.unwrap(), 6086)); + // The target's 0x9F releases it. + crate::server::holepunch::keepalive_arrived(&s, &[0xB; 16]); + assert!(matches!(a.rx.await, Ok(()))); + let packet = answer_packet(&s, &[0xB; 16], 0x03).unwrap(); + assert_eq!(&packet[..2], &[0xE3, 0xA7]); + assert_eq!(&packet[2..12], &[9, 9, 9, 9, 0x50, 0xC3, 13, 0, 0, 0]); + assert_eq!(packet[12], 0x01 | 0x80); + assert_eq!(&packet[13..29], &[0xB; 16]); + // Without the crypt layer on both sides: no 0x80, the hash all the same. + let packet = answer_packet(&s, &[0xB; 16], 0).unwrap(); + assert_eq!(packet.len(), 2 + 27); + assert_eq!(packet[12], 0x01); + assert_eq!(&packet[13..29], &[0xB; 16]); + // A second request for the same pair while one waits: woken, no + // second answer. + let (_, again) = handle(&s, &p, peer, KEY, 6086).unwrap(); + assert!(again.is_none()); + } + + #[test] + fn the_rules_for_foreign_targets() { + let s = st(); + let _h = s.register_test_client_at([0xC; 16], 0x0A00_0001, true, 0, IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))); + let _c = client(&s, 0xD, 14, [8, 8, 8, 8], 0); // stock + let _e = client(&s, 0xE, 15, [7, 7, 7, 7], NATT); // no crypt layer + let peer: SocketAddr = "5.6.7.8:40000".parse().unwrap(); + assert_eq!(handle(&s, &foreign(99), peer, KEY, 6086).unwrap_err(), Refused::UnknownTarget); + assert_eq!(handle(&s, &foreign(0x0A00_0001), peer, KEY, 6086).unwrap_err(), Refused::TargetHighId); + assert_eq!(handle(&s, &foreign(14), peer, KEY, 6086).unwrap_err(), Refused::TargetNotCapable); + // The requester requires the crypt layer; the target can't, and the + // server has no hash to give it anyway. + let mut p = foreign(15); + p.push(0x07); + assert_eq!(handle(&s, &p, peer, KEY, 6086).unwrap_err(), Refused::CryptMismatch); + let v6: SocketAddr = "[2001:db8::1]:40000".parse().unwrap(); + assert_eq!(handle(&s, &foreign(15), v6, KEY, 6086).unwrap_err(), Refused::WrongAddress); + } + + /// The limits: a burst then 1/s per source, 60 different targets in 10 + /// minutes per source, 6 foreign wake-ups a minute per target. + #[test] + fn foreign_requests_are_limited() { + let s = st(); + let _b = client(&s, 0xB, 13, [9, 9, 9, 9], NATT); + let from = |i: u8| -> SocketAddr { format!("5.6.7.{i}:40000").parse().unwrap() }; + // Per target: six wake-ups from six sources, then no more. + let woke = (1..=10u8) + .filter(|&i| handle(&s, &foreign(13), from(i), KEY, 6086).is_ok()) + .count(); + assert_eq!(woke, FOREIGN_WAKES_PER_MIN); + assert_eq!( + handle(&s, &foreign(13), from(20), KEY, 6086).unwrap_err(), + Refused::ForeignTargetLimited + ); + // Per source: a burst of 10. + let sweeper = from(50); + let allowed = (0..30u32) + .filter(|&t| handle(&s, &foreign(1000 + t), sweeper, KEY, 6086).err() != Some(Refused::ForeignRateLimited)) + .count(); + assert!((10..=11).contains(&allowed), "{allowed}"); + // Distinct targets: with the allowance refilled, the 61st is refused. + let mut e = s.natcb_foreign_sources.get_mut(&sweeper.ip()).unwrap(); + e.bucket = crate::admission::TokenBucket::new(1, 1000, Instant::now()); + for t in 0..60u32 { + e.targets.insert(2000 + t, Instant::now()); + } + drop(e); + assert_eq!( + handle(&s, &foreign(5000), sweeper, KEY, 6086).unwrap_err(), + Refused::ForeignTooManyTargets + ); + // A target it already named still goes through the checks. + assert_eq!(handle(&s, &foreign(2000), sweeper, KEY, 6086).unwrap_err(), Refused::UnknownTarget); + } + #[test] fn requests_are_rate_limited_per_requester() { let s = st(); diff --git a/src/server/mod.rs b/src/server/mod.rs index cbcb02e..b59b4a5 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -6,6 +6,7 @@ pub mod connection; pub mod get_sources; pub mod gossip; pub mod highid_probe; +pub mod ipv6_probe; pub mod holepunch; pub mod keepalive; pub mod login; @@ -14,5 +15,6 @@ pub mod obf_ping; pub mod obfuscated_conn; pub mod offer_pacing; pub mod offerfiles; +pub mod review_audit; pub mod search; pub mod udp; diff --git a/src/server/offerfiles.rs b/src/server/offerfiles.rs index 2dc9c11..5361444 100644 --- a/src/server/offerfiles.rs +++ b/src/server/offerfiles.rs @@ -112,8 +112,13 @@ pub fn parse_offerfiles(payload: &[u8]) -> Result> { } } FT_FILESIZE => { + // A 64-bit tag carries the whole size; eMule sends a + // server two 32-bit ones, other clients may not. if let Some(v) = tag.as_u32() { size_lo = v; + } else if let Some(v) = tag.as_u64() { + size_lo = v as u32; + size_hi = (v >> 32) as u32; } } FT_FILESIZE_HI => { diff --git a/src/server/review_audit.rs b/src/server/review_audit.rs new file mode 100644 index 0000000..fc98ce9 --- /dev/null +++ b/src/server/review_audit.rs @@ -0,0 +1,357 @@ +//! Signals for the filter audit (`/api/review?format=audit`). +//! +//! The audit looks for FALSE POSITIVES: a term that fires on song titles, a +//! book, a cartoon episode. Those are found by reading the names the filter +//! blocked — but most blocked names are blocked rightly, and those are not +//! text anyone should have to read to find the few mistakes. So every blocked +//! name is first reduced to a handful of signals, and a name is shown only +//! when nothing in it but the rule that fired points at abuse: +//! +//! * `explicit` — ordinary adult sexual vocabulary; +//! * `age` — a child's age written as a number with a unit ("9 yo"); +//! * `minor` — a generic word for a child, other than the rule itself; +//! * `marker2` — the name still trips a term layer with the rule's own term +//! taken out: a second marker from the operator's lists. This is how the +//! lists themselves join the test without being repeated here. +//! +//! A rule is judged as a whole too: when a quarter of its hits carry a signal +//! that points at a child (age, child word, second marker) it is a working +//! marker, and none of its names are shown. Explicit words alone do not make +//! a marker — a term that fires on legal adult titles is a false positive +//! too — but a name with them is never shown; its hash is listed for review. +//! What remains visible is the case the audit exists for — a term alone in +//! an otherwise ordinary name. +//! +//! The vocabulary here is deliberately generic and errs towards hiding: a +//! name withheld by mistake costs a false positive staying in the list one +//! more round; a name shown by mistake costs much more. + +use crate::filter::{ContentFilter, FilterResult, Layer}; + +/// What a file is, by extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Kind { + Audio, + Video, + Image, + Doc, + Archive, + Software, + Other, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Kind::Audio => "audio", + Kind::Video => "video", + Kind::Image => "image", + Kind::Doc => "doc", + Kind::Archive => "archive", + Kind::Software => "software", + Kind::Other => "other", + } + } +} + +pub fn kind_of(name: &str) -> Kind { + let ext = name + .rsplit_once('.') + .map(|(_, e)| e.to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "mp3" | "flac" | "ogg" | "wma" | "m4a" | "ape" | "wav" | "aac" | "opus" | "mpc" | "wv" + | "mka" | "ac3" | "dts" | "mid" | "midi" | "cue" => Kind::Audio, + "avi" | "mkv" | "mp4" | "wmv" | "mpg" | "mpeg" | "mov" | "flv" | "m4v" | "ts" | "m2ts" + | "vob" | "rmvb" | "rm" | "3gp" | "webm" | "divx" | "ogm" | "asf" | "srt" | "sub" => { + Kind::Video + } + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tif" | "tiff" => Kind::Image, + "pdf" | "epub" | "mobi" | "djvu" | "djv" | "doc" | "docx" | "txt" | "rtf" | "chm" + | "azw" | "azw3" | "fb2" | "lit" | "cbr" | "cbz" | "ppt" | "pptx" | "xls" | "xlsx" + | "odt" | "htm" | "html" | "ps" => Kind::Doc, + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "tgz" => Kind::Archive, + "exe" | "msi" | "apk" | "dmg" | "iso" | "bin" | "img" | "nrg" | "mdf" | "cab" | "jar" + | "deb" | "rpm" => Kind::Software, + _ => Kind::Other, + } +} + +/// Whole words. +const EXPLICIT_WORDS: &[&str] = &[ + "sex", "sexo", "sesso", "porn", "porno", "xxx", "nude", "nudes", "naked", "nackt", "nue", + "nuda", "nudo", "cum", "cumshot", "anal", "oral", "blowjob", "bj", "handjob", "pussy", + "cock", "dick", "tits", "boobs", "dildo", "creampie", "gangbang", "incest", "hentai", "milf", + "hardcore", "softcore", "deepthroat", "threesome", "bdsm", "fetish", "upskirt", "voyeur", + "topless", "stripper", "striptease", "lesbian", "lesbians", "orgy", "slut", "whore", + "minet", "голая", "голые", "минет", "polla", "coño", "salope", "chatte", "puta", +]; +/// Word prefixes. +const EXPLICIT_PREFIXES: &[&str] = &[ + "fuck", "fick", "masturb", "orgasm", "sperm", "rape", "erotic", "erotik", "erotis", "porn", + "порн", "секс", "трах", "ебл", "ебет", "сиськ", "desnud", "scopat", "baise", "follan", +]; +/// Substrings, for scripts written without spaces. +const EXPLICIT_CJK: &[&str] = &["無修正", "エロ", "性交", "中出し", "色情", "做爱", "做愛", "淫"]; + +/// Generic words for a child. Whole words. Not "baby" or "kind": both are in +/// too many song titles, and the other signals cover their adult use. +const MINOR_WORDS: &[&str] = &[ + "child", "children", "childs", "kid", "kids", "teen", "teens", "teenie", "teenies", "preteen", + "preteens", "underage", "toddler", "toddlers", "infant", "infants", + "schoolgirl", "schoolgirls", "schoolboy", "schoolboys", "kinder", "enfant", + "enfants", "fillette", "niña", "niñas", "niño", "niños", "menina", "meninas", "menino", + "bambina", "bambine", "bambino", "bambini", "дети", "детей", "ребенок", "ребёнок", +]; +const MINOR_PREFIXES: &[&str] = &["девочк", "мальчик", "детск", "школьниц", "малолет", "ragazzin"]; +const MINOR_CJK: &[&str] = &["少女", "幼女", "幼児", "児童", "儿童", "小学", "中学生", "女児", "男児"]; + +/// Units a child's age is written with, after the number. +const AGE_UNITS: &[&str] = &[ + "yo", "y", "yrs", "yr", "years", "year", "anos", "años", "anni", "jahre", "ans", "лет", + "года", "год", "歳", "岁", "才", +]; + +fn words(lower: &str) -> impl Iterator { + lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) +} + +pub fn has_explicit(lower: &str) -> bool { + words(lower).any(|w| { + EXPLICIT_WORDS.contains(&w) || EXPLICIT_PREFIXES.iter().any(|p| w.starts_with(p)) + }) || EXPLICIT_CJK.iter().any(|s| lower.contains(s)) +} + +pub fn has_minor_word(lower: &str) -> bool { + words(lower) + .any(|w| MINOR_WORDS.contains(&w) || MINOR_PREFIXES.iter().any(|p| w.starts_with(p))) + || MINOR_CJK.iter().any(|s| lower.contains(s)) +} + +/// A number from 1 to 17 standing alone and followed by an age unit ("9yo", +/// "9 y.o.", "12 anos", "13歳"), or after "age"/"aged". +pub fn has_age(lower: &str) -> bool { + let chars: Vec = lower.chars().collect(); + let mut i = 0; + while i < chars.len() { + if !chars[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + // Part of a word or a longer number ("x264", "s01e12", "1080"). + if start > 0 && chars[start - 1].is_alphanumeric() { + continue; + } + let digits: String = chars[start..i].iter().collect(); + let Ok(n) = digits.parse::() else { continue }; + if !(1..=17).contains(&n) || digits.len() > 2 { + continue; + } + // "age 9" / "aged 9" + let before: String = chars[..start].iter().collect(); + let before = before.trim_end_matches(|c: char| !c.is_alphanumeric()); + if before.ends_with("age") || before.ends_with("aged") { + return true; + } + // Up to two separators, then the unit. + let mut j = i; + let mut seps = 0; + while j < chars.len() && seps < 2 && matches!(chars[j], ' ' | '_' | '-' | '.') { + j += 1; + seps += 1; + } + let rest: String = chars[j..].iter().take(8).collect(); + let rest_letters: String = rest.chars().filter(|c| *c != '.').collect(); + for u in AGE_UNITS { + if let Some(after) = rest_letters.strip_prefix(u) { + let ends = after.chars().next().map_or(true, |c| !c.is_alphabetic()); + // CJK units need no boundary. + if ends || !u.is_ascii() { + return true; + } + } + } + } + false +} + +/// The signals of one blocked name. +#[derive(Debug, Clone, Copy, Default)] +pub struct Signals { + pub explicit: bool, + pub age: bool, + pub minor: bool, + pub marker2: bool, +} + +impl Signals { + /// Any signal: the name is not shown. + pub fn dirty(&self) -> bool { + self.explicit || self.age || self.minor || self.marker2 + } + + /// A signal that points at a child, not just at adult content: what + /// makes a rule a working marker. + pub fn strong(&self) -> bool { + self.age || self.minor || self.marker2 + } +} + +/// The term a rule's reason names, without the decorations the filter adds. +fn reason_term(reason: &str) -> &str { + reason.trim_end_matches(" (mojibake)").trim() +} + +pub fn signals(filter: &ContentFilter, name: &str, layer: Layer, reason: &str) -> Signals { + let lower = name.to_lowercase(); + // The rule's own term does not count against the name: it is what is + // being judged. + let stripped = match layer { + Layer::L1Jargon | Layer::L4Extra => { + let t = reason_term(reason).to_lowercase(); + if t.is_empty() { + lower.clone() + } else { + lower.replace(&t, " ") + } + } + _ => lower.clone(), + }; + let age = match layer { + // The age IS the rule; it is not a second signal. + Layer::L2AgePattern => false, + _ => has_age(&stripped), + }; + // A second marker: the name still trips a term layer with the rule's own + // term removed. An all-zero hash is on no list. + let marker2 = match filter.check_ignoring_whitelist(&[0u8; 16], &stripped) { + FilterResult::Block(l, _) => match layer { + Layer::L2AgePattern => matches!(l, Layer::L1Jargon | Layer::L4Extra), + _ => true, + }, + FilterResult::Allow => false, + }; + Signals { + explicit: has_explicit(&stripped), + age, + minor: has_minor_word(&stripped), + marker2, + } +} + +/// How a rule behaves over all its hits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + /// Few or no hits point at a child: the term fires on ordinary names. + LikelyFalsePositive, + /// A quarter or more of its hits point at a child: a working marker. + /// None of its names are shown. + Marker, + Mixed, +} + +impl Verdict { + pub fn as_str(self) -> &'static str { + match self { + Verdict::LikelyFalsePositive => "likely false positive", + Verdict::Marker => "marker (names withheld)", + Verdict::Mixed => "mixed", + } + } +} + +/// `strong`: hits whose name points at a child (`Signals::strong`). +pub fn verdict(hits: usize, strong: usize) -> Verdict { + if hits == 0 { + return Verdict::Mixed; + } + let share = strong as f64 / hits as f64; + if share >= 0.25 { + Verdict::Marker + } else if hits >= 3 && share < 0.10 { + Verdict::LikelyFalsePositive + } else { + Verdict::Mixed + } +} + +/// May the audit show this name? Only a clean name, in a rule that is not a +/// working marker; for the age rule, only a file that cannot be a picture +/// or a film. +pub fn name_visible(sig: &Signals, verdict: Verdict, layer: Layer, kind: Kind) -> bool { + if sig.dirty() || verdict == Verdict::Marker { + return false; + } + match layer { + Layer::L3HashBlock => false, + Layer::L2AgePattern => matches!(kind, Kind::Audio | Kind::Doc | Kind::Software), + _ => true, + } +} + +/// A name from outside the blocked set (an alias of an indexed file, a +/// decoy's other names): shown only when it carries no signal at all. +pub fn plain_name_visible(filter: &ContentFilter, name: &str) -> bool { + let lower = name.to_lowercase(); + !has_explicit(&lower) + && !has_age(&lower) + && !has_minor_word(&lower) + && !filter.check_ignoring_whitelist(&[0u8; 16], name).is_blocked() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ages_are_found_where_they_are_written_as_ages() { + for s in ["9yo", "9 yo", "a 12 y.o. b", "13_years", "age 11", "aged 8", "12 anos", "13歳"] { + assert!(has_age(s), "{s}"); + } + for s in ["x264", "s01e12", "1080p", "track 12", "21 years", "2 kids", "12 monkeys", "5.1"] { + assert!(!has_age(s), "{s}"); + } + } + + #[test] + fn kinds_and_words() { + assert_eq!(kind_of("Band - Song.MP3"), Kind::Audio); + assert_eq!(kind_of("a.b.mkv"), Kind::Video); + assert_eq!(kind_of("Book.epub"), Kind::Doc); + assert_eq!(kind_of("noext"), Kind::Other); + assert!(has_explicit("some porno title")); + assert!(!has_explicit("elephant walk live")); + assert!(has_minor_word("for kids")); + assert!(!has_minor_word("kidney stones")); + } + + #[test] + fn verdicts() { + assert_eq!(verdict(20, 0), Verdict::LikelyFalsePositive); + assert_eq!(verdict(20, 5), Verdict::Marker); + assert_eq!(verdict(20, 3), Verdict::Mixed); + assert_eq!(verdict(2, 0), Verdict::Mixed); + assert_eq!(verdict(2, 1), Verdict::Marker); + } + + #[test] + fn a_term_alone_in_an_ordinary_name_is_shown_and_nothing_else_is() { + let f = ContentFilter::new().with_extra_terms(vec!["elephant".to_string()]); + let clean = signals(&f, "Band - Elephant (live).mp3", Layer::L4Extra, "elephant"); + assert!(!clean.dirty()); + assert!(name_visible(&clean, Verdict::LikelyFalsePositive, Layer::L4Extra, Kind::Audio)); + let dirty = signals(&f, "elephant porno.avi", Layer::L4Extra, "elephant"); + assert!(dirty.explicit && dirty.dirty() && !dirty.strong()); + assert!(!name_visible(&dirty, Verdict::Mixed, Layer::L4Extra, Kind::Video)); + // A clean name in a working marker's group stays hidden. + assert!(!name_visible(&clean, Verdict::Marker, Layer::L4Extra, Kind::Audio)); + // A second listed term is a second marker. + let f2 = f.with_extra_terms(vec!["walrus".to_string()]); + let two = signals(&f2, "elephant walrus.mp4", Layer::L4Extra, "elephant"); + assert!(two.marker2); + } +} diff --git a/src/server/udp.rs b/src/server/udp.rs index 27524bd..0d1661a 100644 --- a/src/server/udp.rs +++ b/src/server/udp.rs @@ -33,6 +33,8 @@ use tracing::{debug, info, warn}; /// Maximum sources returned in a single UDP GLOBFOUNDSOURCES response. const UDP_MAX_SOURCES: usize = 30; +/// Sources looked at to fill a UDP answer, HighIDs first (issue #49). +const UDP_SOURCE_SCAN: usize = 200; /// Maximum search results in a single UDP GLOBSEARCHRES response. const UDP_MAX_SEARCH_RESULTS: usize = 10; @@ -574,7 +576,7 @@ impl UdpServer { // their packets kept evicting each other's entry, sending // every one of them down the cold path. let sender_sock = std::net::SocketAddrV4::new(sender_v4, peer.port()); - let last = self.state.obf_decode_cache.get(&sender_sock).map(|e| *e); + let last = self.state.obf_decode_cache.get(&sender_sock).map(|e| (e.0, e.1)); // First: try the cached (key, formula) if any — covers 99% of legit packets. let mut decoded_opt = last.and_then(|(k, f)| { @@ -585,6 +587,11 @@ impl UdpServer { }; try_decode(data, k, formula) }); + if decoded_opt.is_some() { + self.state + .obf_decode_hits + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } // Cold path: cache miss or stale entry — enumerate combinations. if decoded_opt.is_none() { @@ -596,6 +603,22 @@ impl UdpServer { { continue; } + let ip_obf_key = ip_obfuscate(&self.seckey, sender_ip_le); + // Nearly every cold sender is a client, and a client + // writes with this server's key for its IP in the 0x6B + // direction: one attempt, before the scans of the peer + // server tables below (which only servers need). + if last != Some((ip_obf_key, 2)) { + if let Some(inner) = try_decode(data, ip_obf_key, Formula::Obf6B) { + decoded_opt = Some(inner); + self.state.obf_decode_cache.insert( + sender_sock, + (ip_obf_key, 2, self.state.file_slab.now_secs()), + ); + } + } + } + if decoded_opt.is_none() { let ip_obf_key = ip_obfuscate(&self.seckey, sender_ip_le); let mut keys_to_try: Vec = self .state @@ -607,7 +630,14 @@ impl UdpServer { self.state.seed_keys_for_ip(sender_v4).into_iter().map(|(_, k)| k), ); keys_to_try.push(ip_obf_key); - let formulas = [Formula::Plain, Formula::ObfA5, Formula::Obf6B]; + // A sender that is no server we know is a client, and a + // client writes with 0x6B: try that first. The order used + // to put it last, the ninth attempt for every client. + let formulas = if keys_to_try.len() == 1 { + [Formula::Obf6B, Formula::ObfA5, Formula::Plain] + } else { + [Formula::Plain, Formula::ObfA5, Formula::Obf6B] + }; 'outer: for &k in &keys_to_try { for &f in &formulas { // Skip the combination we already tried as the cached one. @@ -629,13 +659,21 @@ impl UdpServer { Formula::ObfA5 => 1u8, Formula::Obf6B => 2u8, }; - self.state.obf_decode_cache.insert(sender_sock, (k, fnum)); + self.state.obf_decode_cache.insert( + sender_sock, + (k, fnum, self.state.file_slab.now_secs()), + ); break 'outer; } } } } + if decoded_opt.is_some() && self.state.obf_decode_cache.get(&sender_sock).is_some_and(|e| Some((e.0, e.1)) != last) { + self.state + .obf_decode_cold + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } match decoded_opt { Some(inner) => { debug!(ip = %peer.ip(), "decoded obfuscated server-to-server UDP"); @@ -644,7 +682,7 @@ impl UdpServer { // obfuscated channel cannot read a plaintext answer, and the // decode cache we just populated holds exactly the (key, // formula) pair needed to encode it back. - obf_ctx = self.state.obf_decode_cache.get(&sender_sock).map(|e| *e); + obf_ctx = self.state.obf_decode_cache.get(&sender_sock).map(|e| (e.0, e.1)); decoded_buf = inner; opcode = decoded_buf[1]; payload = &decoded_buf[2..]; @@ -679,7 +717,18 @@ impl UdpServer { // branch), arriving on our obfpingport (TCP+12). // `on_obfping_port` was computed by the pre-flight above, // which is also what lets a 4..=9 byte frame reach here. - if on_obfping_port && is_crypt_ping(data) { + let crypt_ping = on_obfping_port && is_crypt_ping(data); + if crypt_ping { + self.state + .obf_crypt_pings + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } else { + self.state + .obf_decode_failed + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.note_undecodable(peer, data.len(), local_port); + } + if crypt_ping { // Extract peer's random_part (first 4 bytes, LE). let random_part = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); @@ -746,9 +795,14 @@ impl UdpServer { /// its target, by TCP 0x37 or UDP 0xA7. The requester is never answered, /// as on Lugdunum; refusals are only counted. async fn handle_nat_callback(&self, payload: &[u8], peer: SocketAddr, obf: Option<(u32, u8)>) { - use crate::server::lugdunum_natt::{handle_request, Forward}; + use crate::server::lugdunum_natt::{handle, Forward}; let local_port = self.socket.local_addr().map_or(0, |a| a.port()); - let key = match handle_request(&self.state, payload, peer, obf, local_port) { + let (result, answer) = match handle(&self.state, payload, peer, obf, local_port) { + Ok((f, a)) => (Ok(f), a), + Err(r) => (Err(r), None), + }; + let foreign = payload.len() >= 8 && payload[4..8] == [0; 4]; + let key = match result { Ok(Forward::Tcp { target, frame }) => { if let Some(t) = self.state.clients.get(&target) { let outcome = t.send_frame(frame); @@ -777,6 +831,68 @@ impl UdpServer { } }; *self.state.block_stats.entry(key.to_string()).or_insert(0) += 1; + if payload.len() >= 8 { + let tid = u32::from_le_bytes(payload[0..4].try_into().unwrap()); + let rid = u32::from_le_bytes(payload[4..8].try_into().unwrap()); + let req = if foreign { None } else { self.state.client_by_assigned_id(rid) }; + let tgt = self.state.client_by_assigned_id(tid); + crate::server::holepunch::note_event_from( + &self.state, + if foreign { "0xA6 foreign" } else { "0xA6" }, + req.as_ref(), + tgt.as_ref(), + tid, + key, + foreign.then_some(peer), + ); + } + if foreign && key.starts_with("natcb_forwarded") { + *self.state.block_stats.entry("natcb_forwarded_foreign".to_string()).or_insert(0) += 1; + } + // Cross-server (issue #40): the requester's answer once the target + // sent its 0x9F, or after the wait. From the port it wrote to. + if let Some(a) = answer { + let state = Arc::clone(&self.state); + let sock = self + .state + .udp_sockets + .get(&a.local_port) + .map(|s| Arc::clone(&s)) + .unwrap_or_else(|| Arc::clone(&self.socket)); + tokio::spawn(async move { + let crate::server::lugdunum_natt::ForeignAnswer { + rx, + target, + to, + obf, + requester_crypt, + guards, + .. + } = a; + let _guards = guards; + let wait = crate::server::holepunch::EXT_WAIT; + let fresh = matches!(tokio::time::timeout(wait, rx).await, Ok(Ok(()))); + let key = match crate::server::lugdunum_natt::answer_packet(&state, &target, requester_crypt) { + Some(packet) => { + // The requester's only datagram: resent on 0x61's + // schedule, +1.2 s and +3 s (issue #40). The + // requester ignores copies after the first. + let _ = send_reply(&sock, &packet, to, Some(obf)).await; + for wait_ms in [1200u64, 1800] { + tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await; + let _ = send_reply(&sock, &packet, to, Some(obf)).await; + } + if fresh { + "foreign_natcb_answered_fresh" + } else { + "foreign_natcb_answered_after_wait" + } + } + None => "foreign_natcb_no_answer", + }; + *state.block_stats.entry(key.to_string()).or_insert(0) += 1; + }); + } } /// `obf`: how the request was obfuscated, or None if it arrived in the clear. @@ -886,6 +1002,8 @@ impl UdpServer { if payload.len() >= 16 { let mut uh = [0u8; 16]; uh.copy_from_slice(&payload[..16]); + // NAT-T analysis: what became of each 0x9F. + let mut kind = "natt_9f_unknown_hash"; let matched = if let Some(mut h) = self.state.clients.get_mut(&uh) { // The port is believed only from the session's own // address: a user hash is no secret, and a keepalive @@ -910,6 +1028,15 @@ impl UdpServer { .entry("natt_keepalive_plain_ignored".to_string()) .or_insert(0) += 1; } + kind = if !same_ip { + "natt_9f_wrong_ip" + } else if plain_refused { + "natt_9f_plain_ignored" + } else if obf.is_some() { + "natt_9f_obfuscated" + } else { + "natt_9f_plain" + }; if same_ip && !plain_refused { // Issue #37: kept per client. The login port // (`udp_port`, 0xF9) stays as announced; it is @@ -936,6 +1063,29 @@ impl UdpServer { }; // An extension coordination waiting for this client's // fresh port can go ahead (issue #39). + crate::server::holepunch::count(&self.state, kind); + if kind == "natt_9f_wrong_ip" { + // Who sends keepalives from another address than its + // session's: a CGNAT pool, a second uplink, IPv6 vs + // IPv4. Recorded once a minute per client at most. + let first = self + .state + .wrong_ip_9f_seen + .insert(uh, std::time::Instant::now()) + .map_or(true, |t| t.elapsed() > std::time::Duration::from_secs(60)); + if first { + let c = self.state.clients.get(&uh).map(|c| c.clone()); + crate::server::holepunch::note_event_from( + &self.state, + "0x9F wrong IP", + c.as_ref(), + None, + 0, + "natt_9f_wrong_ip", + Some(peer), + ); + } + } if matched { crate::server::holepunch::keepalive_arrived(&self.state, &uh); } @@ -1091,7 +1241,7 @@ impl UdpServer { // inbound packet must not become an unbounded outbound burst. const MAX_BATCH: usize = 64; - let mut hashes: Vec<[u8; 16]> = Vec::new(); + let mut hashes: Vec<([u8; 16], Option)> = Vec::new(); let mut i = 0usize; while i + 20 <= payload.len() && hashes.len() < MAX_BATCH { let mut hash = [0u8; 16]; @@ -1103,6 +1253,7 @@ impl UdpServer { payload[i + 19], ]); i += 20; + let mut size = u64::from(size_lo); if size_lo == 0 { // Large-file sentinel: a u64 follows. A truncated tail here means // the frame is malformed — stop rather than resynchronise at an @@ -1111,9 +1262,12 @@ impl UdpServer { debug!(ip = %peer.ip(), "0x94: truncated large-file record — stopping"); break; } + let mut b = [0u8; 8]; + b.copy_from_slice(&payload[i..i + 8]); + size = u64::from_le_bytes(b); i += 8; } - hashes.push(hash); + hashes.push((hash, Some(size))); } // A single bare hash with no size field is still accepted: some clients @@ -1121,10 +1275,15 @@ impl UdpServer { if hashes.is_empty() && payload.len() >= 16 { let mut hash = [0u8; 16]; hash.copy_from_slice(&payload[..16]); - hashes.push(hash); + hashes.push((hash, None)); } - for hash in hashes { + for (hash, size) in hashes { + // A sized record for a hash stored with another size: its sources + // belong to a different file (issue #45). Not answered. + if self.state.size_mismatch(&hash, size, true, peer.ip()) { + continue; + } self.send_found_sources(&hash, peer, obf).await?; } Ok(()) @@ -1163,11 +1322,28 @@ impl UdpServer { .with_record_by_hash(hash, |_id, e| { e.sources .iter() - .take(UDP_MAX_SOURCES) + .take(UDP_SOURCE_SCAN) .map(|s| (s.user_hash, s.ip(), s.port())) .collect() }) .unwrap_or_default(); + // HighID (and departed) sources first, then LowID, each in publish + // order, before the cap (issue #49): the asker is on another server + // and can reach a LowID only by a cross-server NAT callback, while + // the first 30 in publish order were mostly LowID. + let sources: Vec<_> = { + let (mut direct, mut low) = (Vec::new(), Vec::new()); + for s in sources { + if self.state.clients.get(&s.0).is_some_and(|h| !h.is_high_id) { + low.push(s); + } else { + direct.push(s); + } + } + direct.extend(low); + direct.truncate(UDP_MAX_SOURCES); + direct + }; // Build GLOBFOUNDSOURCES: E3 0x9B hash(16) count(1) sources… // @@ -1311,6 +1487,11 @@ impl UdpServer { // Check for server-to-server magic: challenge bytes [2:3] == 0x55AA // (payload[2] == 0xAA, payload[3] == 0x55 in LE) let is_server_probe = payload.len() >= 4 && payload[2] == 0xAA && payload[3] == 0x55; + // Cross-server NAT callback (issue #40), to clients and servers + // alike: Lugdunum 17.15 ignores bits it doesn't know. + if crate::server::lugdunum_natt::foreign_enabled(&live) { + pingflg |= crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT; + } let mut out = BytesMut::new(); out.put_u8(PROTO_EDONKEY); @@ -1515,6 +1696,51 @@ impl UdpServer { /// plain server probe: 8 stat fields + portUDPobf + portTCPobf + /// ServerKey + our_ip. That gives the peer everything it needs to mark /// us as a verified obfuscation-capable server. + /// An obfuscated datagram no key decodes, from an address that has a + /// session here. For a NAT-T client that is the question in #40: a + /// keepalive obfuscated with a server key fetched for another address + /// (the client's IP changed since its last status ping) is unreadable, + /// and looks exactly like one never sent. Counted each time; who it was + /// goes to /api/natt "recent", once a minute per address at most. + fn note_undecodable(&self, peer: SocketAddr, len: usize, local_port: u16) { + let ip = canon_ip(peer.ip()); + if !self.state.conn_per_ip.contains_key(&ip) { + return; + } + crate::server::holepunch::count(&self.state, "natt_udp_undecodable_from_session"); + let first = self + .state + .undecodable_from_session_seen + .insert(ip, std::time::Instant::now()) + .map_or(true, |t| t.elapsed() > std::time::Duration::from_secs(60)); + if !first { + return; + } + // Rare by construction (once a minute per such address), so a walk + // of the client map is affordable here. + let natt = self + .state + .clients + .iter() + .find(|c| { + c.ip == ip && c.server_flags & crate::server::lugdunum_natt::SRVCAP_NATTRAVERSAL != 0 + }) + .map(|c| c.clone()); + if let Some(c) = natt { + crate::server::holepunch::count(&self.state, "natt_undecodable_from_natt_client"); + let outcome = format!("undecodable, {len} bytes to port {local_port}"); + crate::server::holepunch::note_event_from( + &self.state, + "UDP undecodable", + Some(&c), + None, + 0, + &outcome, + Some(peer), + ); + } + } + async fn reply_to_obf_ping(&self, peer: SocketAddr, random_part: u32) -> Result<()> { // Build the inner eD2k 0x97 GLOBSERVSTATRES message (same fields as // handle_servstat's extended form). @@ -1534,6 +1760,9 @@ impl UdpServer { if live.network.ipv6_publish_sources { pingflg |= crate::server::login::SRV_TCPFLG_IPV6; } + if crate::server::lugdunum_natt::foreign_enabled(&live) { + pingflg |= crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT; + } // Use the peer's random_part as the implicit challenge — Lugdunum // doesn't carry a separate challenge in the OBF ping wire format. @@ -2409,7 +2638,7 @@ fn is_crypt_ping(data: &[u8]) -> bool { /// Send a UDP reply, obfuscated the way the request was. A free function so /// tasks spawned off the receive loop can answer too. -async fn send_reply( +pub(crate) async fn send_reply( socket: &UdpSocket, bytes: &[u8], peer: SocketAddr, diff --git a/src/state/file_id.rs b/src/state/file_id.rs index 1fde742..5af53a9 100644 --- a/src/state/file_id.rs +++ b/src/state/file_id.rs @@ -430,6 +430,30 @@ impl SlabShard { /// map. This is the Lugdunum-style intrusive index that replaced the former /// `DashMap` (Stage 4 "lever C": removes the duplicate hash /// and the DashMap overhead, and the old cross-structure lock ordering). +/// Do two sizes for one hash describe the same file? 0 is unknown and agrees +/// with anything; so does a size cut to 32 bits against the full one above +/// 4 GiB (a client without large-file support publishes or asks with the +/// low 32 bits only), which is the same file and the same sources. +pub fn sizes_agree(a: u64, b: u64) -> bool { + if a == 0 || b == 0 || a == b { + return true; + } + let (lo, hi) = if a < b { (a, b) } else { (b, a) }; + lo <= u64::from(u32::MAX) && hi > u64::from(u32::MAX) && hi & u64::from(u32::MAX) == lo +} + +/// What `add_or_refresh_source_sized` did. +#[derive(Debug, PartialEq, Eq)] +pub enum SizedMerge { + /// Added or refreshed; the stored name when the publisher used another. + Merged(Option>), + /// The record holds another size (given) and still has sources: not + /// merged. + SizeMismatch(u64), + /// No live record for the hash. + Unknown, +} + pub struct FileSlab { shards: Vec>, /// Live file count, maintained by get_or_insert/insert_sourceless/tombstone @@ -620,6 +644,47 @@ impl FileSlab { name } + /// Tombstone `id` whatever its sources, returning its name for the keyword + /// removal that must follow. For a file the content filter now refuses + /// (`/api/indexcheck?purge=1`). + pub fn tombstone_take_name(&self, id: FileId) -> Option { + self.tombstone_where(id, |_| true) + } + + /// Visit every live record as owned copies, one `SCAN_CHUNK` at a time: + /// the copies are taken under the shard lock and `f` runs after it is + /// released, so slow work on each file (a content-filter check) never + /// holds writers off. `(id, hash, size, name, sources)`. + pub fn for_each_live_copied)>(&self, mut f: F) { + const SCAN_CHUNK: usize = 4 * 1024; + for (s_no, shard) in self.shards.iter().enumerate() { + let mut start = 0usize; + loop { + let sh = shard.read().unwrap(); + let end = sh.records.len().min(start + SCAN_CHUNK); + if start >= end { + break; + } + let mut batch = Vec::with_capacity(end - start); + for i in start..end { + let r = &sh.records[i]; + if r.alive() { + batch.push(( + make_id(s_no as u32, i as u32), + r.hash, + r.size, + r.name().to_string(), + r.sources.len() as u32, + )); + } + } + drop(sh); + f(batch); + start = end; + } + } + } + /// Tombstone by hash (convenience for callers that hold a hash, not an id). pub fn tombstone_by_hash(&self, hash: &FileHash) -> bool { if let Some(id) = self.id_of(hash) { @@ -759,6 +824,48 @@ impl FileSlab { } } + /// `add_or_refresh_source_named` for a publisher that also gave a size + /// (issue #45). A record is one (hash, size); a publisher whose size + /// differs is not merged into it, or a sized GETSOURCES for one file + /// would get the sources of another. The first size stays, unless the + /// record has no source left: then the new publisher's size replaces it, + /// so a wrong size cannot hold a hash once its publisher has gone. 0 on + /// either side means unknown and matches anything. + pub fn add_or_refresh_source_sized( + &self, + hash: &FileHash, + size: u64, + src: Source, + published_as: &str, + ) -> SizedMerge { + let Some(id) = self.id_of(hash) else { return SizedMerge::Unknown }; + let Some(shard) = self.shards.get(id_shard(id)) else { return SizedMerge::Unknown }; + let mut sh = shard.write().unwrap(); + let Some(r) = sh.records.get_mut(id_index(id)) else { return SizedMerge::Unknown }; + if !r.alive() { + return SizedMerge::Unknown; + } + if !sizes_agree(r.size, size) { + if !r.sources.is_empty() { + return SizedMerge::SizeMismatch(r.size); + } + r.size = size; + } else if size > r.size { + // The full size of a file stored cut to 32 bits (or unknown). + r.size = size; + } + if let Some(existing) = r.sources.iter_mut().find(|s| s.user_hash == src.user_hash) { + existing.set_complete(src.complete()); + } else { + r.sources.push(src); + } + if r.name() == published_as { + SizedMerge::Merged(None) + } else { + SizedMerge::Merged(Some(Arc::from(r.name()))) + } + } + pub fn add_or_refresh_source(&self, hash: &FileHash, src: Source) -> bool { let id = match self.id_of(hash) { Some(i) => i, @@ -1377,4 +1484,46 @@ mod tests { assert!(slab.id_of(&h_of(i)).is_some()); } } + + #[test] + fn a_publisher_with_another_size_is_not_merged() { + let slab = FileSlab::new(); + let h = [0x45u8; 16]; + let a = Source::new([1; 16], "1.2.3.4".parse().unwrap(), 4662, true); + let b = Source::new([2; 16], "1.2.3.5".parse().unwrap(), 4662, true); + let c = Source::new([3; 16], "1.2.3.6".parse().unwrap(), 4662, true); + let (id, _) = slab.get_or_insert(h, 2_000_000_000, "f", a); + assert_eq!( + slab.add_or_refresh_source_sized(&h, 2_000_000_001, b, "f"), + SizedMerge::SizeMismatch(2_000_000_000) + ); + assert_eq!(slab.get(id).unwrap().sources.len(), 1); + assert_eq!(slab.add_or_refresh_source_sized(&h, 2_000_000_000, c, "f"), SizedMerge::Merged(None)); + assert_eq!(slab.add_or_refresh_source_sized(&h, 0, b, "f"), SizedMerge::Merged(None), "0 is unknown"); + // Once nobody holds the first size, the next publisher's size wins. + for u in [[1u8; 16], [2; 16], [3; 16]] { + slab.remove_user_source(id, &u); + } + let d = Source::new([4; 16], "1.2.3.7".parse().unwrap(), 4662, true); + assert_eq!(slab.add_or_refresh_source_sized(&h, 7, d, "f"), SizedMerge::Merged(None)); + assert_eq!(slab.get(id).unwrap().size, 7); + } + + #[test] + fn a_size_cut_to_32_bits_is_the_same_file() { + let big = 6_442_450_944u64 + 123; + assert!(sizes_agree(big, big & 0xFFFF_FFFF)); + assert!(sizes_agree(big & 0xFFFF_FFFF, big)); + assert!(sizes_agree(0, 5) && sizes_agree(5, 0)); + assert!(!sizes_agree(big, big + 1)); + assert!(!sizes_agree(100, 101)); + // Stored cut, then the full size arrives: the record takes it. + let slab = FileSlab::new(); + let h = [0x46u8; 16]; + let a = Source::new([1; 16], "1.2.3.4".parse().unwrap(), 4662, true); + let b = Source::new([2; 16], "1.2.3.5".parse().unwrap(), 4662, true); + let (id, _) = slab.get_or_insert(h, big & 0xFFFF_FFFF, "f", a); + assert_eq!(slab.add_or_refresh_source_sized(&h, big, b, "f"), SizedMerge::Merged(None)); + assert_eq!(slab.get(id).unwrap().size, big); + } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 252deb0..feaaddd 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -533,6 +533,9 @@ impl Default for HighIdObserve { } } +/// Size disagreements kept for `/api/sizemismatch`. +pub const SIZE_MISMATCH_RECENT_MAX: usize = 1000; + pub struct ServerState { pub clients: DashMap, /// FileId slab (Stage 3): the single authoritative file store. Holds every @@ -570,6 +573,12 @@ pub struct ServerState { /// Keyed by user hash rather than by FileId for the same reason: one entry /// per client, not per published file. pub client_ipv6: DashMap, + /// The dial-back of each client's IPv6 (issue #43): the address it is + /// about and its verdict. Removed with the session. + pub ipv6_probe: DashMap, + /// This host has a route to the IPv6 internet; checked at start and in + /// the sweep. Without it the dial-back is off. + pub ipv6_route: std::sync::atomic::AtomicBool, pub filter: Arc, next_low_id: AtomicU32, pub total_sessions: AtomicU32, @@ -642,6 +651,44 @@ pub struct ServerState { /// Hole-punch extension coordinations waiting for this target's next /// 0x9F (`server::holepunch`). The 0x9F handler fires and removes them. pub natt_keepalive_waiters: DashMap>>, + /// Cross-server NAT callback (issue #40): each foreign requester's + /// allowance and the targets it named, by source IP. Swept when idle. + pub natcb_foreign_sources: DashMap, + /// Foreign wake-ups each target got in the last minute. + pub natcb_foreign_wakes: DashMap>, + /// When each user hash last had its live session probed because a login + /// with the same hash came from elsewhere (issue #41). Swept after a + /// minute. + pub takeover_probes: DashMap>, + /// Last 0x60 per (requester, target), to count repeats (NAT-T analysis). + /// Swept after ten minutes. + pub holepunch_recent: DashMap<(UserHash, u32), Instant>, + /// Contested logins refused (issue #41), by (user hash, contender source): + /// when the contender was last probed against the owner, and how many + /// times it was refused. Read by /api/takeover; swept after an hour idle. + pub takeover_lost: DashMap<(UserHash, IpAddr), (Instant, u32)>, + /// Last 0x9F from another address than the session's, per client, so the + /// detail log gets one entry a minute at most. Swept after an hour. + pub wrong_ip_9f_seen: DashMap, + /// Undecodable obfuscated UDP from an address with a logged-in session: + /// when each address was last looked into (at most once a minute). + pub undecodable_from_session_seen: DashMap, + /// The last NAT-T requests in detail (who asked, for whom, what came of + /// it), for /api/natt. Bounded at `NATT_RECENT_MAX`. + pub natt_recent: std::sync::Mutex>, + /// The last size disagreements (issue #45), for `/api/sizemismatch`. + pub size_mismatch_recent: std::sync::Mutex>, + /// Targets whose extension wait just timed out, to count a 0x9F that + /// arrives late. Swept after a minute. + pub ext_timed_out: DashMap, + /// Obfuscated-UDP decode: answered by the cached key, decoded on the cold + /// path (up to nine attempts), not decoded at all. + pub obf_decode_hits: std::sync::atomic::AtomicU64, + pub obf_decode_cold: std::sync::atomic::AtomicU64, + pub obf_decode_failed: std::sync::atomic::AtomicU64, + /// Crypt pings (eMule's obfuscated status ping to TCP+12): not obfuscated + /// eD2k, answered with an obfuscated 0x97. Counted apart from failures. + pub obf_crypt_pings: std::sync::atomic::AtomicU64, /// Map of local UDP port → bound UdpSocket. Lets any handler send from a /// specific port (e.g. send an obfuscated reply from our :4675 even when /// the request came in on our :4665). Lugdunum's FUN_0042c480 looks up @@ -737,7 +784,11 @@ pub struct ServerState { /// UDP datagram from a given sender. formula_id: 0=Plain, 1=ObfA5, 2=Obf6B. /// Massively reduces CPU on the hot path: a busy peer that sends many obf /// packets only triggers ONE decode attempt per packet instead of 9. - pub obf_decode_cache: DashMap, + /// The third field is when the entry was made (`file_slab.now_secs()`): + /// entries are aged out after `OBF_CACHE_TTL_SECS` rather than the whole + /// map being cleared at a fixed size, which at 17k clients emptied it + /// every minute and sent every obfuscated sender back to the cold path. + pub obf_decode_cache: DashMap, /// Hot-reloadable config snapshot. Updated by `POST /api/config`. Handlers /// that need live values (limits, server name/desc, version, this_ip, log) /// read `state.live_cfg.load()` instead of using a fixed Arc. @@ -1560,6 +1611,8 @@ impl ServerState { // Fixed for the life of the index — see LimitsConfig::index_subtokens. keyword_index: KeywordIndex::with_subtokens(cfg.limits.index_subtokens), client_ipv6: DashMap::new(), + ipv6_probe: DashMap::new(), + ipv6_route: std::sync::atomic::AtomicBool::new(false), smart_sources: SmartSourcesCache::new(), offer_lane: Arc::new(tokio::sync::Semaphore::new(OFFER_JOBS)), filter, @@ -1574,6 +1627,20 @@ impl ServerState { lugdunum_natt_seen: DashMap::new(), natt_keepalive_seen: DashMap::new(), natt_keepalive_waiters: DashMap::new(), + natcb_foreign_sources: DashMap::new(), + natcb_foreign_wakes: DashMap::new(), + takeover_probes: DashMap::new(), + holepunch_recent: DashMap::new(), + takeover_lost: DashMap::new(), + wrong_ip_9f_seen: DashMap::new(), + undecodable_from_session_seen: DashMap::new(), + natt_recent: Default::default(), + size_mismatch_recent: Default::default(), + ext_timed_out: DashMap::new(), + obf_decode_hits: Default::default(), + obf_decode_cold: Default::default(), + obf_decode_failed: Default::default(), + obf_crypt_pings: Default::default(), our_sent_random_parts: DashMap::new(), udp_sockets: DashMap::new(), ip_filter: tokio::sync::RwLock::new(crate::filter::ipfilter::IpFilter::default()), @@ -1845,6 +1912,81 @@ impl ServerState { } } + /// A sized source request for a file whose record holds another size + /// (issue #45). Answered with no sources: they belong to a different + /// file. 0 on either side is unknown. + /// Keep one size disagreement for `/api/sizemismatch` (issue #45): which + /// file, the stored and the other size, and who: the publisher, or the + /// requester of a sized source request. Hashes and addresses only, never + /// a name. The last `SIZE_MISMATCH_RECENT_MAX`. + #[allow(clippy::too_many_arguments)] + pub fn note_size_mismatch( + &self, + kind: &str, + hash: &FileHash, + stored: u64, + other: u64, + ip: IpAddr, + user_hash: Option<&UserHash>, + complete: Option, + ) { + let software = user_hash + .and_then(|h| self.clients.get(h).map(|c| c.software_detail.clone())); + let ev = serde_json::json!({ + "at": self.file_slab.now_secs(), + "kind": kind, + "hash": hex::encode(hash), + "stored_size": stored, + "other_size": other, + "ip": ip.to_string(), + "user_hash": user_hash.map(hex::encode), + "software": software, + "complete": complete, + }); + if let Ok(mut q) = self.size_mismatch_recent.lock() { + if q.len() >= SIZE_MISMATCH_RECENT_MAX { + q.pop_front(); + } + q.push_back(ev); + } + } + + pub fn size_mismatch( + &self, + hash: &FileHash, + size: Option, + udp: bool, + requester: IpAddr, + ) -> bool { + let Some(want) = size.filter(|s| *s != 0) else { return false }; + let stored = self + .file_slab + .with_record_by_hash(hash, |_id, r| r.size) + .unwrap_or(0); + let differs = !crate::state::file_id::sizes_agree(stored, want); + if differs { + // Which side is larger tells a wrong link (asked bigger or + // smaller than every publisher) from anything systematic. + let key = match (udp, want > stored) { + (false, true) => "getsources_size_mismatch_tcp_larger", + (false, false) => "getsources_size_mismatch_tcp_smaller", + (true, true) => "getsources_size_mismatch_udp_larger", + (true, false) => "getsources_size_mismatch_udp_smaller", + }; + *self.block_stats.entry(key.to_string()).or_insert(0) += 1; + self.note_size_mismatch( + if udp { "request_udp" } else { "request_tcp" }, + hash, + stored, + want, + requester, + None, + None, + ); + } + differs + } + pub fn add_file_with_source( &self, hash: FileHash, @@ -1862,13 +2004,37 @@ impl ServerState { let (file_id, newly_added) = self.file_slab.get_or_insert(hash, size, &name, src); if !newly_added { // Records the source and, in the same shard lock, tells us whether - // this publisher used a different name than the one already stored. - if let Some(stored) = self + // this publisher used a different name than the one already stored, + // or another size (issue #45: then it is not merged at all). + use crate::state::file_id::SizedMerge; + match self .file_slab - .add_or_refresh_source_named(&hash, src, &name) + .add_or_refresh_source_sized(&hash, size, src, &name) { - let published: Arc = Arc::from(name.as_str()); - self.note_alias(hash, size, &stored, &published); + SizedMerge::Merged(Some(stored)) => { + let published: Arc = Arc::from(name.as_str()); + self.note_alias(hash, size, &stored, &published); + } + SizedMerge::Merged(None) => {} + SizedMerge::SizeMismatch(stored) => { + *self + .block_stats + .entry("offer_size_mismatch".to_string()) + .or_insert(0) += 1; + self.note_size_mismatch( + "offer", + &hash, + stored, + size, + source.1, + Some(&publisher_hash), + Some(source.3), + ); + return; + } + // Evicted between the two calls: rare, and the next publish + // creates it again. + SizedMerge::Unknown => return, } } else { self.keyword_index.add_file(file_id, &name); @@ -2239,7 +2405,7 @@ impl ServerState { misc += dm_slots(self.server_list_added_at.capacity(), IPV4, INSTANT); misc += dm_slots(self.csam_unique_ips.capacity(), IPADDR, 8); misc += dm_slots(self.csam_blocked_hashes.capacity(), UHASH, 16); - misc += dm_slots(self.obf_decode_cache.capacity(), SOCKV4, 5); + misc += dm_slots(self.obf_decode_cache.capacity(), SOCKV4, 9); misc += dm_slots(self.banned_bots.capacity(), IPADDR, INSTANT); misc += dm_slots(self.banned_publishers.capacity(), UHASH, INSTANT); misc += dm_slots(self.bot_query_log.capacity(), IPADDR, size_of::()); @@ -2368,6 +2534,21 @@ impl ServerState { /// have already been tombstoned, so `id_of(hash)` would resolve to None and /// purge nothing — leaving dead ids in `user_files` forever (an RSS leak). /// FileId values stay valid after tombstone, so this works regardless. + /// Take files out of the index whatever their sources: the slab record, + /// its keywords and the reverse index. For files the content filter now + /// refuses (`/api/indexcheck?purge=1`). Returns how many were live. + pub fn purge_files(&self, ids: &[file_id::FileId]) -> usize { + let mut n = 0; + for id in ids { + if let Some(name) = self.file_slab.tombstone_take_name(*id) { + self.keyword_index.remove_file(*id, &name); + n += 1; + } + } + self.purge_ids_from_user_files(ids); + n + } + pub fn purge_ids_from_user_files(&self, ids: &[file_id::FileId]) { if ids.is_empty() { return; diff --git a/src/state/smart_sources.rs b/src/state/smart_sources.rs index bdd335c..abce5f9 100644 --- a/src/state/smart_sources.rs +++ b/src/state/smart_sources.rs @@ -27,12 +27,32 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; -/// How long a cached FOUNDSOURCES payload stays fresh. -const CACHE_TTL: Duration = Duration::from_secs(5); +/// How long a cached FOUNDSOURCES payload stays fresh by default. 120 s, +/// from the live ages of expired misses at 15 s: about half of all misses +/// came 30 s to 2 min after the entry was built (630k of 1.23M in three +/// hours), against 37k within 30 s. A source list two minutes old is still +/// good for clients that re-ask every ~20 minutes. +/// `limits.getsources_cache_secs` sets it live. +pub const DEFAULT_TTL_SECS: u64 = 120; -/// Soft cap on the number of cached file entries. Beyond this we sweep / -/// clear. At ~22 bytes-per-source * 200 sources that's a few MB worst case. -const MAX_ENTRIES: usize = 4096; +/// How long past its TTL an expired entry is remembered (its key and age +/// only, the payload is freed), so a late request lands in the expired-age +/// histogram instead of reading as a plain miss. +pub const STALE_KEEP_SECS: u64 = 1200; + +/// Cap on remembered expired entries: a key and a u32 each, about 25 bytes, +/// so 10 MB at most. Not counted against `MAX_ENTRIES`. +const STALE_MAX: usize = 400_000; + +/// Cap on cached file entries. It was 4096, and the map was CLEARED when +/// full of fresh entries: at several hundred GETSOURCES a second, 4096 +/// distinct hashes arrive within a few seconds, so the cache was wiped about +/// as fast as it filled and few entries lived long enough to be hit. Now +/// the cap is 65536 (a few MB to a few tens of MB of payloads) and, when it +/// is full of fresh entries, a new one is simply not stored. 200k leaves room +/// for a TTL of a few minutes (live: at 9k clients, 59k entries within the +/// first hours). +const MAX_ENTRIES: usize = 200_000; struct CacheEntry { /// Fully-encoded FOUNDSOURCES payload (everything after proto+opcode). @@ -48,11 +68,25 @@ struct CacheEntry { /// Thread-safe SmartSources cache, keyed by file hash. pub struct SmartSourcesCache { - map: Mutex>, + /// Keyed by file hash and reply kind: `false` the classic FOUNDSOURCES, + /// `true` FOUNDSOURCES_OBFU (crypt options and hashes per source). + map: Mutex>, /// Served-from-cache count (fresh entry found). hits: AtomicU64, /// Cache-miss count (no entry, or entry expired). Equivalent to a rebuild. misses: AtomicU64, + /// Of the misses: an entry was there but had expired. + expired: AtomicU64, + /// New entries not stored because the map was full of fresh ones. + skipped: AtomicU64, + /// Age of the entry at an expired miss: under 30 s, 30-60 s, 1-2 min, + /// 2-5 min, over 5 min. Tells how much a longer TTL would catch. + expired_age: [AtomicU64; 5], + ttl_secs: AtomicU64, + /// Expired entries swept from `map`: key → insert time, in seconds + /// since `base`. + stale: Mutex>, + base: Instant, } impl SmartSourcesCache { @@ -61,6 +95,16 @@ impl SmartSourcesCache { map: Mutex::new(HashMap::new()), hits: AtomicU64::new(0), misses: AtomicU64::new(0), + expired: AtomicU64::new(0), + skipped: AtomicU64::new(0), + expired_age: Default::default(), + ttl_secs: AtomicU64::new(DEFAULT_TTL_SECS), + stale: Mutex::new(HashMap::new()), + // An hour back, so an entry stamped just before (a test's) + // still has a time after it. + base: Instant::now() + .checked_sub(Duration::from_secs(3600)) + .unwrap_or_else(Instant::now), } } @@ -68,18 +112,18 @@ impl SmartSourcesCache { /// Returns the payload bytes if a fresh entry exists, else None. pub fn get(&self, hash: &[u8; 16]) -> Option> { let map = self.map.lock().unwrap(); - let entry = match map.get(hash) { + let entry = match map.get(&(*hash, false)) { Some(e) => e, None => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.note_miss(&(*hash, false)); return None; } }; - if entry.inserted.elapsed() < CACHE_TTL { + if entry.inserted.elapsed() < self.ttl() { self.hits.fetch_add(1, Ordering::Relaxed); Some(entry.payload.clone()) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + self.note_expired(entry.inserted.elapsed()); None } } @@ -88,22 +132,32 @@ impl SmartSourcesCache { /// the lock and returns what `f` builds — so a requester-specific answer is /// cut from the shared payload without cloning it first. pub fn get_with(&self, hash: &[u8; 16], f: impl FnOnce(&[u8], &[u64]) -> R) -> Option { + self.get_kind_with(hash, false, f) + } + + /// `get_with` for one reply kind (`obfu`: FOUNDSOURCES_OBFU). + pub fn get_kind_with( + &self, + hash: &[u8; 16], + obfu: bool, + f: impl FnOnce(&[u8], &[u64]) -> R, + ) -> Option { let map = self.map.lock().unwrap(); - let entry = match map.get(hash) { + let entry = match map.get(&(*hash, obfu)) { Some(e) => e, None => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.note_miss(&(*hash, obfu)); return None; } }; - if entry.inserted.elapsed() < CACHE_TTL { + if entry.inserted.elapsed() < self.ttl() { self.hits.fetch_add(1, Ordering::Relaxed); Some(f(&entry.payload, &entry.owners)) } else { // Expired — counts as a miss (the caller will rebuild). We leave it // for the next insert() to sweep; keeping the lock simple avoids // upgrade churn. - self.misses.fetch_add(1, Ordering::Relaxed); + self.note_expired(entry.inserted.elapsed()); None } } @@ -115,13 +169,15 @@ impl SmartSourcesCache { pub fn size_bytes(&self) -> u64 { let m = self.map.lock().unwrap(); let slots = (m.capacity() - * (std::mem::size_of::<[u8; 16]>() + std::mem::size_of::() + 1)) + * (std::mem::size_of::<([u8; 16], bool)>() + std::mem::size_of::() + 1)) as u64; let payloads: u64 = m .values() .map(|e| (e.payload.capacity() + e.owners.capacity() * 8) as u64) .sum(); - slots + payloads + let st = self.stale.lock().unwrap().capacity() + * (std::mem::size_of::<([u8; 16], bool)>() + std::mem::size_of::() + 1); + slots + payloads + st as u64 } /// Number of cached entries (diagnostics). @@ -129,6 +185,76 @@ impl SmartSourcesCache { self.map.lock().unwrap().len() } + fn ttl(&self) -> Duration { + Duration::from_secs(self.ttl_secs.load(Ordering::Relaxed).max(1)) + } + + /// Set the TTL (live config, `limits.getsources_cache_secs`). + pub fn set_ttl_secs(&self, secs: u64) { + let secs = secs.clamp(1, 600); + // A new TTL starts the statistics over, so the hit rate and the ages + // on the Status tab describe the TTL in force, not a mix. + if self.ttl_secs.swap(secs, Ordering::Relaxed) != secs { + self.hits.store(0, Ordering::Relaxed); + self.misses.store(0, Ordering::Relaxed); + self.expired.store(0, Ordering::Relaxed); + self.skipped.store(0, Ordering::Relaxed); + for a in &self.expired_age { + a.store(0, Ordering::Relaxed); + } + } + } + + pub fn ttl_secs(&self) -> u64 { + self.ttl_secs.load(Ordering::Relaxed) + } + + /// No entry in the map: an expired one remembered by the sweep, or none. + fn note_miss(&self, key: &([u8; 16], bool)) { + let at = self.stale.lock().unwrap().get(key).copied(); + match at { + Some(t) => { + let now = self.base.elapsed().as_secs(); + self.note_expired(Duration::from_secs(now.saturating_sub(u64::from(t)))); + } + None => { + self.misses.fetch_add(1, Ordering::Relaxed); + } + } + } + + /// Bucketed by how long the entry had been expired, not by its age: at a + /// TTL of 300 s every expired entry is over 5 minutes old, and a + /// histogram of ages put them all in one bucket. + fn note_expired(&self, age: Duration) { + self.misses.fetch_add(1, Ordering::Relaxed); + self.expired.fetch_add(1, Ordering::Relaxed); + let past = age.saturating_sub(self.ttl()).as_secs(); + let b = match past { + 0..=59 => 0, + 60..=119 => 1, + 120..=299 => 2, + 300..=599 => 3, + _ => 4, + }; + self.expired_age[b].fetch_add(1, Ordering::Relaxed); + } + + /// Expired misses by how long past the TTL the entry was: under 1 min, + /// 1-2 min, 2-5 min, 5-10 min, 10-20 min. A request later than that is a + /// plain miss. + pub fn expired_ages(&self) -> [u64; 5] { + [0, 1, 2, 3, 4].map(|i| self.expired_age[i].load(Ordering::Relaxed)) + } + + /// (expired misses, inserts skipped for a full map). + pub fn detail(&self) -> (u64, u64) { + ( + self.expired.load(Ordering::Relaxed), + self.skipped.load(Ordering::Relaxed), + ) + } + pub fn stats(&self) -> (u64, u64) { ( self.hits.load(Ordering::Relaxed), @@ -139,21 +265,31 @@ impl SmartSourcesCache { /// Store a freshly-built FOUNDSOURCES payload for `hash`, with the owner /// of each record (see `CacheEntry::owners`). pub fn put(&self, hash: [u8; 16], payload: Vec, owners: Vec) { + self.put_kind(hash, false, payload, owners) + } + + /// `put` for one reply kind (`obfu`: FOUNDSOURCES_OBFU). + pub fn put_kind(&self, hash: [u8; 16], obfu: bool, payload: Vec, owners: Vec) { + let key = (hash, obfu); let mut map = self.map.lock().unwrap(); + self.stale.lock().unwrap().remove(&key); // Bound memory: if the map is getting large, sweep expired entries // first; if that's not enough, clear it entirely. Clearing is safe — // it just means the next requests rebuild and re-cache. - if map.len() >= MAX_ENTRIES { + if map.len() >= MAX_ENTRIES && !map.contains_key(&key) { let now = Instant::now(); - map.retain(|_, e| now.duration_since(e.inserted) < CACHE_TTL); + let ttl = self.ttl(); + map.retain(|_, e| now.duration_since(e.inserted) < ttl); if map.len() >= MAX_ENTRIES { - map.clear(); + // Full of fresh entries: keep them, skip this one. + self.skipped.fetch_add(1, Ordering::Relaxed); + return; } } map.insert( - hash, + key, CacheEntry { payload, owners, @@ -162,19 +298,66 @@ impl SmartSourcesCache { ); } + /// Expire entries. Run by the periodic sweep: without it expired entries + /// stayed until the map reached its cap (195k of them at 15 s, holding + /// their payloads), and the entry count said nothing about what the cache + /// was actually serving. + /// + /// An expired entry leaves the map, payload and all, but its key and + /// insert time are remembered for `STALE_KEEP_SECS` past the TTL, so a + /// request in that window still lands in the histogram. Dropped outright, + /// every request later than one sweep read as a plain miss. + pub fn sweep_expired(&self) -> usize { + let ttl = self.ttl(); + let now = Instant::now(); + let mut map = self.map.lock().unwrap(); + let mut stale = self.stale.lock().unwrap(); + let before = map.len(); + map.retain(|k, e| { + let fresh = now.duration_since(e.inserted) < ttl; + if !fresh && stale.len() < STALE_MAX { + let t = e.inserted.saturating_duration_since(self.base).as_secs(); + stale.insert(*k, u32::try_from(t).unwrap_or(u32::MAX)); + } + fresh + }); + let live = map.len(); + if map.capacity() > live * 4 + 1024 { + map.shrink_to(live * 2); + } + let keep = (ttl.as_secs() + STALE_KEEP_SECS) as u32; + let now_s = u32::try_from(self.base.elapsed().as_secs()).unwrap_or(u32::MAX); + stale.retain(|_, t| now_s.saturating_sub(*t) < keep); + let n = stale.len(); + if stale.capacity() > n * 4 + 1024 { + stale.shrink_to(n * 2); + } + before - live + } + /// Invalidate the cached entry for a file — call when its source list /// changes in a way that matters (e.g. a new source was added). This is /// optional: the short TTL means stale entries self-heal anyway, so /// callers may skip invalidation on the hot path and just let TTL handle it. pub fn invalidate(&self, hash: &[u8; 16]) { let mut map = self.map.lock().unwrap(); - map.remove(hash); + map.remove(&(*hash, false)); + map.remove(&(*hash, true)); + let mut stale = self.stale.lock().unwrap(); + stale.remove(&(*hash, false)); + stale.remove(&(*hash, true)); } - /// Number of currently-cached entries (including not-yet-swept expired - /// ones). Intended for stats/metrics, not correctness. + /// Number of fresh entries, the ones that can be served. Intended for + /// stats/metrics, not correctness. pub fn len(&self) -> usize { - self.map.lock().unwrap().len() + let ttl = self.ttl(); + self.map + .lock() + .unwrap() + .values() + .filter(|e| e.inserted.elapsed() < ttl) + .count() } } @@ -223,11 +406,11 @@ mod tests { { let mut map = cache.map.lock().unwrap(); map.insert( - hash, + (hash, false), CacheEntry { payload: vec![1, 2, 3], owners: Vec::new(), - inserted: Instant::now() - Duration::from_secs(60), + inserted: Instant::now() - Duration::from_secs(DEFAULT_TTL_SECS + 60), }, ); } @@ -235,6 +418,34 @@ mod tests { assert_eq!(cache.get(&hash), None); } + #[test] + fn sweep_remembers_an_expired_entry_by_its_age_alone() { + let cache = SmartSourcesCache::new(); + let at = |secs: u64| CacheEntry { + payload: vec![9; 64], + owners: vec![1, 2], + inserted: Instant::now() - Duration::from_secs(secs), + }; + { + let mut map = cache.map.lock().unwrap(); + map.insert(([1; 16], false), at(10)); + map.insert(([2; 16], false), at(DEFAULT_TTL_SECS + 200)); + } + assert_eq!(cache.sweep_expired(), 1); + assert_eq!(cache.len(), 1, "len counts what can be served"); + assert_eq!(cache.map.lock().unwrap().len(), 1, "the expired one left the map"); + // A request for it lands 2-5 min past the TTL. + assert_eq!(cache.get(&[2; 16]), None); + assert_eq!(cache.expired_ages()[2], 1); + assert_eq!(cache.detail().0, 1); + // A fresh insert forgets it. + cache.put([2; 16], vec![1], Vec::new()); + assert!(cache.stale.lock().unwrap().is_empty()); + // Unknown hash: a plain miss. + assert_eq!(cache.get(&[3; 16]), None); + assert_eq!(cache.detail().0, 1); + } + #[test] fn put_overwrites_with_fresh_timestamp() { let cache = SmartSourcesCache::new(); @@ -256,4 +467,21 @@ mod tests { assert_eq!(hits, 1); assert_eq!(misses, 1); } -} + + /// A cache full of fresh entries keeps them: the new one is skipped, not + /// the whole map cleared (which emptied it every few seconds under load). + #[test] + fn a_full_cache_keeps_its_entries() { + let cache = SmartSourcesCache::new(); + for i in 0..MAX_ENTRIES as u32 { + let mut h = [0u8; 16]; + h[..4].copy_from_slice(&i.to_le_bytes()); + cache.put(h, vec![1], Vec::new()); + } + cache.put([0xFF; 16], vec![2], Vec::new()); + assert_eq!(cache.len(), MAX_ENTRIES); + assert_eq!(cache.detail().1, 1); + assert!(cache.get(&[0u8; 16]).is_some(), "an old entry survived"); + assert!(cache.get(&[0xFF; 16]).is_none()); + } +} \ No newline at end of file diff --git a/src/web.rs b/src/web.rs index d9f26af..2836ab6 100644 --- a/src/web.rs +++ b/src/web.rs @@ -653,6 +653,10 @@ pub fn spawn_admin(state: WebState, admin: crate::config::AdminConfig) { .route("/api/admission", get(api_admission)) .route("/api/ready", get(api_ready)) .route("/api/review", get(api_review)) + .route("/api/indexcheck", get(api_indexcheck)) + .route("/api/natt", get(api_natt)) + .route("/api/sizemismatch", get(api_sizemismatch)) + .route("/api/takeover", get(api_takeover)) .route("/api/updates", get(api_updates_status)) .route("/api/update", post(api_update_run)) .route("/login", get(login_page).post(login_submit)) @@ -723,8 +727,24 @@ struct StatusResp { /// capability (sent CT_EMULE_UDPPORTS at login = our client mod). Lets the /// operator watch adoption of the NAT-traversal client mod. natt_capable_clients: u64, + /// Clients that sent a 0x9F from their session address this session, and + /// of those, obfuscated. + natt_9f_clients: u64, + natt_9f_clients_obf: u64, + /// Obfuscated UDP decoding: by the cached key, on the cold path, failed. + obf_decode_hits: u64, + obf_decode_cold: u64, + obf_decode_failed: u64, + obf_crypt_pings: u64, ipv6_clients: u64, ipv6_sources: u64, + /// The IPv6 dial-back (#43): in force, and its outcomes since start + /// (ok, no answer, wrong hash, skipped) plus probes still pending. + ipv6_probe_active: bool, + ipv6_probe: [u64; 5], + /// Probes without an answer by stage: timeout, refused, unreachable, + /// after the connect. + ipv6_probe_fail: [u64; 4], /// How many LowID↔LowID hole punches the server has coordinated since start. natt_coordinated: u64, /// Connected clients that set Lugdunum's NAT callback bit (0x1000). @@ -741,6 +761,10 @@ struct StatusResp { hpext_fresh: u64, hpext_timeout: u64, hp_bridged: u64, + /// Cross-server NAT callbacks (issue #40): foreign 0xA6 forwarded to a + /// target here, and answers sent to the requester. + natcb_foreign: u64, + natcb_foreign_answered: u64, } async fn api_status( @@ -781,11 +805,37 @@ async fn api_status( incoming_seed_challenges_entries: s.server.incoming_seed_challenges.len() as u64, banned_bots: s.server.banned_bots.len() as u64, natt_capable_clients: s.server.clients.iter().filter(|c| c.natt_capable).count() as u64, + natt_9f_clients: s.server.natt_keepalive_seen.len() as u64, + natt_9f_clients_obf: s.server.natt_keepalive_seen.iter().filter(|e| *e.value()).count() as u64, + obf_decode_hits: s.server.obf_decode_hits.load(std::sync::atomic::Ordering::Relaxed), + obf_decode_cold: s.server.obf_decode_cold.load(std::sync::atomic::Ordering::Relaxed), + obf_decode_failed: s.server.obf_decode_failed.load(std::sync::atomic::Ordering::Relaxed), + obf_crypt_pings: s.server.obf_crypt_pings.load(std::sync::atomic::Ordering::Relaxed), // Two different questions, and both are worth watching while this is new: // how many sessions could receive an inline IPv6 source record, and how // many publishers actually have an address to put in one. ipv6_clients: s.server.clients.iter().filter(|c| c.ipv6_capable).count() as u64, ipv6_sources: s.server.client_ipv6.len() as u64, + ipv6_probe_active: crate::server::ipv6_probe::active(&s.server), + ipv6_probe_fail: { + let c = |k: &str| s.server.block_stats.get(k).map_or(0, |v| *v); + [ + c("ipv6_probe_fail_timeout"), + c("ipv6_probe_fail_refused"), + c("ipv6_probe_fail_unreachable"), + c("ipv6_probe_fail_after_connect"), + ] + }, + ipv6_probe: { + let c = |k: &str| s.server.block_stats.get(k).map_or(0, |v| *v); + [ + c("ipv6_probe_ok"), + c("ipv6_probe_no_answer"), + c("ipv6_probe_wrong_hash"), + c("ipv6_probe_skipped_busy") + c("ipv6_probe_skipped_limit"), + s.server.ipv6_probe.iter().filter(|p| p.verdict.is_none()).count() as u64, + ] + }, natt_coordinated: s .server .block_stats @@ -820,6 +870,11 @@ async fn api_status( hpext_fresh: s.server.block_stats.get("holepunch_ext_fresh").map_or(0, |v| *v), hpext_timeout: s.server.block_stats.get("holepunch_ext_timeout").map_or(0, |v| *v), hp_bridged: s.server.block_stats.get("holepunch_bridged").map_or(0, |v| *v), + natcb_foreign: s.server.block_stats.get("natcb_forwarded_foreign").map_or(0, |v| *v), + natcb_foreign_answered: ["foreign_natcb_answered_fresh", "foreign_natcb_answered_after_wait"] + .iter() + .map(|k| s.server.block_stats.get(*k).map_or(0, |v| *v)) + .sum(), }) } @@ -922,6 +977,13 @@ struct StatsResp { /// real structural cost without lazy-page noise. 0.0 if no files / no jemalloc. bytes_per_file: f64, cache_hit_pct: f64, + /// GETSOURCES cache detail: misses on an expired entry, entries not + /// stored for a full cache, entries now. + gs_cache_expired: u64, + gs_cache_skipped: u64, + gs_cache_entries: u64, + gs_cache_expired_ages: [u64; 5], + gs_cache_ttl: u64, } async fn api_stats(State(s): State) -> Json { @@ -1098,6 +1160,11 @@ async fn api_stats(State(s): State) -> Json { } }, cache_hit_pct: hit_rate as f64 * 100.0, + gs_cache_expired: s.server.smart_sources.detail().0, + gs_cache_skipped: s.server.smart_sources.detail().1, + gs_cache_entries: s.server.smart_sources.entry_count() as u64, + gs_cache_expired_ages: s.server.smart_sources.expired_ages(), + gs_cache_ttl: s.server.smart_sources.ttl_secs(), }) } @@ -2640,6 +2707,18 @@ async fn api_review(State(s): State, uri: axum::http::Uri) -> impl Int } } + // The audit: the same window, reduced to signals, with a name shown only + // where nothing but the rule that fired points at abuse. + if let Some(fmt) = param("format").filter(|f| *f == "audit" || *f == "auditcsv") { + let out = audit_report(&s, &by_hash, since, &window_desc, fmt == "auditcsv", max_examples); + if !flag("peek") { + if let Ok(mut g) = s.server.review_watermark.lock() { + *g = Some(now); + } + } + return ([("content-type", "text/plain; charset=utf-8")], out); + } + if csv { // `size` and `sizeflag` exist so the masquerade test can be done by // sorting a column instead of opening each file in a client. `sizeflag` @@ -2952,6 +3031,592 @@ async fn api_review(State(s): State, uri: axum::http::Uri) -> impl Int ([("content-type", "text/plain; charset=utf-8")], out) } +/// `/api/review?format=audit` and `format=auditcsv`: the false-positive +/// audit (see `server::review_audit`). Grouped by rule like the plain review, +/// but every name is first reduced to signals, and shown only when it is +/// clean in a rule that is not a working marker. L3 hits stay counted, never +/// named; decoy and alias names follow the same rule. +fn audit_report( + s: &WebState, + by_hash: &std::collections::HashMap<[u8; 16], (crate::state::CsamCatch, Vec)>, + since: Option, + window_desc: &str, + csv: bool, + max_examples: usize, +) -> String { + use crate::filter::Layer; + use crate::server::review_audit::{self as ra, Verdict}; + let filter = &s.server.filter; + + // Publishers per hash, in the same window. + let mut publishers: std::collections::HashMap<[u8; 16], usize> = std::collections::HashMap::new(); + for e in s.server.csam_files_by_user.iter() { + for (h, c) in e.value().0.iter() { + if since.map_or(true, |cut| c.at >= cut) { + *publishers.entry(*h).or_insert(0) += 1; + } + } + } + + struct Entry { + hash: [u8; 16], + layer: Layer, + reason: String, + name: String, + size: u64, + kind: ra::Kind, + sig: ra::Signals, + names: usize, + unrelated: usize, + sizeflag: bool, + publishers: usize, + } + let mut entries: Vec = Vec::with_capacity(by_hash.len()); + for (h, (c, names)) in by_hash { + let sig = if c.layer == Layer::L3HashBlock { + ra::Signals::default() + } else { + ra::signals(filter, &c.name, c.layer, &c.reason) + }; + entries.push(Entry { + hash: *h, + layer: c.layer, + reason: c.reason.clone(), + name: c.name.clone(), + size: c.size, + kind: ra::kind_of(&c.name), + sig, + names: names.len(), + unrelated: unrelated_name_count(names), + sizeflag: size_contradicts_extension(&c.name, c.size), + publishers: publishers.get(h).copied().unwrap_or(0), + }); + } + + // Rules: (layer, reason) -> indexes into `entries`. + let mut rules: std::collections::HashMap<(String, String), Vec> = + std::collections::HashMap::new(); + for (i, e) in entries.iter().enumerate() { + if e.layer != Layer::L3HashBlock { + rules + .entry((format!("{:?}", e.layer), e.reason.clone())) + .or_default() + .push(i); + } + } + let verdict_of = |ix: &Vec| -> Verdict { + ra::verdict(ix.len(), ix.iter().filter(|&&i| entries[i].sig.strong()).count()) + }; + let mut verdicts: std::collections::HashMap<(String, String), Verdict> = + std::collections::HashMap::new(); + for (k, ix) in &rules { + verdicts.insert(k.clone(), verdict_of(ix)); + } + let visible = |e: &Entry| -> bool { + let v = verdicts + .get(&(format!("{:?}", e.layer), e.reason.clone())) + .copied() + .unwrap_or(Verdict::Marker); + ra::name_visible(&e.sig, v, e.layer, e.kind) + }; + let is_decoy = |e: &Entry| e.layer == Layer::L3HashBlock && (e.unrelated > 0 || e.sizeflag); + let suggest = |e: &Entry| -> &'static str { + if e.layer == Layer::L3HashBlock { + return if is_decoy(e) { "poison?" } else { "keep" }; + } + let v = verdicts + .get(&(format!("{:?}", e.layer), e.reason.clone())) + .copied() + .unwrap_or(Verdict::Marker); + if e.sig.strong() || v == Verdict::Marker { + "keep" + } else if v == Verdict::LikelyFalsePositive && !e.sig.explicit { + "whitelist?" + } else { + // Explicit words only, or a mixed rule: the hash is for a look. + "review" + } + }; + let b = |x: bool| if x { "1" } else { "0" }; + + if csv { + let mut out = String::from( + "hash;layer;reason;size;kind;explicit;age;minor;marker2;sizeflag;names;unrelated;publishers;suggest;name\n", + ); + for e in &entries { + let name = if visible(e) { e.name.replace(';', ",") } else { String::new() }; + out.push_str(&format!( + "{};{:?};{};{};{};{};{};{};{};{};{};{};{};{};{}\n", + hex::encode(e.hash), + e.layer, + e.reason.replace(';', ","), + e.size, + e.kind.as_str(), + b(e.sig.explicit), + b(e.sig.age), + b(e.sig.minor), + b(e.sig.marker2), + if e.sizeflag { "!" } else { "" }, + e.names, + e.unrelated, + e.publishers, + suggest(e), + name + )); + } + return out; + } + + let l3: Vec<&Entry> = entries.iter().filter(|e| e.layer == Layer::L3HashBlock).collect(); + let mut out = format!( + "# ed2k-server filter audit\n# window: {window_desc}\n# blocks in window: {} unique hashes; L3 hash-list hits: {} (counted, never named)\n\ + # A name is shown only when nothing in it but the rule that fired points at abuse\n\ + # (no explicit word, no child's age, no child word, no second listed term), and\n\ + # its rule is not a working marker. Everything else is counted.\n\n", + entries.len(), + l3.len() + ); + + let mut keys: Vec<&(String, String)> = rules.keys().collect(); + keys.sort_by_key(|k| (std::cmp::Reverse(rules[*k].len()), (*k).clone())); + out.push_str("=== RULES ===\n# hits | child% (age/child word/2nd term) | e=explicit a=age m=child word t=2nd term | audio doc video image other | publishers | verdict | layer | rule\n"); + for k in &keys { + let ix = &rules[*k]; + let n = ix.len(); + let cnt = |f: &dyn Fn(&Entry) -> bool| ix.iter().filter(|&&i| f(&entries[i])).count(); + let strong = cnt(&|e| e.sig.strong()); + let pubs: usize = ix.iter().map(|&i| entries[i].publishers).sum(); + out.push_str(&format!( + "{:>5} | {:>3}% | e{} a{} m{} t{} | {} {} {} {} {} | {:>4} | {} | {} | {}\n", + n, + strong * 100 / n.max(1), + cnt(&|e| e.sig.explicit), + cnt(&|e| e.sig.age), + cnt(&|e| e.sig.minor), + cnt(&|e| e.sig.marker2), + cnt(&|e| e.kind == ra::Kind::Audio), + cnt(&|e| e.kind == ra::Kind::Doc), + cnt(&|e| e.kind == ra::Kind::Video), + cnt(&|e| e.kind == ra::Kind::Image), + cnt(&|e| !matches!(e.kind, ra::Kind::Audio | ra::Kind::Doc | ra::Kind::Video | ra::Kind::Image)), + pubs, + verdicts[*k].as_str(), + k.0, + k.1 + )); + } + + out.push_str("\n=== VISIBLE NAMES, BY RULE ===\n# hash | MB | kind | [!] size impossible for the extension | name\n"); + for k in &keys { + let ix = &rules[*k]; + let shown: Vec<&Entry> = ix.iter().map(|&i| &entries[i]).filter(|e| visible(e)).collect(); + if shown.is_empty() { + continue; + } + out.push_str(&format!( + "\n--- {} | {} | {} | {} of {} shown ---\n", + k.0, + k.1, + verdicts[*k].as_str(), + shown.len(), + ix.len() + )); + for e in shown.iter().take(max_examples) { + let short: String = e.name.chars().take(140).collect(); + out.push_str(&format!( + "{} {:>6} {:<8}{} {}\n", + hex::encode(e.hash), + e.size / 1048576, + e.kind.as_str(), + if e.sizeflag { " [!]" } else { "" }, + short + )); + } + if shown.len() > max_examples { + out.push_str(&format!("... +{} more (format=auditcsv lists all)\n", shown.len() - max_examples)); + } + } + + // Hashes with explicit words only, in rules that are not markers: names + // withheld, for a look in the admin panel. + let review: Vec<&Entry> = entries + .iter() + .filter(|e| e.layer != Layer::L3HashBlock && suggest(e) == "review" && !visible(e)) + .collect(); + if !review.is_empty() { + out.push_str(&format!( + "\n=== HASHES TO LOOK AT ({}) ===\n# Name withheld (explicit words), rule not a marker: legal adult content the term\n# fires on is a false positive too. hash | layer | rule | MB | kind\n", + review.len() + )); + for e in review.iter().take(200) { + out.push_str(&format!( + "{} | {:?} | {} | {} | {}\n", + hex::encode(e.hash), + e.layer, + e.reason, + e.size / 1048576, + e.kind.as_str() + )); + } + if review.len() > 200 { + out.push_str(&format!("... +{} more (format=auditcsv lists all)\n", review.len() - 200)); + } + } + + // Decoy signature among L3 hits: candidates to move to the poison list. + let decoys: Vec<&&Entry> = l3.iter().filter(|e| is_decoy(e)).collect(); + if !decoys.is_empty() { + out.push_str(&format!( + "\n=== L3 HITS WITH A DECOY SIGNATURE ({} of {}) ===\n# Candidates to move from the CSAM hash list to the poison list. Names only when clean.\n", + decoys.len(), + l3.len() + )); + for e in decoys.iter().take(60) { + let name = if ra::plain_name_visible(filter, &e.name) { + e.name.chars().take(120).collect::() + } else { + "[name withheld]".to_string() + }; + out.push_str(&format!( + "{} {} names ({} unrelated), {} MB{} {}\n", + hex::encode(e.hash), + e.names, + e.unrelated, + e.size / 1048576, + if e.sizeflag { " [!]" } else { "" }, + name + )); + } + } + + // Indexed files carrying unrelated names: decoy leads for the poison list. + let mut aliases: Vec<_> = s + .server + .file_aliases + .iter() + .map(|e| { + let a = e.value().clone(); + let u = unrelated_name_count(&a.names); + (*e.key(), a, u) + }) + .filter(|(_, _, u)| *u > 0) + .collect(); + aliases.sort_by_key(|(_, a, u)| (std::cmp::Reverse(*u), std::cmp::Reverse(a.size))); + if !aliases.is_empty() { + out.push_str(&format!( + "\n=== INDEXED FILES UNDER UNRELATED NAMES ({}) ===\n# Decoy leads for the poison list. Names only when clean.\n", + aliases.len() + )); + for (h, a, u) in aliases.iter().take(40) { + let flag = a.names.iter().any(|n| size_contradicts_extension(n, a.size)); + out.push_str(&format!( + "{} {} names ({} unrelated), {} B{}\n", + hex::encode(h), + a.names.len(), + u, + a.size, + if flag { " [!]" } else { "" } + )); + let mut withheld = 0; + for n in a.names.iter().take(6) { + if ra::plain_name_visible(filter, n) { + let short: String = n.chars().take(110).collect(); + out.push_str(&format!(" {short}\n")); + } else { + withheld += 1; + } + } + if withheld > 0 { + out.push_str(&format!(" [{withheld} withheld]\n")); + } + } + } + out +} + +/// `/api/natt`: every NAT-T counter since start, and the live gauges, for +/// analysing the hole punch, Lugdunum's callback, the extension and the +/// cross-server path. Counters are `block_stats` keys with these prefixes: +/// `holepunch_`, `hp_`, `natt_`, `natcb`, `foreign_natcb_`. +/// `/api/sizemismatch` (issue #45): the last size disagreements, newest +/// first, and the same grouped by file: for each hash the stored size and +/// every other size seen, with how many distinct addresses published or asked +/// for it. Hashes, sizes and addresses only. +async fn api_sizemismatch(State(s): State) -> Json { + use std::collections::{BTreeMap, BTreeSet}; + let recent: Vec = s + .server + .size_mismatch_recent + .lock() + .map(|q| q.iter().rev().cloned().collect()) + .unwrap_or_default(); + // hash -> (stored, other size -> (publisher ips, requester ips)) + type Seen = (BTreeSet, BTreeSet); + let mut by_hash: BTreeMap)> = BTreeMap::new(); + for e in &recent { + let (Some(h), Some(st), Some(o), Some(ip), Some(k)) = ( + e["hash"].as_str(), + e["stored_size"].as_u64(), + e["other_size"].as_u64(), + e["ip"].as_str(), + e["kind"].as_str(), + ) else { + continue; + }; + let entry = by_hash.entry(h.to_string()).or_insert((st, BTreeMap::new())); + let seen = entry.1.entry(o).or_default(); + if k == "offer" { + seen.0.insert(ip.to_string()); + } else { + seen.1.insert(ip.to_string()); + } + } + let mut files: Vec = by_hash + .into_iter() + .map(|(h, (stored, others))| { + let current = s + .server + .file_slab + .with_record_by_hash( + &{ + let mut b = [0u8; 16]; + if let Ok(v) = hex::decode(&h) { + if v.len() == 16 { + b.copy_from_slice(&v); + } + } + b + }, + |_id, r| (r.size, r.sources.len(), r.complete_source_count()), + ); + let others: Vec = others + .into_iter() + .map(|(size, (pubs, reqs))| { + serde_json::json!({ + "size": size, + "publishers": pubs.len(), + "publisher_ips": pubs, + "requesters": reqs.len(), + }) + }) + .collect(); + serde_json::json!({ + "hash": h, + "stored_size_then": stored, + "stored_size_now": current.map(|c| c.0), + "sources_now": current.map(|c| c.1), + "complete_sources_now": current.map(|c| c.2), + "other_sizes": others, + }) + }) + .collect(); + files.sort_by_key(|f| { + std::cmp::Reverse( + f["other_sizes"] + .as_array() + .map_or(0, |a| a.iter().map(|o| o["publishers"].as_u64().unwrap_or(0) + o["requesters"].as_u64().unwrap_or(0)).sum::()), + ) + }); + Json(serde_json::json!({ + "kept": recent.len(), + "cap": crate::state::SIZE_MISMATCH_RECENT_MAX, + "files": files, + "recent": recent, + })) +} + +async fn api_natt(State(s): State) -> Json { + let mut counters: std::collections::BTreeMap = Default::default(); + for e in s.server.block_stats.iter() { + let k = e.key(); + if ["holepunch_", "hp_", "natt_", "natcb", "foreign_natcb_"] + .iter() + .any(|p| k.starts_with(p)) + { + counters.insert(k.clone(), *e.value()); + } + } + let srv = &s.server; + let flags = |f: u32| srv.clients.iter().filter(|c| c.server_flags & f == f).count(); + let now = srv.file_slab.now_secs(); + let recent: Vec = srv + .natt_recent + .lock() + .map(|q| { + q.iter() + .rev() + .map(|e| { + let mut e = e.clone(); + let at = e["secs_ago_base"].as_u64().unwrap_or(0) as u32; + e["secs_ago"] = serde_json::json!(now.saturating_sub(at)); + if let Some(o) = e.as_object_mut() { + o.remove("secs_ago_base"); + } + e + }) + .collect() + }) + .unwrap_or_default(); + Json(serde_json::json!({ + "recent": recent, + "counters": counters, + "gauges": { + "clients": srv.clients.len(), + "lowid_clients": srv.lowid_count(), + "clients_0x1000": flags(crate::server::lugdunum_natt::SRVCAP_NATTRAVERSAL), + "clients_extension": flags(crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT | crate::server::lugdunum_natt::SRVCAP_NATTRAVERSAL), + "clients_sent_9f": srv.natt_keepalive_seen.len(), + "clients_sent_9f_obfuscated": srv.natt_keepalive_seen.iter().filter(|e| *e.value()).count(), + "clients_udp_port_announced": srv.clients.iter().filter(|c| c.natt_capable).count(), + "keepalive_ports_known": srv.natt_udp_ports.len(), + "extension_waits_pending": srv.natt_keepalive_waiters.len(), + "lugdunum_requesters_seen": srv.lugdunum_natt_seen.len(), + "foreign_sources_tracked": srv.natcb_foreign_sources.len(), + "foreign_targets_woken_last_min": srv.natcb_foreign_wakes.len(), + "pairs_asked_last_10min": srv.holepunch_recent.len(), + }, + "config": { + "lugdunum_natt": srv.live_cfg.load().network.lugdunum_natt, + "holepunch_extension": srv.live_cfg.load().network.holepunch_extension, + "holepunch_foreign": srv.live_cfg.load().network.holepunch_foreign, + } + })) +} + +/// `/api/takeover`: logins refused because their user hash was online from +/// another address (issue #41), grouped by hash, worst first. A hash with +/// many refusals from one or two other addresses is two installs sharing a +/// user hash; many addresses is a hash someone hands around. +async fn api_takeover(State(s): State) -> Json { + use std::collections::HashMap; + let mut by_hash: HashMap<[u8; 16], (u32, Vec)> = HashMap::new(); + for e in s.server.takeover_lost.iter() { + let ((h, src), (_, n)) = (e.key(), e.value()); + let slot = by_hash.entry(*h).or_default(); + slot.0 += n; + if slot.1.len() < 8 { + slot.1.push(src.to_string()); + } + } + let mut rows: Vec<_> = by_hash.into_iter().collect(); + rows.sort_by_key(|(_, (n, _))| std::cmp::Reverse(*n)); + let total: u32 = rows.iter().map(|(_, (n, _))| n).sum(); + let hashes = rows.len(); + let list: Vec = rows + .into_iter() + .take(100) + .map(|(h, (n, contenders))| { + let owner = s.server.clients.get(&h).map(|c| { + serde_json::json!({ + "ip": c.ip.to_string(), + "software": c.software_detail.clone(), + "high_id": c.is_high_id, + }) + }); + serde_json::json!({ + "user_hash": hex::encode(h), + "refused": n, + "contenders": contenders, + // Report only: many contenders suggest a hash that installs + // share, to add to limits.shared_user_hashes after a look. An + // attacker can produce the same picture, so it is never + // acted on by itself. + "looks_shared": contenders.len() >= 3, + "owner": owner, + }) + }) + .collect(); + Json(serde_json::json!({ + "refused_tracked": total, + "hashes": hashes, + "top": list, + })) +} + +/// `/api/indexcheck`: every file in the index against the content filter as +/// it is now (issue: lists reloaded after a file was indexed are never +/// applied to it). Hashes only, never names: +/// `hash;layer;reason;sources;size;whitelisted`. `whitelisted=1` marks a +/// file the filter would block but the whitelist lets through. With +/// `purge=1` the blocked, non-whitelisted files are taken out of the index. +/// One run at a time; it walks the whole index. +async fn api_indexcheck(State(s): State, uri: axum::http::Uri) -> impl IntoResponse { + static RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + use std::sync::atomic::Ordering; + let purge = uri.query().unwrap_or("").split('&').any(|kv| kv == "purge=1" || kv == "purge=true"); + if RUNNING.swap(true, Ordering::AcqRel) { + return ( + axum::http::StatusCode::CONFLICT, + [("content-type", "text/plain; charset=utf-8")], + "# an index check is already running\n".to_string(), + ); + } + let state = Arc::clone(&s.server); + let res = tokio::task::spawn_blocking(move || { + let started = std::time::Instant::now(); + let mut rows = String::from("hash;layer;reason;sources;size;whitelisted\n"); + let mut purge_ids = Vec::new(); + let mut checked = 0u64; + let mut by_layer: std::collections::BTreeMap<&'static str, u64> = Default::default(); + let mut whitelisted = 0u64; + state.file_slab.for_each_live_copied(|batch| { + for (id, hash, size, name, sources) in batch { + checked += 1; + if let crate::filter::FilterResult::Block(layer, reason) = + state.filter.check_ignoring_whitelist(&hash, &name) + { + let wl = state.filter.is_whitelisted(&hash); + if wl { + whitelisted += 1; + } else { + *by_layer.entry(layer.stat_key()).or_insert(0) += 1; + purge_ids.push(id); + } + rows.push_str(&format!( + "{};{:?};{};{};{};{}\n", + hex::encode(hash), + layer, + reason.replace(';', ","), + sources, + size, + if wl { 1 } else { 0 } + )); + } + } + }); + let purged = if purge { state.purge_files(&purge_ids) } else { 0 }; + if purged > 0 { + *state + .block_stats + .entry("indexcheck_purged".to_string()) + .or_insert(0) += purged as u64; + } + let mut head = format!( + "# index check: {checked} files in {:.1} s; would block {} (not whitelisted), whitelisted {whitelisted}; purged {purged}\n", + started.elapsed().as_secs_f64(), + purge_ids.len() + ); + for (k, v) in &by_layer { + head.push_str(&format!("# {k}: {v}\n")); + } + head + &rows + }) + .await; + RUNNING.store(false, Ordering::Release); + match res { + Ok(body) => ( + axum::http::StatusCode::OK, + [("content-type", "text/plain; charset=utf-8")], + body, + ), + Err(e) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + [("content-type", "text/plain; charset=utf-8")], + format!("# index check failed: {e}\n"), + ), + } +} + /// Admission gauges and counters (issue #25). async fn api_admission(State(s): State) -> Json { Json(s.server.admission.metrics()) @@ -3942,7 +4607,7 @@ async function refreshStatus() { TCP Port${st.tcp_port} Seckey${st.seckey_hex} ${st.tls_fingerprint ? `HTTPS certificate${st.tls_fingerprint} SHA-256; the browser shows the same when the connection is genuine` : ''} - Cache hits${sys.cache_hit_pct.toFixed(1)}% + Cache hits${sys.cache_hit_pct.toFixed(1)}% · ${fmt(sys.gs_cache_entries)} entries · ${fmt(sys.gs_cache_expired)} misses on an expired entry (past the TTL by <1 min ${fmt(sys.gs_cache_expired_ages[0])} · 1–2 min ${fmt(sys.gs_cache_expired_ages[1])} · 2–5 min ${fmt(sys.gs_cache_expired_ages[2])} · 5–10 min ${fmt(sys.gs_cache_expired_ages[3])} · 10–20 min ${fmt(sys.gs_cache_expired_ages[4])}) · ${fmt(sys.gs_cache_skipped)} not stored (full) TCP GETSOURCES answered from the ${sys.gs_cache_ttl} s source-list cache (limits.getsources_cache_secs); how long past the TTL a request came shows what a longer TTL would catch; "not stored" means the 200k cap is too small Searches served${fmt(sys.searches_served)} since start ${sys.highid_observe_enabled ? `HighID hello check (${sys.highid_downgrade_enabled ? 'verdict' : 'observe'})${fmt(sys.highid_observe_verified)} verified${sys.highid_observe_verified_marker ? ` (${fmt(sys.highid_observe_verified_marker)} with a different client-type marker in the hello)` : ''} · ${fmt(sys.highid_observe_no_answer)} no answer · ${fmt(sys.highid_observe_mismatch)} wrong hash · ${fmt(sys.highid_observe_skipped)} skipped${sys.highid_downgrade_enabled || sys.highid_downgraded ? `
${fmt(sys.highid_marks_active)} marked now · ${fmt(sys.highid_downgraded)} logins given LowID by a mark · ${fmt(sys.highid_marks_cleared)} marks cleared (own hash again)` : ''} ${sys.highid_downgrade_enabled ? 'HighID clients re-checked with OP_HELLO in the background, login not delayed. A wrong hash marks (IP, port, hash); its next logins get LowID until the mark expires or its own hash answers again. "No answer" never costs HighID.' : 'HighID clients re-checked with OP_HELLO; "no answer" would be LowID on Lugdunum. Verdict unchanged.'}${sys.highid_observe_reasons ? `
${escapeHtml(sys.highid_observe_reasons)}` : ''}${sys.highid_mismatch_recent ? `
wrong hash, last ${fmt(sys.highid_mismatch_recent)}: answering client connected from the SAME IP ${fmt(sys.highid_mismatch_same_ip)} (second client behind one NAT) · from another IP ${fmt(sys.highid_mismatch_other_ip)} · not connected here ${fmt(sys.highid_mismatch_not_here)}` : ''}` : ''} Busiest IP${fmt(sys.busiest_ip_logged_in)} logged in${sys.busiest_ip ? ` from ${sys.busiest_ip}${sys.busiest_ip_country ? ' ('+countryFlag(sys.busiest_ip_country)+' '+escapeHtml(sys.busiest_ip_country)+')' : ''}${sys.busiest_ip_provider ? ' · '+escapeHtml(sys.busiest_ip_provider) : ''} · ${fmt(sys.busiest_ip_connections)} connections` : ''} most logged-in clients behind one address right now (a NAT or CGNAT); connections = its open TCP sockets, which limits.max_clients_per_ip = ${sys.max_clients_per_ip ? fmt(sys.max_clients_per_ip) : 'off'} caps; loopback exempt @@ -3955,13 +4620,15 @@ async function refreshStatus() { Searches by file hash${fmt(sys.searches_by_hash)} ed2k::HASH, ed2k links and magnets, answered from the hash index (hash lookup services) Ranking scan cap hit${fmt(sys.searches_rank_capped)} (${sys.searches_rank_capped_pct.toFixed(1)}%) ranked over part of the candidate set, not all of it — raise limits.search_rank_scan if this is a large share Banned bots (24h)${fmt(st.banned_bots)} - NAT-T capable clients${fmt(st.natt_capable_clients)} connected clients with the NAT-traversal mod + NAT-T clients${fmt(st.natt_9f_clients)} sent 0x9F this session (${fmt(st.natt_9f_clients_obf)} obfuscated) · ${fmt(st.natt_capable_clients)} announced a UDP port (0xF9) a 0x9F from the session's address shows a client that reads 0x61; 0xF9 is sent by stock clients too. Detail: /api/natt IPv6-capable clients${fmt(st.ipv6_clients)} sessions that can parse an inline IPv6 source record IPv6 publishers${fmt(st.ipv6_sources)} connected clients with a usable IPv6 address + IPv6 dial-back${st.ipv6_probe_active ? `ok ${fmt(st.ipv6_probe[0])} · no answer ${fmt(st.ipv6_probe[1])} (timeout ${fmt(st.ipv6_probe_fail[0])}, refused ${fmt(st.ipv6_probe_fail[1])}, unreachable ${fmt(st.ipv6_probe_fail[2])}, after connect ${fmt(st.ipv6_probe_fail[3])}) · wrong hash ${fmt(st.ipv6_probe[2])} · skipped ${fmt(st.ipv6_probe[3])} · pending ${fmt(st.ipv6_probe[4])}` : 'off'} ${st.ipv6_probe_active ? 'addresses published only after they answer' : 'off, no IPv6 route, or not publishing IPv6: addresses published on trust'} NAT-T punches coordinated${fmt(st.natt_coordinated)} LowID↔LowID since start (0x60 → 0x61 to both) Lugdunum NAT callbacks${fmt(st.natcb_forwarded_tcp + st.natcb_forwarded_udp)} forwarded (TCP 0x37 ${fmt(st.natcb_forwarded_tcp)} · UDP 0xA7 ${fmt(st.natcb_forwarded_udp)}) · ${fmt(st.natcb_refused)} refused · ${fmt(st.natcb_capable_clients)} capable clients 0xA6 requests from clients that set 0x1000 at login; reasons in the Blocks tab (natcb_*) Hole punch extension${fmt(st.hpext_started)} started · ${fmt(st.hpext_fresh)} with a fresh 0x9F · ${fmt(st.hpext_timeout)} after the 3 s wait · ${fmt(st.hpext_clients)} clients · ${fmt(st.hp_bridged)} bridged 0x60 between two clients with 0x100000; bridged = 0x37 to a Lugdunum-only target - obf_decode_cache${fmt(st.obf_decode_cache_entries)} entries (capped at 8192) + Cross-server NAT callbacks${fmt(st.natcb_foreign)} forwarded · ${fmt(st.natcb_foreign_answered)} answered 0xA6 from clients logged in elsewhere (requester id 0); answered = 0xA7 to the requester from a target with the extension; refusals in the Blocks tab (natcb_foreign_* and the natcb_* target reasons) + Obfuscated UDP${fmt(st.obf_decode_hits)} by the cached key · ${fmt(st.obf_decode_cold)} cold decodes · ${fmt(st.obf_crypt_pings)} crypt pings answered · ${fmt(st.obf_decode_failed)} undecodable · ${fmt(st.obf_decode_cache_entries)} cached senders a cold decode first tries the client key for the sender's IP (one attempt), then the peer-server keys; most cold senders are clients of other servers sending one global query; senders stay cached 30 min (cap 262k) seed_challenges${fmt(st.incoming_seed_challenges_entries)} entries (capped at 2048) `; } @@ -4311,6 +4978,7 @@ async function refreshBlocks() { 'rate_limit': { label: 'Rate limit', desc: 'queries throttled' }, 'max_connections_per_ip': { label: 'Too many connections', desc: 'TCP connections refused: the IP already had limits.max_clients_per_ip open' }, 'server_full': { label: 'Server full', desc: 'logins refused: limits.max_clients clients were connected' }, + 'login_hash_in_use': { label: 'Hash online elsewhere', desc: 'logins refused: the user hash was online from another address (issue #41); limits.hash_takeover_from_any_address restores the old rule' }, }[r.reason] || { label: r.reason, desc: '' }; const uniq = (r.unique_ips != null) ? r.unique_ips.toLocaleString() : '—'; return `${meta.label}${r.count.toLocaleString()}` diff --git a/tests/integration.rs b/tests/integration.rs index a0c7e85..42073d5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -880,3 +880,72 @@ async fn tied_results_are_broken_per_query_not_by_file_id() { #[path = "support/offerfiles_v1_cases.rs"] mod offerfiles_v1_cases; + +/// A login that replaces a session fences the old socket (issue #46): the old +/// socket's next frame closes it instead of being handled under the user hash +/// it no longer owns. +#[tokio::test] +async fn a_replaced_session_cannot_act_for_the_user() { + let (port, state) = spawn_test_server().await; + let hash = [0x46u8; 16]; + let login = |nick: &'static str| async move { + let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let mut f = Framed::new(stream, Ed2kCodec::new(1_000_000)); + f.send(Frame::new(OP_LOGINREQUEST, build_login(hash, 4662, nick))) + .await + .unwrap(); + // Drain the welcome batch. + while let Ok(Some(Ok(_))) = + tokio::time::timeout(std::time::Duration::from_millis(300), f.next()).await + {} + f + }; + let mut old = login("old").await; + let _new = login("new").await; + assert_eq!(state.clients.len(), 1); + + // The old socket publishes: refused, and the socket is closed. + old.send(Frame::new(OP_OFFERFILES, build_offerfiles([0x47; 16], "x.bin", 1000))) + .await + .unwrap(); + let next = tokio::time::timeout(std::time::Duration::from_secs(2), old.next()).await; + assert!( + matches!(next, Ok(None) | Ok(Some(Err(_)))), + "the replaced socket is closed" + ); + assert!(state.file_slab.get_by_hash(&[0x47; 16]).is_none(), "nothing was published"); + assert_eq!( + state.block_stats.get("replaced_session_frame_refused").map(|v| *v), + Some(1) + ); +} + +/// ...but not for a shared hash: its sessions are different installs, and +/// closing the displaced one only sends it back to displace the other. +#[tokio::test] +async fn a_replaced_session_of_a_shared_hash_keeps_working() { + let (port, state) = spawn_test_server().await; + // Degenerate (12+ zero bytes): shared by definition. + let mut hash = [0u8; 16]; + hash[5] = 0x0E; + hash[14] = 0x6F; + let login = |nick: &'static str| async move { + let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let mut f = Framed::new(stream, Ed2kCodec::new(1_000_000)); + f.send(Frame::new(OP_LOGINREQUEST, build_login(hash, 4662, nick))) + .await + .unwrap(); + while let Ok(Some(Ok(_))) = + tokio::time::timeout(std::time::Duration::from_millis(300), f.next()).await + {} + f + }; + let mut old = login("old").await; + let _new = login("new").await; + old.send(Frame::new(OP_OFFERFILES, build_offerfiles([0x48; 16], "y.bin", 1000))) + .await + .unwrap(); + let next = tokio::time::timeout(std::time::Duration::from_millis(500), old.next()).await; + assert!(next.is_err() || matches!(next, Ok(Some(Ok(_)))), "still open"); + assert!(state.block_stats.get("replaced_session_frame_refused").is_none()); +} From 3742f9e4bc64e36faf4b8ed4cb355be3744ff96c Mon Sep 17 00:00:00 2001 From: andrey23127 Date: Sun, 1 Nov 2026 00:17:47 +0300 Subject: [PATCH 4/5] Release 0.9.87: NAT-T session token, size guesses from part hashes, bad UDP 0x94 sizes From the first 14 hours of 0.9.86 on ed2k-rust (/api/sizemismatch): * Cross-server callback (#40): for a target with the extension, a required crypt layer on either side only needs support on the other (the requester dials with the target's hash from 0xA7); the old refusal stays for Lugdunum-only targets, which dial without the requester's hash. * A size equal to (hashes - 1) x 9728000 agrees with the real size of the file (issue #45): it is the lower bound a client uses for a download started from a bare hash, and most disagreements were exactly that. Such publishers are merged as sources, such requests answered (getsources_size_part_estimate), and a record holding only the guess takes the real size when it arrives. * UDP 0x94 records with the large-file sentinel followed by a size above 256 GiB are answered by hash alone and counted (udp_getsources2_bad_size) instead of being refused as a size mismatch. * NAT-T token (issue #40): sessions that may send 0x9F (0x100000, 0x1000 or 0xF9) get 16 random bytes in OP_SERVERIDENT, tag ST_NATT_TOKEN 0xAC; 0x100000 clients get OP_SERVERIDENT at login for it. A 0x9F of is believed from any address, and that address and port go to peers in 0x61 / 0xA7 (an IPv6 session can then punch over IPv4). Wrong token ignored. natt_9f_token, natt_9f_token_other_ip, natt_9f_bad_token. * Filter audit: format=hashlists (ban-list additions: films, pictures, archives a term layer blocked; filter-list additions: indexed files under 3+ unrelated names; audio/doc/software hits to look at), format=terms (words in 5+ blocked files, 90%+ blocked, not blocked alone), format=audio (every blocked audio file with its name, unless it carries a child signal). A ban-list candidate is never also listed as a decoy. cbr/cbz count as archives, disc images as other. The second- marker signal strips the rule's term across separators (it had made every L1/L4 rule a "marker"). Names shown only for audio, documents, software. The age rule on a film or picture is never a whitelist suggestion and reads "age alone (by design)". * login_takeover_same_address counts live sessions replaced from their own address, beside replaced_session_frame_refused. Tests: 626 unit + 29 integration pass. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 54 +++++++-- src/server/connection.rs | 17 +++ src/server/holepunch.rs | 30 ++++- src/server/login.rs | 21 +++- src/server/lugdunum_natt.rs | 87 ++++++++++++--- src/server/review_audit.rs | 68 +++++++++--- src/server/udp.rs | 45 +++++++- src/state/file_id.rs | 22 +++- src/state/mod.rs | 20 ++++ src/web.rs | 212 ++++++++++++++++++++++++++++++++++-- 12 files changed, 527 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db8164a..b0a0a6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "ed2k-server" -version = "0.9.86" +version = "0.9.87" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index edde9a3..7528cf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ed2k-server" -version = "0.9.86" +version = "0.9.87" edition = "2021" description = "Modern eDonkey2000 / eMule index server (test/MVP build)" license = "MIT" diff --git a/README.md b/README.md index 70015b4..b07511c 100644 --- a/README.md +++ b/README.md @@ -705,9 +705,13 @@ never has to go through the names the filter blocked rightly. **`/api/review?format=audit`** (and `format=auditcsv`) looks for false positives in the blocks of the window (same `hours`, `all`, `peek` parameters as the plain review). Every blocked name is reduced to signals — explicit -adult words, a child's age, a word for a child, a second listed term — and a -name is shown only when it has none of them and its rule is not a working -marker (a quarter or more of its hits point at a child). The report has a +adult words, a child's age, a word for a child, a second listed term (with +the rule's own term removed across separators) — and a name is shown only +when it has none of them, its rule is not a working marker (a quarter or more +of its hits point at a child), and the file is audio, a document or software: +a film, picture or archive is never named, since a clean-looking name there +can still be a studio or series marker. The false positives that occur are +song, book and program titles. The report has a table per rule (hits, signal counts, audio/doc/video/image split, publishers, verdict: likely false positive / mixed / marker), the visible names under each rule, the hashes of names withheld for explicit words only @@ -716,7 +720,21 @@ L3 hits with a decoy signature and indexed files under unrelated names — those with clean names only. The CSV has one line per hash: `hash;layer;reason;size;kind;explicit;age;minor;marker2;sizeflag;names;unrelated;publishers;suggest;name`, `suggest` being `whitelist?`, `review`, `keep` or (L3) `poison?`, and `name` -empty when withheld. +empty when withheld. The age rule on a film or picture is always `keep`. + +**`format=hashlists`** gives the additions ready to paste, hashes only: for +`hash_banlist.txt` every film, picture, archive or other file a term layer +(L1, L2, L4) blocked in the window; for `hash_filter.txt` every indexed file +under 3+ unrelated names (decoys); commented out, the audio, document and +software hits to look at first and the ban-list files that carry 3+ unrelated +names. **`format=terms`** gives leads for the term lists: words of 4+ +characters in 5+ blocked files of the window that 90% or more of the files +carrying them are blocked, and that the filter does not block on their own, +as `word;blocked files;served files`. Words only, never a name. +**`format=audio`** lists every blocked audio file (mp3, flac, ogg, wav, m4a and +the rest) by rule, with its name: a sound file is almost never abuse material, +so these are the false-positive leads. A name with a child signal is still +withheld. A hash in `hashlists`' ban-list part never appears among its decoys. **`/api/indexcheck`** runs every indexed file through the filter as it is now. Lists reloaded after a file was indexed were never applied to it; this finds @@ -869,7 +887,8 @@ else the port you announced (`0xF9`, or the one in your `0x60`). | `OP_LOWID_HOLEPUNCH_REQUEST` | `0x60` | client → server (TCP) | `` | | `OP_LOWID_HOLEPUNCH_INFO` | `0x61` | server → both (TCP) | `` | | `OP_LOWID_HOLEPUNCH_FAIL` | `0x62` | server → client (TCP) | `` | -| `OP_SERVER_NATT_KEEPALIVE` | `0x9F` | client → server (UDP) | `` | +| `OP_SERVER_NATT_KEEPALIVE` | `0x9F` | client → server (UDP) | `[]` | +| `OP_SERVERIDENT` tag `ST_NATT_TOKEN` | `0xAC`, 16 bytes | server → client (TCP) | the session's NAT-T token (since 0.9.87) | | `OP_IDCHANGE` TCP flags bit | `0x20000` | server → client (TCP) | set when the server accepts `0x60` | To download from a LowID source **B**, send `0x60` with B's assigned id and your UDP @@ -890,6 +909,19 @@ is appended, the peer's `(CT_SERVER_FLAGS >> 9) & 7` (supports / requests / requires the crypt layer). With the user hash at bytes 8–23 it lets the dialler obfuscate from its first datagram, so a peer that requires the crypt layer accepts the stream. Read it only from 30 bytes or more. +**The NAT-T token (issue #40, since 0.9.87).** A `0x9F` is believed only from the +session's own address, since a user hash is no secret. That leaves out a client +whose carrier NAT sends its UDP out of another public IPv4 than its TCP, and one +logged in over IPv6. So each session that may send `0x9F` (login flag +`0x100000` or `0x1000`, or a UDP port announced in `0xF9`) gets 16 random bytes, +`ST_NATT_TOKEN` (`0xAC`, hash type) in `OP_SERVERIDENT`; a client with +`0x100000` gets that `OP_SERVERIDENT` at login. A `0x9F` of +`` is then the session's own from any address: its source +address and port become what peers are given in `0x61` and `0xA7`, an IPv6 +session included (no `0x62` reason 5 then). A `0x9F` with a wrong token is +ignored. Without the token everything is as before. The token is new on every +login. Counters: `natt_9f_token`, `natt_9f_token_other_ip`, `natt_9f_bad_token`. + (`0x60`–`0x62` are unused in the server TCP opcode space, which runs `0x01`–`0x44`, and `0x9F` was free in the server UDP namespace — no conflict on the server link.) @@ -1017,9 +1049,15 @@ wake-up, the bridge — always carries the requester's own ID, which is never server" alone: punch towards the requester's endpoint, send its `0x9F` burst, and wait to be dialled. -The target must be a logged-in LowID that set `0x1000`. Nothing is sent when -one side requires the crypt layer (the target can't get the requester's hash -here). Every other case gets no answer at all: an unknown, HighID, gone or +The target must be a logged-in LowID that set `0x1000`. The crypt layer: a +target with the extension is dialled by the requester, which gets the +target's hash in the `0xA7` and obfuscates its first datagram with it (an +obfuscated uTP dial opens with a key frame the target answers without knowing +the requester), so a side that requires the layer only needs the other side +to support it; the requester's setting is the crypt byte of its `0xA6`, none +meaning none. A Lugdunum-only target dials the requester itself without its +hash, so there nothing is sent when one side requires the layer (since +0.9.87; before, that rule held for every target). Every other case gets no answer at all: an unknown, HighID, gone or incapable target, a plain request, a limit reached. So the path is no oracle for who is online. Without a session to kick, the limits sit on the source IP and on the target: diff --git a/src/server/connection.rs b/src/server/connection.rs index a33897c..2331438 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -674,6 +674,8 @@ pub(crate) fn end_session(state: &ServerState, c: &ClientHandle) { // must not be handed out with the old mapping (issue #37). state.natt_udp_ports.remove(&c.user_hash); state.natt_keepalive_seen.remove(&c.user_hash); + state.natt_token.remove(&c.user_hash); + state.natt_udp_ip.remove(&c.user_hash); // Retract the id→user mapping. Guarded inside by user hash. state.unindex_client_id(c.assigned_id, &c.user_hash); state.remove_sources_of(&c.user_hash); @@ -823,6 +825,12 @@ async fn dispatch( if let Some(prev) = online { if prev.is_alive() && is_shared_hash(state, &prev.user_hash) { crate::server::holepunch::count(state, "login_takeover_shared_hash"); + } else if prev.is_alive() && may_replace_session(state, &prev, peer.ip(), req.port) { + // A live session taken over from its own address (a NAT + // reconnect, or two installs behind one NAT): set beside + // `replaced_session_frame_refused`, it shows whether the + // fence of #46 makes such pairs take turns. + crate::server::holepunch::count(state, "login_takeover_same_address"); } if !may_replace_session(state, &prev, peer.ip(), req.port) { // A contender this hash's owner already outlasted: two @@ -943,6 +951,15 @@ async fn dispatch( } // A new session proves its 0x9F handling afresh (issue #39). state.natt_keepalive_seen.remove(&new_client.user_hash); + state.natt_udp_ip.remove(&new_client.user_hash); + // And gets a fresh token for its 0x9F (issue #40), if it does NAT-T. + if crate::server::holepunch::wants_natt_token(&new_client) { + let mut t = [0u8; 16]; + t.copy_from_slice(&crate::proto::obfuscation::random_bytes(16)); + state.natt_token.insert(new_client.user_hash, t); + } else { + state.natt_token.remove(&new_client.user_hash); + } let prev = state .clients .insert(new_client.user_hash, new_client.clone()); diff --git a/src/server/holepunch.rs b/src/server/holepunch.rs index eb9f583..e4cd4ba 100644 --- a/src/server/holepunch.rs +++ b/src/server/holepunch.rs @@ -78,6 +78,20 @@ use tracing::{debug, warn}; /// (the next keepalive overwrites it within ~2 min anyway). const OBSERVED_UDP_FRESH: Duration = Duration::from_secs(600); +/// Sessions that get a NAT-T token: those that may send `0x9F` — the +/// extension's `0x100000`, Lugdunum's `0x1000`, or a UDP port announced at +/// login (aMule's hole punch). The tag reaches only a session that is sent +/// `OP_SERVERIDENT`. +pub(crate) fn wants_natt_token(c: &ClientHandle) -> bool { + c.natt_capable || c.server_flags & (SRVCAP_NATTRAVERSAL | SRVFLG_HOLEPUNCH_EXT) != 0 +} + +/// The address to hand peers for this session's UDP: the IPv4 its token +/// `0x9F` proved, when that is not the session's own, else the session's. +pub(crate) fn natt_ip(state: &ServerState, user_hash: &[u8; 16], session_ip: IpAddr) -> IpAddr { + state.natt_udp_ip.get(user_hash).map_or(session_ip, |e| *e) +} + /// The UDP port to hand a peer for the client `user_hash` at `ip` (issue #37). /// In order: /// 1. the source port of the client's own last 0x9F keepalive, if fresh: it @@ -163,6 +177,9 @@ pub fn handle_holepunch_request( requester_udp_port: u16, target_id: u32, ) -> Option { + // Where the requester's UDP really is (issue #40): its token 0x9F may + // have proved another IPv4 than its session's. + let requester_ip = natt_ip(state, requester_user_hash, requester_ip); // Record the requester's UDP port so that, if it is later a target, the // server already knows how to reach it. Cheap single-shard update. This is // the announced port, the last resort of `observed_udp_port`: the port of @@ -196,6 +213,9 @@ pub fn handle_holepunch_request( ) }); + let target_data = target_data.map(|(ip, tcp, udp, hash, high, alive, flags)| { + (natt_ip(state, &hash, ip), tcp, udp, hash, high, alive, flags) + }); let Some((t_ip, t_tcp, t_udp, t_hash, t_high, t_alive, t_flags)) = target_data else { debug!(requester_id, target_id, "holepunch: target not connected"); send_fail( @@ -585,7 +605,10 @@ pub fn request( .client_by_assigned_id(target_id) .filter(|t| t.user_hash != requester_user_hash && !t.is_high_id && t.is_alive()) // No wake-up for a pair that cannot be given IPv4 endpoints. - .filter(|t| session_v4(t.ip).is_some() && session_v4(requester_ip).is_some()); + .filter(|t| { + session_v4(natt_ip(state, &t.user_hash, t.ip)).is_some() + && session_v4(natt_ip(state, &requester_user_hash, requester_ip)).is_some() + }); if let Some(t) = &target { let r_flags = state .clients @@ -654,6 +677,7 @@ enum Ext { /// has for the requester now. None when the crypt settings rule it out. fn wake_body(state: &ServerState, requester_user_hash: &[u8; 16], requester_ip: IpAddr, target_flags: u32) -> Option> { let req = state.clients.get(requester_user_hash).map(|c| c.clone())?; + let requester_ip = natt_ip(state, requester_user_hash, requester_ip); let port = observed_udp_port(state, requester_user_hash, requester_ip, req.udp_port); lugdunum_natt::forward_body(&req, SocketAddr::new(requester_ip, port), target_flags) } @@ -1083,6 +1107,10 @@ mod tests { "nor is an IPv4 requester told an IPv6 target's 0.0.0.0" ); assert_eq!(session_v4("::ffff:1.2.3.4".parse().unwrap()), Some(Ipv4Addr::new(1, 2, 3, 4))); + // Once the IPv6 session's token 0x9F has proved an IPv4 (issue #40), + // the pair is punched to that address. + state.natt_udp_ip.insert(b, IpAddr::V4(Ipv4Addr::new(5, 6, 7, 10))); + assert_eq!(handle_holepunch_request(&state, &b, 201, v6, 4662, 4672, 101), None); } #[test] diff --git a/src/server/login.rs b/src/server/login.rs index 468c836..a4a13ce 100644 --- a/src/server/login.rs +++ b/src/server/login.rs @@ -374,10 +374,15 @@ pub fn build_welcome_batch_with( /// Sent at login only to a client that announced something that needs it /// there: OFFERFILES v1 (the policy tags, issue #19; a client that asked gets /// it even when v1 is off, to learn that it is legacy, see the connection -/// handler) or the IPv6 extension (`ST_IPV6`). Both are recent clients with -/// a correct parser. +/// handler), the IPv6 extension (`ST_IPV6`) or the hole-punch extension +/// (`0x100000`, for `ST_NATT_TOKEN`). All are recent clients with a correct +/// parser. pub fn ident_at_login(client: &ClientHandle) -> bool { - client.offer_policy.is_some() || client.ipv6_capable + client.offer_policy.is_some() + || client.ipv6_capable + // The hole-punch extension, for its NAT-T token (issue #40): a recent + // client too. Not Lugdunum's 0x1000 alone, which older mods set. + || client.server_flags & crate::proto::opcodes::SRVFLG_HOLEPUNCH_EXT != 0 } /// OP_SERVERIDENT: server hash, IP, port and tags. @@ -495,6 +500,13 @@ pub fn build_server_ident(cfg: &Config, state: &ServerState, client: &ClientHand tags.push(Tag::byte(ST_IPV6_STATUS, TagValue::U8(status))); } } + // ST_NATT_TOKEN (issue #40): the session's NAT-T token. A 0x9F carrying + // it after the user hash, , is believed from any + // address: the token went to this session only, over its TCP. Only to a + // session that does NAT-T, so every other SERVERIDENT is unchanged. + if let Some(t) = state.natt_token.get(&client.user_hash) { + tags.push(Tag::byte(ST_NATT_TOKEN, TagValue::Hash16(*t))); + } // OFFERFILES v1 (issue #19): all three or none, string-named so a // client that does not know them skips them. A legacy session (v1 off, // or a configuration that cannot be advertised) gets a SERVERIDENT @@ -539,6 +551,9 @@ const ST_IPV6: u8 = 0xAE; /// `ST_IPV6_STATUS` — the server's verdict on the client's advertised IPv6. const ST_IPV6_STATUS: u8 = 0xAB; +/// `ST_NATT_TOKEN` — the session's NAT-T token, 16 bytes (issue #40). 0xAC: +/// next to the IPv6 tags, and not one eNode-go uses (0xA8, 0xAD, 0xAF). +pub(crate) const ST_NATT_TOKEN: u8 = 0xAC; /// The server holds a public IPv6 for this session. pub(crate) const IPV6ST_HAVE: u8 = 0x01; /// That address is treated as reachable and the client is published as a v6 diff --git a/src/server/lugdunum_natt.rs b/src/server/lugdunum_natt.rs index 393b572..3555eed 100644 --- a/src/server/lugdunum_natt.rs +++ b/src/server/lugdunum_natt.rs @@ -43,7 +43,10 @@ //! marks it foreign. The key is `IPObfuscate(secret, sender IP)`, so a //! datagram that decodes proves the sender's IP; its source port is the //! requester's endpoint. The target gets the usual `0x37` with requester id -//! 0, and no hash (the server does not know the requester's). A target with +//! 0, and no hash (the server does not know the requester's); for a target +//! with the extension a required crypt layer on either side only needs +//! support on the other, since the requester dials with the target's hash +//! from the `0xA7` (0.9.87). A target with //! the extension (0x100000) answers with `0x9F`; the requester then gets //! `0xA7` `` by UDP, //! obfuscated, from the port it wrote to; without that `0x9F` within 3 s, @@ -259,6 +262,24 @@ fn body(ip: Ipv4Addr, port: u16, id: u32, own: u8, hash: Option<&[u8; 16]>, othe Some(b) } +/// Can two crypt settings meet? A side that requires the layer needs the +/// other to support it. +pub fn crypt_compatible(a: u8, b: u8) -> bool { + !((a & 4 != 0 && b & 1 == 0) || (b & 4 != 0 && a & 1 == 0)) +} + +/// The wake-up body for a foreign request to a target with the extension: +/// ``, no hash (the server has none), the requester's +/// setting as it sent it. +fn wake_body(ip: Ipv4Addr, port: u16, id: u32, crypt: u8) -> Vec { + let mut b = Vec::with_capacity(11); + b.extend_from_slice(&ip.octets()); + b.extend_from_slice(&port.to_le_bytes()); + b.extend_from_slice(&id.to_le_bytes()); + b.push(crypt); + b +} + /// The cross-server path is offered: it builds on the extension. pub fn foreign_enabled(cfg: &crate::config::Config) -> bool { cfg.network.holepunch_foreign && crate::server::holepunch::extension_enabled(cfg) @@ -368,15 +389,24 @@ pub fn handle_foreign( if !target.is_alive() { return Err(Refused::TargetGone); } - let body = body( - ip, - peer.port(), - 0, - requester_crypt, - None, - crypt_setting(target.server_flags), - ) - .ok_or(Refused::CryptMismatch)?; + let target_crypt = crypt_setting(target.server_flags); + let extension = crate::server::holepunch::has_extension(state, target.server_flags); + let body = if extension { + // The requester dials (issue #40), and the 0xA7 always gives it the + // target's hash: it can obfuscate its first datagram with it, and an + // obfuscated uTP dial opens with a key frame the target answers + // without knowing the requester. So "requires" on either side is + // fine as long as the other side supports the layer (got3nks). + if !crypt_compatible(requester_crypt, target_crypt) { + return Err(Refused::CryptMismatch); + } + wake_body(ip, peer.port(), 0, requester_crypt) + } else { + // A Lugdunum-only target dials the requester itself, with no hash + // to obfuscate with: a required layer stops it, as before. + body(ip, peer.port(), 0, requester_crypt, None, target_crypt) + .ok_or(Refused::CryptMismatch)? + }; { let mut w = state.natcb_foreign_wakes.entry(target.user_hash).or_default(); while w.front().is_some_and(|t| t.elapsed() >= Duration::from_secs(60)) { @@ -391,13 +421,13 @@ pub fn handle_foreign( // the extension sends 0x9F on the 0x37. Listen before it is woken. crate::server::holepunch::count( state, - if crate::server::holepunch::has_extension(state, target.server_flags) { + if extension { "natcbx_foreign_target_extension" } else { "natcbx_foreign_target_lugdunum_only" }, ); - let answer = if crate::server::holepunch::has_extension(state, target.server_flags) { + let answer = if extension { let mut pseudo = [0xFFu8; 16]; pseudo[..4].copy_from_slice(&ip.octets()); pseudo[4..6].copy_from_slice(&peer.port().to_le_bytes()); @@ -438,8 +468,9 @@ pub fn answer_packet(state: &ServerState, target: &[u8; 16], requester_crypt: u8 if !t.is_alive() || t.is_high_id { return None; } - let ip = v4(t.ip)?; - let port = crate::server::holepunch::observed_udp_port(state, &t.user_hash, t.ip, t.udp_port); + let t_ip = crate::server::holepunch::natt_ip(state, &t.user_hash, t.ip); + let ip = v4(t_ip)?; + let port = crate::server::holepunch::observed_udp_port(state, &t.user_hash, t_ip, t.udp_port); if port == 0 { return None; } @@ -732,6 +763,29 @@ mod tests { assert_eq!(handle(&s, &foreign(13), peer, KEY, 6086).unwrap_err(), Refused::ForeignDisabled); } + /// With the extension the requester dials with the target's hash, so a + /// side that requires the crypt layer is fine when the other supports it + /// (got3nks, #40); without support on the other side it is refused. + #[test] + fn a_required_crypt_layer_on_the_foreign_path() { + let s = st(); + let _r = client(&s, 0xB, 13, [9, 9, 9, 9], EXT | 0xE00); // requires + let _p = client(&s, 0xC, 15, [9, 9, 9, 8], EXT); // no crypt layer + let peer = |i: u8| -> SocketAddr { format!("5.6.7.{i}:40000").parse().unwrap() }; + let with = |id: u32, c: u8| { + let mut p = foreign(id); + p.push(c); + p + }; + let (f, _) = handle(&s, &with(13, 0x01), peer(1), KEY, 6086).unwrap(); + let Forward::Tcp { frame, .. } = f else { panic!("{f:?}") }; + assert_eq!(frame.payload, vec![5, 6, 7, 1, 0x40, 0x9C, 0, 0, 0, 0, 0x01]); + assert_eq!(handle(&s, &with(13, 0x00), peer(2), KEY, 6086).unwrap_err(), Refused::CryptMismatch); + assert_eq!(handle(&s, &foreign(13), peer(3), KEY, 6086).unwrap_err(), Refused::CryptMismatch); + // The requester requires it, the target lacks it: refused. + assert_eq!(handle(&s, &with(15, 0x07), peer(4), KEY, 6086).unwrap_err(), Refused::CryptMismatch); + } + /// A target with the extension owes the requester an answer: its /// endpoint, id, crypt setting and hash (both use the crypt layer). #[tokio::test] @@ -779,6 +833,11 @@ mod tests { let mut p = foreign(15); p.push(0x07); assert_eq!(handle(&s, &p, peer, KEY, 6086).unwrap_err(), Refused::CryptMismatch); + // With the extension the requester dials with the target's hash: a + // side that requires the layer only needs the other to support it. + assert!(crypt_compatible(0x07, 0x01) && crypt_compatible(0x01, 0x07)); + assert!(crypt_compatible(0x07, 0x07) && crypt_compatible(0x00, 0x00)); + assert!(!crypt_compatible(0x07, 0x00) && !crypt_compatible(0x00, 0x07)); let v6: SocketAddr = "[2001:db8::1]:40000".parse().unwrap(); assert_eq!(handle(&s, &foreign(15), v6, KEY, 6086).unwrap_err(), Refused::WrongAddress); } diff --git a/src/server/review_audit.rs b/src/server/review_audit.rs index fc98ce9..2501183 100644 --- a/src/server/review_audit.rs +++ b/src/server/review_audit.rs @@ -60,19 +60,24 @@ pub fn kind_of(name: &str) -> Kind { .map(|(_, e)| e.to_ascii_lowercase()) .unwrap_or_default(); match ext.as_str() { - "mp3" | "flac" | "ogg" | "wma" | "m4a" | "ape" | "wav" | "aac" | "opus" | "mpc" | "wv" - | "mka" | "ac3" | "dts" | "mid" | "midi" | "cue" => Kind::Audio, + "mp3" | "mp2" | "flac" | "ogg" | "oga" | "wma" | "m4a" | "m4b" | "m4r" | "ape" | "wav" + | "wave" | "aac" | "opus" | "mpc" | "wv" | "mka" | "ac3" | "dts" | "mid" | "midi" + | "cue" | "aif" | "aiff" | "amr" | "au" | "ra" | "tta" | "shn" | "spx" | "caf" | "dsf" + | "dff" | "xm" | "mod" | "s3m" | "it" => Kind::Audio, "avi" | "mkv" | "mp4" | "wmv" | "mpg" | "mpeg" | "mov" | "flv" | "m4v" | "ts" | "m2ts" | "vob" | "rmvb" | "rm" | "3gp" | "webm" | "divx" | "ogm" | "asf" | "srt" | "sub" => { Kind::Video } "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tif" | "tiff" => Kind::Image, "pdf" | "epub" | "mobi" | "djvu" | "djv" | "doc" | "docx" | "txt" | "rtf" | "chm" - | "azw" | "azw3" | "fb2" | "lit" | "cbr" | "cbz" | "ppt" | "pptx" | "xls" | "xlsx" - | "odt" | "htm" | "html" | "ps" => Kind::Doc, - "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "tgz" => Kind::Archive, - "exe" | "msi" | "apk" | "dmg" | "iso" | "bin" | "img" | "nrg" | "mdf" | "cab" | "jar" - | "deb" | "rpm" => Kind::Software, + | "azw" | "azw3" | "fb2" | "lit" | "ppt" | "pptx" | "xls" | "xlsx" | "odt" | "htm" + | "html" | "ps" => Kind::Doc, + // Comic archives are pictures, and disc images may hold films: neither + // is a document or a program, whose clean names the audit shows. + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "tgz" | "cbr" | "cbz" | "cb7" => { + Kind::Archive + } + "exe" | "msi" | "apk" | "dmg" | "cab" | "jar" | "deb" | "rpm" => Kind::Software, _ => Kind::Other, } } @@ -202,6 +207,22 @@ impl Signals { } /// The term a rule's reason names, without the decorations the filter adds. +/// Lower-cased, with every run of non-alphanumeric characters as one space. +pub fn separators_to_spaces(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut gap = true; + for c in s.to_lowercase().chars() { + if c.is_alphanumeric() { + out.push(c); + gap = false; + } else if !gap { + out.push(' '); + gap = true; + } + } + out +} + fn reason_term(reason: &str) -> &str { reason.trim_end_matches(" (mojibake)").trim() } @@ -209,14 +230,25 @@ fn reason_term(reason: &str) -> &str { pub fn signals(filter: &ContentFilter, name: &str, layer: Layer, reason: &str) -> Signals { let lower = name.to_lowercase(); // The rule's own term does not count against the name: it is what is - // being judged. + // being judged. Removed from a copy with every separator turned into a + // space, the way the term layers see it: removed from the raw name, a + // term the layers matched across "." or "_" ("Star.Sessions") stayed in, + // tripped the layer again and counted as a second marker. On 31.10.2026 + // that made every L1 and L4 rule a "marker" and withheld every name. let stripped = match layer { Layer::L1Jargon | Layer::L4Extra => { - let t = reason_term(reason).to_lowercase(); - if t.is_empty() { + let t = separators_to_spaces(reason_term(reason)); + if t.trim().is_empty() { lower.clone() } else { - lower.replace(&t, " ") + let spaced = separators_to_spaces(&lower); + let squashed: String = t.chars().filter(|c| *c != ' ').collect(); + let once = spaced.replace(t.trim(), " "); + if squashed.len() > 2 { + once.replace(&squashed, " ") + } else { + once + } } } _ => lower.clone(), @@ -288,8 +320,12 @@ pub fn name_visible(sig: &Signals, verdict: Verdict, layer: Layer, kind: Kind) - } match layer { Layer::L3HashBlock => false, - Layer::L2AgePattern => matches!(kind, Kind::Audio | Kind::Doc | Kind::Software), - _ => true, + // A film, a picture or an archive is never shown, under any rule: a + // clean-looking name there can still be a studio or series marker + // with nothing else in it. The false positives that do occur are + // song, book and program titles that carry a term, and those are + // what is shown. + _ => matches!(kind, Kind::Audio | Kind::Doc | Kind::Software), } } @@ -344,6 +380,7 @@ mod tests { let clean = signals(&f, "Band - Elephant (live).mp3", Layer::L4Extra, "elephant"); assert!(!clean.dirty()); assert!(name_visible(&clean, Verdict::LikelyFalsePositive, Layer::L4Extra, Kind::Audio)); + assert!(!name_visible(&clean, Verdict::LikelyFalsePositive, Layer::L4Extra, Kind::Video)); let dirty = signals(&f, "elephant porno.avi", Layer::L4Extra, "elephant"); assert!(dirty.explicit && dirty.dirty() && !dirty.strong()); assert!(!name_visible(&dirty, Verdict::Mixed, Layer::L4Extra, Kind::Video)); @@ -353,5 +390,10 @@ mod tests { let f2 = f.with_extra_terms(vec!["walrus".to_string()]); let two = signals(&f2, "elephant walrus.mp4", Layer::L4Extra, "elephant"); assert!(two.marker2); + // The term across separators is the rule's own term, not a second one. + let f3 = ContentFilter::new().with_extra_terms(vec!["star sessions".to_string()]); + for n in ["Star.Sessions set 01.zip", "Star_Sessions set 01.zip", "star sessions set.zip"] { + assert!(!signals(&f3, n, Layer::L4Extra, "star sessions").marker2, "{n}"); + } } } diff --git a/src/server/udp.rs b/src/server/udp.rs index 0d1661a..b7b2020 100644 --- a/src/server/udp.rs +++ b/src/server/udp.rs @@ -33,6 +33,8 @@ use tracing::{debug, info, warn}; /// Maximum sources returned in a single UDP GLOBFOUNDSOURCES response. const UDP_MAX_SOURCES: usize = 30; +/// eMule's largest file, 256 GiB. A size above it is not a size. +const MAX_ED2K_FILE_SIZE: u64 = 256 << 30; /// Sources looked at to fill a UDP answer, HighIDs first (issue #49). const UDP_SOURCE_SCAN: usize = 200; @@ -1009,6 +1011,16 @@ impl UdpServer { // address: a user hash is no secret, and a keepalive // from elsewhere would send the client's peers there. let same_ip = canon_ip(h.ip) == canon_ip(peer.ip()); + // Unless it carries the session's NAT-T token (issue + // #40): . The token went to + // this session alone, so the datagram is its own from + // whatever address, and that address is where its + // UDP is. A wrong token is no keepalive at all. + let token = (payload.len() >= 32).then(|| &payload[16..32]); + let token_ok = token.is_some_and(|t| { + self.state.natt_token.get(&uh).is_some_and(|k| k.as_slice() == t) + }); + let token_bad = token.is_some() && !token_ok; // Issue #39: on a server offering the extension, once // a session has sent an obfuscated 0x9F its plain // ones are ignored. Only the server key makes one, @@ -1020,6 +1032,7 @@ impl UdpServer { .is_some_and(|o| *o); let plain_refused = obf.is_none() && sent_obfuscated + && !token_ok && crate::server::holepunch::extension_enabled(&self.state.live_cfg.load()); if plain_refused { *self @@ -1028,7 +1041,14 @@ impl UdpServer { .entry("natt_keepalive_plain_ignored".to_string()) .or_insert(0) += 1; } - kind = if !same_ip { + // Accepted from the session's own address, or with + // the token from any. + let accepted = !token_bad && (same_ip || token_ok); + kind = if token_bad { + "natt_9f_bad_token" + } else if token_ok && !same_ip { + "natt_9f_token_other_ip" + } else if !same_ip { "natt_9f_wrong_ip" } else if plain_refused { "natt_9f_plain_ignored" @@ -1037,7 +1057,17 @@ impl UdpServer { } else { "natt_9f_plain" }; - if same_ip && !plain_refused { + if accepted && !plain_refused { + if token_ok { + crate::server::holepunch::count(&self.state, "natt_9f_token"); + } + // Where peers must punch to: the proven address + // when it is not the session's (issue #40). + if same_ip { + self.state.natt_udp_ip.remove(&uh); + } else { + self.state.natt_udp_ip.insert(uh, canon_ip(peer.ip())); + } // Issue #37: kept per client. The login port // (`udp_port`, 0xF9) stays as announced; it is // filled from here only if the client sent none, @@ -1057,7 +1087,7 @@ impl UdpServer { // source is otherwise silent on TCP and would be wrongly // evicted after the idle timeout despite a live link. h.touch_activity(); - same_ip && !plain_refused + accepted && !plain_refused } else { false }; @@ -1266,6 +1296,15 @@ impl UdpServer { b.copy_from_slice(&payload[i..i + 8]); size = u64::from_le_bytes(b); i += 8; + // Live, a quarter of the size disagreements were records + // with the 0 sentinel followed by 8 bytes no file has (high + // dword random, low byte 0), for files that are not large. + // Not a size: answered by hash alone, as before #45. + if size > MAX_ED2K_FILE_SIZE { + crate::server::holepunch::count(&self.state, "udp_getsources2_bad_size"); + hashes.push((hash, None)); + continue; + } } hashes.push((hash, Some(size))); } diff --git a/src/state/file_id.rs b/src/state/file_id.rs index 5af53a9..4a2b83c 100644 --- a/src/state/file_id.rs +++ b/src/state/file_id.rs @@ -439,7 +439,22 @@ pub fn sizes_agree(a: u64, b: u64) -> bool { return true; } let (lo, hi) = if a < b { (a, b) } else { (b, a) }; - lo <= u64::from(u32::MAX) && hi > u64::from(u32::MAX) && hi & u64::from(u32::MAX) == lo + if lo <= u64::from(u32::MAX) && hi > u64::from(u32::MAX) && hi & u64::from(u32::MAX) == lo { + return true; + } + is_part_estimate(lo, hi) +} + +/// eD2k part size: a file's part hashes cover this much each. +pub const PARTSIZE: u64 = 9_728_000; + +/// Is `lo` the size a client guesses for the file of size `hi` from its +/// part hashes alone, (hashes - 1) × PARTSIZE? A download started from a +/// bare hash has no size until it learns one, and some clients use that +/// lower bound meanwhile: live, most size disagreements on ed2k-rust were +/// exactly this (issue #45). It is the same file. +pub fn is_part_estimate(lo: u64, hi: u64) -> bool { + lo >= PARTSIZE && lo % PARTSIZE == 0 && lo == hi / PARTSIZE * PARTSIZE } /// What `add_or_refresh_source_sized` did. @@ -1517,6 +1532,11 @@ mod tests { assert!(sizes_agree(0, 5) && sizes_agree(5, 0)); assert!(!sizes_agree(big, big + 1)); assert!(!sizes_agree(100, 101)); + // A size guessed from the part hashes: (hashes - 1) x PARTSIZE. + assert!(sizes_agree(471_335_211, 48 * PARTSIZE)); + assert!(sizes_agree(19_456_000, 24_903_680)); + assert!(!sizes_agree(49 * PARTSIZE, 471_335_211), "not a rounding up"); + assert!(!sizes_agree(734_396_416, 738_213_657), "another file"); // Stored cut, then the full size arrives: the record takes it. let slab = FileSlab::new(); let h = [0x46u8; 16]; diff --git a/src/state/mod.rs b/src/state/mod.rs index feaaddd..78799a0 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -638,6 +638,14 @@ pub struct ServerState { /// written only by 0x9F sent from the session's own IP, and preferred by /// the hole-punch coordinator. Swept with `observed_udp_ports`. pub natt_udp_ports: DashMap, + /// NAT-T session token (issue #40): 16 random bytes sent to the session + /// in `OP_SERVERIDENT` (`ST_NATT_TOKEN`). A `0x9F` that carries it is the + /// session's own from whatever address it comes. + pub natt_token: DashMap, + /// The IPv4 a session's `0x9F` proved with its token, when it is not the + /// session's own address (a carrier NAT that maps UDP elsewhere, or a + /// session over IPv6). Handed to peers instead of the session address. + pub natt_udp_ip: DashMap, /// Each client's last Lugdunum NAT callback request (UDP 0xA6): its /// endpoint, the key to answer it with, and its request allowance /// (`server::lugdunum_natt`). Swept after `lugdunum_natt::SEEN_TTL`. @@ -1624,6 +1632,8 @@ impl ServerState { incoming_seed_challenges: DashMap::new(), observed_udp_ports: DashMap::new(), natt_udp_ports: DashMap::new(), + natt_token: DashMap::new(), + natt_udp_ip: DashMap::new(), lugdunum_natt_seen: DashMap::new(), natt_keepalive_seen: DashMap::new(), natt_keepalive_waiters: DashMap::new(), @@ -1964,6 +1974,16 @@ impl ServerState { .with_record_by_hash(hash, |_id, r| r.size) .unwrap_or(0); let differs = !crate::state::file_id::sizes_agree(stored, want); + if !differs + && stored != 0 + && crate::state::file_id::is_part_estimate(stored.min(want), stored.max(want)) + { + // A size guessed from the part hashes (issue #45): served. + *self + .block_stats + .entry("getsources_size_part_estimate".to_string()) + .or_insert(0) += 1; + } if differs { // Which side is larger tells a wrong link (asked bigger or // smaller than every publisher) from anything systematic. diff --git a/src/web.rs b/src/web.rs index 2836ab6..00d8608 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2709,8 +2709,13 @@ async fn api_review(State(s): State, uri: axum::http::Uri) -> impl Int // The audit: the same window, reduced to signals, with a name shown only // where nothing but the rule that fired points at abuse. - if let Some(fmt) = param("format").filter(|f| *f == "audit" || *f == "auditcsv") { - let out = audit_report(&s, &by_hash, since, &window_desc, fmt == "auditcsv", max_examples); + if let Some(fmt) = param("format") + .filter(|f| matches!(*f, "audit" | "auditcsv" | "hashlists" | "terms" | "audio")) + { + let out = match fmt { + "terms" => term_candidates(&s, &by_hash, &window_desc), + _ => audit_report(&s, &by_hash, since, &window_desc, fmt, max_examples), + }; if !flag("peek") { if let Ok(mut g) = s.server.review_watermark.lock() { *g = Some(now); @@ -3041,9 +3046,10 @@ fn audit_report( by_hash: &std::collections::HashMap<[u8; 16], (crate::state::CsamCatch, Vec)>, since: Option, window_desc: &str, - csv: bool, + mode: &str, max_examples: usize, ) -> String { + let csv = mode == "auditcsv"; use crate::filter::Layer; use crate::server::review_audit::{self as ra, Verdict}; let filter = &s.server.filter; @@ -3128,7 +3134,15 @@ fn audit_report( .get(&(format!("{:?}", e.layer), e.reason.clone())) .copied() .unwrap_or(Verdict::Marker); - if e.sig.strong() || v == Verdict::Marker { + // The age rule on a film or a picture is never a whitelist lead. Its + // unpaired notations ("12yo", "13y") block on the age alone, so a + // missing second signal is the rule working, not a sign of a false + // positive: on 31.10.2026 the audit suggested 1160 such hashes for + // the whitelist, every one a video or picture with an age and no + // other word to judge it by. + let age_on_media = e.layer == Layer::L2AgePattern + && !matches!(e.kind, ra::Kind::Audio | ra::Kind::Doc | ra::Kind::Software); + if e.sig.strong() || v == Verdict::Marker || age_on_media { "keep" } else if v == Verdict::LikelyFalsePositive && !e.sig.explicit { "whitelist?" @@ -3139,6 +3153,110 @@ fn audit_report( }; let b = |x: bool| if x { "1" } else { "0" }; + // Every blocked audio file, by rule, with its name: a sound file is + // almost never abuse material, so these are the false-positive leads. + // A name with a child signal (an age, a child word, a second listed + // term) is still withheld. + if mode == "audio" { + let mut aud: Vec<&Entry> = entries.iter().filter(|e| e.kind == ra::Kind::Audio).collect(); + aud.sort_by(|a, b| { + (format!("{:?}", a.layer), &a.reason, &a.name).cmp(&(format!("{:?}", b.layer), &b.reason, &b.name)) + }); + let mut out = format!( + "# ed2k-server blocked audio files, {window_desc}: {} files. Almost always a\n\ + # song or book title that carries a term. Names with a child signal (an age,\n\ + # a child word, a second listed term) are withheld.\n\ + # hash | layer | rule | MB | name\n", + aud.len() + ); + for e in aud { + let sig = if e.layer == Layer::L3HashBlock { + ra::signals(filter, &e.name, e.layer, &e.reason) + } else { + e.sig + }; + let name = if sig.strong() { + "[withheld: child signal]".to_string() + } else { + e.name.chars().take(160).collect::() + }; + out.push_str(&format!( + "{} | {:?} | {} | {} | {}\n", + hex::encode(e.hash), + e.layer, + e.reason, + e.size / 1_048_576, + name + )); + } + return out; + } + + // Ready-made additions for the two hash lists, hashes only. + if mode == "hashlists" { + let media = |e: &Entry| !matches!(e.kind, ra::Kind::Audio | ra::Kind::Doc | ra::Kind::Software); + let term_hit = |e: &Entry| !matches!(e.layer, Layer::L3HashBlock | Layer::L5Poison); + let mut ban: Vec<&Entry> = entries.iter().filter(|e| term_hit(e) && media(e)).collect(); + let mut look: Vec<&Entry> = entries.iter().filter(|e| term_hit(e) && !media(e)).collect(); + ban.sort_by(|a, b| (format!("{:?}", a.layer), &a.reason).cmp(&(format!("{:?}", b.layer), &b.reason))); + look.sort_by(|a, b| (format!("{:?}", a.layer), &a.reason).cmp(&(format!("{:?}", b.layer), &b.reason))); + let mut out = format!( + "# ed2k-server hash list candidates, {window_desc}. Hashes only, never a name.\n\n\ + # ===== hash_banlist.txt: {} files a term layer (L1, L2, L4) blocked =====\n\ + # Films, pictures, archives and other files. Not audio, documents or\n\ + # software: those are below, and format=audit shows their clean names.\n", + ban.len() + ); + for e in &ban { + out.push_str(&format!("{} ; {:?} {} {}\n", hex::encode(e.hash), e.layer, e.reason, e.kind.as_str())); + } + out.push_str(&format!( + "\n# ===== look first: {} possible false positives =====\n\ + # Audio, documents and software: song, book and program titles that carry\n\ + # a term. format=audit shows the clean ones. Not for either list as they are.\n", + look.len() + )); + for e in &look { + out.push_str(&format!("# {} ; {:?} {} {}\n", hex::encode(e.hash), e.layer, e.reason, e.kind.as_str())); + } + // Decoys: indexed files advertised under 3+ unrelated names. + let banned: std::collections::HashSet<[u8; 16]> = ban.iter().map(|e| e.hash).collect(); + let mut decoys: Vec<([u8; 16], usize, u64)> = s + .server + .file_aliases + .iter() + .map(|e| (*e.key(), unrelated_name_count(&e.value().names), e.value().size)) + .filter(|(h, u, _)| *u >= 3 && !s.server.filter.is_whitelisted(h)) + // A hash some publisher named with a listed term goes to the ban + // list, not here: the two lists never share a hash. + .filter(|(h, _, _)| !banned.contains(h)) + .collect(); + decoys.sort_by_key(|d| (std::cmp::Reverse(d.1), std::cmp::Reverse(d.2))); + out.push_str(&format!( + "\n# ===== hash_filter.txt: {} indexed files under 3+ unrelated names =====\n\ + # Decoys: blocked without accusing whoever shares them.\n", + decoys.len() + )); + for (h, u, size) in &decoys { + out.push_str(&format!("{} ; decoy, {} unrelated names, {} B\n", hex::encode(h), u, size)); + } + let l3: Vec<&Entry> = entries + .iter() + .filter(|e| e.layer == Layer::L3HashBlock && e.unrelated >= 3) + .collect(); + out.push_str(&format!( + "\n# ===== on hash_banlist.txt with 3+ unrelated names: {} =====\n\ + # A decoy, or abuse material passed around under other names; both look\n\ + # alike from here. Move one to hash_filter.txt only once it is known to be\n\ + # a decoy.\n", + l3.len() + )); + for e in &l3 { + out.push_str(&format!("# {} ; {} unrelated names, {} B\n", hex::encode(e.hash), e.unrelated, e.size)); + } + return out; + } + if csv { let mut out = String::from( "hash;layer;reason;size;kind;explicit;age;minor;marker2;sizeflag;names;unrelated;publishers;suggest;name\n", @@ -3170,9 +3288,11 @@ fn audit_report( let l3: Vec<&Entry> = entries.iter().filter(|e| e.layer == Layer::L3HashBlock).collect(); let mut out = format!( "# ed2k-server filter audit\n# window: {window_desc}\n# blocks in window: {} unique hashes; L3 hash-list hits: {} (counted, never named)\n\ - # A name is shown only when nothing in it but the rule that fired points at abuse\n\ - # (no explicit word, no child's age, no child word, no second listed term), and\n\ - # its rule is not a working marker. Everything else is counted.\n\n", + # A name is shown only for audio, documents and software, only when nothing in it\n\ + # but the rule that fired points at abuse (no explicit word, no child's age, no\n\ + # child word, no second listed term), and only when its rule is not a working\n\ + # marker. Everything else is counted. format=hashlists: list additions;\n\ + # format=terms: term leads.\n\n", entries.len(), l3.len() ); @@ -3200,7 +3320,13 @@ fn audit_report( cnt(&|e| e.kind == ra::Kind::Image), cnt(&|e| !matches!(e.kind, ra::Kind::Audio | ra::Kind::Doc | ra::Kind::Video | ra::Kind::Image)), pubs, - verdicts[*k].as_str(), + // Same reason as in `suggest`: for the age rule a low share of + // second signals is how it is built, not a false-positive sign. + if k.0 == "L2AgePattern" && verdicts[*k] == Verdict::LikelyFalsePositive { + "age alone (by design)" + } else { + verdicts[*k].as_str() + }, k.0, k.1 )); @@ -3334,6 +3460,76 @@ fn audit_report( out } +/// `/api/review?format=terms`: words that keep turning up in blocked names +/// and almost nowhere else, as leads for the term lists. Every word of 4+ +/// characters in the window's blocked names (by distinct file) is counted +/// against the whole index; listed when 5+ blocked files carry it, 90% or +/// more of the files that carry it were blocked, and the filter does not +/// already block the word on its own. Words only, never a name. +fn term_candidates( + s: &WebState, + by_hash: &std::collections::HashMap<[u8; 16], (crate::state::CsamCatch, Vec)>, + window_desc: &str, +) -> String { + use std::collections::{HashMap, HashSet}; + let words = |n: &str| -> HashSet { + crate::server::review_audit::separators_to_spaces(n) + .split(' ') + .filter(|w| w.chars().count() >= 4 && !w.chars().all(|c| c.is_ascii_digit())) + .map(str::to_string) + .collect() + }; + let mut blocked: HashMap = HashMap::new(); + for (_, names) in by_hash.values() { + let mut seen = HashSet::new(); + for n in names { + seen.extend(words(n)); + } + for w in seen { + *blocked.entry(w).or_insert(0) += 1; + } + } + let filter = &s.server.filter; + let mut cands: HashMap = blocked + .into_iter() + .filter(|(w, n)| { + *n >= 5 + && matches!( + filter.check_ignoring_whitelist(&[0u8; 16], w), + crate::filter::FilterResult::Allow + ) + }) + .map(|(w, n)| (w, (n, 0))) + .collect(); + // How often each candidate appears in indexed (served) names. + s.server.file_slab.for_each_live_copied(|chunk| { + for (_, _, _, name, _) in chunk { + for w in words(&name) { + if let Some(c) = cands.get_mut(&w) { + c.1 += 1; + } + } + } + }); + let mut list: Vec<(String, usize, usize)> = cands + .into_iter() + .map(|(w, (b, i))| (w, b, i)) + .filter(|(_, b, i)| *b as f64 / (*b + *i) as f64 >= 0.9) + .collect(); + list.sort_by_key(|(w, b, i)| (std::cmp::Reverse(*b), *i, w.clone())); + let mut out = format!( + "# ed2k-server term candidates, {window_desc}: words in 5+ blocked files and\n\ + # almost never in served ones, not blocked by the filter on their own.\n\ + # Leads, not rules: a studio, series or site name is a good term; a name of\n\ + # a person or an ordinary word needs care. Test before adding.\n\ + # word;blocked files;served files\n" + ); + for (w, b, i) in list { + out.push_str(&format!("{w};{b};{i}\n")); + } + out +} + /// `/api/natt`: every NAT-T counter since start, and the live gauges, for /// analysing the hole punch, Lugdunum's callback, the extension and the /// cross-server path. Counters are `block_stats` keys with these prefixes: From 66b892d215821d499dd9c351f14e234ae309d2f1 Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Sun, 1 Nov 2026 18:27:36 +0100 Subject: [PATCH 5/5] ci: enforce upstream-clean server sources --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ AGENTS.md | 8 +++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a75056..e19a8ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,28 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + UPSTREAM_COMMIT: 3742f9e4bc64e36faf4b8ed4cb355be3744ff96c jobs: + upstream-clean: + name: Upstream-owned paths are unchanged + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Fetch reviewed upstream commit + run: >- + git fetch --no-tags --depth=1 + https://github.com/andrey23127/ed2k-server.git + "${UPSTREAM_COMMIT}" + + - name: Reject fork source divergence + run: | + git diff --exit-code --ignore-space-at-eol "${UPSTREAM_COMMIT}" -- \ + .cargo CHANGELOG.md CPU-TUNING.md Cargo.lock Cargo.toml LICENSE \ + README.md UPDATE-SERVICE.md assets config contrib examples src tests + msrv: name: Rust 1.75 locked test runs-on: ubuntu-24.04 diff --git a/AGENTS.md b/AGENTS.md index e3d1744..66aa6dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,11 @@ Everything below is this repo's local deltas only: under `EMULEBB_WORKSPACE_OUTPUT_ROOT`. - The fork is the sole approved local server for deterministic harness and parity runs. The retired Go server is archived and must not be reintroduced. -- Follow upstream closely. Limit local server changes to generic, upstreamable - testing and observability improvements; keep provider-specific configuration, - compatibility translation, fixtures, and orchestration in the harness. +- Keep every upstream-owned runtime, protocol, configuration, dependency, test, + and documentation path identical to the reviewed upstream commit pinned by + CI. Do not carry local server behavior, observability, or compatibility + changes. Contribute generic changes upstream first; keep probes, fixtures, + diagnostics adapters, and orchestration in the harness. - The fork is not an eMuleBB product or production service. Do not wire it into release or production campaigns without an explicit follow-up decision. - Upstream updates are reviewed merges from the `upstream` remote. Do not add