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/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e8f6d..5973571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,52 +2,358 @@ Earlier releases are described in their release commit messages. -## Unreleased - -### Displaced-session fencing - -- A newer login with the same user hash now retires and closes the displaced - socket immediately. The old connection is checked before every subsequent - frame, so it cannot keep publishing files or issuing relays after replacement. -- Session identity and retirement are explicit shared lifecycle state rather - than being inferred only from the current client-map entry. Replacement still - preserves the newer session's ID mapping, sources and admission slot. - -### Verified live HighID - -- Public IPv4 clients now enter as provisional LowID and are upgraded in place - only after an `OP_HELLOANSWER` proves the advertised listener belongs to the - same user hash. A bare open port never grants HighID. -- The upgrade queues a second `OP_IDCHANGE`, then atomically moves the shared - session route, ID index and cached LowID count. Replaced sessions cannot - upgrade their successor, and disconnect cleanup sees the final route. -- Wrong hash, no answer, refusal, timeout, probe shedding and closed push - channels all keep LowID. Recent wrong-hash evidence remains available at - `/api/highid_mismatches`. -- Removed `network.highid_verify_observe`, - `network.highid_downgrade_on_wrong_hash` and - `network.highid_wrong_hash_ttl_secs`; the remembered mark/TTL state and its - API fields are gone. Status counters are now `highid_verify_*`. - -### Crypt-compatible source selection - -- TCP source replies now exclude peers when either side requires protocol - obfuscation and the other side did not advertise support. Mere preference is - not an eligibility constraint; plaintext fallback remains available. -- `OP_FOUNDSOURCES_OBFU` now appends the source hash only when both peers support - crypt and either one requests it, matching Lugdunum, eMule and aMule. Other - compatible records retain the seven-byte extended form without a hash. -- Source filtering scans past incompatible prefixes before applying the - 200-source reply cap. Crypt-specific replies bypass the shared SmartSources - cache so one requester's capability class cannot leak into another's answer. +## 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 +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 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 @@ -80,7 +386,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 @@ -91,15 +407,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. @@ -108,7 +436,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 @@ -127,9 +455,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) @@ -142,6 +473,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/Cargo.lock b/Cargo.lock index a49ef58..b0a0a6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,9 +40,9 @@ checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] @@ -128,18 +128,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.117", ] [[package]] @@ -150,9 +150,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" @@ -235,9 +235,9 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bitflags" -version = "2.13.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -250,9 +250,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" @@ -262,9 +262,9 @@ checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" [[package]] name = "cc" -version = "1.9.0" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -272,9 +272,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" @@ -322,7 +322,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -360,9 +360,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -401,7 +401,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -446,6 +446,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -472,7 +483,7 @@ dependencies = [ [[package]] name = "ed2k-server" -version = "0.9.84" +version = "0.9.87" dependencies = [ "aho-corasick", "anyhow", @@ -490,6 +501,7 @@ dependencies = [ "hex", "hyper", "hyper-util", + "idna_adapter", "indexmap", "libc", "md-5", @@ -519,15 +531,14 @@ dependencies = [ "tracing", "tracing-subscriber", "ureq", - "url", "zeroize", ] [[package]] name = "equivalent" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -541,9 +552,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fiat-crypto" @@ -553,9 +564,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.15" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" @@ -593,9 +604,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -603,15 +614,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -620,38 +631,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -723,9 +734,9 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -735,9 +746,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -745,9 +756,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", @@ -755,9 +766,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", @@ -837,14 +848,143 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "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 = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "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 = "0.5.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -871,26 +1011,27 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.106" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] [[package]] name = "lazy_static" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.191" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" @@ -898,6 +1039,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + [[package]] name = "lock_api" version = "0.4.14" @@ -909,9 +1056,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "matchers" @@ -940,9 +1087,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -1007,9 +1154,9 @@ checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] @@ -1122,7 +1269,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1158,23 +1305,23 @@ checksum = "a8f355e230af5c0f5f95c46c5aeca3ce7bcb4e3e73300b44d7064b281ec8f124" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1217,14 +1364,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.18", - "regex-syntax 0.8.11", + "regex-automata 0.4.14", + "regex-syntax 0.8.10", ] [[package]] @@ -1238,13 +1385,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.11", + "regex-syntax 0.8.10", ] [[package]] @@ -1255,9 +1402,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "ring" @@ -1275,9 +1422,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.28" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc_version" @@ -1290,9 +1437,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -1337,9 +1484,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1376,7 +1523,7 @@ checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1431,9 +1578,9 @@ dependencies = [ [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -1486,6 +1633,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.10.0" @@ -1500,9 +1653,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1511,9 +1664,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.9" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -1526,11 +1679,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -1556,14 +1720,14 @@ checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "thread_local" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] @@ -1619,10 +1783,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] -name = "tinyvec" -version = "1.13.3" +name = "tinystr" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] name = "tokio" @@ -1651,7 +1819,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1667,9 +1835,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.19" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -1782,7 +1950,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1826,30 +1994,15 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" -version = "1.0.27" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" - -[[package]] -name = "unicode-normalization" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" -dependencies = [ - "tinyvec", -] +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "untrusted" @@ -1877,15 +2030,28 @@ dependencies = [ [[package]] name = "url" -version = "2.5.0" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1912,18 +2078,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.129" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -1934,9 +2100,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.129" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1944,22 +2110,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.129" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.129" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2025,7 +2191,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2036,7 +2202,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2226,6 +2392,18 @@ 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.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "yasna" version = "0.5.2" @@ -2235,8 +2413,75 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerovec" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml index be55775..7528cf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ed2k-server" -version = "0.9.84" +version = "0.9.87" edition = "2021" description = "Modern eDonkey2000 / eMule index server (test/MVP build)" license = "MIT" @@ -65,10 +65,6 @@ chrono = { version = "=0.4.38", features = ["serde"] } # everything else, and to a version whose MSRV is below the 1.75 this crate # targets. ureq = { version = "=2.9.7", default-features = false, features = ["tls", "json"] } -# ureq accepts newer url 2.x releases, but url 2.5.8 pulls ICU data crates -# whose manifests require Edition 2024-aware Cargo. Keep the declared Rust -# 1.75 floor usable until the fork deliberately raises its MSRV. -url = "=2.5.0" # Signature verification for downloaded data files. The public key is compiled # in; see src/updates.rs for why the signature matters more than the transport. @@ -94,16 +90,11 @@ clap = { version = "=4.4.18", features = ["derive"] } [dev-dependencies] tokio-test = "=0.4.4" -# tempfile 3.25+ permits getrandom 0.4, whose manifest requires Rust 1.85. -# Keep the test-only dependency on the last line constrained to getrandom 0.3. -tempfile = "=3.24.0" +tempfile = "3" # Pin transitive deps that pulled in edition2024 with newer Rust. # These are dependencies-of-dependencies; we list them to force the # resolver to a version compatible with Rust 1.75 (Debian/Ubuntu stable). -[dependencies.base64ct] -version = "=1.6.0" - [dependencies.indexmap] version = "=2.6.0" @@ -111,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 @@ -120,11 +126,6 @@ version = "=0.2.0" version = "=1.13.2" features = ["union"] -# The security crates accept any zeroize 1.x release. 1.9 switched its -# manifest to Edition 2024, so retain the latest Rust 1.75-compatible line. -[dependencies.zeroize] -version = "=1.8.1" - # Memory allocator (Stage 4): jemalloc replaces the system glibc malloc to cut # fragmentation and RSS on the many-small-allocations index workload. Target- # gated so it never builds on MSVC/Windows (jemalloc is unsupported there); the diff --git a/README.md b/README.md index ee5f315..b07511c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,5 @@ # ed2k-server -> **eMuleBB workspace note:** This repository is the managed reference fork of -> upstream `andrey23127/ed2k-server` and the sole local deterministic server for -> harness and parity runs. The retired Go server is archived and must not be -> reintroduced. The fork follows upstream closely. Local changes are limited to -> generic, upstreamable testing and observability improvements plus workspace -> CI; provider-specific compatibility belongs in the harness. - A modern, open-source **eDonkey2000 / eMule index server**, written from scratch in **Rust**. It is a clean-room replacement for the long-unmaintained, closed-source *Lugdunum* eserver — built to be lean, memory-efficient, and @@ -20,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. @@ -60,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 @@ -335,11 +334,17 @@ a watched file) or needs a **restart**. | `login_timeout_ms` | Login handshake timeout | restart | | `support_crypt` | Advertise protocol obfuscation support | restart | | `hairpin_lan_clients` | Let a client on the server's own network reach HighID (see below). Off by default | live | -| `private_highid_probe` | For routed private deployments/labs, let a private client earn HighID by answering an identity probe on its observed address and advertised port. Off by default | live | +| `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 `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 @@ -365,8 +370,8 @@ a watched file) or needs a **restart**. > `server.this_ip` on the client's port — in that topology the client's public > address is by definition the server's own. > -> It runs only after the private login has started LowID, so it can add a HighID -> but never take one away, and it requires the peer to answer a +> It runs only after the ordinary probe has already decided LowID, so it can add +> a HighID but never take one away, and it requires the peer to answer a > client-to-client handshake with **the user hash that just logged in**. That > check is what makes it safe: where a router source-NATs hairpin traffic, every > local client arrives from the same address and cannot be told apart, so a port @@ -376,34 +381,41 @@ a watched file) or needs a **restart**. > Off by default because a server in a datacentre may see private addresses from > a management network that has nothing to do with its public address. -> **`private_highid_probe` — for a routed private server or compatibility lab.** -> A client arriving from RFC1918 space can earn HighID only when the server can -> connect to that same address and advertised TCP port and the peer answers with -> the user hash that logged in. A bare open port or a different client's answer -> keeps LowID. A verified private HighID source is published only to requesters -> that also arrived from private space, and those replies bypass the shared -> source cache. Leave this off on an Internet-facing server. - -> **HighID identity verification is always on.** A public IPv4 client first -> receives a provisional LowID in the normal welcome batch. After that answer -> has been sent, the server connects to the advertised client port, sends -> `OP_HELLO`, and compares the `OP_HELLOANSWER` user hash with the hash that -> logged in. Matching hashes upgrade the same live session to HighID and queue -> a second `OP_IDCHANGE`; stock eMule, aMule and eMuleBB all accept that in-place -> reassignment without reconnecting. +> **HighID hello check (`highid_verify_observe`, `highid_downgrade_on_wrong_hash`).** +> HighID is decided by a TCP connect to the client's address and port. That +> proves *something* is listening there, not that it is this client. Behind a +> NAT shared by two eD2k clients — two machines at home, or strangers behind a +> provider's CGNAT where one of them has the port open — both claim the default +> port, the forward leads to one of them, and the other one used to get a HighID +> every peer is sent to the wrong machine by. +> +> With either option on, a client that passed the connect is also sent a +> client-to-client `OP_HELLO` in the background, and the user hash in its +> answer is compared with the one that logged in (the two client-type marker +> bytes are ignored: some clients send MLDonkey's in the hello and eMule's at +> login). The login is **never delayed** — a synchronous check was tried, and +> clients whose login is still pending mostly do not answer in time. > -> Wrong hash, no answer, refusal, timeout, a saturated probe budget, or a closed -> session all leave the client LowID. This removes the first-session false -> HighID window and the old remembered mark/TTL state. The type-marker bytes are -> ignored when comparing hashes because some clients send MLDonkey's marker in -> the hello and eMule's at login. Counters stay on the Status tab and recent -> wrong-hash evidence stays in `/api/highid_mismatches`. +> In verdict mode a *different* hash marks (IP, port, login hash) for +> `highid_wrong_hash_ttl_secs`, and that client's next logins get LowID +> immediately, which lets it be reached through server callback. Its first +> session keeps the HighID. Every later login re-checks in the background: its +> own hash again (forwarding fixed) clears the mark, a wrong hash renews it. +> +> **No answer never costs HighID.** A silent close or a reset is what eMule's IP +> filter does to us, a timeout proves nothing, and Lugdunum-style "no answer → +> LowID" would take HighID from clients that are in fact reachable. Only a +> positive answer from another client counts. The hairpin path above is +> separate and unchanged. ### `[limits]` | Key | Meaning | Apply | |---|---|---| | `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 | @@ -486,11 +498,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`: @@ -510,10 +525,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). @@ -529,7 +548,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 @@ -553,8 +572,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 @@ -678,6 +697,53 @@ 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 (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 +(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. 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 +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 @@ -789,7 +855,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 @@ -818,14 +885,17 @@ 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_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 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. @@ -834,7 +904,24 @@ 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. 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. +**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.) @@ -869,6 +956,127 @@ 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, 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, 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 + `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. + +### 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`. 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: + +- 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 @@ -883,25 +1091,64 @@ 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. ---- -## Source obfuscation compatibility +### Watching NAT-T: `/api/natt` -The server records each client's protocol-obfuscation settings from bits 9-11 -of the login `CT_SERVER_FLAGS` tag: support, request and require. Source -selection follows the same symmetric rule as Lugdunum, eMule and aMule: +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. -- when both peers support crypt and either one requests it, the source is - returned as a hash-bearing crypt record; -- when either peer requires crypt but mutual support is absent, the source is - excluded; and -- every other pair remains compatible through plaintext fallback. +### `OP_IDCHANGE` TCP flag bits in use -`OP_FOUNDSOURCES_OBFU` always carries the source's three option bits. Its -`0x80` marker and 16-byte user hash are added only for the first case above. -Legacy `OP_FOUNDSOURCES` applies the same eligibility rule but retains its -six-byte source records. Replies whose result depends on requester capabilities -bypass the shared SmartSources cache. +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` | +| `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 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: + +| 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 | +| `0x200000` | "wake me", no standing PR_NAT registration (`SRVCAP_NAT_WAKE`) | eNode-go | --- @@ -930,16 +1177,18 @@ 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 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: @@ -957,6 +1206,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. @@ -981,7 +1242,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. @@ -1346,12 +1611,6 @@ sorted and paged 100 at a time. The same filter is available as (limit at most 500). `GET /api/clients` still returns every connected client for scripts. -`GET /api/files/<32-hex-ed2k-hash>` returns one indexed file without scanning -the file catalogue. The response includes the canonical name and size, total -and complete source counts, and up to 1,024 source endpoints. A -`sources_truncated` flag says when the counts exceed the returned detail. An -invalid hash returns `400`; a valid hash that is not indexed returns `404`. - `OP_SERVERIDENT` goes out once per connection, as from Lugdunum after `OP_SERVERLIST` in the answer to `OP_GETSERVERLIST`; at login only to a client that asked for OFFERFILES v1 or speaks the IPv6 extension. Sending it to @@ -1414,9 +1673,9 @@ showed two letters instead. The page loads nothing from other hosts. `GET /api/highid_mismatches` exports the recent wrong-hash cases of the HighID check (the last 1000) with a summary: which answering clients are connected -from the same address, plus repeated addresses and hashes. `?anonymize=1` -replaces each address with its /24 plus a per-process salted tag, so repeats -stay visible and the export can be shared. +from the same address, repeated addresses and hashes, and whether a mark was +set. `?anonymize=1` replaces each address with its /24 plus a per-process salted +tag, so repeats stay visible and the export can be shared. --- diff --git a/config/config.toml b/config/config.toml index 9990aac..80439ef 100644 --- a/config/config.toml +++ b/config/config.toml @@ -81,13 +81,6 @@ support_crypt = true # weakened, this option must go with it. hairpin_lan_clients = false -# Routed private deployments and local compatibility labs only. When enabled, -# a client arriving from RFC1918 space can earn HighID only by answering an -# identity probe on that same address and advertised TCP port with the user hash -# that logged in. Private HighID sources are published only to private requesters. -# Off by default so an Internet-facing server never trusts private ingress. -private_highid_probe = false - # ── HighID hello check ────────────────────────────────────────────────────── # HighID is decided by a TCP connect to the client's port. That proves something # listens there, not that it is THIS client: behind a NAT shared by two eD2k @@ -107,6 +100,25 @@ private_highid_probe = 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 = 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 +# 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 @@ -123,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 @@ -311,11 +337,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/admission.rs b/src/admission.rs index 8138f97..48666c4 100644 --- a/src/admission.rs +++ b/src/admission.rs @@ -166,8 +166,8 @@ impl ClientSlots { } /// Reserve regardless of the cap: a client replacing its own session is - /// always let in. Registration retires the displaced task and releases its - /// old slot immediately after the new session takes ownership. + /// always let in, and its stale task still holds the old slot until it + /// ends. pub fn force_reserve(&self) -> ClientSlot { self.held.fetch_add(1, Relaxed); ClientSlot(Arc::clone(&self.held)) diff --git a/src/config.rs b/src/config.rs index 8e34266..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, @@ -128,10 +168,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 +186,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 +220,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 +286,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 +301,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() @@ -822,17 +920,87 @@ pub struct NetworkConfig { #[serde(default)] pub hairpin_lan_clients: bool, - /// Let a client that reaches the server from a private IPv4 address earn - /// HighID by answering an identity probe on that same address and its - /// advertised TCP port. + /// OBSERVE ONLY: after a client gets HighID from the plain TCP probe, also + /// run the identity probe (OP_HELLO → OP_HELLOANSWER with the user hash that + /// logged in) in the background, and count what it finds. The verdict is + /// NOT changed and the login does NOT wait for it. + /// + /// Why: HighID is decided by a bare TCP connect. A stock Lugdunum requires a + /// protocol answer — measured, it reports "No answer from your NNNN port" + /// and gives LowID to a port that accepts connections but does not speak + /// eD2k, where this server gives HighID. Such a client is published as a + /// source nobody can download from. Before tightening the verdict to match, + /// this counts how many real HighID clients would lose it, and why — so the + /// change is decided on numbers, not on a guess. /// - /// Intended for routed private deployments and compatibility labs where - /// the server and clients can reach the same private network. The peer must - /// answer with the user hash that logged in; a bare open port is not enough. - /// Off by default so Internet-facing servers never treat private ingress as - /// a publishable route. + /// Cost: one extra outbound connection per HighID login (two if the client + /// advertised obfuscation and refuses it), bounded by a concurrency cap so a + /// reconnect storm after a restart cannot turn into a probe storm. + /// + /// Does not touch the hairpin path: that runs only when the plain probe has + /// already FAILED, and this only when it has SUCCEEDED. #[serde(default)] - pub private_highid_probe: bool, + pub highid_verify_observe: bool, + + /// VERDICT, remembered: when a HighID client's port answers OP_HELLO with + /// a DIFFERENT user hash (type-marker bytes ignored, see + /// `same_client_hash`), remember (address, port, login hash) for + /// `highid_wrong_hash_ttl_secs`, and give that client LowID on its next + /// logins while the mark lasts. + /// + /// That answer proves the forward belongs to another eD2k client: two + /// machines behind one NAT, both on the default port, the router forwarding + /// it to one of them. The other one holds a HighID every peer is sent to + /// the wrong machine by; as LowID it is reached through server callback. + /// + /// The check runs in the BACKGROUND and the login is answered at once — a + /// synchronous check was tried and measured: clients whose login is still + /// pending mostly do not answer the hello in time, so the login waited and + /// the check caught little. The price is that the first session after a + /// mismatch keeps its HighID; such clients reconnect often. + /// + /// A marked client is re-checked in the background on every login. If its + /// port now answers with its own hash (forwarding fixed), the mark is + /// dropped and the next login gets HighID again. A wrong hash again renews + /// the mark. No answer leaves it as it is. + /// + /// Every outcome other than a different hash keeps HighID exactly as + /// before: silent close and reset (the peer's IP filter drops us), timeout, + /// refused, garbage. Supersedes `highid_verify_observe` while on (the same + /// counters are fed). Hairpin path unaffected. + #[serde(default)] + pub highid_downgrade_on_wrong_hash: bool, + + /// How long a wrong-hash mark lasts, seconds. Default 86400 (a day). + #[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, 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. /// @@ -860,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 { @@ -892,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. /// @@ -1167,6 +1370,9 @@ fn default_max_decompressed() -> u32 { fn default_login_timeout_ms() -> u64 { 2000 } +fn default_highid_wrong_hash_ttl_secs() -> u64 { + 86_400 +} fn default_true() -> bool { true } @@ -1193,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 } @@ -1383,6 +1593,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\ @@ -1409,7 +1636,6 @@ mod tests { fn udp_port_is_derived_from_tcp_port() { let cfg = Config::minimal_test_config(); assert_eq!(cfg.network.tcp_port, 4661); - assert!(!cfg.network.private_highid_probe); // Protocol-fixed offset: main UDP is always TCP+4. assert_eq!(cfg.network.udp_port(), 4665); } 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 cb35766..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, @@ -897,6 +901,46 @@ 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 + .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); // 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 @@ -927,6 +971,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 @@ -961,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 c9683ac..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 @@ -136,13 +140,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 +223,27 @@ 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. +/// +/// 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; @@ -301,6 +325,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/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/callback.rs b/src/server/callback.rs index da10417..ec7142f 100644 --- a/src/server/callback.rs +++ b/src/server/callback.rs @@ -33,7 +33,7 @@ pub async fn handle_callback_request( framed: &mut Framed, ) -> Result<()> { // Only HighID clients can initiate callbacks — LowID has no routable address. - if !requester.is_high_id() { + if !requester.is_high_id { debug!( ip = %requester.ip, "CALLBACKREQUEST from LowID — ignoring" @@ -163,7 +163,7 @@ mod tests { fn client(flags: u32, hash: u8) -> ClientHandle { ClientHandle { user_hash: [hash; 16], - route: crate::state::ClientRoute::shared(0x0A00_0001, true), + assigned_id: 0x0A00_0001, ip: IpAddr::V4(std::net::Ipv4Addr::new(1, 0, 0, 10)), port: 4662, udp_port: 0, @@ -172,6 +172,7 @@ mod tests { server_flags: flags, ipv6_capable: false, ipv6: None, + is_high_id: true, connected_at: std::time::Instant::now(), country: "??".into(), software: "test".into(), @@ -182,7 +183,7 @@ mod tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), } } diff --git a/src/server/connection.rs b/src/server/connection.rs index 73d9add..2331438 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -8,7 +8,7 @@ use crate::config::Config; use crate::proto::{opcodes::*, CryptStream, Ed2kCodec, Frame}; use crate::server::callback::handle_callback_request; -use crate::server::get_sources::{handle_get_sources, GetSourcesRequest, SourceReplyFormat}; +use crate::server::get_sources::{handle_get_sources, GetSourcesRequest}; use crate::server::login::{handle_login, LoginRequest}; /// OFFERFILES batches of up to this many records are handled on the runtime. const OFFER_INLINE_MAX: usize = 8; @@ -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, @@ -218,11 +372,23 @@ pub async fn handle_admitted_connection( // dark on BOTH TCP and UDP for that whole span AND the socket somehow never // closed — effectively never, but bounds truly leaked sessions. let logged_in_backstop = std::time::Duration::from_secs(6 * 3600); + // A session that a newer login of the same user has replaced is no + // longer in `clients`: it gets no server pings, its files and sources + // belong to the new session, and it holds nothing but a socket and a + // 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 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(); - // Bound to the login's explicit session identity after LOGINREQUEST. - // A same-hash replacement retires that identity and wakes this task. - let mut retirement = tokio::sync::watch::channel(false).1; - let mut retirement_bound = false; + // 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(), &[]); @@ -237,37 +403,57 @@ pub async fn handle_admitted_connection( loop { // The next deadline depends on whether the client has logged in yet. - let deadline = if let Some(c) = &client { + let mut replaced = false; + let (deadline, wake) = if let Some(c) = &client { let now = tokio::time::Instant::now(); let tcp_idle = last_activity.elapsed(); - // Logged in: rely on the socket for liveness. Use the loose - // backstop, refreshed by the more recent of TCP / UDP activity, - // so a NAT-T LowID source that only speaks UDP — or a plain - // client that is simply quiet — is NOT evicted while its TCP - // link is alive. - let udp_idle = std::time::Duration::from_millis(c.idle_ms()); - now + logged_in_backstop.saturating_sub(tcp_idle.min(udp_idle)) + replaced = !state + .clients + .get(&c.user_hash) + .is_some_and(|e| e.same_session(c)); + let deadline = if replaced { + // Only this socket's own traffic counts: the shared UDP clock + // is bumped through the live session's handle, not this one. + now + replaced_idle.saturating_sub(tcp_idle) + } else { + // Logged in: rely on the socket for liveness. Use the loose + // backstop, refreshed by the more recent of TCP / UDP activity, + // so a NAT-T LowID source that only speaks UDP — or a plain + // client that is simply quiet — is NOT evicted while its TCP + // link is alive. + let udp_idle = std::time::Duration::from_millis(c.idle_ms()); + now + logged_in_backstop.saturating_sub(tcp_idle.min(udp_idle)) + }; + 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. - login_deadline + (login_deadline, login_deadline) }; tokio::select! { - biased; - changed = retirement.changed(), if retirement_bound => { - if changed.is_ok() && *retirement.borrow() { - debug!(ip = %peer.ip(), "session replaced — closing displaced socket"); - state - .replaced_sessions_closed - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - break; + _ = 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; } - } - _ = tokio::time::sleep_until(deadline) => { if client.is_none() { debug!(ip = %peer.ip(), "login timeout — no LOGINREQUEST received, dropping"); + } else if replaced { + debug!(ip = %peer.ip(), idle_s = replaced_idle.as_secs(), + "replaced session silent — closing its socket"); + state + .replaced_sessions_closed + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); } else { // DIAGNOSTIC: info-level so the operator can see exactly when // and why a logged-in client is evicted, and which clock was @@ -288,14 +474,33 @@ 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() { - if c.is_retired() || !state.is_current_session(c) { - c.retire(); + 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(), - "session replaced — rejecting frame and closing displaced socket"); - state - .replaced_sessions_closed - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + 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; } } @@ -310,22 +515,8 @@ pub async fn handle_admitted_connection( &mut pending_search, &mut sess, peer, frame )).await; // Logged in: no longer a pending login. - if let Some(c) = client.as_ref() { + if client.is_some() { pending_login.take(); - if !retirement_bound { - retirement = c.retirement(); - retirement_bound = true; - // A third login can replace this one while its - // welcome batch is still being written. A fresh - // subscriber sees the current value but has no - // change event to await, so close it here. - if *retirement.borrow() { - state - .replaced_sessions_closed - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - break; - } - } } if let Err(e) = res { // Throttled: a banned publisher's client retries every @@ -384,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; @@ -428,7 +628,7 @@ pub async fn handle_admitted_connection( } if let Some(c) = client { - info!(ip = %peer.ip(), nick = %c.nick, id = c.assigned_id(), "client disconnected"); + info!(ip = %peer.ip(), nick = %c.nick, id = c.assigned_id, "client disconnected"); c.release_slot(); end_session(&state, &c); } @@ -458,7 +658,7 @@ pub(crate) fn end_session(state: &ServerState, c: &ClientHandle) { .remove_if(&c.user_hash, |_, entry| entry.same_session(c)) .is_some(); if removed { - if !c.is_high_id() { + if !c.is_high_id { state .lowid_count_cached .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); @@ -469,11 +669,15 @@ 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); + 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.unindex_client_id(c.assigned_id, &c.user_hash); state.remove_sources_of(&c.user_hash); } else { // Replaced, or already gone. If the live session holds a different @@ -481,9 +685,9 @@ pub(crate) fn end_session(state: &ServerState, c: &ClientHandle) { // still map to this user in the id index: retract that mapping // and nothing else. If it holds the same id (a HighID reconnect // from the same address), everything belongs to it. - let live_id = state.clients.get(&c.user_hash).map(|e| e.assigned_id()); - if live_id.is_some_and(|id| id != c.assigned_id()) { - state.unindex_client_id(c.assigned_id(), &c.user_hash); + let live_id = state.clients.get(&c.user_hash).map(|e| e.assigned_id); + if live_id.is_some_and(|id| id != c.assigned_id) { + state.unindex_client_id(c.assigned_id, &c.user_hash); } } } @@ -615,6 +819,47 @@ 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"); + } 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 + // 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 @@ -643,7 +888,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,39 +927,59 @@ async fn dispatch( // adjustment our LowID counter would double-count until the old // task eventually times out and decrements. eMule then reports // a LowID count > total connected clients. - state.index_client_id(new_client.assigned_id(), new_client.user_hash); + state.index_client_id(new_client.assigned_id, new_client.user_hash); // Side table of publisher IPv6 addresses. Written here, next to the // 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). + 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()); - // Retire the displaced task and give its logged-in slot back now: - // one user, one active session and one slot. The explicit signal - // wakes its connection loop even when the old socket is silent. + // The replaced session (a NAT-dropped connection whose task has not + // noticed yet) gives its logged-in slot back now: one user, one + // slot, or routine reconnects would fill max_clients with dead + // sessions until their sockets time out. if let Some(old) = &prev { - old.retire(); old.release_slot(); } // Maintain cached lowid count so handle_servstat doesn't do an O(N) // iter on every UDP probe (was 2.58% of CPU in v0.9.36 profile). if let Some(old) = &prev { - if !old.is_high_id() { + if !old.is_high_id { state .lowid_count_cached .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); } } - if !new_client.is_high_id() { + if !new_client.is_high_id { state .lowid_count_cached .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -729,14 +1003,18 @@ 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?; } - crate::server::login::spawn_public_highid_verification( - Arc::clone(state_arc), - new_client, - cfg.network.tcp_port, - ); + 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 => { @@ -933,7 +1211,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, @@ -947,14 +1226,7 @@ async fn dispatch( crate::server::bot_detector::note_banned(state_arc, peer.ip(), &[]); return Ok(()); } - let reply_format = if frame.opcode == OP_GETSOURCES_OBFU { - SourceReplyFormat::Obfuscated - } else { - SourceReplyFormat::Classic - }; - framed - .send(handle_get_sources(state, c, req, reply_format)) - .await?; + framed.send(handle_get_sources(state, c, req)).await?; } } @@ -993,18 +1265,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"); } @@ -1028,23 +1312,12 @@ 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.assigned_id, c.ip, c.port, requester_udp_port, @@ -1188,7 +1461,7 @@ mod session_end_tests { fn login(st: &ServerState, id: u32, ip: [u8; 4], high: bool, file: u8) -> ClientHandle { let h = ClientHandle { user_hash: [7; 16], - route: crate::state::ClientRoute::shared(id, high), + assigned_id: id, ip: IpAddr::V4(Ipv4Addr::from(ip)), port: 4662, udp_port: 0, @@ -1197,6 +1470,7 @@ mod session_end_tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: high, connected_at: Instant::now(), country: "??".into(), software: "test".into(), @@ -1207,9 +1481,9 @@ mod session_end_tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)), }; - st.index_client_id(h.assigned_id(), h.user_hash); + st.index_client_id(h.assigned_id, h.user_hash); st.clients.insert(h.user_hash, h.clone()); st.add_file_with_source( [file; 16], @@ -1282,20 +1556,6 @@ mod session_end_tests { let b = login(&st, 5, [1, 0, 0, 10], true, 1); assert!(!a.same_session(&b), "same id, same IP, different session"); } - - #[test] - fn retirement_is_shared_and_visible_to_late_subscribers() { - let st = state(); - let session = login(&st, 5, [1, 0, 0, 10], true, 1); - let early = session.retirement(); - - session.retire(); - - assert!(session.is_retired()); - assert!(*early.borrow()); - let late = session.retirement(); - assert!(*late.borrow()); - } } #[cfg(test)] @@ -1323,3 +1583,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 1771b3a..a4221c9 100644 --- a/src/server/get_sources.rs +++ b/src/server/get_sources.rs @@ -13,61 +13,44 @@ use tracing::debug; /// Maximum sources returned per response (SPEC.md §6.2.6 SmartSources tiers). const MAX_SOURCES_PER_REPLY: usize = 200; -/// 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; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum SourceReplyFormat { - Classic, - Obfuscated, -} - -impl SourceReplyFormat { - fn opcode(self) -> u8 { - match self { - Self::Classic => OP_FOUNDSOURCES, - Self::Obfuscated => OP_FOUNDSOURCES_OBFU, - } - } +/// 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 } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SourceCryptDisposition { - Plain, - Crypt, - Incompatible, +/// 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 } -const CLIENT_CRYPT_MASK: u32 = - CAPABLE_SUPPORTCRYPT | CAPABLE_REQUESTCRYPT | CAPABLE_REQUIRECRYPT; +/// 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; -/// Apply the source compatibility rule used by Lugdunum and enforced by -/// eMule/aMule when they receive a source. -/// -/// A hash-bearing crypt record is useful only when both peers support crypt and -/// either one requests it. A hard requirement without mutual support is -/// incompatible. All other pairs may use a plain connection. -fn source_crypt_disposition( - requester_flags: u32, - source_flags: u32, -) -> SourceCryptDisposition { - let both_support = requester_flags & source_flags & CAPABLE_SUPPORTCRYPT != 0; - let either_requests = (requester_flags | source_flags) & CAPABLE_REQUESTCRYPT != 0; - if both_support && either_requests { - SourceCryptDisposition::Crypt - } else if (requester_flags | source_flags) & CAPABLE_REQUIRECRYPT != 0 { - SourceCryptDisposition::Incompatible - } else { - SourceCryptDisposition::Plain - } -} +/// 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; #[derive(Debug)] 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 { @@ -112,23 +95,48 @@ impl GetSourcesRequest { Ok(Self { file_hash: hash, size, + obfu: false, }) } } -/// Build the matching FOUNDSOURCES frame for the given request. Returns Frame -/// even when there are no sources (count=0) — clients expect a reply for every -/// request. +/// Build a FOUNDSOURCES frame for the given file. Returns Frame even when +/// there are no sources (count=0) — clients expect a reply for every request. /// /// Hot-path optimization: results are cached in the SmartSources cache for a /// few seconds. For popular files this turns a source-list iteration + encode -/// into a single map lookup. The cache key is the canonical file id; the requester is +/// into a single map lookup. The cache key is the file hash; the requester is /// not part of the key, so we must still filter "self" out of a cache hit — /// but that's a cheap scan over an already-built payload, done below. /// A well-formed FOUNDSOURCES payload carrying zero sources. /// /// 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); @@ -136,11 +144,10 @@ fn encode_empty_sources(file_hash: &[u8; 16]) -> Vec { payload.to_vec() } -pub(crate) fn handle_get_sources( +pub fn handle_get_sources( state: &ServerState, requester: &ClientHandle, req: GetSourcesRequest, - reply_format: SourceReplyFormat, ) -> Frame { // Hash lists first, BEFORE the cache. Handing out sources is how a download // actually starts, so a listed file must stop being served here too, not @@ -149,24 +156,22 @@ pub(crate) fn handle_get_sources( // // Ahead of the cache on purpose: a cached payload built before the hash was // listed would otherwise keep being served for the life of its TTL. - let file_id = state.file_slab.id_for_request(&req.file_hash, req.size); - let indexed_file = file_id.and_then(|id| { - state - .file_slab - .with_record(id, |r| (r.name().to_string(), r.size)) - }); + let withheld_name = state + .file_slab + .with_record_by_hash(&req.file_hash, |_id, r| r.name().to_string()); if state .filter - .is_withheld_opt( - &req.file_hash, - indexed_file.as_ref().map(|(name, _size)| name.as_str()), - ) + .is_withheld_opt(&req.file_hash, withheld_name.as_deref()) { debug!( file_hash = hex::encode(req.file_hash), "getsources refused: file is withheld by the content filter" ); - return Frame::new(reply_format.opcode(), 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 @@ -181,67 +186,87 @@ pub(crate) 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. - let live = state.live_cfg.load(); - let v6_capable = live.network.ipv6_publish_sources + 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(); - let private_highid_routes = live.network.private_highid_probe - && matches!(requester.ip, std::net::IpAddr::V4(ip) if ip.is_private()); - let requester_specific = reply_format == SourceReplyFormat::Obfuscated - || requester.server_flags & CLIENT_CRYPT_MASK != 0 - || !requester.is_high_id() - || private_highid_routes; + && !requester.is_high_id; - // Fast path: a freshly-cached payload for this canonical file id. + // Fast path: a freshly-cached payload for this hash. // - // ⚠ NOT FOR A REQUESTER-SPECIFIC REPLY. The cache is keyed on the file id - // alone. LowID source eligibility depends on the requester's NAT-T - // capability, crypt compatibility depends on both peers' login flags, - // IPv6-capable requesters receive longer sentinel records, and verified - // private HighID routes must never reach public requesters. + // ⚠ NOT FOR A SENTINEL-CAPABLE REQUESTER. The cache is keyed on the file + // hash alone, and the payload now depends on WHO asked: a v6-capable + // session and a legacy one must receive different bytes for the same + // file. Serving one from a payload built for the other hands a legacy + // client a record 16 bytes longer than it expects, and it mis-parses the + // rest of the packet. // // Bypassing rather than adding the capability to the key, because the - // cache exists to absorb bursts of identical public HighID requests. The - // requester-specific classes are uncommon and correctness matters more - // than splitting this hot cache into several key spaces. + // cache exists to absorb bursts of identical requests and v6-capable + // 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); - if !requester_specific { - if let Some(file_id) = file_id { - if let Some(payload) = state - .smart_sources - .get_with(file_id, |p, owners| without_owner(p, owners, me)) - { - debug!( - file_hash = hex::encode(req.file_hash), - "getsources answered from SmartSources cache" - ); - return Frame::new(OP_FOUNDSOURCES, payload); - } + let reach = Reach::of(requester); + if !v6_capable { + if let Some(payload) = state + .smart_sources + .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(reply_opcode(&req), payload); } } // Slow path: build the source list from the index. // Only the sources are copied out, not the whole record and its name. - let sources = file_id - .and_then(|id| state.file_slab.with_record(id, |entry| { + let sources = state + .file_slab + .with_record_by_hash(&req.file_hash, |_id, entry| { entry .sources .iter() // The requester is NOT filtered out here: the payload built // from this list is cached and served to everyone who asks for - // the file, so it must contain every compatible source. Each - // requester's own record is removed when the payload is served - // (below, and on a cache hit above). + // the file, so it must contain every source. Each requester's + // own record is removed when the payload is served (below, and + // on a cache hit above). One extra is taken so that removing + // it still leaves a full reply. + // // - // Route and crypt filtering happens after this lock is released - // because it consults the live-client map. Walk the whole source - // set so incompatible peers at the front cannot hide compatible - // peers behind them. Shared classic replies are cached after - // this rebuild. + // 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), @@ -255,37 +280,23 @@ pub(crate) fn handle_get_sources( // leave the client parsing past the end of the payload. let mut entries = BytesMut::new(); let mut emitted: u8 = 0; - let record_cap = if requester_specific { - MAX_SOURCES_PER_REPLY - } else { - MAX_SOURCES_PER_REPLY + 1 - }; - let mut owners: Vec = Vec::with_capacity(if requester_specific { - 0 - } else { - record_cap - }); + let mut owners: Vec = Vec::with_capacity(sources.len()); for s in &sources { - if emitted as usize == record_cap { + // A v6-capable reply is never cached, so it is built for this + // requester alone: skip their own record and stop at the cap here. A + // classic payload keeps everyone; it is cut per requester when served. + if v6_capable { + if s.user_hash == requester.user_hash { + continue; + } + 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; } - // A requester-specific reply is never cached, so it is built for this - // requester alone: skip their own record. A shared HighID payload keeps - // everyone; it is cut per requester later. - if requester_specific && s.user_hash == requester.user_hash { - continue; - } - - let source_handle = state.clients.get(&s.user_hash); - let source_flags = source_handle - .as_ref() - .map(|handle| handle.server_flags) - .unwrap_or(0); - let crypt_disposition = - source_crypt_disposition(requester.server_flags, source_flags); - if crypt_disposition == SourceCryptDisposition::Incompatible { - continue; - } // Encode the source ID the way eD2k clients expect: // * HighID source -> its real IPv4 (client connects directly) // * LowID source -> its server-assigned low ID (< 0x01000000), so the @@ -315,48 +326,38 @@ pub(crate) fn handle_get_sources( // that same source and reaches it by callback, exactly as before. That // is why gating per requester loses nothing rather than forcing a choice // between the two addresses. - let source_is_live_lowid = source_handle - .as_ref() - .map(|handle| !handle.is_high_id()) - .unwrap_or(false); - let v6 = if v6_capable && source_is_live_lowid { + let v6 = if v6_capable { state .client_ipv6 .get(&s.user_hash) .map(|e| *e.value()) .filter(|a| crate::server::login::is_publishable_ipv6(*a)) + .filter(|_| { + !state + .clients + .get(&s.user_hash) + .map(|h| h.is_high_id) + .unwrap_or(false) + }) } else { None }; - // Classic eD2k has no usable LowID-to-LowID route. Preserve that source - // only when both live clients advertised this server's NAT-T extension. - // A HighID requester can still reach a LowID source through callback, - // and a publishable IPv6 sentinel is already a direct route. - if v6.is_none() && source_is_live_lowid && !requester.is_high_id() { - let source_is_natt = source_handle - .as_ref() - .map(|handle| handle.natt_capable) - .unwrap_or(false); - if !(requester.natt_capable && source_is_natt) { - continue; - } + 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, source_handle.as_deref()) { + 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(), - (None, Some(_)) => { - if ServerState::is_publishable_source_ip(s.ip()) - || (private_highid_routes - && matches!(s.ip(), std::net::IpAddr::V4(ip) if ip.is_private())) - { - s.ipv4 - } else { - continue; - } - } - (None, None) => { + (None, Some(handle)) if !handle.is_high_id => handle.assigned_id, + (None, _) => { // STALE SOURCE. The client is gone, so there is no low id to // substitute and the stored address goes out verbatim. If that // address is private it means nothing outside one LAN: every @@ -373,12 +374,10 @@ pub(crate) fn handle_get_sources( }; entries.put_u32_le(id); entries.put_u16_le(s.port()); - if reply_format == SourceReplyFormat::Obfuscated { - let hash_follows = crypt_disposition == SourceCryptDisposition::Crypt; - let mut crypt_options = ((source_flags >> 9) & 0x07) as u8; - crypt_options |= u8::from(hash_follows) << 7; - entries.put_u8(crypt_options); - if hash_follows { + 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); } } @@ -389,9 +388,7 @@ pub(crate) fn handle_get_sources( entries.put_slice(&a.octets()); } emitted += 1; - if !requester_specific { - 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(); @@ -400,53 +397,106 @@ pub(crate) fn handle_get_sources( payload.put_slice(&entries); let payload_vec = payload.to_vec(); - if requester_specific { + if v6_capable { // Already this requester's own reply (see the loop). Not cached, for // the reason at the read side. - return Frame::new(reply_format.opcode(), payload_vec); + return Frame::new(reply_opcode(&req), payload_vec); } - // Cache the payload with every compatible source, and serve this requester - // the same cut every later requester gets. It used to be cached with the first + // 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); - if let Some(file_id) = file_id { - state.smart_sources.put(file_id, payload_vec, owners); - } - Frame::new(reply_format.opcode(), 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; @@ -588,429 +638,68 @@ mod tests { ))); } - fn classic_records(frame: &Frame) -> Vec<(u32, u16)> { - let payload = &frame.payload; - let count = payload[16] as usize; - assert_eq!(payload.len(), 17 + count * 6); - (0..count) - .map(|i| { - let offset = 17 + i * 6; - ( - u32::from_le_bytes(payload[offset..offset + 4].try_into().unwrap()), - u16::from_le_bytes(payload[offset + 4..offset + 6].try_into().unwrap()), - ) - }) - .collect() - } - - fn one_source_state(source_highid: bool, source_udp: u16) -> (ServerState, [u8; 16]) { - use std::net::{IpAddr, Ipv4Addr}; - let state = ServerState::for_test(); - let file_hash = [0x77u8; 16]; - let source = [0xAAu8; 16]; - state.add_file_with_source( - file_hash, - 1000, - "f".into(), - ( - source, - IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)), - 4001, - true, - ), - ); - state.register_test_client( - source, - if source_highid { 0x0102_0304 } else { 42 }, - source_highid, - source_udp, - ); - (state, file_hash) - } - - fn ask_as( - state: &ServerState, - file_hash: [u8; 16], - user_hash: [u8; 16], - highid: bool, - udp: u16, - ) -> Frame { - state.register_test_client( - user_hash, - if highid { 0x0506_0708 } else { 77 }, - highid, - udp, - ); - let requester = state.clients.get(&user_hash).unwrap().clone(); - handle_get_sources( - state, - &requester, - GetSourcesRequest { - file_hash, - size: None, - }, - SourceReplyFormat::Classic, - ) - } - - #[test] - fn highid_requester_gets_lowid_callback_record() { - let (state, file_hash) = one_source_state(false, 0); - let records = classic_records(&ask_as(&state, file_hash, [0xC1; 16], true, 0)); - assert_eq!(records, vec![(42, 4001)]); - } - - #[test] - fn crypt_disposition_matches_client_and_lugdunum_rules() { - use SourceCryptDisposition::{Crypt, Incompatible, Plain}; - - let support = CAPABLE_SUPPORTCRYPT; - let request = support | CAPABLE_REQUESTCRYPT; - let require = request | CAPABLE_REQUIRECRYPT; - for (requester, source, expected) in [ - (0, 0, Plain), - (0, support, Plain), - (0, request, Plain), - (0, require, Incompatible), - (support, 0, Plain), - (support, support, Plain), - (support, request, Crypt), - (support, require, Crypt), - (request, 0, Plain), - (request, support, Crypt), - (request, request, Crypt), - (request, require, Crypt), - (require, 0, Incompatible), - (require, support, Crypt), - (require, request, Crypt), - (require, require, Crypt), - ] { - assert_eq!( - source_crypt_disposition(requester, source), - expected, - "requester=0x{requester:04x}, source=0x{source:04x}" - ); - } - } - #[test] - fn obfuscated_reply_maps_source_crypt_options_and_user_hash() { - let (state, file_hash) = one_source_state(true, 0); - let source_hash = [0xAA; 16]; - let requester_hash = [0xC1; 16]; - state.register_test_client(requester_hash, 0x0506_0708, true, 0); - let support = CAPABLE_SUPPORTCRYPT; - let request = support | CAPABLE_REQUESTCRYPT; - let require = request | CAPABLE_REQUIRECRYPT; - - for (requester_flags, source_flags, count, expected_options, has_hash) in [ - (support, support, 1, 0x01, false), - (request, support, 1, 0x81, true), - (support, request, 1, 0x83, true), - (0, request, 1, 0x03, false), - (0, require, 0, 0x00, false), - (require, 0, 0, 0x00, false), - (require, require, 1, 0x87, true), - ] { - state.clients.get_mut(&requester_hash).unwrap().server_flags = requester_flags; - state.clients.get_mut(&source_hash).unwrap().server_flags = source_flags; - let requester = state.clients.get(&requester_hash).unwrap().clone(); - let reply = handle_get_sources( - &state, - &requester, - GetSourcesRequest { - file_hash, - size: None, - }, - SourceReplyFormat::Obfuscated, - ); - - assert_eq!(reply.opcode, OP_FOUNDSOURCES_OBFU); - assert_eq!(reply.payload[16], count); - assert_eq!( - reply.payload.len(), - 17 + usize::from(count) * (4 + 2 + 1 + usize::from(has_hash) * 16) - ); - if count == 1 { - assert_eq!( - u32::from_le_bytes(reply.payload[17..21].try_into().unwrap()), - u32::from_le_bytes([198, 51, 100, 7]) - ); - assert_eq!( - u16::from_le_bytes(reply.payload[21..23].try_into().unwrap()), - 4001 - ); - assert_eq!(reply.payload[23], expected_options); - if has_hash { - assert_eq!(&reply.payload[24..40], &source_hash); - } - } - } - } - - #[test] - fn classic_replies_filter_only_hard_crypt_incompatibilities() { - let (state, file_hash) = one_source_state(true, 0); - let source_hash = [0xAA; 16]; - let requester_hash = [0xC1; 16]; - state.register_test_client(requester_hash, 0x0506_0708, true, 0); - let support = CAPABLE_SUPPORTCRYPT; - let request = support | CAPABLE_REQUESTCRYPT; - let require = request | CAPABLE_REQUIRECRYPT; - - for (requester_flags, source_flags, expected_count) in [ - (0, require, 0), - (support, require, 1), - (require, support, 1), - (require, 0, 0), - ] { - state.clients.get_mut(&requester_hash).unwrap().server_flags = requester_flags; - state.clients.get_mut(&source_hash).unwrap().server_flags = source_flags; - let requester = state.clients.get(&requester_hash).unwrap().clone(); - let reply = handle_get_sources( - &state, - &requester, - GetSourcesRequest { - file_hash, - size: None, - }, - SourceReplyFormat::Classic, - ); - assert_eq!(classic_records(&reply).len(), expected_count); - } - } - - #[test] - fn compatible_source_after_incompatible_prefix_is_not_hidden() { + fn lowid_source_encoded_as_assigned_id() { use std::net::{IpAddr, Ipv4Addr}; - let state = ServerState::for_test(); - let file_hash = [0x78; 16]; - let require = - CAPABLE_SUPPORTCRYPT | CAPABLE_REQUESTCRYPT | CAPABLE_REQUIRECRYPT; - for index in 0..=MAX_SOURCES_PER_REPLY { - let mut user_hash = [0xA0; 16]; - user_hash[..2].copy_from_slice(&(index as u16).to_le_bytes()); - state.add_file_with_source( - file_hash, - 1000, - "f".into(), - ( - user_hash, - IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)), - 4000 + index as u16, - true, - ), - ); - state.register_test_client(user_hash, 0x0102_0304 + index as u32, true, 0); - state.clients.get_mut(&user_hash).unwrap().server_flags = require; - } - - let plain_hash = [0xBB; 16]; - state.add_file_with_source( - file_hash, - 1000, - "f".into(), - ( - plain_hash, - IpAddr::V4(Ipv4Addr::new(198, 51, 100, 8)), - 4999, - true, - ), - ); - state.register_test_client(plain_hash, 0x0506_0708, true, 0); - let requester_hash = [0xC1; 16]; - let reply = ask_as(&state, file_hash, requester_hash, true, 0); - assert_eq!( - classic_records(&reply), - vec![(u32::from_le_bytes([198, 51, 100, 8]), 4999)] - ); - } - - #[test] - fn wrong_size_does_not_reuse_cached_sources() { - let (state, file_hash) = one_source_state(true, 0); - let requester_hash = [0xC1; 16]; - state.register_test_client(requester_hash, 0x0506_0708, true, 0); - let requester = state.clients.get(&requester_hash).unwrap().clone(); - - let correct = handle_get_sources( - &state, - &requester, - GetSourcesRequest { - file_hash, - size: Some(1000), - }, - SourceReplyFormat::Classic, - ); - let indexed_ip = u32::from_le_bytes([198, 51, 100, 7]); - assert_eq!(classic_records(&correct), vec![(indexed_ip, 4001)]); - - let wrong = handle_get_sources( - &state, - &requester, - GetSourcesRequest { - file_hash, - size: Some(1001), - }, - SourceReplyFormat::Classic, - ); - assert!(classic_records(&wrong).is_empty()); - - let legacy = handle_get_sources( + let file_hash = [0x77u8; 16]; + let low_uh = [0xAAu8; 16]; + let high_uh = [0xBBu8; 16]; + + // A firewalled (LowID) source and a HighID source for the same file. + let low_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)); + let high_ip = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)); + state.add_file_with_source(file_hash, 1000, "f".into(), (low_uh, low_ip, 4001, true)); + state.add_file_with_source(file_hash, 1000, "f".into(), (high_uh, high_ip, 4002, true)); + + // Register both as live clients: low id 42 (firewalled), high id (real). + 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, HighID (a + // stock LowID one is not given LowID sources, issue #49). + let req_uh = [0xCCu8; 16]; + state.register_test_client(req_uh, 0x0506_0708, true, 0); + let requester = state.clients.get(&req_uh).unwrap().clone(); + + let frame = handle_get_sources( &state, &requester, GetSourcesRequest { file_hash, size: None, + obfu: false, }, - SourceReplyFormat::Classic, - ); - assert_eq!(classic_records(&legacy), vec![(indexed_ip, 4001)]); - } - - #[test] - fn same_hash_size_variants_keep_sources_and_cache_separate() { - use std::net::{IpAddr, Ipv4Addr}; - - let (state, file_hash) = one_source_state(true, 0); - let second_source = [0xAB; 16]; - state.add_file_with_source( - file_hash, - 2000, - "other-size.bin".into(), - ( - second_source, - IpAddr::V4(Ipv4Addr::new(198, 51, 100, 8)), - 4002, - true, - ), - ); - state.register_test_client(second_source, 0x0102_0305, true, 0); - let requester_hash = [0xC1; 16]; - state.register_test_client(requester_hash, 0x0506_0708, true, 0); - let requester = state.clients.get(&requester_hash).unwrap().clone(); - - let ask = |size| { - handle_get_sources( - &state, - &requester, - GetSourcesRequest { - file_hash, - size, - }, - SourceReplyFormat::Classic, - ) - }; - - assert_eq!( - classic_records(&ask(Some(1000))), - vec![(u32::from_le_bytes([198, 51, 100, 7]), 4001)] - ); - assert_eq!( - classic_records(&ask(Some(2000))), - vec![(u32::from_le_bytes([198, 51, 100, 8]), 4002)] - ); - assert!( - classic_records(&ask(None)).is_empty(), - "legacy hash-only lookup must reject an ambiguous collision" - ); - } - - #[test] - fn classic_lowid_requester_does_not_get_classic_lowid_source() { - let (state, file_hash) = one_source_state(false, 0); - let records = classic_records(&ask_as(&state, file_hash, [0xC2; 16], false, 0)); - assert!(records.is_empty()); - } - - #[test] - fn natt_lowid_pair_gets_lowid_source() { - let (state, file_hash) = one_source_state(false, 5001); - let records = classic_records(&ask_as(&state, file_hash, [0xC3; 16], false, 5002)); - assert_eq!(records, vec![(42, 4001)]); - } - - #[test] - fn natt_lowid_requester_does_not_get_non_natt_lowid_source() { - let (state, file_hash) = one_source_state(false, 0); - let records = classic_records(&ask_as(&state, file_hash, [0xC4; 16], false, 5002)); - assert!(records.is_empty()); - } - - #[test] - fn classic_lowid_requester_still_gets_highid_source() { - use std::net::Ipv4Addr; - let (state, file_hash) = one_source_state(true, 0); - let records = classic_records(&ask_as(&state, file_hash, [0xC5; 16], false, 0)); - assert_eq!( - records, - vec![(u32::from_le_bytes(Ipv4Addr::new(198, 51, 100, 7).octets()), 4001)] ); - } - #[test] - fn lowid_requester_never_reuses_a_highid_cached_reply() { - let (state, file_hash) = one_source_state(false, 0); + // payload: hash(16) count(1) then count*(id(4) port(2)) + let p = &frame.payload; + assert_eq!(&p[0..16], &file_hash); + let count = p[16] as usize; + assert_eq!(count, 2); + + // Walk the entries; collect (id, port) pairs. + let mut got = std::collections::HashMap::new(); + for i in 0..count { + let off = 17 + i * 6; + let id = u32::from_le_bytes([p[off], p[off + 1], p[off + 2], p[off + 3]]); + let port = u16::from_le_bytes([p[off + 4], p[off + 5]]); + got.insert(port, id); + } + // LowID source (port 4001) must carry the assigned low id 42, NOT its IP. assert_eq!( - classic_records(&ask_as(&state, file_hash, [0xD1; 16], true, 0)), - vec![(42, 4001)] + got.get(&4001), + Some(&42u32), + "LowID source must encode assigned_id" ); - assert!(classic_records(&ask_as(&state, file_hash, [0xD2; 16], false, 0)).is_empty()); + // HighID source (port 4002) must carry its real IPv4 as the id. + let expect_high = u32::from_le_bytes(Ipv4Addr::new(198, 51, 100, 7).octets()); assert_eq!( - classic_records(&ask_as(&state, file_hash, [0xD3; 16], true, 0)), - vec![(42, 4001)] + got.get(&4002), + Some(&expect_high), + "HighID source must encode real IP" ); - let (hits, _) = state.smart_sources.stats(); - assert_eq!(hits, 1, "only the second HighID request may hit the cache"); - } - - #[test] - fn verified_private_highid_routes_never_leak_to_public_requesters() { - use std::net::{IpAddr, Ipv4Addr}; - let state = ServerState::for_test(); - let mut cfg = (**state.live_cfg.load()).clone(); - cfg.network.private_highid_probe = true; - state.live_cfg.store(std::sync::Arc::new(cfg)); - - let file_hash = [0x76u8; 16]; - let source = [0xAAu8; 16]; - let source_ip = Ipv4Addr::new(10, 0, 0, 43); - state.add_file_with_source( - file_hash, - 1000, - "f".into(), - (source, IpAddr::V4(source_ip), 4001, true), - ); - state.register_test_client(source, u32::from_le_bytes(source_ip.octets()), true, 0); - - let private_user = [0xCCu8; 16]; - state.register_test_client(private_user, 0x0506_0708, true, 0); - let private_requester = state.clients.get(&private_user).unwrap().clone(); - let private_reply = handle_get_sources( - &state, - &private_requester, - GetSourcesRequest { file_hash, size: None }, - SourceReplyFormat::Classic, - ); - assert_eq!(private_reply.payload[16], 1); - assert_eq!( - u32::from_le_bytes(private_reply.payload[17..21].try_into().unwrap()), - u32::from_le_bytes(source_ip.octets()) - ); - - let public_user = [0xDDu8; 16]; - state.register_test_client(public_user, 0x090A_0B0C, true, 0); - let mut public_requester = state.clients.get(&public_user).unwrap().clone(); - public_requester.ip = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 90)); - let public_reply = handle_get_sources( - &state, - &public_requester, - GetSourcesRequest { file_hash, size: None }, - SourceReplyFormat::Classic, - ); - assert_eq!(public_reply.payload[16], 0); } /// Issue #33: the sentinel goes to a LowID requester only. @@ -1037,12 +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 }, - SourceReplyFormat::Classic, - ) + 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; @@ -1083,12 +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 }, - SourceReplyFormat::Classic, - ) + 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]); @@ -1111,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 c271ab0..34acf78 100644 --- a/src/server/highid_probe.rs +++ b/src/server/highid_probe.rs @@ -1,17 +1,25 @@ //! HighID/LowID detection probe (SPEC.md §3.2). //! -//! Public clients first receive a provisional LowID. After the login answer is -//! sent, the server opens an outbound TCP connection to (client_ip, -//! client_port), sends OP_HELLO and requires OP_HELLOANSWER with the same user -//! hash before upgrading that live session to HighID. +//! The server opens an outbound TCP connection to (client_ip, client_port). +//! If successful → HighID (client is reachable, assigned_id = IPv4 as u32). +//! If timeout/refused → LowID (client is behind NAT, assigned_id from pool). //! -//! `probe_identity` sends OP_HELLO, then waits for OP_HELLOANSWER and compares +//! ⚠ TWO PROBES LIVE HERE AND THEY ARE NOT THE SAME CHECK. +//! +//! `probe` — a bare TCP connect. NOTHING IS SENT. This decides HighID for +//! every ordinary login. An earlier version of this comment said the server +//! sends OP_HELLO here; it does not, and that sentence cost a day of doubting +//! a correct measurement. +//! +//! `probe_identity` — OP_HELLO, then waits for OP_HELLOANSWER and compares //! the user hash. This is what a stock Lugdunum does for EVERY login: measured, //! it reports "No answer from your NNNN port" and gives LowID to a port that //! accepts the connection but does not answer the hello, where `probe` here -//! gives HighID. This is the public-session verdict and is also used for the -//! opt-in routed-private and hairpin compatibility paths. Wrong hash, no -//! answer, refusal and timeout all leave the session LowID. +//! gives HighID. Used for the verdict on the hairpin path; on every HighID +//! login, in the background, for counting when `network.highid_verify_observe` +//! is on, and to set wrong-hash marks when +//! `network.highid_downgrade_on_wrong_hash` is on — there ONLY a different +//! answering hash costs HighID (on the next login), no answer never does. //! //! This is the standard eD2k callback reachability check (server connects back //! to the client to learn whether it has an open port); it is NOT a backdoor. @@ -55,7 +63,7 @@ const PROTO_PACKED: u8 = 0xD4; /// probe as well. /// /// ```text -/// on the wire: [semi-random byte][keypart u32, CLEAR][RC4: magic|supported|preferred|padlen|pad][RC4: payload…] +/// on the wire: [semi-random byte][keypart u32, CLEAR][RC4: magic|method|padlen|pad][RC4: payload…] /// send key: RC4( MD5( peer user hash ‖ 34 ‖ keypart ) ), first 1024 bytes discarded /// recv key: RC4( MD5( peer user hash ‖ 203 ‖ keypart ) ), same /// ``` @@ -68,10 +76,6 @@ struct ClientCrypt { recv: crate::proto::obfuscation::Rc4, } -fn response_padding_length(encoded: u8) -> usize { - usize::from(encoded) -} - impl ClientCrypt { /// Build the keys and the opening bytes. /// @@ -116,12 +120,16 @@ impl ClientCrypt { 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. + // 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); // supported encryption method: RC4 - body.push(0); // preferred 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); @@ -130,11 +138,46 @@ impl ClientCrypt { } } +/// Probe the client's advertised (ip, port) with a bare TCP connect. +/// Returns true if something accepted the connection (HighID). +/// +/// Nothing is written to the socket. This proves a port is open, not that an +/// eD2k client is behind it — see the module note and `probe_identity`. +pub async fn probe(ip: IpAddr, port: u16, timeout_ms: u64) -> bool { + // Private / loopback / link-local are always LowID — no point probing. + if !is_routable(ip) { + // NOT the final verdict when the hairpin fallback is enabled: that path + // runs afterwards and probes a different address. Worded carefully — + // "→ LowID" here sent a bug reporter looking for a second code path that + // does not exist, because two lines appeared for one login. + debug!(ip = %ip, "private IP → skipping plain probe (hairpin fallback may still run)"); + return false; + } + + let addr = SocketAddr::new(ip, port); + match tokio::time::timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await { + Ok(Ok(_stream)) => { + // Connected — HighID. Stream is immediately dropped; the client + // will see a brief incoming connection which it handles gracefully. + debug!(addr = %addr, "HighID probe succeeded → HighID"); + true + } + Ok(Err(e)) => { + debug!(addr = %addr, error = %e, "HighID probe refused → LowID"); + false + } + Err(_) => { + debug!(addr = %addr, "HighID probe timeout → LowID"); + false + } + } +} + /// Probe that also proves WHO answered. /// -/// A bare TCP connect only proves that something accepts connections on that -/// port. Even when the address the client connected from and the address we -/// probe are the same, the +/// The plain `probe` above only proves that something accepts connections on +/// that port. For a public client that is enough, because the address it +/// connected FROM and the address we probe are the same by construction — the /// port belongs to the same host or the packets would not have arrived. /// /// That guarantee disappears the moment the probed address is not the address @@ -232,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!( @@ -280,11 +330,9 @@ pub async fn probe_identity_hash( None }; - // The peer answers the handshake before application bytes are legal, with - // its own magic value under ITS send key — which is our recv key. Do not - // send HELLO until this completes: a fast local transport can coalesce the - // opening and HELLO, and stock eMule rejects those extra bytes while it is - // still negotiating the encryption layer. + // 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. if let Some(c) = crypt.as_mut() { use crate::proto::obfuscation::MAGIC_SYNC; let mut head = [0u8; 6]; @@ -311,9 +359,12 @@ pub async fn probe_identity_hash( if head[4] != 0 { return Err("peer chose an encryption method we do not implement"); } - // Client-to-client TCP obfuscation uses the full byte. Stock eMule can - // send any configured padding length up to 255 bytes here. - let pad = response_padding_length(head[5]); + // 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 { @@ -325,6 +376,9 @@ 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); @@ -577,7 +631,7 @@ pub fn high_id_from_ip(ip: IpAddr) -> Option { } } -pub(crate) fn is_routable(ip: IpAddr) -> bool { +fn is_routable(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => { !v4.is_loopback() @@ -641,11 +695,11 @@ 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 - // [7] RC4( magic 0x835E6FC4 | supported 0 | preferred 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(), 12, "1 + 4 + 7"); @@ -686,65 +740,105 @@ mod tests { MAGIC_SYNC, "the peer must recover the magic value" ); - assert_eq!(body[4], 0, "supported encryption method RC4"); - assert_eq!(body[5], 0, "preferred encryption method RC4"); - assert_eq!(body[6] & 0x0F, 0, "no padding"); - } - - #[test] - fn response_padding_length_uses_the_full_wire_byte() { - assert_eq!(response_padding_length(0), 0); - assert_eq!(response_padding_length(128), 128); - assert_eq!(response_padding_length(u8::MAX), 255); - } - - #[test] - fn two_handshakes_never_share_a_keystream() { - // The clock-only seed produced identical streams for calls landing in - // the same nanosecond bucket, which is what happens when several logins - // arrive at once. - let peer = [0x11u8; 16]; - let (_a, one) = ClientCrypt::start(&peer); - let (_b, two) = ClientCrypt::start(&peer); - assert_ne!(one[1..5], two[1..5], "key parts must differ"); + 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 obfuscated_probe_waits_for_handshake_answer_before_hello() { - use tokio::io::AsyncReadExt; - use tokio::net::TcpListener; + 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 listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + 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 stream, _) = listener.accept().await.unwrap(); + let (mut s, _) = listener.accept().await.unwrap(); let mut opening = [0u8; 12]; - stream.read_exact(&mut opening).await.unwrap(); - let mut early = [0u8; 1]; - tokio::time::timeout( - std::time::Duration::from_millis(100), - stream.read(&mut early), - ) - .await - .is_ok() + 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 _ = probe_identity_hash( - IpAddr::V4(Ipv4Addr::LOCALHOST), + let got = probe_identity_hash( + "127.0.0.1".parse().unwrap(), port, - &[0x42; 16], - &[0x11; 16], - 0, - 4661, - 500, + &peer_hash, + &ours, + 0x0100_007F, + 6082, + 3000, true, ) .await; + peer.await.unwrap(); + assert_eq!(got, Ok(peer_hash)); + } - assert!( - !peer.await.unwrap(), - "HELLO must not arrive before the peer answers the obfuscation handshake" - ); + #[test] + fn two_handshakes_never_share_a_keystream() { + // The clock-only seed produced identical streams for calls landing in + // the same nanosecond bucket, which is what happens when several logins + // arrive at once. + let peer = [0x11u8; 16]; + let (_a, one) = ClientCrypt::start(&peer); + let (_b, two) = ClientCrypt::start(&peer); + assert_ne!(one[1..5], two[1..5], "key parts must differ"); } #[test] diff --git a/src/server/holepunch.rs b/src/server/holepunch.rs index 5f5f3af..e4cd4ba 100644 --- a/src/server/holepunch.rs +++ b/src/server/holepunch.rs @@ -30,11 +30,34 @@ //! 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 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, +//! 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, Ipv4Addr, SocketAddr}; +use std::sync::Arc; use std::time::Duration; use tracing::{debug, warn}; @@ -55,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 @@ -63,7 +100,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 { @@ -86,12 +123,41 @@ 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; 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. /// @@ -111,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 @@ -138,12 +207,16 @@ pub fn handle_holepunch_request( t.port, t.udp_port, t.user_hash, - t.is_high_id(), + 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 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( state, @@ -191,6 +264,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 { @@ -215,6 +295,19 @@ pub fn handle_holepunch_request( let requester_udp_eff = observed_udp_port(state, requester_user_hash, requester_ip, requester_udp_port); + // 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 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 // requests in flight this answers; the target the requester's id. @@ -225,6 +318,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 +327,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,41 +414,423 @@ 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) +} + +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. +#[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, +) { + // 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"); } - let _ = handle_holepunch_request( - &state, - &requester_user_hash, + } + } + // 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()) + // No wake-up for a pair that cannot be given IPv4 endpoints. + .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 + .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; + } + } + 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 => {} + } + } + } + let failed = handle_holepunch_request( + state, + &requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + 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); + } + 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 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) +} + +#[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); + 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 failed = handle_holepunch_request( + &state, + &requester_user_hash, + requester_id, + requester_ip, + requester_tcp_port, + 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, + requester_id, + requester_ip, + requester_tcp_port, + requester_udp_port, + target_id, + ) + .await; }); + Ext::Started } -/// OP_LOWID_HOLEPUNCH_INFO payload, 29 bytes: +/// 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(()); + } + } +} + +/// 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"); + count(state, &format!("hp_bridged_sw:{}", sw_family(state, &target.user_hash))); +} + +/// 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 /// 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,19 +838,22 @@ 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. - 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); 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 +881,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: 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)] { + 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, 30)], "{fa:#x} {fb:#x}"); + assert_eq!(opcodes(&drain(&mut b)), vec![(OP_LOWID_HOLEPUNCH_INFO, 30)], "{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, 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 + /// 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, 30)]); + let to_b = drain(&mut b); + 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]); + } + // 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, 30)]); + 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, 30)]); + } + + /// 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 @@ -454,6 +1088,31 @@ 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))); + // 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] 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 a14537c..a4a13ce 100644 --- a/src/server/login.rs +++ b/src/server/login.rs @@ -196,44 +196,11 @@ pub(crate) fn reported_client_ip( } } -/// Build one `OP_IDCHANGE` for a specific route. The initial welcome uses the -/// session's provisional LowID; verified public sessions use this again for -/// their in-place HighID promotion. -pub(crate) fn build_id_change( - bound_tcp_port: u16, - state: &ServerState, - client: &ClientHandle, - assigned_id: u32, - is_high_id: bool, -) -> Frame { - let live = state.live_cfg.load(); - let reported_ip = reported_client_ip( - client.ip, - is_high_id, - assigned_id, - &live.server.this_ip, - ); - let mut tcp_flags: u32 = 0x0000_05DD; - tcp_flags |= crate::proto::opcodes::SRVFLG_LOWID_HOLEPUNCH; - if live.network.ipv6_publish_sources { - tcp_flags |= SRV_TCPFLG_IPV6; - tcp_flags |= crate::proto::opcodes::SRVFLG_IPV6_UPDATE; - } - - let mut payload = BytesMut::with_capacity(20); - payload.put_u32_le(assigned_id); - payload.put_u32_le(tcp_flags); - payload.put_u32_le(bound_tcp_port as u32); - payload.put_u32_le(reported_ip); - payload.put_u32_le(bound_tcp_port as u32); - Frame::new(OP_IDCHANGE, payload.to_vec()) -} - /// Build the canonical post-login welcome batch (SPEC.md §3.1.2): /// 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, @@ -243,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 @@ -278,14 +248,78 @@ pub fn build_welcome_batch_with( // 0x0100 LARGEFILES // 0x0400 TCPOBFUSCATION — required (with non-zero obf port) for "Obfuscation: Yes" // = 0x05DD - let (assigned_id, is_high_id) = client.route(); - frames.push(build_id_change( - cfg.network.tcp_port, - state, - client, - assigned_id, - is_high_id, - )); + { + let reported_ip = reported_client_ip( + client.ip, + client.is_high_id, + client.assigned_id, + &live.server.this_ip, + ); + // 0x4000 IPV6 — added only when the server actually speaks it + 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 + // packet. The asymmetry is deliberate in the published spec and is + // the single easiest thing to get backwards here. + // + // Announced from `ipv6_publish_sources` rather than from + // `ipv6_enabled`: the bit tells a client it may receive inline IPv6 + // sources, and a server that accepts IPv6 logins but publishes no v6 + // sources would be claiming something it does not do. + 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); + payload.put_u32_le(client.assigned_id); // [0-3] client_id + payload.put_u32_le(tcp_flags); // [4-7] tcp_flags + // [8-11] AUX PORT — the server's "standard" TCP port. + // + // This is NOT a filler. eMule skips these bytes (it reads the reported IP + // straight from packet+12), but aMule reads them as the aux-port field and + // does `cur_server->SetPort(ConnPort)` — and CServer::realport is a uint16, + // so whatever we put here gets truncated to 16 bits and BECOMES the server's + // port in the client's list. We used to put client_id here, so every aMule + // session rewrote our port to `assigned_id & 0xFFFF`: client id 1968999842 + // showed up as port 36258, the next session as 34750, and so on — the + // "phantom clones of our server on random ports" that only ever appeared in + // aMule. The field means "if the client logged in on an auxiliary port, here + // is the standard port to advertise", so send our real TCP port. aMule then + // sets the port to what it already is (no-op) and eMule is unaffected. + 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 { let mut payload = BytesMut::with_capacity(8); @@ -340,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. @@ -397,7 +436,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. @@ -438,17 +489,24 @@ 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))); } } + // 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 @@ -463,9 +521,8 @@ pub fn build_server_ident(cfg: &Config, state: &ServerState, client: &ClientHand /// Detect HighID/LowID via HighID probe (SPEC.md §3.2). /// -/// Probes the client's (ip, port). Public IPs are tested with an outbound TCP -/// connection. Private and loopback addresses get LowID by default; the full -/// login path can opt into an identity-verified private probe. +/// Probes the client's (ip, port). Public IPs are tested with an outbound +/// TCP connection; private/loopback always get LowID without probing. pub async fn assign_client_id( state: &ServerState, // Kept for API stability; the probe timeout now comes from live_cfg so it is @@ -478,15 +535,14 @@ pub async fn assign_client_id( (id, high) } -/// As `assign_client_id`, but able to attempt the private and hairpin fallbacks, -/// which need the login user hash to verify who answers the probe. +/// As `assign_client_id`, but able to attempt the hairpin fallback, which needs +/// the user hash from the login in order to verify who answers the probe. /// /// Returns the address the client should be RECORDED under as well. For every -/// ordinary or verified routed-private login that is the address it connected -/// from; for a verified hairpin client it is the public address, because that is -/// the one peers must be given as a source. Recording the private one there -/// would undo the whole fallback: the client would hold a HighID nobody could -/// act on. +/// ordinary login that is the address it connected from; for a verified hairpin +/// client it is the public address, because that is the one peers must be given +/// as a source. Recording the private one would undo the whole fallback: the +/// client would hold a HighID nobody could act on. /// `ST_IPV6` — the server's own public IPv6 in `OP_SERVERIDENT`, 16 raw bytes. /// /// Same tag id and encoding a client uses for `CT_MOD_IP_V6`; the two directions @@ -495,11 +551,16 @@ 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. -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. @@ -588,10 +649,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), } } @@ -602,7 +663,9 @@ pub async fn assign_client_id_for( login_user_hash: Option<&[u8; 16]>, client_flags: u32, ) -> (u32, bool, IpAddr) { - use crate::server::highid_probe::{high_id_from_ip, probe_identity, server_pseudo_user_hash}; + use crate::server::highid_probe::{ + high_id_from_ip, probe, probe_identity, server_pseudo_user_hash, + }; // Hot-reloadable: the probe timeout is read per login, so tuning it via // /api/config takes effect immediately. @@ -613,7 +676,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 @@ -622,68 +685,77 @@ 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); } - // Public IPv4 sessions start with a provisional LowID. Once the login - // answer has been sent and the client can service its listener, the - // connection task runs an identity probe and upgrades this same session in - // place. A bare open port is never enough for HighID. - if crate::server::highid_probe::is_routable(peer_ip) { - return (state.allocate_low_id(), false, peer_ip); - } - - // Only the opt-in private/hairpin compatibility paths still probe before - // login. Both require a verified identity and share the global probe cap. - let private_peer = matches!(peer_ip, IpAddr::V4(v4) if v4.is_private()); - if !private_peer - || (!live.network.private_highid_probe && !live.network.hairpin_lan_clients) - { - return (state.allocate_low_id(), false, peer_ip); - } + // admission.max_probe_jobs (issue #25): every outbound probe — this one, + // the hairpin probe below, and the background identity checks — shares + // one ceiling. Held until this function returns. With none free the login + // is not delayed: it gets LowID, the conservative answer, and its next + // login is probed again. 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); }; - // ─── ROUTED PRIVATE CLIENT (opt-in) ───────────────────────────────── - // A routed private deployment can legitimately reach a client's observed - // RFC1918 address. Never infer that from an open port: require the peer to - // answer OP_HELLO with the same user hash that just logged in. - if live.network.private_highid_probe - && matches!(peer_ip, IpAddr::V4(v4) if v4.is_private()) - { - if let Some(user_hash) = login_user_hash { - let supports_crypt = client_flags & SRVCAP_SUPPORTCRYPT != 0; - let ident = HighIdProbeIdent::from_config(&live); - let outcome = highid_identity_answer( - peer_ip, - client_port, - *user_hash, - ident, - probe_timeout, - supports_crypt, - ) - .await; - match record_private_highid_outcome( - &state.highid_verification, - peer_ip, - client_port, - *user_hash, - outcome, - ) { - HighIdCheck::Same => { - info!( + if probe(peer_ip, client_port, probe_timeout).await { + // VERDICT, opt-in, remembered: a port that answered the hello with + // ANOTHER client's hash belongs to someone else behind the same NAT. + // Decided from a mark left by an earlier background check, so the + // login never waits; the check itself runs again in the background, + // which renews or clears the mark. See + // `network.highid_downgrade_on_wrong_hash`. + if live.network.highid_downgrade_on_wrong_hash { + if let Some(uh) = login_user_hash { + // Read the mark BEFORE starting the re-check, so this login is + // decided by what was known when it arrived. + let marked = state.highid_observe.is_marked(peer_ip, client_port, uh); + spawn_highid_check( + state, + &live, + peer_ip, + client_port, + *uh, + client_flags, + probe_timeout, + true, + ); + if marked { + state + .highid_observe + .downgraded + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + debug!( ip = %peer_ip, port = client_port, - "private highid: client identity verified → HighID" + "highid: wrong-hash mark active → LowID" ); - let (id, high) = high_or_low_id(state, peer_ip); - return (id, high, peer_ip); + return (state.allocate_low_id_for(client_flags), false, peer_ip); } - HighIdCheck::Wrong | HighIdCheck::NoAnswer => {} + } + 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, 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 + // wait for it. See `network.highid_verify_observe`. + if live.network.highid_verify_observe { + if let Some(uh) = login_user_hash { + spawn_highid_check( + state, + &live, + peer_ip, + client_port, + *uh, + client_flags, + probe_timeout, + false, + ); } } + return (id, high, peer_ip); } // ─── HAIRPIN FALLBACK (opt-in) ────────────────────────────────────── @@ -775,7 +847,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) => { @@ -798,17 +870,18 @@ 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 /// client advertised it, then once in the clear if the crypt handshake itself /// was refused. Returns the user hash that answered. /// -/// Shared by public-session verification and the focused probe tests. The -/// hairpin code keeps its own copy on purpose; if its fallback list ever -/// changes, change this too. -async fn highid_identity_answer( +/// Shared by observe and verdict mode so the two cannot drift: the +/// 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. +pub(crate) async fn highid_identity_answer( ip: IpAddr, port: u16, user_hash: [u8; 16], @@ -851,14 +924,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 @@ -889,13 +962,16 @@ enum HighIdCheck { NoAnswer, } -/// Count one identity-probe outcome and classify it. +/// Count one identity-probe outcome and classify it. `marking` says the caller +/// acts on a wrong hash (sets a mark), so the mismatch record says which of +/// the two modes produced it. fn record_highid_outcome( - obs: &crate::state::HighIdVerification, + obs: &crate::state::HighIdObserve, ip: IpAddr, port: u16, user_hash: [u8; 16], outcome: Result<[u8; 16], &'static str>, + marking: bool, ) -> HighIdCheck { use crate::server::highid_probe::same_client_hash; use std::sync::atomic::Ordering::Relaxed; @@ -916,6 +992,7 @@ fn record_highid_outcome( %ip, port, login_hash = %hex::encode(user_hash), answered_hash = %hex::encode(answered), + marked = marking, "highid check: port answers with a DIFFERENT user hash" ); obs.record_mismatch(crate::state::HighIdMismatch { @@ -927,6 +1004,7 @@ fn record_highid_outcome( port, login_hash: user_hash, answered_hash: answered, + marked: marking, }); HighIdCheck::Wrong } @@ -939,57 +1017,62 @@ fn record_highid_outcome( } } -fn record_private_highid_outcome( - obs: &crate::state::HighIdVerification, +/// Apply a check result to the wrong-hash marks (verdict mode only): a wrong +/// hash sets or renews the mark, the client's own hash clears it, no answer +/// leaves it alone — silence proves nothing. +fn apply_highid_mark( + obs: &crate::state::HighIdObserve, ip: IpAddr, port: u16, user_hash: [u8; 16], - outcome: Result<[u8; 16], &'static str>, -) -> HighIdCheck { - let check = record_highid_outcome(obs, ip, port, user_hash, outcome); - match (check, outcome) { - (HighIdCheck::Wrong, _) => { - warn!( - %ip, port, - "private highid: port answers with a different user hash → LowID" - ); + check: HighIdCheck, + ttl: std::time::Duration, +) { + use std::sync::atomic::Ordering::Relaxed; + match check { + HighIdCheck::Wrong => { + if !obs.mark(ip, port, user_hash, ttl) { + warn!(%ip, port, "highid: wrong-hash mark table full, mark not set"); + } } - (HighIdCheck::NoAnswer, Err(reason)) => { - debug!( - %ip, port, reason, - "private highid: no verified answer → LowID" - ); + HighIdCheck::Same => { + if obs.unmark(ip, port, &user_hash) { + obs.marks_cleared.fetch_add(1, Relaxed); + info!(%ip, port, "highid: port answers with the client's own hash again → mark cleared"); + } } - (HighIdCheck::Same | HighIdCheck::NoAnswer, _) => {} + HighIdCheck::NoAnswer => {} } - check } -/// Verify a public session after its LowID login answer was sent, then queue a -/// second `OP_IDCHANGE` and move the server-side route in one guarded step. -pub(crate) fn spawn_public_highid_verification( - state: std::sync::Arc, - client: ClientHandle, - bound_tcp_port: u16, -) { - if !crate::server::highid_probe::is_routable(client.ip) - || client.is_high_id() - || client.port == 0 - { - return; - } - spawn_highid_verification(state, client, bound_tcp_port, std::time::Duration::from_millis(250)); -} - -fn spawn_highid_verification( - state: std::sync::Arc, - client: ClientHandle, - bound_tcp_port: u16, - delay: std::time::Duration, +/// Background identity probe after the plain probe succeeded. Never delays +/// the login. +/// +/// `marking` false: observe mode, counts only. `marking` true: verdict mode, +/// the result also sets, renews or clears the wrong-hash mark that decides +/// this client's NEXT login (`network.highid_downgrade_on_wrong_hash`). +/// +/// Same probe, same obfuscation-then-plain fallback, same timeout in both +/// modes — they share `highid_identity_answer`. The hairpin code is +/// deliberately left as it is rather than shared with this: it is a verdict +/// path with its own history, and a refactor for tidiness is not worth the +/// risk of changing it. +#[allow(clippy::too_many_arguments)] +fn spawn_highid_check( + state: &ServerState, + live: &Config, + ip: IpAddr, + port: u16, + user_hash: [u8; 16], + client_flags: u32, + timeout_ms: u64, + marking: bool, ) { use std::sync::atomic::Ordering::Relaxed; - let obs = std::sync::Arc::clone(&state.highid_verification); + let obs = std::sync::Arc::clone(&state.highid_observe); + // Shed load rather than queue it: a reconnect storm is when this matters + // least and costs most. A mark is left as it is when its re-check is shed. let permit = match std::sync::Arc::clone(&obs.permits).try_acquire_owned() { Ok(p) => p, Err(_) => { @@ -1002,36 +1085,25 @@ fn spawn_highid_verification( obs.skipped_busy.fetch_add(1, Relaxed); return; }; - let live = state.live_cfg.load_full(); - let ident = HighIdProbeIdent::from_config(&live); - let timeout_ms = live.network.login_timeout_ms; - let supports_crypt = client.server_flags & SRVCAP_SUPPORTCRYPT != 0; - let ip = client.ip; - let port = client.port; - let user_hash = client.user_hash; - let Some(high_id) = crate::server::highid_probe::high_id_from_ip(ip) else { - return; - }; + let ident = HighIdProbeIdent::from_config(live); + let supports_crypt = client_flags & SRVCAP_SUPPORTCRYPT != 0; + let ttl = std::time::Duration::from_secs(live.network.highid_wrong_hash_ttl_secs.max(1)); tokio::spawn(async move { let _permit = permit; let _global_permit = global_permit; - tokio::time::sleep(delay).await; let outcome = highid_identity_answer(ip, port, user_hash, ident, timeout_ms, supports_crypt).await; - let check = record_highid_outcome(&obs, ip, port, user_hash, outcome); - if check == HighIdCheck::Same { - let id_change = build_id_change(bound_tcp_port, &state, &client, high_id, true); - if state.promote_client_to_high_id(&client, high_id, id_change) { - info!(%ip, port, id = high_id, "highid: live client identity verified → HighID"); - } + let check = record_highid_outcome(&obs, ip, port, user_hash, outcome, marking); + if marking { + apply_highid_mark(&obs, ip, port, user_hash, check, ttl); } }); } /// Observe mode: count only. The name the observe tests use. #[cfg(test)] -fn spawn_highid_probe_for_test( +fn spawn_highid_observe( state: &ServerState, live: &Config, ip: IpAddr, @@ -1040,28 +1112,16 @@ fn spawn_highid_probe_for_test( client_flags: u32, timeout_ms: u64, ) { - use std::sync::atomic::Ordering::Relaxed; - let obs = std::sync::Arc::clone(&state.highid_verification); - let permit = match std::sync::Arc::clone(&obs.permits).try_acquire_owned() { - Ok(p) => p, - Err(_) => { - obs.skipped_busy.fetch_add(1, Relaxed); - return; - } - }; - let Some(global_permit) = state.admission.probes.try_take() else { - obs.skipped_busy.fetch_add(1, Relaxed); - return; - }; - let ident = HighIdProbeIdent::from_config(live); - let supports_crypt = client_flags & SRVCAP_SUPPORTCRYPT != 0; - tokio::spawn(async move { - let _permit = permit; - let _global_permit = global_permit; - let outcome = - highid_identity_answer(ip, port, user_hash, ident, timeout_ms, supports_crypt).await; - record_highid_outcome(&obs, ip, port, user_hash, outcome); - }); + spawn_highid_check( + state, + live, + ip, + port, + user_hash, + client_flags, + timeout_ms, + false, + ); } /// Process a LOGINREQUEST and register the client. @@ -1242,7 +1302,7 @@ pub async fn handle_login( let handle = ClientHandle { user_hash: req.user_hash, - route: crate::state::ClientRoute::shared(assigned_id, is_high_id), + assigned_id, // Usually the login address; the public one for a verified hairpin // client, since this is what gets published as a source. ip: source_ip, @@ -1251,6 +1311,7 @@ pub async fn handle_login( natt_capable: udp_port != 0, nick: nick.clone(), server_flags, + is_high_id, // ⚠ THE BARE CAPABILITY BIT IS NOT ACCEPTED, and the asymmetry of the // two mistakes is why. Judging a client capable when it is not means // sending it a source record 16 bytes longer than the one it expects, @@ -1286,7 +1347,9 @@ pub async fn handle_login( offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(ClientHandle::now_ms()), + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new( + ClientHandle::now_ms(), + )), }; info!( @@ -1372,7 +1435,7 @@ mod ipv6_update_tests { fn handle() -> ClientHandle { ClientHandle { user_hash: [5; 16], - route: crate::state::ClientRoute::shared(42, false), + assigned_id: 42, ip: "203.0.113.5".parse().unwrap(), port: 4662, udp_port: 0, @@ -1381,6 +1444,7 @@ mod ipv6_update_tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: false, connected_at: Instant::now(), country: "??".into(), software: "test".into(), @@ -1391,7 +1455,7 @@ mod ipv6_update_tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), } } @@ -1502,7 +1566,7 @@ mod tests { ); let client = ClientHandle { user_hash: [7; 16], - route: crate::state::ClientRoute::shared(42, false), + assigned_id: 42, ip: IpAddr::V4(std::net::Ipv4Addr::new(93, 184, 216, 34)), port: 4662, udp_port: 0, @@ -1511,6 +1575,7 @@ mod tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: false, connected_at: Instant::now(), country: "??".into(), software: "test".into(), @@ -1521,7 +1586,7 @@ mod tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), }; let ident = build_server_ident(&cfg, &state, &client); assert_eq!(&ident.payload[..16], &server_ident_hash(&cfg)); @@ -1620,7 +1685,7 @@ mod tests { ); let client = ClientHandle { user_hash: [7; 16], - route: crate::state::ClientRoute::shared(42, false), + assigned_id: 42, ip: IpAddr::V4(std::net::Ipv4Addr::new(93, 184, 216, 34)), port: 4662, udp_port: 0, @@ -1629,6 +1694,7 @@ mod tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: false, connected_at: Instant::now(), country: "??".into(), software: "test".into(), @@ -1639,7 +1705,7 @@ mod tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), }; let frames = build_welcome_batch(&cfg, &state, &client); let idc = frames.iter().find(|f| f.opcode == OP_IDCHANGE).unwrap(); @@ -1648,6 +1714,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 @@ -1733,8 +1867,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] @@ -1846,7 +1979,7 @@ mod tests { } #[cfg(test)] -mod highid_verification_tests { +mod highid_observe_tests { //! The observation path must count, never decide. These run the real //! identity probe against local listeners that behave like the three kinds //! of port found on the network. @@ -1866,28 +1999,28 @@ mod highid_verification_tests { async fn wait_for( state: &ServerState, - pred: impl Fn(&HighIdVerificationView) -> bool, - ) -> HighIdVerificationView { + pred: impl Fn(&HighIdObserveView) -> bool, + ) -> HighIdObserveView { for _ in 0..200 { - let v = HighIdVerificationView::of(state); + let v = HighIdObserveView::of(state); if pred(&v) { return v; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } - HighIdVerificationView::of(state) + HighIdObserveView::of(state) } #[derive(Debug)] - struct HighIdVerificationView { + struct HighIdObserveView { verified: u64, mismatch: u64, no_answer: u64, skipped: u64, } - impl HighIdVerificationView { + impl HighIdObserveView { fn of(s: &ServerState) -> Self { - let o = &s.highid_verification; + let o = &s.highid_observe; Self { verified: o.verified.load(Relaxed), mismatch: o.mismatch.load(Relaxed), @@ -1962,73 +2095,8 @@ mod highid_verification_tests { } #[tokio::test] - async fn private_clients_stay_lowid_when_private_probe_is_disabled() { - let st = state(); - let private = IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 8)); - let (id, high, source_ip) = - assign_client_id_for(&st, private, 4662, Some(&[0x42; 16]), 0).await; - assert!(!high); - assert!(id < crate::server::highid_probe::LOWID_CEILING && id != 0); - assert_eq!(source_ip, private); - } - - #[test] - fn private_probe_success_updates_verification_counters() { - let st = state(); - let user_hash = [0x42; 16]; - let check = record_private_highid_outcome( - &st.highid_verification, - IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 8)), - 4662, - user_hash, - Ok(user_hash), - ); - - assert_eq!(check, HighIdCheck::Same); - let view = HighIdVerificationView::of(&st); - assert_eq!((view.verified, view.mismatch, view.no_answer), (1, 0, 0)); - } - - #[test] - fn private_probe_mismatch_updates_verification_counters() { - let st = state(); - let check = record_private_highid_outcome( - &st.highid_verification, - IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 8)), - 4662, - [0x42; 16], - Ok([0x99; 16]), - ); - - assert_eq!(check, HighIdCheck::Wrong); - let view = HighIdVerificationView::of(&st); - assert_eq!((view.verified, view.mismatch, view.no_answer), (0, 1, 0)); - let mismatches = st.highid_verification.recent_mismatches.lock().unwrap(); - assert_eq!(mismatches.len(), 1); - assert_eq!(mismatches[0].login_hash, [0x42; 16]); - assert_eq!(mismatches[0].answered_hash, [0x99; 16]); - } - - #[test] - fn private_probe_no_answer_updates_verification_reasons() { - let st = state(); - let reason = "connect timed out"; - let check = record_private_highid_outcome( - &st.highid_verification, - IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 8)), - 4662, - [0x42; 16], - Err(reason), - ); - - assert_eq!(check, HighIdCheck::NoAnswer); - let view = HighIdVerificationView::of(&st); - assert_eq!((view.verified, view.mismatch, view.no_answer), (0, 0, 1)); - assert_eq!(st.highid_verification.reasons.get(reason).map(|v| *v), Some(1)); - } - - #[tokio::test] - async fn public_login_is_provisional_lowid_without_probing() { + async fn a_full_probe_ceiling_gives_lowid_without_probing() { + // issue #25: the login does not wait for a probe slot. let st = state(); let cap = st.admission.probes.cap() as usize; let held: Vec<_> = (0..cap) @@ -2042,11 +2110,11 @@ mod highid_verification_tests { t0.elapsed() < std::time::Duration::from_millis(200), "no probe was run" ); - assert_eq!(st.admission.probe_shed_lowid.load(Relaxed), 0); + assert_eq!(st.admission.probe_shed_lowid.load(Relaxed), 1); // A background check is shed as well, counted as busy. let live = st.live_cfg.load_full(); - spawn_highid_probe_for_test(&st, &live, LOCAL, 1, [1; 16], 0, 2000); - assert_eq!(st.highid_verification.skipped_busy.load(Relaxed), 1); + spawn_highid_observe(&st, &live, LOCAL, 1, [1; 16], 0, 2000); + assert_eq!(st.highid_observe.skipped_busy.load(Relaxed), 1); drop(held); assert_eq!(st.admission.probes.in_use(), 0); } @@ -2057,71 +2125,22 @@ mod highid_verification_tests { let live = st.live_cfg.load_full(); let uh = [0x42u8; 16]; let port = answering_listener(uh).await; - spawn_highid_probe_for_test(&st, &live, LOCAL, port, uh, 0, 2000); + spawn_highid_observe(&st, &live, LOCAL, port, uh, 0, 2000); let v = wait_for(&st, |v| v.verified + v.mismatch + v.no_answer > 0).await; assert_eq!((v.verified, v.mismatch, v.no_answer), (1, 0, 0), "{v:?}"); } - #[tokio::test] - async fn a_verified_live_session_is_promoted_and_notified_once() { - let st = state(); - let uh = [0x51u8; 16]; - let port = answering_listener(uh).await; - let mut pushed = st.register_test_client_at(uh, 51, false, 0, LOCAL); - { - let mut live = st.clients.get_mut(&uh).unwrap(); - live.port = port; - } - st.lowid_count_cached.store(1, Relaxed); - let session = st.clients.get(&uh).unwrap().clone(); - let high_id = crate::server::highid_probe::high_id_from_ip(LOCAL).unwrap(); - - spawn_highid_verification( - Arc::clone(&st), - session.clone(), - 4661, - std::time::Duration::ZERO, - ); - for _ in 0..200 { - if session.is_high_id() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - assert_eq!(session.route(), (high_id, true)); - assert_eq!(st.lowid_count_cached.load(Relaxed), 0); - assert!(st.client_by_assigned_id(51).is_none()); - assert!(st.client_by_assigned_id(high_id).is_some()); - let id_change = tokio::time::timeout( - std::time::Duration::from_secs(1), - pushed.recv(), - ) - .await - .unwrap() - .unwrap(); - assert_eq!(id_change.opcode, OP_IDCHANGE); - assert_eq!( - u32::from_le_bytes(id_change.payload[..4].try_into().unwrap()), - high_id - ); - - crate::server::connection::end_session(&st, &session); - assert_eq!(st.lowid_count_cached.load(Relaxed), 0); - assert!(st.client_by_assigned_id(high_id).is_none()); - } - #[tokio::test] async fn a_port_answering_for_someone_else_is_a_mismatch() { let st = state(); let live = st.live_cfg.load_full(); let port = answering_listener([0x99u8; 16]).await; - spawn_highid_probe_for_test(&st, &live, LOCAL, port, [0x42u8; 16], 0, 2000); + spawn_highid_observe(&st, &live, LOCAL, port, [0x42u8; 16], 0, 2000); let v = wait_for(&st, |v| v.verified + v.mismatch + v.no_answer > 0).await; assert_eq!((v.verified, v.mismatch, v.no_answer), (0, 1, 0), "{v:?}"); // Both hashes are kept, so the case can be traced to a second client. let rec: Vec<_> = st - .highid_verification + .highid_observe .recent_mismatches .lock() .unwrap() @@ -2208,12 +2227,12 @@ mod highid_verification_tests { let live = st.live_cfg.load_full(); let login = h("b060873b2d0ed162b0b037c02c716f0e"); let port = answering_listener(h("b060873b2d4dd162b0b037c02c714c0e")).await; - spawn_highid_probe_for_test(&st, &live, LOCAL, port, login, 0, 2000); + spawn_highid_observe(&st, &live, LOCAL, port, login, 0, 2000); let v = wait_for(&st, |v| v.verified + v.mismatch + v.no_answer > 0).await; assert_eq!((v.verified, v.mismatch, v.no_answer), (1, 0, 0), "{v:?}"); - assert_eq!(st.highid_verification.verified_marker_variant.load(Relaxed), 1); + assert_eq!(st.highid_observe.verified_marker_variant.load(Relaxed), 1); assert!(st - .highid_verification + .highid_observe .recent_mismatches .lock() .unwrap() @@ -2253,8 +2272,9 @@ mod highid_verification_tests { port: 4662, login_hash: [0xA0; 16], answered_hash: answered, + marked: false, }; - let o = &st.highid_verification; + let o = &st.highid_observe; // Second client behind the same NAT: answered hash is logged in from // exactly the address we probed. o.record_mismatch(rec([10, 0, 0, 2], [0xB1; 16])); @@ -2268,21 +2288,22 @@ mod highid_verification_tests { #[test] fn the_mismatch_buffer_is_bounded() { let st = ServerState::for_test(); - for i in 0..(crate::state::HighIdVerification::RECENT_MISMATCHES + 50) { - st.highid_verification + for i in 0..(crate::state::HighIdObserve::RECENT_MISMATCHES + 50) { + st.highid_observe .record_mismatch(crate::state::HighIdMismatch { at: i as u64, ip: LOCAL, port: 1, login_hash: [0; 16], answered_hash: [1; 16], + marked: false, }); } - let q = st.highid_verification.recent_mismatches.lock().unwrap(); - assert_eq!(q.len(), crate::state::HighIdVerification::RECENT_MISMATCHES); + let q = st.highid_observe.recent_mismatches.lock().unwrap(); + assert_eq!(q.len(), crate::state::HighIdObserve::RECENT_MISMATCHES); assert_eq!( q.back().unwrap().at, - (crate::state::HighIdVerification::RECENT_MISMATCHES + 49) as u64 + (crate::state::HighIdObserve::RECENT_MISMATCHES + 49) as u64 ); } @@ -2293,10 +2314,10 @@ mod highid_verification_tests { let st = state(); let live = st.live_cfg.load_full(); let port = silent_listener().await; - spawn_highid_probe_for_test(&st, &live, LOCAL, port, [0x42u8; 16], 0, 2000); + spawn_highid_observe(&st, &live, LOCAL, port, [0x42u8; 16], 0, 2000); let v = wait_for(&st, |v| v.verified + v.mismatch + v.no_answer > 0).await; assert_eq!((v.verified, v.mismatch, v.no_answer), (0, 0, 1), "{v:?}"); - let reasons: Vec<&str> = st.highid_verification.reasons.iter().map(|e| *e.key()).collect(); + let reasons: Vec<&str> = st.highid_observe.reasons.iter().map(|e| *e.key()).collect(); assert_eq!(reasons.len(), 1); assert!(SILENT_CLOSE.contains(&Err(reasons[0])), "{reasons:?}"); } @@ -2306,13 +2327,13 @@ mod highid_verification_tests { let st = state(); let live = st.live_cfg.load_full(); // Take every permit, as a reconnect storm would. - let _held = Arc::clone(&st.highid_verification.permits) - .acquire_many_owned(crate::state::HighIdVerification::MAX_CONCURRENT as u32) + let _held = Arc::clone(&st.highid_observe.permits) + .acquire_many_owned(crate::state::HighIdObserve::MAX_CONCURRENT as u32) .await .unwrap(); let port = silent_listener().await; - spawn_highid_probe_for_test(&st, &live, LOCAL, port, [0x42u8; 16], 0, 2000); - let v = HighIdVerificationView::of(&st); + spawn_highid_observe(&st, &live, LOCAL, port, [0x42u8; 16], 0, 2000); + let v = HighIdObserveView::of(&st); assert_eq!(v.skipped, 1); assert_eq!( v.verified + v.mismatch + v.no_answer, @@ -2321,4 +2342,137 @@ mod highid_verification_tests { ); } + // ─── verdict mode: network.highid_downgrade_on_wrong_hash ───────────── + + fn verdict_live(st: &ServerState) -> Arc { + let mut c = (*st.live_cfg.load_full()).clone(); + c.network.highid_downgrade_on_wrong_hash = true; + Arc::new(c) + } + + async fn settle(st: &ServerState) { + wait_for(st, |v| v.verified + v.mismatch + v.no_answer > 0).await; + // The mark is applied right after the counters, in the same task. + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + } + + #[tokio::test] + async fn a_wrong_hash_marks_the_login_for_next_time() { + let st = state(); + let live = verdict_live(&st); + let uh = h("b060873b2d0ed162b0b037c02c716f0e"); + let port = answering_listener(h("8746e2693c0e1e4a165d044caf576f7b")).await; + assert!(!st.highid_observe.is_marked(LOCAL, port, &uh)); + spawn_highid_check(&st, &live, LOCAL, port, uh, 0, 2000, true); + settle(&st).await; + assert!(st.highid_observe.is_marked(LOCAL, port, &uh)); + // Only this exact login is marked: another hash, or another port, is not. + assert!(!st.highid_observe.is_marked(LOCAL, port, &[0x42; 16])); + assert!(!st + .highid_observe + .is_marked(LOCAL, port.wrapping_add(1), &uh)); + let rec = st.highid_observe.recent_mismatches.lock().unwrap(); + assert!(rec[0].marked); + } + + #[tokio::test] + async fn the_own_hash_again_clears_the_mark() { + let st = state(); + let live = verdict_live(&st); + let uh = [0x42u8; 16]; + let port = answering_listener(uh).await; + st.highid_observe + .mark(LOCAL, port, uh, std::time::Duration::from_secs(3600)); + spawn_highid_check(&st, &live, LOCAL, port, uh, 0, 2000, true); + settle(&st).await; + assert!(!st.highid_observe.is_marked(LOCAL, port, &uh)); + assert_eq!(st.highid_observe.marks_cleared.load(Relaxed), 1); + } + + #[tokio::test] + async fn a_marker_variant_neither_marks_nor_keeps_a_mark() { + let st = state(); + let live = verdict_live(&st); + let uh = h("b060873b2d0ed162b0b037c02c716f0e"); + let port = answering_listener(h("b060873b2d4dd162b0b037c02c714c0e")).await; + st.highid_observe + .mark(LOCAL, port, uh, std::time::Duration::from_secs(3600)); + spawn_highid_check(&st, &live, LOCAL, port, uh, 0, 2000, true); + settle(&st).await; + assert!( + !st.highid_observe.is_marked(LOCAL, port, &uh), + "same client → cleared" + ); + } + + #[tokio::test] + async fn silence_leaves_a_mark_as_it_is() { + let st = state(); + let live = verdict_live(&st); + let uh = [0x42u8; 16]; + // Silent port: the peer's IP filter, say. Never sets a mark... + let port = silent_listener().await; + spawn_highid_check(&st, &live, LOCAL, port, uh, 0, 2000, true); + settle(&st).await; + assert!(!st.highid_observe.is_marked(LOCAL, port, &uh)); + // ...and never clears one either. + let st = state(); + let port = silent_listener().await; + st.highid_observe + .mark(LOCAL, port, uh, std::time::Duration::from_secs(3600)); + spawn_highid_check(&st, &live, LOCAL, port, uh, 0, 2000, true); + settle(&st).await; + assert!(st.highid_observe.is_marked(LOCAL, port, &uh)); + assert_eq!(st.highid_observe.marks_cleared.load(Relaxed), 0); + } + + #[tokio::test] + async fn observe_mode_records_but_never_marks() { + let st = state(); + let live = st.live_cfg.load_full(); + let port = answering_listener([0x99u8; 16]).await; + spawn_highid_observe(&st, &live, LOCAL, port, [0x42u8; 16], 0, 2000); + settle(&st).await; + assert_eq!(st.highid_observe.mismatch.load(Relaxed), 1); + assert!(!st.highid_observe.is_marked(LOCAL, port, &[0x42u8; 16])); + assert!(!st.highid_observe.recent_mismatches.lock().unwrap()[0].marked); + } + + #[tokio::test] + async fn a_mark_expires() { + let st = ServerState::for_test(); + let o = &st.highid_observe; + o.mark(LOCAL, 4662, [1; 16], std::time::Duration::from_millis(20)); + assert!(o.is_marked(LOCAL, 4662, &[1; 16])); + assert_eq!(o.marks_active(), 1); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + assert!(!o.is_marked(LOCAL, 4662, &[1; 16])); + assert_eq!(o.marks_active(), 0); + assert!( + o.wrong_hash_marks.is_empty(), + "expired mark removed on lookup" + ); + } + + #[test] + fn the_mark_table_is_bounded() { + let st = ServerState::for_test(); + let o = &st.highid_observe; + let ttl = std::time::Duration::from_secs(3600); + for i in 0..crate::state::HighIdObserve::MAX_MARKS { + let mut h = [0u8; 16]; + h[..8].copy_from_slice(&(i as u64).to_le_bytes()); + assert!(o.mark(LOCAL, 1, h, ttl)); + } + assert!( + !o.mark(LOCAL, 1, [0xFF; 16], ttl), + "full table refuses a new mark" + ); + let mut h0 = [0u8; 16]; + h0[..8].copy_from_slice(&0u64.to_le_bytes()); + assert!( + o.mark(LOCAL, 1, h0, ttl), + "renewing an existing mark still works" + ); + } } diff --git a/src/server/lugdunum_natt.rs b/src/server/lugdunum_natt.rs new file mode 100644 index 0000000..3555eed --- /dev/null +++ b/src/server/lugdunum_natt.rs @@ -0,0 +1,893 @@ +//! 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. +//! +//! **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); 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, +//! 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::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 +/// `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); + +/// 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 { + 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, + ForeignDisabled, + ForeignRateLimited, + ForeignTooManyTargets, + ForeignTargetLimited, +} + +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", + Refused::ForeignDisabled => "natcb_foreign_disabled", + Refused::ForeignRateLimited => "natcb_foreign_rate_limited", + Refused::ForeignTooManyTargets => "natcb_foreign_too_many_targets", + Refused::ForeignTargetLimited => "natcb_foreign_target_limited", + } + } +} + +/// 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> { + // 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(&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) +} + +/// 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) +} + +/// 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 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)) { + 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 extension { + "natcbx_foreign_target_extension" + } else { + "natcbx_foreign_target_lugdunum_only" + }, + ); + 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()); + 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 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; + } + 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( + 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 { + // 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)?; + 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 +/// 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]); + } + + 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); + } + + /// 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] + 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); + // 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); + } + + /// 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(); + 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..b59b4a5 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -6,12 +6,15 @@ 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; +pub mod lugdunum_natt; 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 c9430aa..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 => { @@ -345,10 +350,7 @@ pub fn handle_offerfiles( FilterResult::Allow => {} } - if soft_limit > 0 - && sourced >= soft_limit - && !already_sources(state, client, &file.hash, file.size) - { + if soft_limit > 0 && sourced >= soft_limit && !already_sources(state, client, &file.hash) { over_limit += 1; continue; } @@ -359,11 +361,7 @@ pub fn handle_offerfiles( state.add_file_with_source(file.hash, file.size, stored_name, source); accepted += 1; if file.media.any() { - state.file_slab.merge_media( - &file.hash, - file.size, - file.media.to_media(&state.file_slab.codecs), - ); + state.file_slab.merge_media(&file.hash, file.media.to_media(&state.file_slab.codecs)); } if tracing::enabled!(tracing::Level::DEBUG) { @@ -450,13 +448,8 @@ pub fn soft_limit_message(soft: u32) -> String { /// Does this client already source `hash`? A re-offer of such a file is a /// refresh and does not count against the soft limit. -fn already_sources( - state: &ServerState, - client: &ClientHandle, - hash: &[u8; 16], - size: u64, -) -> bool { - let Some(id) = state.file_slab.id_of_exact(hash, size) else { +fn already_sources(state: &ServerState, client: &ClientHandle, hash: &[u8; 16]) -> bool { + let Some(id) = state.file_slab.id_of(hash) else { return false; }; state @@ -573,7 +566,7 @@ mod soft_limit_tests { fn client() -> ClientHandle { ClientHandle { user_hash: [9; 16], - route: crate::state::ClientRoute::shared(0x0A00_0001, true), + assigned_id: 0x0A00_0001, ip: IpAddr::V4(Ipv4Addr::new(1, 0, 0, 10)), port: 4662, udp_port: 0, @@ -582,6 +575,7 @@ mod soft_limit_tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: true, connected_at: Instant::now(), country: "??".into(), software: "test".into(), @@ -592,7 +586,7 @@ mod soft_limit_tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)), } } @@ -785,7 +779,7 @@ mod string_size_tests { fn client() -> ClientHandle { ClientHandle { user_hash: [3; 16], - route: crate::state::ClientRoute::shared(0x0A00_0003, true), + assigned_id: 0x0A00_0003, ip: std::net::IpAddr::V4(std::net::Ipv4Addr::new(1, 0, 0, 3)), port: 4662, udp_port: 0, @@ -794,6 +788,7 @@ mod string_size_tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: true, connected_at: std::time::Instant::now(), country: "??".into(), software: "test".into(), @@ -804,7 +799,7 @@ mod string_size_tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)), } } diff --git a/src/server/review_audit.rs b/src/server/review_audit.rs new file mode 100644 index 0000000..2501183 --- /dev/null +++ b/src/server/review_audit.rs @@ -0,0 +1,399 @@ +//! 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" | "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" | "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, + } +} + +/// 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. +/// 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() +} + +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. 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 = separators_to_spaces(reason_term(reason)); + if t.trim().is_empty() { + lower.clone() + } else { + 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(), + }; + 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, + // 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), + } +} + +/// 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)); + 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)); + // 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); + // 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/search.rs b/src/server/search.rs index efdb958..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,9 +544,9 @@ 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, file.size, &mut tags); + push_media_tags(state, &file.hash, &mut tags); write_tag_list(&mut payload, &tags); } @@ -566,8 +592,7 @@ mod pagination_and_largefile_tests { keep.push(rx); let mut h = st.clients.get_mut(&[hash; 16]).unwrap(); h.tx = Some(tx); - let assigned_id = h.assigned_id(); - h.route = crate::state::ClientRoute::shared(assigned_id, high); + h.is_high_id = high; } fn src(hash: u8, ip: Ipv4Addr, complete: bool) -> crate::state::Source { @@ -751,21 +776,16 @@ pub(crate) fn prepared_tree(state: &ServerState, tree: &SearchNode) -> SearchNod /// FT_MEDIA_LENGTH (0xD3), FT_MEDIA_BITRATE (0xD4) and FT_MEDIA_CODEC (0xD5) /// for a result, when the file was offered with them, as Lugdunum sends them. /// Both result paths call this, so TCP and UDP answers carry the same tags. -pub(crate) fn push_media_tags( - state: &ServerState, - hash: &[u8; 16], - size: u64, - tags: &mut Vec, -) { - let Some(m) = state.file_slab.media_of_key(hash, size) else { return }; +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()))); } } @@ -805,9 +825,7 @@ mod media_search_tests { state.add_file_with_source(h, 1_000_000, n.into(), ([h[0]; 16], ip, 4662, true)); if let Some((len, rate, codec)) = m { let c = state.file_slab.codecs.intern(codec); - state - .file_slab - .merge_media(&h, 1_000_000, Media::new(len, rate, c)); + state.file_slab.merge_media(&h, Media::new(len, rate, c)); } } let t = || SearchNode::Term("euphoria".into()); diff --git a/src/server/udp.rs b/src/server/udp.rs index 54a452f..b7b2020 100644 --- a/src/server/udp.rs +++ b/src/server/udp.rs @@ -33,6 +33,10 @@ 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; /// Maximum search results in a single UDP GLOBSEARCHRES response. const UDP_MAX_SEARCH_RESULTS: usize = 10; @@ -316,55 +320,6 @@ fn pick_reply_server_port( .unwrap_or(plain) } -/// Parse a GLOBGETSOURCES2 request without guessing across malformed tails. -/// An exact 16-byte payload is the legacy single-hash form; all other payloads -/// are a sequence of 20-byte normal-file or 28-byte large-file records. -fn parse_getsources2_files(payload: &[u8]) -> (Vec<([u8; 16], Option)>, bool) { - const MAX_BATCH: usize = 64; - - if payload.len() == 16 { - let mut hash = [0u8; 16]; - hash.copy_from_slice(payload); - return (vec![(hash, None)], false); - } - - let mut files = Vec::new(); - let mut offset = 0usize; - let mut truncated = false; - while offset + 20 <= payload.len() && files.len() < MAX_BATCH { - let mut hash = [0u8; 16]; - hash.copy_from_slice(&payload[offset..offset + 16]); - let size_lo = u32::from_le_bytes( - payload[offset + 16..offset + 20] - .try_into() - .expect("bounded size field"), - ); - offset += 20; - let size = if size_lo == 0 { - if offset + 8 > payload.len() { - truncated = true; - break; - } - let size = u64::from_le_bytes( - payload[offset..offset + 8] - .try_into() - .expect("bounded large-file size"), - ); - offset += 8; - size - } else { - size_lo as u64 - }; - files.push((hash, Some(size))); - } - - if files.len() < MAX_BATCH && offset < payload.len() { - truncated = true; - } - - (files, truncated) -} - impl UdpServer { pub async fn bind(cfg: Arc, state: Arc, seckey: [u8; 16]) -> Result { let bind_addr = format!("{}:{}", cfg.network.listen_ip, cfg.network.udp_port()); @@ -623,7 +578,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)| { @@ -634,6 +589,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() { @@ -645,6 +605,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 @@ -656,7 +632,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. @@ -678,13 +661,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"); @@ -693,7 +684,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..]; @@ -728,7 +719,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]]); @@ -791,6 +793,110 @@ 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, Forward}; + let local_port = self.socket.local_addr().map_or(0, |a| a.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); + 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; + 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. /// Replies must mirror it — see `reply()`. async fn dispatch( @@ -898,12 +1004,70 @@ 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 // from elsewhere would send the client's peers there. let same_ip = canon_ip(h.ip) == canon_ip(peer.ip()); - if same_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, + // 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 + && !token_ok + && 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; + } + // 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" + } else if obf.is_some() { + "natt_9f_obfuscated" + } else { + "natt_9f_plain" + }; + 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, @@ -914,16 +1078,47 @@ 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 + accepted && !plain_refused } else { false }; + // 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); + } // 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 @@ -949,6 +1144,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, @@ -1023,7 +1225,7 @@ impl UdpServer { for chunk in payload.chunks_exact(16).take(MAX_BATCH) { let mut hash = [0u8; 16]; hash.copy_from_slice(chunk); - self.send_found_sources(&hash, None, peer, obf).await?; + self.send_found_sources(&hash, peer, obf).await?; } Ok(()) } @@ -1065,13 +1267,63 @@ impl UdpServer { peer: SocketAddr, obf: Option<(u32, u8)>, ) -> Result<()> { - let (files, truncated) = parse_getsources2_files(payload); - if truncated { - debug!(ip = %peer.ip(), "0x94: truncated or malformed record — stopping"); + // Cap the fan-out: each record yields its own reply datagram, so one + // inbound packet must not become an unbounded outbound burst. + const MAX_BATCH: usize = 64; + + 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]; + hash.copy_from_slice(&payload[i..i + 16]); + let size_lo = u32::from_le_bytes([ + payload[i + 16], + payload[i + 17], + payload[i + 18], + 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 + // arbitrary offset and invent hashes. + if i + 8 > payload.len() { + 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; + // 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))); } - for (hash, size) in files { - self.send_found_sources(&hash, size, peer, obf).await?; + // A single bare hash with no size field is still accepted: some clients + // send exactly 16 bytes. + if hashes.is_empty() && payload.len() >= 16 { + let mut hash = [0u8; 16]; + hash.copy_from_slice(&payload[..16]); + hashes.push((hash, None)); + } + + 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(()) } @@ -1080,7 +1332,6 @@ impl UdpServer { async fn send_found_sources( &self, hash: &[u8; 16], - size: Option, peer: SocketAddr, obf: Option<(u32, u8)>, ) -> Result<()> { @@ -1088,12 +1339,10 @@ impl UdpServer { // a download, so a withheld file must stop being served on this channel // as well. The name comes from the index — see is_withheld_opt for what // happens when the file is not there. - let file_id = self.state.file_slab.id_for_request(hash, size); - let name = file_id.and_then(|id| { - self.state - .file_slab - .with_record(id, |r| r.name().to_string()) - }); + let name = self + .state + .file_slab + .with_record_by_hash(hash, |_id, r| r.name().to_string()); if self.state.filter.is_withheld_opt(hash, name.as_deref()) { let mut out = BytesMut::new(); out.put_u8(PROTO_EDONKEY); @@ -1106,15 +1355,34 @@ impl UdpServer { // Collect (user_hash, ip, port) so the encoder below can resolve a LowID // publisher to its assigned id — see the note there. - let sources: Vec<([u8; 16], std::net::IpAddr, u16)> = file_id - .and_then(|id| self.state.file_slab.with_record(id, |e| { + let sources: Vec<([u8; 16], std::net::IpAddr, u16)> = self + .state + .file_slab + .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… // @@ -1163,7 +1431,7 @@ impl UdpServer { .state .clients .get(user_hash) - .map(|h| h.is_high_id()) + .map(|h| h.is_high_id) .unwrap_or(false) }) } else { @@ -1177,7 +1445,7 @@ impl UdpServer { continue; } let id = match self.state.clients.get(user_hash) { - Some(handle) if !handle.is_high_id() => handle.assigned_id(), + Some(handle) if !handle.is_high_id => handle.assigned_id, _ => { // STALE SOURCE — the client has gone, so there is no low id // to substitute and the stored address goes out as it is. A @@ -1258,6 +1526,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); @@ -1462,6 +1735,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). @@ -1481,6 +1799,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. @@ -2042,6 +2363,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. @@ -2206,14 +2531,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 @@ -2221,16 +2546,16 @@ 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, entry.size, &mut tags); + crate::server::search::push_media_tags(state, &hash, &mut tags); write_tag_list(&mut out, &tags); out } @@ -2352,7 +2677,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, @@ -2531,52 +2856,6 @@ mod several_servers_per_ip_tests { } } -#[cfg(test)] -mod getsources2_parser_tests { - use super::parse_getsources2_files; - - #[test] - fn preserves_sizes_for_same_hash_records() { - let hash = [0xA5; 16]; - let large_size = u32::MAX as u64 + 42; - let mut payload = Vec::new(); - payload.extend_from_slice(&hash); - payload.extend_from_slice(&1234u32.to_le_bytes()); - payload.extend_from_slice(&hash); - payload.extend_from_slice(&0u32.to_le_bytes()); - payload.extend_from_slice(&large_size.to_le_bytes()); - - let (files, truncated) = parse_getsources2_files(&payload); - - assert!(!truncated); - assert_eq!(files, vec![(hash, Some(1234)), (hash, Some(large_size))]); - } - - #[test] - fn accepts_only_an_exact_bare_hash_as_legacy_form() { - let hash = [0x5A; 16]; - - let (files, truncated) = parse_getsources2_files(&hash); - - assert!(!truncated); - assert_eq!(files, vec![(hash, None)]); - } - - #[test] - fn truncated_large_file_does_not_fall_back_to_hash_only() { - let hash = [0x33; 16]; - let mut payload = Vec::new(); - payload.extend_from_slice(&hash); - payload.extend_from_slice(&0u32.to_le_bytes()); - payload.extend_from_slice(&1234u32.to_le_bytes()); - - let (files, truncated) = parse_getsources2_files(&payload); - - assert!(truncated); - assert!(files.is_empty()); - } -} - #[cfg(test)] mod crypt_ping_tests { use super::is_crypt_ping; diff --git a/src/state/file_id.rs b/src/state/file_id.rs index e96e0b2..4a2b83c 100644 --- a/src/state/file_id.rs +++ b/src/state/file_id.rs @@ -3,11 +3,11 @@ //! WHY: at Lugdunum scale (30M files, 50k users) storing the full 16-byte MD4 //! hash inside every keyword posting and every user_files entry costs ~3.5 GB. //! Replacing those references with a 4-byte `FileId` saves the bulk of it. The -//! 16-byte hash then lives in exactly ONE place (the slab record), and an -//! intrusive `(hash, size) → id` index provides the publish-time lookup. +//! 16-byte hash then lives in exactly ONE place (the slab record), and a +//! `hash → id` map provides the publish-time lookup. //! //! THIS MODULE IS STEP 1 (foundation only): it introduces the `FileId` type, -//! the slab store, and the bidirectional `(hash, size)`↔id mapping. It does NOT yet +//! the slab store, and the bidirectional hash↔id mapping. It does NOT yet //! rewire keyword_index / user_files / files to use ids — that is a later step. //! Built and tested in isolation so the core search paths are untouched until //! the migration is done deliberately, one path at a time. @@ -99,8 +99,8 @@ impl FileRecord { /// Slab-allocated file store with an intrusive per-shard hash index. /// /// - `records[id]` → FileRecord (dense Vec, indexed by id) -/// - `(hash, size)` → id resolves by walking the bucket chain in the hash's -/// shard; the 16-byte hash lives once in the record (no separate key map). +/// - hash → id resolves by walking the bucket chain in the hash's shard; the +/// 16-byte hash lives once in the record (no separate hash→id map). /// Number of slab shards. FileId encodes the shard in its top bits, the local /// index in the low bits, so reads/writes to different shards never contend — /// recovering DashMap-like parallelism while keeping a dense packed Vec per @@ -361,14 +361,15 @@ impl SlabShard { } } - /// Find one exact `(hash, size)` record. Same-hash variants deliberately - /// share a bucket chain, preserving the intrusive index's memory model. - fn find_exact(&self, hash: &FileHash, size: u64) -> Option { + /// Find the within-shard index of a live record with this hash by walking + /// its bucket chain. Caller holds the shard read (or write) lock. The 16-byte + /// hash compare distinguishes records that share a bucket. O(chain length). + fn find(&self, hash: &FileHash) -> Option { let b = bucket_of(hash_u64(hash), self.bucket_mask); let mut cur = self.buckets[b]; while cur != NIL { let r = &self.records[cur as usize]; - if r.alive() && &r.hash == hash && r.size == size { + if r.alive() && &r.hash == hash { return Some(cur); } cur = self.next[cur as usize]; @@ -376,28 +377,9 @@ impl SlabShard { None } - /// Resolve a legacy hash-only identity only when exactly one live size is - /// indexed. Ambiguous collisions must not select an arbitrary variant. - fn find_unique_hash(&self, hash: &FileHash) -> Option { - let b = bucket_of(hash_u64(hash), self.bucket_mask); - let mut cur = self.buckets[b]; - let mut found = None; - while cur != NIL { - let r = &self.records[cur as usize]; - if r.alive() && &r.hash == hash { - if found.is_some() { - return None; - } - found = Some(cur); - } - cur = self.next[cur as usize]; - } - found - } - /// Append a record and link it into its bucket, returning its within-shard - /// index. Caller holds the shard write lock and must have confirmed the file - /// identity is new (via `find_exact`). Grows the bucket array first when load would exceed + /// index. Caller holds the shard write lock and must have confirmed the hash + /// is new (via `find`). Grows the bucket array first when load would exceed /// ~1; grow_buckets re-links everything incl. the new record, so we only /// `link` separately when we did NOT grow (else we'd double-link). fn push_and_link(&mut self, rec: FileRecord) -> u32 { @@ -448,6 +430,45 @@ 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) }; + 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. +#[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 @@ -505,29 +526,13 @@ impl FileSlab { self.epoch.elapsed().as_secs().min(u32::MAX as u64) as u32 } - /// Look up a hash only when exactly one size is indexed. Hash-only protocol - /// requests use this compatibility path; collisions return `None`. + /// Look up the id for a hash, if present. Walks the bucket chain in the + /// hash's shard (intrusive index — no separate hash→id map). One shard read + /// lock; chains stay ~1 long at load factor ~1. pub fn id_of(&self, hash: &FileHash) -> Option { let shard_no = shard_of_hash(hash); let sh = self.shards[shard_no as usize].read().unwrap(); - sh.find_unique_hash(hash).map(|idx| make_id(shard_no, idx)) - } - - /// Look up the canonical file identity used by publication and sized source - /// requests. - pub fn id_of_exact(&self, hash: &FileHash, size: u64) -> Option { - let shard_no = shard_of_hash(hash); - let sh = self.shards[shard_no as usize].read().unwrap(); - sh.find_exact(hash, size).map(|idx| make_id(shard_no, idx)) - } - - /// Resolve a sized protocol request exactly, or a legacy hash-only request - /// only when the hash has one unambiguous live size. - pub fn id_for_request(&self, hash: &FileHash, size: Option) -> Option { - match size { - Some(size) => self.id_of_exact(hash, size), - None => self.id_of(hash), - } + sh.find(hash).map(|idx| make_id(shard_no, idx)) } /// Resolve an id back to its hash, if the slot is alive. The reverse of @@ -542,12 +547,12 @@ impl FileSlab { .map(|r| r.hash) } - /// Insert a new `(hash, size)` file or return the existing id. Returns - /// `(id, was_new)`. + /// Insert a new file or return the existing id. Returns (id, was_new). /// /// The whole search-then-insert runs under one shard WRITE lock, so the - /// operation is atomic: two threads publishing the same identity serialize - /// on the lock. No separate map, no entry-API race dance, and exactly one + /// operation is atomic: two threads publishing the same new hash serialize on + /// the lock — the first inserts, the second's `find` sees it and returns the + /// existing id. No separate map, no entry-API race dance, and exactly one /// lock taken (this is what removes the old DashMap↔slab lock-ordering). pub fn get_or_insert( &self, @@ -559,7 +564,7 @@ impl FileSlab { let now = self.now_secs(); let shard_no = shard_of_hash(&hash); let mut sh = self.shards[shard_no as usize].write().unwrap(); - if let Some(idx) = sh.find_exact(&hash, size) { + if let Some(idx) = sh.find(&hash) { return (make_id(shard_no, idx), false); } let index = sh.insert_record( @@ -572,14 +577,13 @@ impl FileSlab { (make_id(shard_no, index), true) } - /// Insert a file with NO sources. Returns the id (existing if the exact - /// `(hash, size)` identity is already known). Same single-write-lock - /// discipline as `get_or_insert`. + /// Insert a file with NO sources. Returns the id (existing if the hash is + /// already known). Same single-write-lock discipline as `get_or_insert`. pub fn insert_sourceless(&self, hash: FileHash, size: u64, name: &str) -> FileId { let now = self.now_secs(); let shard_no = shard_of_hash(&hash); let mut sh = self.shards[shard_no as usize].write().unwrap(); - if let Some(idx) = sh.find_exact(&hash, size) { + if let Some(idx) = sh.find(&hash) { return make_id(shard_no, idx); } let index = sh.insert_record( @@ -655,7 +659,48 @@ impl FileSlab { name } - /// Tombstone by hash only when exactly one live size exists. + /// 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) { self.tombstone(id) @@ -736,16 +781,17 @@ impl FileSlab { // entirely (Stage 3c). Each takes a single shard lock, so operations on // different files run concurrently, preserving the sharded parallelism. - /// Resolve an unambiguous hash directly to a clone of its live record. - /// One intrusive chain walk + one shard read lock. + /// Resolve a hash directly to a clone of its live record. Replaces + /// `files.get(&hash)`. One intrusive chain walk + one shard read lock. pub fn get_by_hash(&self, hash: &FileHash) -> Option { let id = self.id_of(hash)?; self.get(id) } - /// Add or refresh a source on an existing file (by hash and size). Returns - /// true if the file existed (and was updated). Deduplicates by user_hash and - /// refreshes the completeness flag. Used by add_file_with_source for the + /// Add or refresh a source on an existing file (by hash). Returns true if + /// the file existed (and was updated). Mirrors the and_modify arm of the old + /// `files.entry(hash).and_modify(...)`: dedups by user_hash, refreshes the + /// completeness flag. Used by add_file_with_source for the /// "already known file" path. /// Like `add_or_refresh_source`, but also reports the name already stored for /// this file when the publisher used a DIFFERENT one. @@ -770,11 +816,10 @@ impl FileSlab { pub fn add_or_refresh_source_named( &self, hash: &FileHash, - size: u64, src: Source, published_as: &str, ) -> Option> { - let id = self.id_of_exact(hash, size)?; + let id = self.id_of(hash)?; let shard = self.shards.get(id_shard(id))?; let mut sh = shard.write().unwrap(); let r = sh.records.get_mut(id_index(id))?; @@ -794,8 +839,50 @@ impl FileSlab { } } - pub fn add_or_refresh_source(&self, hash: &FileHash, size: u64, src: Source) -> bool { - let id = match self.id_of_exact(hash, size) { + /// `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, None => return false, }; @@ -863,8 +950,7 @@ impl FileSlab { Some(f(r)) } - /// Same, resolved only when the hash has one live size. One lock - /// acquisition, not two. + /// Same, resolved by hash. One lock acquisition, not two. /// /// Callers that already hold a `FileId` should use `with_record`: going /// FileId → hash → FileId costs an extra lock and an extra hash-chain walk @@ -876,7 +962,7 @@ impl FileSlab { ) -> Option { let shard_no = shard_of_hash(hash); let sh = self.shards[shard_no as usize].read().unwrap(); - let idx = sh.find_unique_hash(hash)?; + let idx = sh.find(hash)?; let r = sh.records.get(idx as usize)?; if !r.alive() { return None; @@ -885,24 +971,24 @@ impl FileSlab { } /// Record the media fields of a file, filling only what it lacks (a - /// re-offer does not overwrite, as in Lugdunum). No-op for an unknown - /// `(hash, size)` identity or empty `m`. - pub fn merge_media(&self, hash: &FileHash, size: u64, m: crate::state::media::Media) { + /// re-offer does not overwrite, as in Lugdunum). No-op for an unknown hash + /// or empty `m`. + pub fn merge_media(&self, hash: &FileHash, m: crate::state::media::Media) { if m.is_empty() { return; } let shard_no = shard_of_hash(hash); let mut sh = self.shards[shard_no as usize].write().unwrap(); - let Some(idx) = sh.find_exact(hash, size) else { return }; + let Some(idx) = sh.find(hash) else { return }; let e = sh.media.entry(idx).or_default(); *e = e.merged(m); } /// The media fields of a file, if any were offered. - pub fn media_of_key(&self, hash: &FileHash, size: u64) -> Option { + pub fn media_of_hash(&self, hash: &FileHash) -> Option { let shard_no = shard_of_hash(hash); let sh = self.shards[shard_no as usize].read().unwrap(); - let idx = sh.find_exact(hash, size)?; + let idx = sh.find(hash)?; sh.media.get(&idx).copied() } @@ -1028,15 +1114,15 @@ mod tests { slab.get_or_insert(h, 100, "f", partial(1)); let r = slab.get_by_hash(&h).unwrap(); assert_eq!((r.sources.len(), r.complete_source_count()), (1, 0)); - slab.add_or_refresh_source(&h, 100, complete(2)); - slab.add_or_refresh_source(&h, 100, partial(3)); + slab.add_or_refresh_source(&h, complete(2)); + slab.add_or_refresh_source(&h, partial(3)); let r = slab.get_by_hash(&h).unwrap(); assert_eq!((r.sources.len(), r.complete_source_count()), (3, 1)); // The download finishes and the client re-offers: it counts now. - slab.add_or_refresh_source(&h, 100, complete(1)); + slab.add_or_refresh_source(&h, complete(1)); assert_eq!(slab.get_by_hash(&h).unwrap().complete_source_count(), 2); // A complete copy re-offered as incomplete (aMule, corrupt parts). - slab.add_or_refresh_source(&h, 100, partial(2)); + slab.add_or_refresh_source(&h, partial(2)); let r = slab.get_by_hash(&h).unwrap(); assert_eq!((r.sources.len(), r.complete_source_count()), (3, 1)); } @@ -1050,7 +1136,7 @@ mod tests { assert!(slab.remove_user_source(id, &[1u8; 16])); // ...but another publisher added a source before the tombstone. let other = Source::new([2u8; 16], IpAddr::V4(Ipv4Addr::new(1, 2, 3, 5)), 4662, true); - assert!(slab.add_or_refresh_source(&h, 100, other)); + assert!(slab.add_or_refresh_source(&h, other)); assert!(slab.tombstone_if_sourceless(id).is_none()); assert_eq!(slab.get(id).map(|r| r.sources.len()), Some(1)); @@ -1185,27 +1271,10 @@ mod tests { let (id1, n1) = slab.get_or_insert([10u8; 16], 100, "a.bin", src()); let (id2, n2) = slab.get_or_insert([10u8; 16], 100, "a.bin", src()); assert!(n1 && !n2); - assert_eq!(id1, id2, "same hash and size must map to same id"); + assert_eq!(id1, id2, "same hash must map to same id"); assert_eq!(slab.live_count(), 1); } - #[test] - fn same_hash_with_different_sizes_has_distinct_ids() { - let slab = FileSlab::new(); - let hash = [10u8; 16]; - let (small, small_new) = slab.get_or_insert(hash, 100, "small.bin", src()); - let (large, large_new) = slab.get_or_insert(hash, 200, "large.bin", src()); - - assert!(small_new && large_new); - assert_ne!(small, large); - assert_eq!(slab.live_count(), 2); - assert_eq!(slab.id_of_exact(&hash, 100), Some(small)); - assert_eq!(slab.id_of_exact(&hash, 200), Some(large)); - assert_eq!(slab.id_of(&hash), None, "hash-only lookup must reject ambiguity"); - assert_eq!(slab.get(small).unwrap().size, 100); - assert_eq!(slab.get(large).unwrap().size, 200); - } - #[test] fn ids_are_unique_and_resolve() { let slab = FileSlab::new(); @@ -1234,19 +1303,19 @@ mod tests { let h = [0x42u8; 16]; let src = Source::new([1; 16], "203.0.113.1".parse().unwrap(), 4662, true); let (id, _) = slab.get_or_insert(h, 1000, "movie.avi", src); - assert_eq!(slab.media_of_key(&h, 1000), None); - slab.merge_media(&h, 1000, Media::new(0, 1200, 0)); - slab.merge_media(&h, 1000, Media::new(5400, 999, 3)); - let m = slab.media_of_key(&h, 1000).unwrap(); + assert_eq!(slab.media_of_hash(&h), None); + slab.merge_media(&h, Media::new(0, 1200, 0)); + slab.merge_media(&h, Media::new(5400, 999, 3)); + let m = slab.media_of_hash(&h).unwrap(); assert_eq!((m.length(), m.bitrate(), m.codec()), (5400, 1200, 3)); assert_eq!(slab.with_record_media(id, true, |_, m| m), Some(Some(m))); assert_eq!(slab.with_record_media(id, false, |_, m| m), Some(None)); assert_eq!(slab.media_report().0, 1); assert!(slab.tombstone(id)); assert_eq!(slab.media_report().0, 0); - assert_eq!(slab.media_of_key(&h, 1000), None); + assert_eq!(slab.media_of_hash(&h), None); // Unknown hash: nothing stored. - slab.merge_media(&[9; 16], 1000, Media::new(1, 1, 1)); + slab.merge_media(&[9; 16], Media::new(1, 1, 1)); assert_eq!(slab.media_report().0, 0); } @@ -1430,4 +1499,51 @@ 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)); + // 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]; + 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 ac8ce21..78799a0 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -21,29 +21,13 @@ use dashmap::DashMap; use keyword_index::KeywordIndex; use smart_sources::SmartSourcesCache; use std::net::{IpAddr, SocketAddrV4}; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use std::time::Instant; -use tokio::sync::{mpsc, watch, RwLock}; +use tokio::sync::{mpsc, RwLock}; pub type UserHash = [u8; 16]; -/// Identity and lifecycle state shared by every handle for one login session. -#[derive(Debug)] -pub struct ClientSession { - last_activity_ms: AtomicU64, - retired: watch::Sender, -} - -impl ClientSession { - pub fn new(last_activity_ms: u64) -> Arc { - Arc::new(Self { - last_activity_ms: AtomicU64::new(last_activity_ms), - retired: watch::channel(false).0, - }) - } -} - /// OFFERFILES batches processed in parallel on the blocking pool. Each is /// CPU-bound (a content-filter check per record), so a few cover any core /// count this server runs on; more would only compete with searches. @@ -63,47 +47,11 @@ pub type FileHash = [u8; 16]; /// Keeps a small buffer so the sending task doesn't block on a single slow receiver. const CLIENT_CHANNEL_CAP: usize = 16; -#[derive(Debug)] -pub struct ClientRoute { - packed: std::sync::atomic::AtomicU64, -} - -impl ClientRoute { - const HIGH_ID_BIT: u64 = 1 << 32; - - pub fn shared(assigned_id: u32, is_high_id: bool) -> Arc { - Arc::new(Self { - packed: std::sync::atomic::AtomicU64::new(Self::pack(assigned_id, is_high_id)), - }) - } - - fn pack(assigned_id: u32, is_high_id: bool) -> u64 { - u64::from(assigned_id) | if is_high_id { Self::HIGH_ID_BIT } else { 0 } - } - - pub fn snapshot(&self) -> (u32, bool) { - let packed = self.packed.load(Ordering::Acquire); - (packed as u32, packed & Self::HIGH_ID_BIT != 0) - } - - fn promote(&self, low_id: u32, high_id: u32) -> bool { - self.packed - .compare_exchange( - Self::pack(low_id, false), - Self::pack(high_id, true), - Ordering::AcqRel, - Ordering::Acquire, - ) - .is_ok() - } - -} - /// Per-client state held while a TCP session is alive. #[derive(Debug, Clone)] pub struct ClientHandle { pub user_hash: UserHash, - pub route: Arc, + pub assigned_id: u32, pub ip: IpAddr, pub port: u16, /// Client's UDP port for LowID↔LowID NAT traversal (§3.12). 0 = unknown. @@ -118,6 +66,7 @@ pub struct ClientHandle { pub natt_capable: bool, pub nick: String, pub server_flags: u32, + pub is_high_id: bool, /// This client can parse an inline IPv6 source record. /// /// Set at login from either signal: the session itself arrived over IPv6, or @@ -159,14 +108,7 @@ pub struct ClientHandle { /// Channel to push frames to this client's connection task. /// Used by callback and keepalive code. None when channel is closed/dropped. pub tx: Option>, - /// Identity, retirement signal and shared "last activity" clock for this - /// login session. - /// - /// A newer login with the same user hash retires this session immediately; - /// the connection task observes that signal and closes its displaced socket - /// before accepting another frame. - /// - /// Activity is measured in milliseconds since `ServerState::epoch`. + /// Shared "last activity" clock, in milliseconds since `ServerState::epoch`. /// The TCP connection task owns the idle timeout, but a NAT-T LowID *source* /// is silent on TCP for hours (it only shares — it never searches, asks for /// sources, or downloads). Its only regular contact with the server is the @@ -177,22 +119,10 @@ pub struct ClientHandle { /// unsearchable even though the TCP link was never broken. Both the UDP /// keepalive handler and the TCP task bump this; the TCP task reads it to /// decide whether the client is really idle. - pub session: Arc, + pub last_activity_ms: std::sync::Arc, } impl ClientHandle { - pub fn route(&self) -> (u32, bool) { - self.route.snapshot() - } - - pub fn assigned_id(&self) -> u32 { - self.route().0 - } - - pub fn is_high_id(&self) -> bool { - self.route().1 - } - /// Wall-clock milliseconds (UNIX epoch). Used for the shared activity clock /// so the UDP keepalive handler and the TCP task can compare timestamps /// without sharing an `Instant`. @@ -203,47 +133,30 @@ impl ClientHandle { .unwrap_or(0) } - /// Give back this session's logged-in slot, if it still holds one. - pub fn release_slot(&self) { - crate::admission::release_slot(&self.slot); - } - /// Are these two handles the same login session? Copies of one session's /// handle (the connection's own, the one in `clients`) share the - /// `ClientSession` Arc, which each login allocates afresh. assigned_id + /// `last_activity_ms` Arc, which each login allocates afresh. assigned_id /// is not an identity: a HighID's id is its IPv4, and a reconnect from the /// same address gets the same one (issue #23). - pub fn same_session(&self, other: &ClientHandle) -> bool { - Arc::ptr_eq(&self.session, &other.session) - } - - /// Subscribe to this login's retirement signal. - pub fn retirement(&self) -> watch::Receiver { - self.session.retired.subscribe() - } - - /// Retire this login. Idempotent; every subscribed connection task wakes. - pub fn retire(&self) { - self.session.retired.send_replace(true); + /// Give back this session's logged-in slot, if it still holds one. + pub fn release_slot(&self) { + crate::admission::release_slot(&self.slot); } - /// Has a newer same-hash login displaced this session? - pub fn is_retired(&self) -> bool { - *self.session.retired.borrow() + pub fn same_session(&self, other: &ClientHandle) -> bool { + std::sync::Arc::ptr_eq(&self.last_activity_ms, &other.last_activity_ms) } /// Mark the client active right now (any TCP frame or UDP keepalive). pub fn touch_activity(&self) { - self.session - .last_activity_ms + self.last_activity_ms .store(Self::now_ms(), std::sync::atomic::Ordering::Relaxed); } /// Milliseconds since the last recorded activity (TCP or UDP). pub fn idle_ms(&self) -> u64 { Self::now_ms().saturating_sub( - self.session - .last_activity_ms + self.last_activity_ms .load(std::sync::atomic::Ordering::Relaxed), ) } @@ -443,11 +356,13 @@ pub const ALIAS_MAX_FILES: usize = 20_000; /// stays bounded by ALIAS_MAX_FILES and its TTL regardless. pub const ALIAS_MIN_SIZE: u64 = 2 * 1024 * 1024; -/// Counters and recent evidence for post-login HighID identity verification. +/// Counters for `network.highid_verify_observe`: what the identity probe finds +/// on clients the plain probe already gave HighID. Observation only — nothing +/// here changes an id. /// /// Held in an `Arc` because the probe runs in a detached task that outlives the /// login's borrow of the state. -pub struct HighIdVerification { +pub struct HighIdObserve { /// Answered OP_HELLOANSWER with the user hash that logged in: HighID under /// the stricter rule too. pub verified: std::sync::atomic::AtomicU64, @@ -458,9 +373,19 @@ pub struct HighIdVerification { /// Answered, but with a DIFFERENT user hash: the port is forwarded to some /// other eD2k client. HighID here points peers at the wrong machine. pub mismatch: std::sync::atomic::AtomicU64, - /// Accepted the TCP connection but produced no usable answer. These clients - /// remain on their provisional LowID. + /// Accepted the TCP connection but produced no usable answer. These are the + /// clients a Lugdunum-style check would turn into LowID. pub no_answer: std::sync::atomic::AtomicU64, + /// Logins given LowID because a wrong-hash mark was active for them + /// (`network.highid_downgrade_on_wrong_hash`). + pub downgraded: std::sync::atomic::AtomicU64, + /// Marks dropped because the port answered with the client's own hash + /// again: forwarding fixed, HighID from the next login on. + pub marks_cleared: std::sync::atomic::AtomicU64, + /// Wrong-hash marks: (address, port, login hash) → expiry. A login matching + /// a live mark gets LowID. Small — a handful an hour — and bounded by + /// `MAX_MARKS` regardless. + pub wrong_hash_marks: DashMap<(IpAddr, u16, UserHash), std::time::Instant>, /// Not probed because the concurrency cap was full. pub skipped_busy: std::sync::atomic::AtomicU64, /// `no_answer` broken down by the probe's own reason string, which is what @@ -482,19 +407,73 @@ pub struct HighIdMismatch { pub at: u64, pub ip: IpAddr, pub port: u16, - /// The hash that logged in and remained LowID. + /// The hash that logged in and was given HighID. pub login_hash: UserHash, /// The hash of whoever actually answered on that address and port. pub answered_hash: UserHash, + /// Whether a mark was set (or renewed) for it, so the client's next logins + /// get LowID — verdict mode — rather than the case only being counted. + pub marked: bool, } -impl HighIdVerification { +impl HighIdObserve { /// Enough for steady-state logins; a reconnect storm after a restart is /// exactly when this should shed load rather than add it. pub const MAX_CONCURRENT: usize = 64; /// Mismatches kept for inspection. Around 20 a hour on a 10k-client server, /// so a thousand covers roughly two days; each entry is a few dozen bytes. pub const RECENT_MISMATCHES: usize = 1000; + /// Wrong-hash marks kept at most. Tens a day are expected; the cap only + /// matters if something floods mismatches, and then new marks are dropped + /// (the client keeps HighID, the old behaviour) rather than memory grown. + pub const MAX_MARKS: usize = 10_000; + + /// Is there a live wrong-hash mark for this login? Expired marks are + /// removed on the way. + pub fn is_marked(&self, ip: IpAddr, port: u16, hash: &UserHash) -> bool { + let key = (ip, port, *hash); + let now = std::time::Instant::now(); + match self.wrong_hash_marks.get(&key).map(|e| *e.value()) { + Some(exp) if exp > now => true, + Some(_) => { + self.wrong_hash_marks.remove_if(&key, |_, exp| *exp <= now); + false + } + None => false, + } + } + + /// Set or renew a mark. Returns false when the table is full even after + /// dropping expired marks. + pub fn mark(&self, ip: IpAddr, port: u16, hash: UserHash, ttl: std::time::Duration) -> bool { + let now = std::time::Instant::now(); + let key = (ip, port, hash); + if !self.wrong_hash_marks.contains_key(&key) + && self.wrong_hash_marks.len() >= Self::MAX_MARKS + { + self.wrong_hash_marks.retain(|_, exp| *exp > now); + if self.wrong_hash_marks.len() >= Self::MAX_MARKS { + return false; + } + } + self.wrong_hash_marks.insert(key, now + ttl); + true + } + + /// Drop a mark; true if there was one. + pub fn unmark(&self, ip: IpAddr, port: u16, hash: &UserHash) -> bool { + self.wrong_hash_marks.remove(&(ip, port, *hash)).is_some() + } + + /// Live marks now. + pub fn marks_active(&self) -> usize { + let now = std::time::Instant::now(); + self.wrong_hash_marks + .iter() + .filter(|e| *e.value() > now) + .count() + } + /// Classify the recent mismatches against who is connected now: /// `(same_ip, other_ip, not_connected, total)`. /// @@ -537,6 +516,9 @@ impl HighIdVerification { verified_marker_variant: std::sync::atomic::AtomicU64::new(0), mismatch: std::sync::atomic::AtomicU64::new(0), no_answer: std::sync::atomic::AtomicU64::new(0), + downgraded: std::sync::atomic::AtomicU64::new(0), + marks_cleared: std::sync::atomic::AtomicU64::new(0), + wrong_hash_marks: DashMap::new(), skipped_busy: std::sync::atomic::AtomicU64::new(0), reasons: DashMap::new(), permits: std::sync::Arc::new(tokio::sync::Semaphore::new(Self::MAX_CONCURRENT)), @@ -545,12 +527,15 @@ impl HighIdVerification { } } -impl Default for HighIdVerification { +impl Default for HighIdObserve { fn default() -> Self { Self::new() } } +/// 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 @@ -588,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, @@ -647,6 +638,65 @@ 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`. + 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>>, + /// 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 @@ -742,7 +792,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. @@ -846,6 +900,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 @@ -863,10 +919,12 @@ pub struct ServerState { /// its connections share. pub offer_v1: crate::server::offer_pacing::OfferV1Stats, pub offer_v1_pacer: crate::server::offer_pacing::GlobalOfferPacer, - /// Sockets closed because a newer login of the same user retired them. + /// Sockets of replaced sessions (a newer login of the same user took + /// over) closed after replaced-session silence. See handle_connection. pub replaced_sessions_closed: std::sync::atomic::AtomicU64, - pub highid_verification: std::sync::Arc, + /// See `network.highid_verify_observe`. + pub highid_observe: std::sync::Arc, /// Server-wide work admission (issue #25). Built once from the startup /// configuration; restart-only. @@ -910,7 +968,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; @@ -1231,7 +1291,7 @@ impl ServerState { let (tx, rx) = mpsc::channel(16); let handle = ClientHandle { user_hash, - route: ClientRoute::shared(id, high_id), + assigned_id: id, ip, port: 4662, udp_port: udp, @@ -1240,6 +1300,7 @@ impl ServerState { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: high_id, connected_at: std::time::Instant::now(), country: "??".into(), software: "test".into(), @@ -1250,9 +1311,11 @@ impl ServerState { offer_policy: None, slot: Default::default(), tx: Some(tx), - session: ClientSession::new(ClientHandle::now_ms()), + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new( + ClientHandle::now_ms(), + )), }; - self.index_client_id(handle.assigned_id(), user_hash); + self.index_client_id(handle.assigned_id, user_hash); self.clients.insert(user_hash, handle); rx } @@ -1262,54 +1325,7 @@ impl ServerState { /// O(1) against the id index instead of a walk over `clients`. pub fn client_by_assigned_id(&self, id: u32) -> Option { let uh = *self.client_id_index.get(&id)?; - let handle = self.clients.get(&uh)?.clone(); - (handle.assigned_id() == id).then_some(handle) - } - - /// Does the client registry still belong to this exact login session? - pub fn is_current_session(&self, session: &ClientHandle) -> bool { - self.clients - .get(&session.user_hash) - .is_some_and(|current| current.same_session(session)) - } - - /// Queue the HighID update and atomically move one live session from its - /// provisional LowID to that verified route. Every clone of the handle sees - /// the same transition. - /// - /// The client-map guard prevents a same-hash reconnect from replacing this - /// session while its frame, route, id index and cached LowID count move. - pub(crate) fn promote_client_to_high_id( - &self, - session: &ClientHandle, - high_id: u32, - id_change: Frame, - ) -> bool { - if high_id == 0 { - return false; - } - let Some(live) = self.clients.get(&session.user_hash) else { - return false; - }; - if !live.same_session(session) { - return false; - } - let (low_id, is_high_id) = live.route(); - if is_high_id { - return false; - } - if live.send_frame(id_change) != crate::admission::PushOutcome::Queued { - return false; - } - let promoted = live.route.promote(low_id, high_id); - debug_assert!(promoted, "live session route changed outside ServerState"); - if !promoted { - return false; - } - self.index_client_id(high_id, session.user_hash); - self.unindex_client_id(low_id, &session.user_hash); - self.lowid_count_cached.fetch_sub(1, Ordering::Relaxed); - true + self.clients.get(&uh).map(|e| e.clone()) } /// Publish `id → user_hash`. Call whenever a handle enters `clients`. @@ -1417,6 +1433,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)) @@ -1597,6 +1619,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, @@ -1608,6 +1632,25 @@ 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(), + 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()), @@ -1645,6 +1688,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), @@ -1653,7 +1697,7 @@ impl ServerState { offer_v1: Default::default(), offer_v1_pacer: Default::default(), replaced_sessions_closed: std::sync::atomic::AtomicU64::new(0), - highid_verification: std::sync::Arc::new(HighIdVerification::new()), + highid_observe: std::sync::Arc::new(HighIdObserve::new()), admission, } } @@ -1708,6 +1752,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) @@ -1722,6 +1777,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 { @@ -1774,14 +1845,13 @@ impl ServerState { if !h.is_alive() || s.port() == 0 { continue; } - let (id, is_high_id) = h.route(); - let rank = if is_high_id { + let (id, rank) = if h.is_high_id { if !Self::is_publishable_source_ip(h.ip) { continue; } - if s.complete() { 0 } else { 2 } + (h.assigned_id, if s.complete() { 0 } else { 2 }) } else { - if s.complete() { 1 } else { 3 } + (h.assigned_id, if s.complete() { 1 } else { 3 }) }; if id == 0 { continue; @@ -1852,6 +1922,91 @@ 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 + && 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. + 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, @@ -1863,19 +2018,43 @@ impl ServerState { let src = Source::new(source.0, source.1, source.2, source.3); // The slab is the single store. get_or_insert creates the record (with - // this first source, and its own copy of the name) if the `(hash, size)` - // identity is new; otherwise we add/refresh the exact existing record. - // Both take one shard lock. Nothing is allocated for a known file. + // this first source, and its own copy of the name) if the hash is new; + // otherwise we add/refresh the source on the existing record. Both take + // one shard lock. Nothing is allocated for a known file. 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, size, 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); @@ -2024,7 +2203,7 @@ impl ServerState { let (tx, _rx) = mpsc::channel::(16); let handle = ClientHandle { user_hash, - route: ClientRoute::shared(id, false), + assigned_id: id, ip, port: 4662, udp_port, @@ -2033,6 +2212,7 @@ impl ServerState { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: false, connected_at: std::time::Instant::now(), country, software_detail: software.clone(), @@ -2043,9 +2223,9 @@ impl ServerState { offer_policy: None, slot: Default::default(), tx: Some(tx), - session: ClientSession::new(0), + last_activity_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)), }; - self.index_client_id(handle.assigned_id(), user_hash); + self.index_client_id(handle.assigned_id, user_hash); self.clients.insert(user_hash, handle); } @@ -2245,7 +2425,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::()); @@ -2265,6 +2445,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, @@ -2368,6 +2554,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; @@ -2403,62 +2604,6 @@ impl ServerState { } } -#[cfg(test)] -mod client_route_tests { - use super::*; - - #[test] - fn promotion_moves_the_live_route_once() { - let state = ServerState::for_test(); - let hash = [0x42; 16]; - let low_id = 42; - let high_id = 0x5db8_d822; - state.register_test_client(hash, low_id, false, 0); - state.lowid_count_cached.store(1, Ordering::Relaxed); - let session = state.clients.get(&hash).unwrap().clone(); - let session_copy = session.clone(); - - assert!(state.promote_client_to_high_id( - &session, - high_id, - Frame::new(crate::proto::opcodes::OP_IDCHANGE, vec![]), - )); - assert_eq!(session_copy.route(), (high_id, true)); - assert!(state.client_by_assigned_id(low_id).is_none()); - assert_eq!( - state.client_by_assigned_id(high_id).unwrap().user_hash, - hash - ); - assert_eq!(state.lowid_count_cached.load(Ordering::Relaxed), 0); - - assert!(!state.promote_client_to_high_id( - &session, - high_id, - Frame::new(crate::proto::opcodes::OP_IDCHANGE, vec![]), - )); - assert_eq!(state.lowid_count_cached.load(Ordering::Relaxed), 0); - } - - #[test] - fn a_replaced_session_cannot_promote_the_live_route() { - let state = ServerState::for_test(); - let hash = [0x24; 16]; - state.register_test_client(hash, 24, false, 0); - let stale = state.clients.get(&hash).unwrap().clone(); - state.register_test_client(hash, 25, false, 0); - state.lowid_count_cached.store(1, Ordering::Relaxed); - - assert!(!state.promote_client_to_high_id( - &stale, - 0x5db8_d822, - Frame::new(crate::proto::opcodes::OP_IDCHANGE, vec![]), - )); - assert_eq!(state.clients.get(&hash).unwrap().route(), (25, false)); - assert!(state.client_by_assigned_id(24).is_none()); - assert_eq!(state.lowid_count_cached.load(Ordering::Relaxed), 1); - } -} - #[cfg(test)] mod callback_tests { use super::*; @@ -2469,7 +2614,7 @@ mod callback_tests { fn mk_client(id: u32, high: bool) -> ClientHandle { ClientHandle { user_hash: [id as u8; 16], - route: ClientRoute::shared(id, high), + assigned_id: id, ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, id as u8)), port: 4662, udp_port: 0, @@ -2478,6 +2623,7 @@ mod callback_tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: high, connected_at: Instant::now(), country: "??".to_string(), software: "test".to_string(), @@ -2488,7 +2634,9 @@ mod callback_tests { offer_policy: None, slot: Default::default(), tx: None, - session: ClientSession::new(ClientHandle::now_ms()), + last_activity_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new( + ClientHandle::now_ms(), + )), } } diff --git a/src/state/smart_sources.rs b/src/state/smart_sources.rs index 4a6a58f..abce5f9 100644 --- a/src/state/smart_sources.rs +++ b/src/state/smart_sources.rs @@ -6,8 +6,8 @@ //! those identical requests is wasted work. //! //! This cache stores the *already-encoded* FOUNDSOURCES payload bytes for a -//! canonical file id, for a short TTL (a few seconds). Within that window every -//! GETSOURCES for the same `(hash, size)` is answered from the cache with a single +//! file hash, for a short TTL (a few seconds). Within that window every +//! GETSOURCES for the same hash is answered from the cache with a single //! HashMap lookup and a clone — no source-list iteration, no re-encoding. //! //! The TTL is deliberately short: source lists change as clients come and @@ -27,14 +27,32 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; -use super::file_id::FileId; +/// 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; -/// How long a cached FOUNDSOURCES payload stays fresh. -const CACHE_TTL: Duration = Duration::from_secs(5); +/// 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; -/// 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; +/// 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,13 +66,27 @@ struct CacheEntry { inserted: Instant, } -/// Thread-safe SmartSources cache, keyed by canonical file id. +/// 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 { @@ -63,25 +95,35 @@ 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), } } - /// Look up a cached FOUNDSOURCES payload for `file_id`. + /// Look up a cached FOUNDSOURCES payload for `hash`. /// Returns the payload bytes if a fresh entry exists, else None. - pub fn get(&self, file_id: FileId) -> Option> { + pub fn get(&self, hash: &[u8; 16]) -> Option> { let map = self.map.lock().unwrap(); - let entry = match map.get(&file_id) { + 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 } } @@ -89,23 +131,33 @@ impl SmartSourcesCache { /// Like `get`, but hands the fresh payload and its owner list to `f` under /// 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, file_id: FileId, f: impl FnOnce(&[u8], &[u64]) -> R) -> Option { + 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(&file_id) { + 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 } } @@ -117,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::() + 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). @@ -131,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), @@ -138,24 +262,34 @@ impl SmartSourcesCache { ) } - /// Store a freshly-built FOUNDSOURCES payload for `file_id`, with the owner + /// Store a freshly-built FOUNDSOURCES payload for `hash`, with the owner /// of each record (see `CacheEntry::owners`). - pub fn put(&self, file_id: FileId, payload: Vec, owners: Vec) { + 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( - file_id, + key, CacheEntry { payload, owners, @@ -164,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, file_id: FileId) { + pub fn invalidate(&self, hash: &[u8; 16]) { let mut map = self.map.lock().unwrap(); - map.remove(&file_id); + 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() } } @@ -193,27 +374,27 @@ mod tests { #[test] fn hit_then_miss_after_ttl() { let cache = SmartSourcesCache::new(); - let file_id = FileId(7); + let hash = [7u8; 16]; let payload = vec![1, 2, 3, 4, 5]; - cache.put(file_id, payload.clone(), Vec::new()); + cache.put(hash, payload.clone(), Vec::new()); // Immediate get is a hit with identical bytes. - assert_eq!(cache.get(file_id), Some(payload)); + assert_eq!(cache.get(&hash), Some(payload)); // A different hash is a miss. - assert_eq!(cache.get(FileId(9)), None); + assert_eq!(cache.get(&[9u8; 16]), None); } #[test] fn invalidate_removes_entry() { let cache = SmartSourcesCache::new(); - let file_id = FileId(3); - cache.put(file_id, vec![0xAA, 0xBB], Vec::new()); - assert!(cache.get(file_id).is_some()); + let hash = [3u8; 16]; + cache.put(hash, vec![0xAA, 0xBB], Vec::new()); + assert!(cache.get(&hash).is_some()); - cache.invalidate(file_id); - assert!(cache.get(file_id).is_none()); + cache.invalidate(&hash); + assert!(cache.get(&hash).is_none()); } #[test] @@ -221,41 +402,86 @@ mod tests { // We can't easily fast-forward Instant, so this test validates the // logic path by constructing an entry with an old timestamp directly. let cache = SmartSourcesCache::new(); - let file_id = FileId(5); + let hash = [5u8; 16]; { let mut map = cache.map.lock().unwrap(); map.insert( - file_id, + (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), }, ); } // Entry exists but is well past TTL → get() returns None. - assert_eq!(cache.get(file_id), None); + 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(); - let file_id = FileId(1); - cache.put(file_id, vec![0x01], Vec::new()); - cache.put(file_id, vec![0x02, 0x03], Vec::new()); + let hash = [1u8; 16]; + cache.put(hash, vec![0x01], Vec::new()); + cache.put(hash, vec![0x02, 0x03], Vec::new()); // Latest put wins. - assert_eq!(cache.get(file_id), Some(vec![0x02, 0x03])); + assert_eq!(cache.get(&hash), Some(vec![0x02, 0x03])); } #[test] fn stats_count_hits_and_misses() { let cache = SmartSourcesCache::new(); - let file_id = FileId(4); - assert_eq!(cache.get(file_id), None); // miss (no entry) - cache.put(file_id, vec![1, 2, 3], Vec::new()); - assert!(cache.get(file_id).is_some()); // hit + let hash = [4u8; 16]; + assert_eq!(cache.get(&hash), None); // miss (no entry) + cache.put(hash, vec![1, 2, 3], Vec::new()); + assert!(cache.get(&hash).is_some()); // hit let (hits, misses) = cache.stats(); 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 9c981b9..00d8608 100644 --- a/src/web.rs +++ b/src/web.rs @@ -16,7 +16,6 @@ //! GET /api/status JSON: uptime, file count, client count, seckey hex //! GET /api/stats JSON: process CPU/RSS, traffic counters, cache hit //! GET /api/clients JSON: connected clients (ip, nick, files, dur) -//! GET /api/files/:hash JSON: exact indexed file and bounded source details //! GET /api/peers JSON: known peer servers + ServerKey learned? //! POST /api/reload trigger SIGHUP-equivalent config + filter reload @@ -25,7 +24,7 @@ use std::sync::Arc; use std::time::Instant; use axum::{ - extract::{ConnectInfo, Path as AxumPath, Request, State}, + extract::{ConnectInfo, Request, State}, http::{header, HeaderMap, HeaderValue, Method, StatusCode}, middleware::{self, Next}, response::{Html, IntoResponse, Json, Response}, @@ -628,7 +627,6 @@ pub fn spawn_admin(state: WebState, admin: crate::config::AdminConfig) { .route("/api/highid_mismatches", get(api_highid_mismatches)) .route("/api/clients", get(api_clients)) .route("/api/clients/search", get(api_clients_search)) - .route("/api/files/:hash", get(api_file)) .route("/assets/flags.woff2", get(asset_flags_font)) .route("/api/peers", get(api_peers)) .route("/api/reload", post(api_reload)) @@ -655,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)) @@ -725,10 +727,44 @@ 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). + 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, + /// 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( @@ -769,17 +805,76 @@ 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 .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), + 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(), }) } @@ -810,6 +905,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, @@ -838,14 +934,24 @@ struct StatsResp { /// "AS3269 Telecom Italia S.p.A." when the GeoIP database carries it. busiest_ip_provider: Option, max_clients_per_ip: u32, - highid_verify_verified: u64, + /// `network.highid_verify_observe`: whether the counters below are live. + highid_observe_enabled: bool, + highid_observe_verified: u64, /// Of the verified: same client, different type marker in login vs hello. - highid_verify_verified_marker: u64, - highid_verify_mismatch: u64, - highid_verify_no_answer: u64, - highid_verify_skipped: u64, + highid_observe_verified_marker: u64, + highid_observe_mismatch: u64, + highid_observe_no_answer: u64, + highid_observe_skipped: u64, + /// `network.highid_downgrade_on_wrong_hash`: the check is a verdict. + highid_downgrade_enabled: bool, + /// Logins given LowID because a wrong-hash mark was active for them. + highid_downgraded: u64, + /// Wrong-hash marks live now. + highid_marks_active: u64, + /// Marks dropped because the client's own hash answered again. + highid_marks_cleared: u64, /// "reason: count" pairs, most frequent first. - highid_verify_reasons: String, + highid_observe_reasons: String, /// Of the recent wrong-hash cases (bounded buffer), how many answering /// clients are connected to us RIGHT NOW from the same address — a second /// client behind the same NAT, the case where HighID provably misleads. @@ -871,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 { @@ -883,7 +996,7 @@ async fn api_stats(State(s): State) -> Json { let (search_capped, search_served) = s.server.search_rank_stats(); let (mm_same, mm_other, mm_none, mm_total) = s .server - .highid_verification + .highid_observe .classify_mismatches(&s.server.clients); let (gs_hits, gs_misses) = s.server.smart_sources.stats(); let gs_total = gs_hits + gs_misses; @@ -904,6 +1017,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, @@ -960,23 +1074,40 @@ async fn api_stats(State(s): State) -> Json { }), busiest_ip_logged_in: busiest.map_or(0, |(_, n, _)| n), max_clients_per_ip: s.server.live_cfg.load().limits.max_clients_per_ip, - highid_verify_verified: s.server.highid_verification.verified.load(Ordering::Relaxed), - highid_verify_verified_marker: s + highid_observe_enabled: { + let n = &s.server.live_cfg.load().network; + n.highid_verify_observe || n.highid_downgrade_on_wrong_hash + }, + highid_downgrade_enabled: s + .server + .live_cfg + .load() + .network + .highid_downgrade_on_wrong_hash, + highid_downgraded: s.server.highid_observe.downgraded.load(Ordering::Relaxed), + highid_marks_active: s.server.highid_observe.marks_active() as u64, + highid_marks_cleared: s .server - .highid_verification + .highid_observe + .marks_cleared + .load(Ordering::Relaxed), + highid_observe_verified: s.server.highid_observe.verified.load(Ordering::Relaxed), + highid_observe_verified_marker: s + .server + .highid_observe .verified_marker_variant .load(Ordering::Relaxed), - highid_verify_mismatch: s.server.highid_verification.mismatch.load(Ordering::Relaxed), - highid_verify_no_answer: s.server.highid_verification.no_answer.load(Ordering::Relaxed), - highid_verify_skipped: s.server.highid_verification.skipped_busy.load(Ordering::Relaxed), + highid_observe_mismatch: s.server.highid_observe.mismatch.load(Ordering::Relaxed), + highid_observe_no_answer: s.server.highid_observe.no_answer.load(Ordering::Relaxed), + highid_observe_skipped: s.server.highid_observe.skipped_busy.load(Ordering::Relaxed), highid_mismatch_same_ip: mm_same, highid_mismatch_other_ip: mm_other, highid_mismatch_not_here: mm_none, highid_mismatch_recent: mm_total, - highid_verify_reasons: { + highid_observe_reasons: { let mut v: Vec<(&'static str, u64)> = s .server - .highid_verification + .highid_observe .reasons .iter() .map(|e| (*e.key(), *e.value())) @@ -1029,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(), }) } @@ -1073,7 +1209,7 @@ fn client_row(s: &WebState, c: &crate::state::ClientHandle, now: Instant) -> Cli // The handle had its own counter that nothing ever incremented, so // this column read 0 for everyone. shared_files: shared_files_of(s, c), - high_id: c.is_high_id(), + high_id: c.is_high_id, connected_seconds: now.saturating_duration_since(c.connected_at).as_secs(), ipv6_capable: c.ipv6_capable, ipv6: c.ipv6.map(|a| a.to_string()), @@ -1103,80 +1239,6 @@ async fn api_clients(State(s): State) -> Json> { Json(rows) } -const FILE_DIAGNOSTIC_SOURCE_LIMIT: usize = 1024; - -#[derive(Debug, Serialize, PartialEq, Eq)] -struct FileSourceRow { - user_hash: String, - ip: String, - port: u16, - complete: bool, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -struct FileDiagnosticRow { - hash: String, - name: String, - size: u64, - source_count: usize, - complete_source_count: u32, - sources_truncated: bool, - sources: Vec, -} - -fn parse_file_hash(value: &str) -> Option { - let bytes = hex::decode(value).ok()?; - bytes.try_into().ok() -} - -fn file_diagnostic_row(record: &crate::state::file_id::FileRecord) -> FileDiagnosticRow { - let mut sources: Vec = record - .sources - .iter() - .take(FILE_DIAGNOSTIC_SOURCE_LIMIT) - .map(|source| FileSourceRow { - user_hash: hex::encode(source.user_hash), - ip: source.ip().to_string(), - port: source.port(), - complete: source.complete(), - }) - .collect(); - sources.sort_by(|left, right| { - (&left.ip, left.port, &left.user_hash).cmp(&(&right.ip, right.port, &right.user_hash)) - }); - FileDiagnosticRow { - hash: hex::encode(record.hash), - name: record.name().to_string(), - size: record.size, - source_count: record.sources.len(), - complete_source_count: record.complete_source_count(), - sources_truncated: record.sources.len() > FILE_DIAGNOSTIC_SOURCE_LIMIT, - sources, - } -} - -async fn api_file(State(s): State, AxumPath(hash): AxumPath) -> Response { - let Some(hash) = parse_file_hash(&hash) else { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "file hash must be 32 hexadecimal characters" })), - ) - .into_response(); - }; - match s - .server - .file_slab - .with_record_by_hash(&hash, |_id, record| file_diagnostic_row(record)) - { - Some(row) => Json(row).into_response(), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "file is not indexed" })), - ) - .into_response(), - } -} - /// Decode one query-string value: `+` is a space, `%XX` a byte; invalid /// escapes are kept as written. Lossy UTF-8. fn url_decode(v: &str) -> String { @@ -1289,7 +1351,7 @@ impl ClientQuery { if !self.software.is_empty() && c.software != self.software { return false; } - if self.high_id.is_some_and(|h| h != c.is_high_id()) { + if self.high_id.is_some_and(|h| h != c.is_high_id) { return false; } if self.ipv6_only && !c.ipv6_capable { @@ -2645,6 +2707,23 @@ 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| 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); + } + } + 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` @@ -2957,6 +3036,783 @@ 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, + 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; + + // 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); + // 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?" + } else { + // Explicit words only, or a mixed rule: the hash is for a look. + "review" + } + }; + 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", + ); + 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 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() + ); + + 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, + // 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 + )); + } + + 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/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: +/// `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()) @@ -3305,7 +4161,7 @@ async fn api_highid_mismatches( let recent: Vec = s .server - .highid_verification + .highid_observe .recent_mismatches .lock() .map(|q| q.iter().cloned().collect()) @@ -3343,6 +4199,7 @@ async fn api_highid_mismatches( let mut logins: std::collections::HashSet<[u8; 16]> = Default::default(); let (mut same, mut other, mut none, mut echo) = (0u64, 0u64, 0u64, 0u64); let (mut login_marked, mut answered_marked) = (0u64, 0u64); + let mut marked = 0u64; let mut records = Vec::with_capacity(recent.len()); for m in &recent { @@ -3387,10 +4244,14 @@ async fn api_highid_mismatches( if emule_marker(&m.answered_hash) { answered_marked += 1; } + if m.marked { + marked += 1; + } records.push(serde_json::json!({ "at": chrono::DateTime::from_timestamp(m.at as i64, 0).map(|d| d.to_rfc3339()), "ip": ip_shown, "port": m.port, + "marked": m.marked, "login_hash": hex::encode(m.login_hash), "answered_hash": hex::encode(m.answered_hash), "answered_is_our_probe_hash": is_echo, @@ -3417,10 +4278,15 @@ async fn api_highid_mismatches( Json(serde_json::json!({ "anonymized": anonymize, "our_probe_hash": hex::encode(our_probe_hash), - "total_since_start": s.server.highid_verification.mismatch.load(Ordering::Relaxed), + "total_since_start": s.server.highid_observe.mismatch.load(Ordering::Relaxed), + "lowid_logins_by_mark_since_start": s.server.highid_observe.downgraded.load(Ordering::Relaxed), + "marks_active": s.server.highid_observe.marks_active(), + "marks_cleared_since_start": s.server.highid_observe.marks_cleared.load(Ordering::Relaxed), + "downgrade_on_wrong_hash": live.network.highid_downgrade_on_wrong_hash, "summary": { "records": recent.len(), - "buffer_capacity": crate::state::HighIdVerification::RECENT_MISMATCHES, + "marked": marked, + "buffer_capacity": crate::state::HighIdObserve::RECENT_MISMATCHES, "distinct_ips": distinct_ips, "distinct_login_hashes": logins.len(), "distinct_answered_hashes": distinct_answered, @@ -3765,8 +4631,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 · @@ -3912,23 +4803,28 @@ 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 - HighID identity verification${fmt(sys.highid_verify_verified)} verified${sys.highid_verify_verified_marker ? ` (${fmt(sys.highid_verify_verified_marker)} with a different client-type marker in the hello)` : ''} · ${fmt(sys.highid_verify_no_answer)} no answer · ${fmt(sys.highid_verify_mismatch)} wrong hash · ${fmt(sys.highid_verify_skipped)} skipped Public clients log in provisionally LowID and are upgraded in place only after OP_HELLOANSWER proves the listening port belongs to the same client.${sys.highid_verify_reasons ? `
${escapeHtml(sys.highid_verify_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)}` : ''} + ${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 - Replaced sessions closed${fmt(sys.replaced_sessions_closed)} displaced sockets closed immediately when a newer login of the same user takes ownership + Replaced sessions closed${fmt(sys.replaced_sessions_closed)} sockets a newer login of the same user took over, closed after 10 min without traffic of their own; a copy that still talks keeps its socket Soft file limit reached${fmt(sys.offer_over_soft_batches)} batches · ${fmt(sys.offer_over_soft_records)} files not indexed limits.soft_limit_files = ${sys.soft_limit_files ? fmt(sys.soft_limit_files) : 'off'}; new files beyond it are not indexed, the client is told once, the session stays up Admission${(()=>{const a=sys.admission||{};const p=x=>x?`${fmt(x.in_use)}/${fmt(x.cap)}${x.rejected?` · ${fmt(x.rejected)} refused`:''}`:'–';const u=a.udp||{};return `${a.ready?'ready':'NOT READY: '+(a.saturated||[]).join(', ')} · sockets ${p(a.open_tcp)} · pending logins ${p(a.pending_login)} · probes ${p(a.probes)} (${fmt(a.probe_shed_lowid||0)} → LowID) · search jobs ${p(a.search_jobs)}, queue ${p(a.search_queue)} · UDP ${u.enforce?'enforced':'observe'}: refused ${fmt((u.refused_global||0)+(u.refused_source||0)+(u.refused_table_full||0))} (global ${fmt(u.refused_global||0)}, source ${fmt(u.refused_source||0)}, table ${fmt(u.refused_table_full||0)}), sources ${fmt(u.source_entries||0)}/${fmt(u.source_entries_cap||0)}`})()} [admission] — details at /api/admission 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 + 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 - NAT-T punches coordinated${fmt(st.natt_coordinated)} LowID↔LowID since start - obf_decode_cache${fmt(st.obf_decode_cache_entries)} entries (capped at 8192) + 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 + 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) `; } @@ -4148,17 +5044,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); @@ -4185,7 +5119,8 @@ async function refreshBots() { + `` + `` + + `${b.ipfilter_added ? ' IP FILTER' : ''}` + + `${botState(b) === 'ended' ? ' OVER' : ''}` + `` + ``; }).join('') @@ -4239,6 +5174,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 `` @@ -4426,6 +5362,7 @@ mod highid_mismatch_endpoint_tests { port: 4662, login_hash: login, answered_hash: answered, + marked: false, } } @@ -4451,7 +5388,7 @@ mod highid_mismatch_endpoint_tests { let mut emule = [0x11u8; 16]; emule[5] = 0x0E; emule[14] = 0x6F; - let o = &st.highid_verification; + let o = &st.highid_observe; o.record_mismatch(rec([10, 0, 0, 2], emule, [0xB1; 16])); // same-IP NAT o.record_mismatch(rec([10, 9, 9, 9], [0x22; 16], ours)); // echo of our hello o.record_mismatch(rec([10, 9, 9, 9], [0x22; 16], ours)); // same IP again @@ -4475,7 +5412,7 @@ mod highid_mismatch_endpoint_tests { #[tokio::test] async fn anonymize_hides_addresses_but_keeps_repeats_visible() { let ws = web_state(); - let o = &ws.server.highid_verification; + let o = &ws.server.highid_observe; o.record_mismatch(rec([85, 17, 116, 5], [1; 16], [2; 16])); o.record_mismatch(rec([85, 17, 116, 5], [1; 16], [2; 16])); o.record_mismatch(rec([85, 17, 116, 6], [3; 16], [4; 16])); @@ -4508,7 +5445,7 @@ mod client_search_tests { fn client(ip: [u8; 4], nick: &str, country: &str, sw: &str, high: bool) -> crate::state::ClientHandle { crate::state::ClientHandle { user_hash: [ip[3]; 16], - route: crate::state::ClientRoute::shared(1, high), + assigned_id: 1, ip: IpAddr::V4(Ipv4Addr::from(ip)), port: 4662, udp_port: 0, @@ -4517,6 +5454,7 @@ mod client_search_tests { server_flags: 0, ipv6_capable: false, ipv6: None, + is_high_id: high, connected_at: Instant::now(), country: country.into(), software: sw.into(), @@ -4527,7 +5465,7 @@ mod client_search_tests { offer_policy: None, slot: Default::default(), tx: None, - session: crate::state::ClientSession::new(0), + last_activity_ms: Arc::new(AtomicU64::new(0)), } } @@ -4583,110 +5521,6 @@ mod client_search_tests { } } -#[cfg(test)] -mod file_diagnostic_tests { - use super::*; - use crate::state::file_id::FileRecord; - use crate::state::Source; - use smallvec::smallvec; - use std::net::{IpAddr, Ipv4Addr}; - - fn source(user_hash: u8, ip: [u8; 4], port: u16, complete: bool) -> Source { - Source::new( - [user_hash; 16], - IpAddr::V4(Ipv4Addr::from(ip)), - port, - complete, - ) - } - - #[test] - fn parses_exact_md4_hex_case_insensitively() { - let expected = [0xabu8; 16]; - assert_eq!( - parse_file_hash("abababababababababababababababab"), - Some(expected) - ); - assert_eq!( - parse_file_hash("ABABABABABABABABABABABABABABABAB"), - Some(expected) - ); - assert_eq!(parse_file_hash("abab"), None); - assert_eq!(parse_file_hash("gggggggggggggggggggggggggggggggg"), None); - } - - #[test] - fn builds_stable_exact_file_diagnostics() { - let record = FileRecord::new( - [0x42; 16], - 4_294_967_296, - "release.iso", - smallvec![ - source(3, [10, 0, 0, 2], 4663, false), - source(1, [10, 0, 0, 1], 4662, true), - source(2, [10, 0, 0, 2], 4662, true), - ], - ); - - let row = file_diagnostic_row(&record); - - assert_eq!(row.hash, "42".repeat(16)); - assert_eq!(row.name, "release.iso"); - assert_eq!(row.size, 4_294_967_296); - assert_eq!(row.source_count, 3); - assert_eq!(row.complete_source_count, 2); - assert!(!row.sources_truncated); - assert_eq!( - row.sources, - vec![ - FileSourceRow { - user_hash: "01".repeat(16), - ip: "10.0.0.1".to_string(), - port: 4662, - complete: true, - }, - FileSourceRow { - user_hash: "02".repeat(16), - ip: "10.0.0.2".to_string(), - port: 4662, - complete: true, - }, - FileSourceRow { - user_hash: "03".repeat(16), - ip: "10.0.0.2".to_string(), - port: 4663, - complete: false, - }, - ] - ); - } - - #[test] - fn caps_source_details_without_hiding_counts() { - let sources = (0..=FILE_DIAGNOSTIC_SOURCE_LIMIT) - .map(|index| { - source( - (index % 256) as u8, - [10, 0, (index / 256) as u8, (index % 256) as u8], - 4662, - true, - ) - }) - .collect(); - let record = FileRecord::new([0x24; 16], 1, "many-sources.bin", sources); - - let row = file_diagnostic_row(&record); - - assert_eq!(row.source_count, FILE_DIAGNOSTIC_SOURCE_LIMIT + 1); - assert_eq!( - row.complete_source_count, - (FILE_DIAGNOSTIC_SOURCE_LIMIT + 1) as u32 - ); - assert!(row.sources_truncated); - assert_eq!(row.sources.len(), FILE_DIAGNOSTIC_SOURCE_LIMIT); - } -} - #[cfg(test)] mod cross_site_tests { use super::*; diff --git a/tests/integration.rs b/tests/integration.rs index ac2ca67..42073d5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -12,7 +12,6 @@ use ed2k_server::proto::{opcodes::*, Ed2kCodec, Frame}; use ed2k_server::server::connection::handle_connection; use ed2k_server::state::ServerState; use futures::{SinkExt, StreamExt}; -use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; use tokio_util::codec::Framed; @@ -267,174 +266,6 @@ async fn full_client_lifecycle() { assert_eq!(state.client_count(), 1); } -#[tokio::test] -async fn replacement_login_closes_and_fences_the_displaced_session() { - let (port, state) = spawn_test_server().await; - let user_hash = [0xA5; 16]; - - let old_stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - let mut old = Framed::new(old_stream, Ed2kCodec::new(1_000_000)); - old.send(Frame::new( - OP_LOGINREQUEST, - build_login(user_hash, 4001, "old-session"), - )) - .await - .unwrap(); - for _ in 0..4 { - tokio::time::timeout(std::time::Duration::from_secs(2), old.next()) - .await - .expect("timeout reading old welcome frame") - .expect("old connection closed during welcome") - .expect("old welcome frame error"); - } - - let replacement_stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - let mut replacement = Framed::new(replacement_stream, Ed2kCodec::new(1_000_000)); - replacement - .send(Frame::new( - OP_LOGINREQUEST, - build_login(user_hash, 4002, "replacement"), - )) - .await - .unwrap(); - for _ in 0..4 { - tokio::time::timeout(std::time::Duration::from_secs(2), replacement.next()) - .await - .expect("timeout reading replacement welcome frame") - .expect("replacement closed during welcome") - .expect("replacement welcome frame error"); - } - - let stale_file = [0x5A; 16]; - let _ = old - .send(Frame::new( - OP_OFFERFILES, - build_offerfiles(stale_file, "stale-session.bin", 123_456), - )) - .await; - let closed = tokio::time::timeout(std::time::Duration::from_secs(2), old.next()) - .await - .expect("displaced session socket stayed open"); - assert!( - matches!(closed, None | Some(Err(_))), - "displaced session received another server frame" - ); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - assert_eq!(state.file_count(), 0, "displaced session published after replacement"); - let live = state.clients.get(&user_hash).expect("replacement not registered"); - assert_eq!(live.nick, "replacement"); - assert_eq!( - state - .replaced_sessions_closed - .load(std::sync::atomic::Ordering::Relaxed), - 1 - ); -} - -#[tokio::test] -async fn same_hash_size_variants_return_only_the_exact_endpoint() { - let (port, state) = spawn_test_server().await; - let file_hash = [0xBCu8; 16]; - state.add_file_with_source( - file_hash, - 2_000_000_000, - "Linux Mint 22.iso".into(), - ( - [0xAB; 16], - IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)), - 4662, - true, - ), - ); - state.add_file_with_source( - file_hash, - 2_000_000_001, - "Different Size.iso".into(), - ( - [0xAD; 16], - IpAddr::V4(Ipv4Addr::new(198, 51, 100, 8)), - 4663, - true, - ), - ); - - let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - let mut framed = Framed::new(stream, Ed2kCodec::new(1_000_000)); - framed - .send(Frame::new( - OP_LOGINREQUEST, - build_login([0xAC; 16], 4001, "requester"), - )) - .await - .unwrap(); - for _ in 0..4 { - tokio::time::timeout(std::time::Duration::from_secs(2), framed.next()) - .await - .expect("timeout reading welcome frame") - .expect("connection closed during welcome") - .expect("frame error"); - } - - let mut correct = BytesMut::new(); - correct.put_slice(&file_hash); - correct.put_u32_le(2_000_000_000); - framed - .send(Frame::new(OP_GETSOURCES, correct.to_vec())) - .await - .unwrap(); - let foundsources = framed.next().await.unwrap().unwrap(); - assert_eq!(foundsources.opcode, OP_FOUNDSOURCES); - assert_eq!(foundsources.payload[16], 1); - assert_eq!( - u32::from_le_bytes(foundsources.payload[17..21].try_into().unwrap()), - u32::from_le_bytes([198, 51, 100, 7]) - ); - assert_eq!( - u16::from_le_bytes(foundsources.payload[21..23].try_into().unwrap()), - 4662 - ); - - let mut variant = BytesMut::new(); - variant.put_slice(&file_hash); - variant.put_u32_le(2_000_000_001); - framed - .send(Frame::new(OP_GETSOURCES, variant.to_vec())) - .await - .unwrap(); - let foundsources = framed.next().await.unwrap().unwrap(); - assert_eq!(foundsources.opcode, OP_FOUNDSOURCES); - assert_eq!(&foundsources.payload[..16], &file_hash); - assert_eq!(foundsources.payload[16], 1); - assert_eq!( - u32::from_le_bytes(foundsources.payload[17..21].try_into().unwrap()), - u32::from_le_bytes([198, 51, 100, 8]) - ); - assert_eq!( - u16::from_le_bytes(foundsources.payload[21..23].try_into().unwrap()), - 4663 - ); - - let mut wrong = BytesMut::new(); - wrong.put_slice(&file_hash); - wrong.put_u32_le(2_000_000_002); - framed - .send(Frame::new(OP_GETSOURCES, wrong.to_vec())) - .await - .unwrap(); - let foundsources = framed.next().await.unwrap().unwrap(); - assert_eq!(foundsources.opcode, OP_FOUNDSOURCES); - assert_eq!(foundsources.payload[16], 0); - - framed - .send(Frame::new(OP_GETSOURCES, file_hash.to_vec())) - .await - .unwrap(); - let foundsources = framed.next().await.unwrap().unwrap(); - assert_eq!(foundsources.opcode, OP_FOUNDSOURCES); - assert_eq!(foundsources.payload[16], 0, "hash-only collision is ambiguous"); -} - #[tokio::test] async fn csam_filename_is_blocked() { let (port, state) = spawn_test_server().await; @@ -1049,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()); +}
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
${meta.label}${r.count.toLocaleString()}