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 stable at the scale of the largest real eD2k servers (tens of millions of files).
- Full eD2k protocol: TCP (login, search, get-sources, offer-files) and UDP (search, get-sources, server pings, server-to-server gossip) — UDP is the bulk of real-world traffic.
- 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, aned2k://|file|…|link or amagnet:?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.
- Mandatory multi-layer CSAM content filter on every published file (age+context heuristics in code; operator-supplied jargon, hash, and extra-term lists loaded at runtime — see Content filter below).
- Server-to-server gossip with obfuscation; mldonkey/junk-server filtering via verification.
- NAT traversal (NAT-T) hole-punch coordination for LowID↔LowID transfers.
- IPv6 (opt-in): clients can connect over IPv6, and a client that is LowID on IPv4 but has a public IPv6 is published as an IPv6 source to peers that can parse it — see IPv6 below.
- OFFERFILES v1 (opt-in): tells capable clients how many files per batch and how often they may publish, and holds them to it, so a large library is published in minutes instead of hours — see OFFERFILES v1 below.
- HighID verified by a client-to-client hello, not only a TCP connect: a port forwarded to a different client behind the same NAT no longer earns HighID.
- IP filtering in eMule guarding.p2p format, with per-range hit statistics.
- GeoIP country and provider for IPv4 and IPv6 (MaxMind DB, with the
ip-to-country.csvfallback), bot/scanner detection, CSAM-publisher banning. - Work admission: bounded sockets, logins, probes, searches and UDP, sized for 50 000 clients and the reconnect storm after a restart.
- Built-in admin web panel (status, clients with server-side search, peers with their names, filters, bots, blocks, settings) bound to localhost.
- Hot-reloadable filter lists and config without a restart.
Status: production-used test/MVP build (v0.9.x). The protocol surface is complete and running live; expect ongoing iteration.
- Rust (edition 2021,
rust-version >= 1.75), async on Tokio. - Memory allocator: jemalloc (
tikv-jemallocator) on non-MSVC targets. - Web panel: axum. All dependency versions are pinned in
Cargo.toml.
- 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. The committedCargo.lockbuilds with 1.75 (cargo build --release --locked);.cargo/config.tomlkeeps a newer Cargo'scargo updateon 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 hundred MB; planning for tens of millions of files, budget ~10–13 GB.
- Raise the open-file limit for production (see systemd service below).
# 1. Install Rust (if not present)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
sudo apt update && sudo apt install -y build-essential
# 2. Get the source
git clone https://github.com/andrey23127/ed2k-server.git
cd ed2k-server
# 3. Build (release)
cargo build --release
# Binary: target/release/ed2k-serverThe server is Linux software; on Windows build it inside WSL (a real Linux environment), not native Windows.
# In PowerShell (once): install WSL with Debian
wsl --install -d DebianThen open the Debian (WSL) shell and follow the exact same Linux steps
above (rustup, apt install build-essential, cargo build --release). The
resulting binary is a Linux binary — run it inside WSL, or copy it to your VPS.
The repo ships a portable .cargo/config.toml that bakes the jemalloc memory
tuning into the binary for everyone. It also contains commented-out examples
for tuning the binary to a specific CPU. Uncomment one block that matches the
machine that will run the binary:
target-cpu=native— optimize for the build host (safe if you build on the same machine you deploy to).target-cpu=x86-64-v3— any CPU with AVX2 (Intel since 2013, every AMD Zen). The right choice on a VPS: the hypervisor may hide instructions the model name promises, so a model-specific value such asznver3can crash there.- A model name (
znver3,znver4,icelake-server, …) — only on a dedicated server whose CPU you know.
CPU-TUNING.md has the full table (AMD, Intel, ARM), how to check what a machine supports, and how to trace periodic CPU load.
A binary built for a specific
target-cpumust only run on that CPU family or newer, or it will crash with an illegal-instruction error. When in doubt, leave all CPU blocks commented out — the default build runs anywhere.
You can confirm the jemalloc tuning was embedded at runtime:
ps -T -p $(pgrep -f ed2k-server) | grep jemalloc # a jemalloc_bg_thd thread = OKThe tuning includes thp:never: jemalloc's memory is kept out of transparent
huge pages. On a system with THP set to always (cat /sys/kernel/mm/transparent_hugepage/enabled) the kernel otherwise counts every
partly used 2 MB page as resident, and RSS ran ~25% above what the server held
(live: 1.20 GB RSS, 0.94 GB held, AnonHugePages 938 MB). Check with
grep AnonHugePages /proc/$(pidof ed2k-server)/smaps_rollup — near 0 with the
tuning in place. A binary built without it gets the same effect from
Environment=_RJEM_MALLOC_CONF=thp:never in the service unit.
sudo install -m 0755 target/release/ed2k-server /usr/local/bin/ed2k-serversudo mkdir -p /etc/ed2k-server
sudo cp config/config.toml /etc/ed2k-server/config.toml
sudo nano /etc/ed2k-server/config.toml # replace every CHANGE_MEconfig/config.toml is the only template, and it is set up for a public
server: public = true, max_clients = 5000, the seed servers, IPv6, and every
data file under /etc/ed2k-server, kept current by [updates]. (Up to 0.9.77
there were two templates; config.toml was a small local test setup — not
public, 1000 clients — and servers started from it by mistake stopped at 1000
users. config.vps.toml is gone; its content is config.toml now.)
Fill in at least:
| Key | What |
|---|---|
server.name, server.desc |
how the server appears in eMule server lists |
server.this_ip |
the server's public IPv4 |
welcome.messages |
the greeting clients see |
network.tcp_port |
4661 in the template; UDP is this + 4, the admin panel is on admin.port (8080, localhost only unless admin.bind and a password are set) |
limits.max_clients |
5000 in the template; raise it for a large server (see LimitNOFILE below) |
The server refuses to start while server.name or server.this_ip still
read CHANGE_ME. Because the unit below discards the server's output, run it
once by hand to see any configuration error before installing the service:
sudo /usr/local/bin/ed2k-server --config /etc/ed2k-server/config.toml
# Ctrl+C once it is runningPut your runtime data files here too (the template already points at them):
-
ip-to-country.csv— GeoIP database for the admin panel's country stats. Download and unpack it from https://upd.emule-security.org/ip-to-country.csv.zip:cd /etc/ed2k-server curl -O https://upd.emule-security.org/ip-to-country.csv.zip unzip ip-to-country.csv.zip # produces ip-to-country.csv
The template's
storage.country_db_pathalready points here. -
ipinfo_lite.mmdb(optional) — a MaxMind DB file, IPv4 and IPv6, with each network's provider (ASN and name). IPinfo Lite (ipinfo_lite.mmdb) and MaxMind GeoLite2 Country files both work:cd /etc/ed2k-server curl -O https://ed2k.emule-security.org/pub/ipinfo_lite.mmdb.zip unzip ipinfo_lite.mmdb.zip # produces ipinfo_lite.mmdb
Next to
ip-to-country.csvit is used instead; when it is missing or unreadable the server falls back to the CSV (IPv4 only). Another location:storage.geoip_mmdb_path;"off"disables it. The whole file is held in memory: about 23 MB for IPinfo Lite, against about 7 MB for the CSV. IPinfo Lite data is CC BY-SA 4.0 and requires attribution to IPinfo. -
guarding.p2p— IP blocklist in eMule format (same format used by emule-security). The template'sstorage.ipfilter_pathalready points here. -
Content filter lists (optional but recommended — see Content filter).
Keeping those files current by hand is tedious, and the term and hash lists are not published in this repository. See UPDATE-SERVICE.md for pulling them from an update service — or running one — including the signature checks that make an automatic update safe.
A ready unit is in contrib/ed2k-server.service. Install it:
sudo cp contrib/ed2k-server.service /etc/systemd/system/ed2k-server.service
# edit paths if needed
sudo systemctl daemon-reload
sudo systemctl enable --now ed2k-server
sudo systemctl status ed2k-serverReload filter lists / config live (no downtime):
sudo systemctl reload ed2k-server # sends SIGHUPThe unit (excerpt — full file in contrib/):
[Unit]
Description=ed2k index server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/ed2k-server --config /etc/ed2k-server/config.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
StandardOutput=null
StandardError=null
LimitNOFILE=262144
[Install]
WantedBy=multi-user.target
LimitNOFILEmatters — size it tomax_clients, not to the file index. The default 1024 file-descriptor limit is fatally low. File-descriptor usage tracks concurrent client connections, not the number of indexed files: the 33M-entry index lives entirely in RAM and consumes zero descriptors. Budget roughly one fd per connected client, plus ~10–20 fixed (one TCP + five UDP listeners, the admin socket, logging) and a transient margin for outbound HighID probes and ephemeral gossip sockets during login storms.Size it for the worst moment — the reconnect storm after a restart, when every client logs in again at once: open sockets (
max_clients+ 10% +admission.max_pending_logins) plus one outbound HighID probe per pending login (admission.max_probe_jobs). With the[admission]defaults that is about 121 000 formax_clients = 50000, so the shipped unit sets262144. The server warns at startup if the ceilings exceed the limit.At startup the server raises its own soft limit to the hard limit (no privilege needed), so a unit without
LimitNOFILE, or a server started from a shell or a container, gets the system's hard limit (524288 under current systemd) instead of 1024. An explicitLimitNOFILEstill sets both. If the descriptors do run out, accepts pause briefly and the Health tab shows "accept failed: out of file descriptors". At 1024 that used to happen at ~900–1000 clients, with one core at 100%.
The provided unit sends StandardOutput/StandardError to /dev/null.
On a busy server, logs grow very fast and can fill the disk. Logging is
therefore disabled by default. To enable it for troubleshooting:
- In the unit, change
StandardOutput=null/StandardError=nulltojournal. - Optionally set
Environment=RUST_LOG=ed2k_server=debugfor verbose tracing. systemctl daemon-reload && systemctl restart ed2k-server, thenjournalctl -u ed2k-server -f.
Turn it back off once you're done — debug especially is extremely chatty.
Edit /etc/ed2k-server/config.toml. The table below lists the settings and
whether a change applies live (via systemctl reload / SIGHUP, or by editing
a watched file) or needs a restart.
Rule of thumb: filter lists apply live; anything that changes a listening socket needs a restart. When unsure, restart.
| Key | Meaning | Apply |
|---|---|---|
name, desc |
Server name / description shown to clients | restart |
public |
If true, requires a non-empty hash blocklist (enforced) |
restart |
this_ip |
Mandatory. The server's public IPv4. Used for the obfuscation seckey (derived from this_ip + tcp_port) and identity. |
restart |
seed_servers |
List of ip:port seed servers to gossip with on start |
restart |
version_major, version_minor |
Advertised version — do not set below 17.15 | restart |
this_ipis required. Set it to your server's public IP. The server-to-server obfuscation key is derived fromthis_ip+tcp_port, so it is stable across restarts and rotates automatically only if the IP or port changes.
Keep the version at 17.15 or higher. Lugdunum servers reject servers advertising an older version and will not add them to their
server.met(the 17.15 minimum was confirmed from the Lugdunum decompile). The default is 17.15; raising it is fine, lowering it gets you silently dropped from peer lists.
| Key | Meaning | Apply |
|---|---|---|
tcp_port |
eD2k TCP port (default 4661). All UDP ports are derived from it (see below) | restart |
listen_ip |
Bind address for the listeners | restart |
listen_backlog, max_frame_size |
TCP accept backlog (IPv4 and IPv6 listeners; 0 = 1024; the kernel caps it at net.core.somaxconn) / max accepted frame size |
restart |
max_decompressed_frame_size |
Max bytes one compressed (0xD4) frame may decompress to; max_frame_size bounds only its compressed wire length, and zlib expands up to ~1000:1. Default 8 000 000; must be at least 1 |
new connections |
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 |
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 peer's ports the same way, so there is nothing to set:
Port Purpose tcp_port + 4main eD2k UDP (searches, sources) tcp_port + 8auxiliary UDP tcp_port + 12server-to-server obfuscated ping tcp_port + 14portUDPobf(obfuscated UDP)Earlier versions had a separate
udp_portkey that had to be kept attcp_port + 4by hand; a wrong value silently broke discovery. It has been removed. A staleudp_port = ...left in an oldconfig.tomlis ignored, so existing configs keep working without edits.
hairpin_lan_clients— for running a client on the same network as the server. In that topology the client reaches the server through the router's hairpin NAT, so the login arrives from an RFC1918 address and the ordinary HighID probe refuses to even try: there is nothing useful to connect to at a private address. With this on, the server falls back to probingserver.this_ipon the client's port — in that topology the client's public address is by definition the server's own.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 forward may well lead to a different machine. Without the identity check the server would hand the public address out as a source for the wrong host.
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.
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_HELLOin 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.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.
| 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 |
max_search_results |
Results returned per search, across all pages; best-sourced first. Default 200, ceiling 5000. One value for every client — unlike Lugdunum, which varies it by zlib support and halves it for LowID | live |
search_rank_scan |
Candidates examined when ranking one search. Past it, ranking covers only what was seen; the share of searches that hit it is shown on the Status tab. Default 20000, must be ≥ max_search_results |
live |
index_subtokens |
Also index letter/digit pieces of each word, so S01E08 is found by s01 and e08, 1080p by 1080. Changes what searches return, so off by default. Read once at startup: the index must split names the same way for its whole life. Not a superset of Lugdunum: a query that starts mid-run (1x05 against 01x05) is still not found |
restart |
search_drop_unknown_words |
Ignore query words that no indexed file contains instead of returning nothing because of them (a typo no longer empties the search). A query of only unknown words still returns nothing; OR branches and negated words are never rewritten. Default on | live |
offerfiles_v1 |
Answer clients that ask for OFFERFILES v1 at login with batch size and pace in OP_SERVERIDENT (see OFFERFILES v1) and hold those connections to them. Clients that do not ask are never affected. Off by default; off or invalid = no tag sent, publishing unchanged |
live, new logins |
offerfiles_batch_max, offerfiles_min_interval_ms |
The advertised batch size (default 200) and least time between batches (default 500 ms). A v1 batch above the size (and below the hard limit) is not indexed, the session stays up; an early batch waits, it is never dropped | live, new logins |
offerfiles_global_records_per_sec |
Ceiling on records per second from all v1 connections (clients that asked) together, served in arrival order, so a reconnect wave of fast publishers slows down instead of saturating the content filter. Default 5000 (about a quarter of a core); 0 = none. Legacy publishers are not counted |
live |
| Key | Meaning | Apply |
|---|---|---|
hash_banlist |
L3 ban-list file path(s) — blocked and counted against the publisher | live (file edit / reload) |
hash_filter |
L5 filter-only file path(s) — blocked, publisher not accused | live |
extra_terms_file |
L4 operator extra terms | live |
jargon_terms_file |
L1 jargon list | live |
layer2_terms_file |
L2 vocabulary (see below) | live |
whitelist_hashes_file |
Hash false-positive overrides (wins over every layer) | live |
publisher_attempt_disconnect_threshold |
Distinct blocked files tolerated before a publisher is banned (ban fires on the next one) | restart |
publisher_count_window_seconds |
How far back those files are counted. Defaults to publisher_blacklist_seconds |
restart |
publisher_blacklist_seconds |
Ban duration (by user-hash) | restart |
hash_banlistwashash_blocklistsandhash_filterwaspoison_hashesbefore 0.9.71; the shipped filenames changed to match. See Content filter.
Optional; disabled by default. Fetches the filter data files from an update service, verifies an Ed25519 signature over them, validates them with the same parser that loads them at runtime, keeps rotating backups and installs atomically. Full description, including how to run a service: UPDATE-SERVICE.md.
| Key | Meaning | Apply |
|---|---|---|
enabled |
Master switch | live |
dest_dir |
Where files are installed; must match the paths above | live |
public_key |
Ed25519 public key (32 bytes, hex) files must be signed with | live |
require_signature |
Refuse anything unsigned. Leave on | live |
key_reference_ip |
The server whose exported peer table the service loaded | live |
backups |
Generations kept as <name>.1 … <name>.N |
live |
min_keep_ratio |
Refuse a replacement that shrinks the list below this fraction | live |
export_url, export_token, export_interval_secs |
Peer table export | restart |
[updates.urls] |
Per-file URLs | live |
public_keyis public andexport_tokenis not. The key verifies a signature and cannot create one, so it is safe to ship and to commit. The token lets its holder push a peer table to the service and is issued per server — never copy one out of a repository, and never commit yours.
Updates are triggered from the Health tab, which lists every data file with what is on disk and an Update button. The three hash lists additionally offer Update & merge, which unions the download with the file already installed, comparing hashes only and keeping whichever side carries the comment.
Nothing is written unless the signature verifies, the file parses and it would not collapse the list — a failed update leaves the current file in place and says why.
| Key | Meaning | Apply |
|---|---|---|
ipfilter_path |
Path to guarding.p2p. Applies to TCP connections and, since 0.9.84, to UDP. The bot detector may append lines to it (see [bots]) |
live: a change on disk is picked up within 30 s; also SIGHUP / /api/reload |
country_db_path |
Path to ip-to-country.csv (IPv4) |
restart; a new file at the path reloads live |
geoip_mmdb_path |
MaxMind DB tried before the CSV (IPv4 + IPv6). Empty = ipinfo_lite.mmdb next to the CSV; "off" = CSV only |
restart; a new file at the path reloads live |
bot_log_path |
Ledger of detected bots, one JSON line per detection and per ban end. Empty = bot-log.jsonl next to the config file; "off" = none |
restart |
Bot detection, over UDP and, for source requests, TCP. Every key optional.
The min_span_secs/uniform_action rate rules are restart-only; the other
keys are read on every request, so the Settings tab changes them at once.
| Key | Default | Meaning |
|---|---|---|
min_span_secs |
10 | A source is judged only once its queries span this long, so a burst of 30 packets in a few milliseconds is not read as a flood of 100 000 qpm |
uniform_action |
"throttle" |
What a "uniform timing" source (over 200 qpm, regular intervals, not a flood) gets for 24 h: "throttle" (still answered, at throttle_qpm) or "ban" |
throttle_qpm |
60 | What a throttled source still gets through, per minute |
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_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" |
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:
071.231.168.221 - 071.231.168.221 , 000 , bot
It is written only when no line of the file already covers the address, and
the filter is reloaded at once. The address is also recorded in
bot-ipfilter.txt next to the config file: when the filter file is replaced
(an update from the update service, or by hand), the recorded addresses are
appended again before it is loaded. To take one out for good, delete its line
in both files.
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. 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).
A ban covers TCP as well: the source's sessions are closed, and it can't log
in for the 24 hours (bot_kick and bot_tcp_refused in /api/block_stats).
A throttle leaves its session up and answers only throttle_qpm source
requests a minute.
Whenever a new IP filter is put in place (a bot added, the file changed on
disk, SIGHUP or /api/reload), every TCP session from an address it now
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.
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 rate an ordinary client can't reach. What sets it apart is how many files it asks about, and how often. To choose those criteria from real traffic, the server counts per source (IPv4 address, IPv6 /64) since start or the last reset:
- searches over TCP and UDP, "more results" pages, and the results each search got (TCP: the whole result set; UDP: answers sent);
- source requests over TCP and UDP, the files in each (
1,2-4,5-16,17-32,33+), all files asked, and the number of different ones (estimated, ±13%; no hash is kept); - the gaps between two requests (
<1 s…>1 h), the busiest minute, and requests per hour over the time the source was seen.
| Endpoint | What |
|---|---|
GET /api/activity |
Totals, requests by UTC hour, and for each figure its spread over sources: mean, p50, p90, p99, p99.9, max, plus histograms |
GET /api/activity/sources |
One row per source with its country, provider and, for a logged-in IPv4 client, software and nick. sort=requests (default), searches, sources, files, distinct, peak, per_hour or repeats; limit=200 (0 = all); min=1 (least requests); format=csv |
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. 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.
Server-wide ceilings on the work remote peers can cause, however the load is
spread across addresses, listeners and address families (issue #25). All are
restart-only (the admin config editor says so when they change, including
the open-socket ceiling derived from max_clients), and none accepts 0 — a
zero ceiling is refused at startup.
The whole section is optional; the defaults are below.
The defaults are sized for the load the server is designed for — 50 000
clients (about 45 000 of them LowID) and 30 000 000 files — and for its worst
moment, a restart, after which every client reconnects at once. Each value
leans high: a ceiling costs memory only while it is in use, whereas one that is
too low refuses real users. The pending-login pool is the one a restart
stresses: a login holds its slot through the obfuscation handshake, the login
and the HighID probe, and for a LowID client, whose port does not answer, the
probe lasts the whole network.login_timeout_ms. On a live server a restart
with 9 000 clients overflowed 4 096 pending slots, a peak above 0.45 pending per
client — about 23 000 at 50 000 clients; the default is 32 768. A smaller
server can lower these to save memory during the storm, but need not.
| Key | Default | When full |
|---|---|---|
max_open_tcp_connections |
max_clients + 10% + max_pending_logins (200 000 if max_clients = 0) |
the socket is closed at accept, before a task exists. Keep it under LimitNOFILE; the server warns at startup if it is not |
max_pending_logins |
32 768 | same — sockets still in handshake or login, including the HighID probe. IP-filtered addresses are dropped before they take a slot. Sized for the reconnect storm after a restart (see below) |
max_pending_logins_per_source |
32 | same, per source (IPv4 address, IPv6 prefix), so a few sources cannot hold the whole pending pool. Loopback exempt |
max_probe_jobs |
max_pending_logins + 64 |
HighID probes, at login and in the background together (background checks are at most 64, so logins always find one). A login that finds none free gets LowID at once rather than waiting |
max_retry_tasks |
1024 | hole-punch re-coordination is skipped; one pending retry per (requester, target) pair at most |
session_relay_per_second, session_relay_burst |
10, 200 | per session, callback and hole-punch requests. Over it a callback gets OP_CALLBACK_FAIL; a hole-punch request is ignored |
max_search_jobs |
8 | searches run off the async runtime, at most this many at once |
max_queued_tcp_searches, tcp_search_wait_ms |
512, 2000 | a TCP search waits in arrival order behind at most this many others, for at most this long, then gets an empty result |
max_queued_udp_searches, udp_search_wait_ms |
128, 500 | a UDP search waits the same way, from a task (never in the receive loop), in its own smaller queue so UDP cannot crowd out TCP; beyond it, dropped |
max_rate_state_entries |
524 288 (≈ 50 MB at most) | UDP sources tracked at once; a new source beyond it is refused. Idle entries are forgotten every 30 s |
ipv6_source_prefix_bits |
64 | IPv6 addresses sharing this prefix are one source (UDP budgets and the TCP per-IP limit). IPv4 is per address; IPv4-mapped IPv6 counts as its IPv4 |
udp_global_credits_per_second, udp_global_burst_credits |
200 000, 400 000 | server-wide UDP budget, checked first |
udp_source_credits_per_second, udp_source_burst_credits |
60, 1000 | per-source UDP budget, checked second |
udp_rate_enforce |
false |
Off = observe: over-budget UDP packets are counted but still served. Watch the counters on the Status tab, adjust the budgets, then set true to drop them |
readiness_window_secs |
30 | /api/ready returns 503 once open sockets, pending logins or search jobs have stayed full this long |
UDP costs, in credits: every datagram 1 on arrival (before any parsing); an obfuscated datagram that misses the decode cache +4; a search +9; a source request +1 per file hash (up to 64); a server-list request or response +20. Every UDP listener and both address families share the same budgets.
GET /api/admission returns every gauge and counter (fixed cardinality, no
addresses); the Status tab shows a one-line summary. GET /api/ready is
readiness, separate from the diagnostic /api/health.
contrib/overload_test.py with contrib/overload_test.toml drives a running
server past every ceiling and checks that it stays bounded and recovers.
| Key | Meaning | Apply |
|---|---|---|
enabled |
Enable the admin web panel | restart |
port |
Admin panel port | restart |
bind |
Listen address. Default 127.0.0.1 (SSH tunnel, no login). Any other address — a VPN or LAN address, 0.0.0.0 — needs password_hash, or the server refuses to start |
restart |
password_hash |
pbkdf2-sha256$… from ed2k-server --hash-password. Empty = no login. Set = every page and API call needs a login, from any address (an SSH tunnel too) |
restart |
tls |
"off" (default, plain HTTP), "self-signed" (the server makes and renews its own certificate) or "files" (tls_cert/tls_key, e.g. Let's Encrypt; re-read when they change) |
restart |
tls_cert, tls_key |
Certificate (chain) and key. Default admin-cert.pem / admin-key.pem next to the config file; written by "self-signed", read by "files" |
restart |
tls_ca_cert, tls_ca_key |
"self-signed" only: sign with this CA (ed2k-server --make-admin-ca DIR) instead of self-signing. Both or neither |
restart |
tls_names |
Extra IP addresses or DNS names the certificate covers, besides the bind address (server.this_ip on 0.0.0.0), 127.0.0.1, ::1 and localhost |
restart |
| Key | Meaning | Apply |
|---|---|---|
log.level, log.connection_trace |
Log verbosity (see logging note above) | restart |
welcome.messages |
MOTD lines sent on login | restart |
worker_threads |
Tokio worker threads. Default 1 (single-threaded runtime); 0 is treated as 1 | restart |
Live changes that take effect without a restart: the CSAM filter lists —
L1 jargon, L3 ban list(s), L4 extra terms, L5 filter list(s) and
the hash whitelist — all reload automatically within ~30 s of editing the
file, or immediately on systemctl reload / POST /api/reload. Copy list files
with cp and not cp -p: the watcher polls mtime, so preserving timestamps
means nothing reloads.
Changes take effect on search results and source lists, not just on new publications — a hash added to either list disappears from what the server serves within one reload cycle, with no restart.
The filter runs on every offered file and cannot be disabled. It has five layers:
- L1 – jargon list — known marker terms. The list is not shipped with the
source (publishing a catalog of such terms is itself harmful). Supply your own
via
jargon_terms_file. Operators obtain indicators from authoritative bodies (INHOPE, IWF, NCMEC). Absent ⇒ L1 inactive; the other layers still run. - L2 – age + sexual-context heuristics — compiled into the binary, works out of the box, no data file needed. This is the main heuristic layer.
- L3 – ban list (
hash_banlist) — exact known-file hashes. Obtain from authoritative sources (NCMEC, IWF, Project Arachnid / C3P). These lists are typically licensed and must not be redistributed — keep them private. A hit here counts against the publisher. - L4 – operator extra terms — optional additive substrings.
- L5 – filter list (
hash_filter) — blocked like anything else, but the block carries no accusation: it does not raisecsam_attemptsand does not count towardpublisher_attempt_disconnect_threshold.
Two things belong in hash_filter: decoys — one hash advertised under a
dozen unrelated names, a 700 MB file claiming at once to be a music compilation,
a film and an office installer — and takedown requests, where a rightsholder
complaint means the file should leave the index and says nothing about whoever
happens to share it.
Keeping those in the ban list did two kinds of damage: it pushed ordinary users
toward a ban for downloading a decoy, and it diluted a list whose whole value is
that every entry means one specific thing. /api/review reports blocklisted
hashes carrying several unrelated names or a size their extension cannot hold —
those are the candidates to move.
whitelist_hashes_file overrides every layer, not just the hash lists. The
false positives that actually occur are term matches — song titles that happen to
contain a marker word, or historical texts whose title does — so an override that
left the term layers running did nothing for the one class of mistake that
happens in practice.
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's word lists live in layer2_terms_file rather than in the binary, and
hot-reload like the other lists. They change often, and a rebuild-and-restart per
change costs every connected client. With no file configured the built-in
vocabulary is used, which is what the server did before the option existed; a
file replaces the sections it names and leaves the rest alone. An unknown section
name rejects the whole file and keeps the previous lists, because a typo would
otherwise silently empty a category.
Layer 2 normally needs BOTH an age claim and a sexual term, so a birthday video is not caught. Three refinements to that rule:
- Ages of 12 and under stand alone. Below 13 an age written into a filename
is the anomaly by itself — legal material does not label participants "11yo".
Only the compact notations (
yo,yr) qualify;12 yearsis ordinary English and keeps the pairing rule, or "12 Years a Slave" would be blocked. Guard words near the number (whisk,malt,service manual) disqualify it. - Non-English contexts. Russian minor/sexual word lists were added after a search sample found files with both halves written in plain Russian and nothing in any list covering them. These are ordinary words and work only behind the pairing rule — measured against legitimate Russian titles, a bare term list matched nine of thirteen.
- Fixed innocent phrases override everything (
SEX_TERM_EXCEPTIONS). Some broad terms are common words inside set expressions — an idiom that is also a film title, the name of a school subject. Naming the phrase keeps the term usable; narrowing the term instead cost five of six real catches in testing.
Filenames damaged by repeated UTF-8/Latin-1 round-trips are recovered and
re-tested, so a CJK marker mangled into å¹¼ still matches.
L1 and L4 share one matcher. A term is classified by length: ≥6 chars → substring match, ≤5 → word-boundary match. Three refinements matter:
- A long term must not begin immediately after an ASCII letter. Without this a six-character term that is a suffix of an ordinary English word fires inside it — one such term was a suffix of a common medical word and blocked every paper mentioning it.
- Digits and
_separate, letters bind:term_001,2termandterm2011all match,atermandtermlydo not. - A trailing
$on a term additionally forbids digits and_after the match. Use it only when the term is the start of a longer innocent word.
Terms in CJK are exempt from the boundary rules — that script has no word separators, so they match as plain substrings wherever they appear.
Template/format files are provided as config/*.example. The real list files are
git-ignored and never committed. Format: one entry per line, # comments allowed
(; inline comments too in the hash lists).
eD2k clients behind NAT get a LowID (no routable address). This server helps such clients still exchange data, acting purely as a lightweight coordinator — it relays small address packets over the TCP control channel it already has to every logged-in client, and never relays file data (that would turn a light index server into a bandwidth relay).
Two mechanisms are active:
-
Classic callback (HighID ↔ LowID). When a HighID client wants a file from a LowID client, it sends
OP_CALLBACKREQUEST(low_id). The server validates the requester is HighID, finds the LowID client by its assigned id, and forwardsOP_CALLBACKREQUESTED(requester_ip, requester_port)so the LowID side connects out to the reachable requester. On failure it returnsOP_CALLBACK_FAIL. -
LowID ↔ LowID hole-punch coordination. Two LowID clients normally cannot connect at all — neither is reachable, so the stock callback doesn't help. Some eMule mods solve this with a Kademlia "buddy" HighID relay; this server needs neither Kad nor a third party. The flow (
src/server/holepunch.rs):- LowID A sends
OP_LOWID_HOLEPUNCH_REQUEST(target_id = B, requester_udp_port). - The server looks up B among connected clients.
- The server sends
OP_LOWID_HOLEPUNCH_INFOto both A and B, each carrying the other side's(ip, tcp_port, udp_port, user_hash)plus a role byte. - Both clients fire UDP packets at each other simultaneously; with cone NAT on both sides this opens the path and a direct connection forms.
The server sends only those two small address packets, and best-effort re-sends them a couple of times over the next few seconds so a lost/late packet on the TCP link doesn't leave the peers punching at non-overlapping times.
Limitation (by design, not a bug): hole punching works when each side's public UDP port is predictable from what the server observed — i.e. cone NAT (including cone-type carrier-grade NAT). If either side is behind a symmetric NAT/CGNAT (a different external port per destination), the punch fails. There is no server-only fix without relaying data, which this server deliberately refuses, so the feature is best-effort.
To keep each client's observed public UDP endpoint fresh, clients send a periodic UDP NAT-T keepalive; the server also enables OS TCP keepalive on each accepted socket so a dropped NAT mapping is detected in ~5 minutes (and kept warm in the meantime).
- LowID A sends
If you maintain an eMule mod and want your client to use this server's LowID↔LowID NAT-T, this is the complete interface. Nothing else in the eD2k protocol changes, and every tag/opcode below is backward-safe — a non-NAT-T client simply omits them.
1. Advertise capability at login. Add tag CT_EMULE_UDPPORTS (0xF9) to the
login request, value ((kadUDPPort << 16) | clientUDPPort). This tells the server
your client UDP port and flags you as NAT-T capable. Lugdunum-style servers already
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 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
link) to the server's UDP port (= server TCP port + 4), payload = your 16-byte
userhash, about every 60 s. The server reads the source port of this datagram to
learn your real post-NAT UDP port — the port peers must actually punch.
The port is kept per client (by user hash), and only from a keepalive that
comes from the same IP as your TCP session. So two clients behind one public IP
each get their own (issue #37). The port the server hands your peers is, in
order: your last keepalive's source port if it is under 600 s old; else the last
UDP port seen from your IP, only when no other client is logged in from it;
else the port you announced (0xF9, or the one in your 0x60).
Timing invariant — do not get this wrong. This server trusts an observed external UDP port for 600 s (
OBSERVED_UDP_FRESH). Your keepalive interval must stay well under that — ~60 s gives a 10× margin. If it lapses, the server falls back to your announced internal port and hands peers a port your NAT never opened. The symptom is distinctive and easy to misdiagnose: freshly connected peers download fine, but hole punching stops a few minutes after connect until a reconnect. The0x9Fkeepalive also doubles as your liveness signal (step 5), so a share-only LowID client must keep sending it.
3. Client ↔ server opcodes (travel over OP_EDONKEYPROT, the server link):
| Opcode | Value | Direction | Payload |
|---|---|---|---|
OP_LOWID_HOLEPUNCH_REQUEST |
0x60 |
client → server (TCP) | <target_id 4><our_udp_port 2> |
OP_LOWID_HOLEPUNCH_INFO |
0x61 |
server → both (TCP) | <peer_ip 4><tcp 2><udp 2><userhash 16><role 1><peer_id 4><peer_crypt 1> |
OP_LOWID_HOLEPUNCH_FAIL |
0x62 |
server → client (TCP) | <target_id 4><reason 1> |
OP_SERVER_NATT_KEEPALIVE |
0x9F |
client → server (UDP) | <userhash 16>[<token 16>] |
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
(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.
peer_id is the server ID of the peer described (issue #38): the requester gets
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. 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
<userhash 16><token 16> 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.)
4. Peer ↔ peer. What the two clients do after 0x61 (the punch, the tunnel,
the transport inside it) goes directly between them; the server never sees it
and doesn't care which scheme a client uses. The one practical advice: both sides
should punch and open the tunnel (a symmetric handshake), so the peer behind the
stricter NAT opens its own mapping, and a per-run nonce in the handshake lets a
peer restart be detected and the stale tunnel rebuilt.
The eMule 0.72a "NAT-T edition" that was the first client of this interface is
retired and its guide is out of date. Its tunnel framed data as 0xB2 frame type
0x01, which collides with eMuleAI's QUIC (issue #36); don't copy that. A new
client should pick a free frame type from the table below.
0xB2 (OP_UDPRESERVEDPROT2) on the peer UDP socket has no opcode table of its
own: the byte after it is a frame type that each client family defines. So that
two schemes never read each other's datagrams, the values known to be taken:
| Frame type | Used by | Meaning |
|---|---|---|
0x00 |
eMuleAI | uTP |
0x01 |
eMuleAI | QUIC (NAT-T transport) |
0x02 |
eMuleAI | NAT-T capability exchange |
0x03 |
eMuleAI | NAT-T capability ack |
0x10 |
emule-qt (planned) | reliable-UDP layer |
0xFF |
eMuleAI | key frame |
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.
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 <target_id 4><requester_id 4>, 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 | <requester_ip 4><requester_udp_port 2><requester_id 4><crypt 1>, 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".
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).
- The requester sends a
0x9F(server-key obfuscated when it has the key) and the0x60, in either order. - The server wakes the target with Lugdunum's
0x37, byte-identical, carrying the requester's best port so far. - The target sends
0x9Fat 0, 0.5 and 1 s and starts punching towards that endpoint. It dials only as the role in0x61says. - On the target's first
0x9Fthe server sends0x61to both (30 bytes, as every0x61), withpeer_crypt= the peer's(CT_SERVER_FLAGS >> 9) & 7(support, request, require). Resends follow at +1.2 s and +3 s as usual. - With no
0x9Ffrom the target within 3 s, the server sends the same0x61with the ports it would have used anyway (the order under step 2 of the contract), or0x62reason 4 where it always would. A lost0x9Fcosts 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.
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 <target_id 4><0 4>[<crypt 1>], 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): <requester_ip><requester_udp_port><0><crypt>, 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 <target_ip><target_udp_port><target_id><crypt><hash 16>, 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
TCP frame or the UDP keepalive), (b) enabling OS TCP keepalive on the accepted
socket (first probe at 60 s, then every 30 s, give up after 8 → ~5 min) to keep the
NAT mapping warm and reap a genuinely dead peer, and (c) tolerating a multi-minute
NAT outage during a heavy transfer before reaping. The same 5 minutes bound data the
server has sent and the client has not acknowledged (TCP_USER_TIMEOUT, Linux): a
connection whose path has gone dark is dropped then, not after the ~15 minutes of
kernel retransmission. Your only obligation is to keep
sending the 0x9F keepalive; the rest is the server's bookkeeping. Get the cadence
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.
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.
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 |
Off by default; two independent switches in [network]:
ipv6_enabled— accept clients over IPv6. A separate IPv6-only TCP listener ontcp_portand UDP socket ontcp_port + 4; the IPv4 sockets are untouched.ipv6_publish_sources— publish IPv6 sources to peers that can parse them. Kept separate because it changes what other peers receive.
An eD2k client id is a 32-bit IPv4 address, so there is no such thing as an IPv6 HighID. A client connected over IPv6 is LowID on IPv4 by construction and is never probed. What IPv6 adds is a second way to reach a LowID client: its IPv6 address, handed to peers as a source.
Agreed with the two other implementations that speak it, eMuleQt and eNode-go. Every element is ignored by a client that does not know it.
| Direction | Element | Value | Meaning |
|---|---|---|---|
| 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 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: 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:
- The address a session actually arrived from is preferred over the one the tag claims: an observed address over an asserted one.
- Only a globally usable IPv6 is published — never link-local, unique-local, loopback, multicast or IPv4-mapped.
- An IPv6 record is sent only for a source that is LowID on IPv4 (a HighID source is reachable already), and only to a requester that can parse it and has no better route: a TCP client that is LowID itself and sent the tag or connected over IPv6, or a UDP query that arrived over IPv6. A HighID TCP requester gets the low id and reaches the source by callback, which always works for it, rather than an IPv6 address nobody has checked (issue #33).
OP_IPV6_UPDATEfollows the login tag's rules: a usable address is 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
0x400000set, 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 portwith the HighID identity probe and got back the user hash that logged in. Until thenST_IPV6_STATUSis0x01. The verdict comes in a freshOP_SERVERIDENT:0x07(published) or0x05(not published; most often a router that drops inbound IPv6). The client stays able to receive IPv6 records either way. A new address inOP_IPV6_UPDATEis 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.
What else covers IPv6:
- Country and provider — from a MaxMind DB (
ipinfo_lite.mmdb); the CSV knows IPv4 only. See Installing on a VPS → Configuration and data files. - Bot detection and its 24-hour bans, the per-IP connection limit and the
UDP budgets count an IPv6 client by its /64 (
admission.ipv6_source_prefix_bits): a host has a whole /64 to rotate through, so per-address counting would never see a flood. An IPv4-mapped address counts as its IPv4. - Not covered: the IP filter.
guarding.p2pcarries IPv4 ranges only.
Legacy eMule and aMule publish at most 200 files per OP_OFFERFILES and wait
about a minute between batches, because a Lugdunum server may black out an IP
that publishes faster. A client sharing 55,000 files then needs four and a half
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; the client
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.
The client asks. A client that implements v1 adds to its OP_LOGINREQUEST
tag list a string-named uint32 tag offerfiles_v carrying the highest
version it implements (1). Any other shape (absent, another type, 0) is a
legacy client. Servers that do not know the tag skip it like any unknown login
tag.
Revised 10.10.2026. The first version advertised to, and enforced on, every login. On the live server that applied the batch limit and the server-wide ceiling to clients that had never seen the advertisement: in one day 2.8 thousand OFFERFILES above
batch_maxfrom such clients were not indexed, and nearly every publisher waited behind the ceiling. The server cannot tell who reads the advertisement, so the client now has to say so.
The server answers, only to a client that asked and only when v1 is on and
valid, in the post-login OP_SERVERIDENT (always sent right after login to a
client that asked, no OP_GETSERVERLIST needed) and only there, each exactly
once, all uint32:
| Tag | Name kind | Meaning |
|---|---|---|
ST_SOFTFILES (0x88) |
numeric | the connection's indexing budget, in distinct files indexed for it |
ST_HARDFILES (0x89) |
numeric | per-packet boundary: every batch must declare fewer records |
offerfiles_v |
string | capability version, 1 |
offerfiles_batch_max |
string | most records in one OP_OFFERFILES |
offerfiles_min_interval_ms |
string | least time between two batches |
The string-named tags are old-format (type 0x03, u16 name length, name,
u32 value), which every client skips when it does not know them. All or
nothing: soft > 0, batch_max > 0, interval > 0 and hard > batch_max, or no
offerfiles_* tag is sent and the server behaves as a legacy one. There is no
burst tag. A client must treat a missing, partial, duplicated or wrongly typed
advertisement as absent (legacy 200 files / 60 s), and must never use these
values against a server that did not send them.
The five values are a snapshot taken at login and hold for the connection's whole life: a configuration change reaches new connections only, and a reconnect starts afresh (new snapshot, full bucket, budget counted from what the client sources then).
One advertisement per connection. The server sends a client that asked
OP_SERVERIDENT once, right after login, and never again on that connection
(not in the answer to OP_GETSERVERLIST either). It holds a connection to
v1 if and only if that SERVERIDENT carried a complete, valid advertisement, so
a client that finds none there (missing, partial, malformed) is legacy on both
sides. A client that did get a valid one keeps it for the connection: a later
SERVERIDENT, valid or not, changes nothing, and slowing down to the legacy pace
does not lift the negotiated batch size (a 200-record batch against
batch_max = 50 is not indexed).
Batch size a v1 client sends: the smallest of its remaining soft budget,
offerfiles_batch_max, ST_HARDFILES - 1 and its own local cap.
batch_max < ST_HARDFILES is required, and the gap is deliberate: ST_HARDFILES
is the final boundary for every client, batch_max the contract for v1 ones.
| The batch… | Result |
|---|---|
declares >= ST_HARDFILES records |
rejected, connection closed (as for every client) |
declares more than offerfiles_batch_max |
not indexed, counted, logged; the session stays up |
| arrives before it is due | waits until it is due, then is processed; nothing is dropped |
would push all v1 connections over offerfiles_global_records_per_sec |
waits its turn (arrival order), then is processed |
| otherwise | content filter, soft budget and indexing exactly as for a legacy client |
"Due" is a token bucket in file records per connection: it holds one batch,
starts full (the first batch goes at once) and refills at batch_max per
min_interval_ms. Smaller batches cost proportionally less. Frames that TCP
delivers together are therefore not an offence, just processed on schedule.
While a connection waits it reads nothing more, so the client is held back by
TCP rather than by a queue in the server.
A legacy connection is held to none of this. The 200-record batch of eMule and
aMule is their own publishing policy (min(ST_SOFTFILES, 200)), not a server
limit: a legacy client may declare any count below ST_HARDFILES, including its
whole share list in one packet, and it is processed. The soft budget is never
fatal for anyone: once it is spent, further new files are not indexed, the
usual server message is sent once, and the connection stays up.
There is no acknowledgement packet in v1: a client cannot tell which records were indexed (filtered, over budget) and should not try to infer it.
Re-offers. A record for a hash the connection already sources (after a
rename, or to mark a download complete) refreshes that source, including its
complete/partial state, and takes no slot of the soft budget. It is still a
record: it counts toward batch_max and is charged to both pacers like any
other. Which name the file is indexed and searched under is this server's own
behaviour, not part of the contract: today it keeps the name the file was first
indexed under.
Versioning. The numbers are configuration, not contract: a client uses
what the server advertised on that connection. The rules above are fixed for
version 1. The client's login tag names the highest version it implements; the
server answers with the version it applies, never above the client's (this
server: 1 to any request of 1 or more). Any incompatible change would be a
new version, so a client implementing 1 keeps getting exactly these rules.
The Status tab shows the active policy and its counters: logins that asked, sessions advertised, batches and records, how often and how long batches waited for their own pace and for the server-wide ceiling, and batches above the size.
- Seed servers (
server.seed_servers): get a current list from https://www.emule-security.org/serverlist or https://peerates.net/. - IP filter: the server reads guarding.p2p in the same format used by
emule-security / eMule (
start_ip - end_ip , level , description). Per-range block-hit statistics are available in the admin panel's Filter tab.
By default the admin panel binds to localhost only (127.0.0.1:<admin.port>,
default 8080) and is not exposed to the internet. To reach it from your PC,
forward the port over SSH, or set a password and another address (see With a
password below).
- Open PuTTY. Enter your VPS host/IP under Session.
- In the left tree: Connection → SSH → Tunnels.
- Source port:
8080· Destination:127.0.0.1:8080· select Local, then click Add (you should seeL8080 127.0.0.1:8080). - Go back to Session, save it, and Open — log in as usual.
- While that session is connected, open http://127.0.0.1:8080/ in your browser. You now see the panel served from the VPS.
If the page is unreachable, the tunnel is down (e.g. the SSH session dropped or the server restarted) — reconnect the PuTTY session. The localhost binding is by design; nothing in the server needs changing.
Command-line SSH (Linux/macOS/WSL) equivalent:
ssh -N -L 8080:127.0.0.1:8080 user@your-vpsSeveral servers on one machine, each with its own admin.port: one SSH
connection can carry all of them. In ~/.ssh/config:
Host ed2k
HostName your-vps
LocalForward 8081 127.0.0.1:8081
LocalForward 8082 127.0.0.1:8082
Instead of a tunnel, the panel can listen on another address with a login:
ed2k-server --hash-password # asks twice, prints the line below[admin]
enabled = true
port = 8080
bind = "0.0.0.0" # or the VPN / LAN address
password_hash = "pbkdf2-sha256$600000$…"Making password_hash. Run the server binary with --hash-password on any
machine (usually the VPS itself). It does not read config.toml, does not
touch a running server and needs no root:
./ed2k-server --hash-password
New admin password: # typed twice, not shown
Repeat:
password_hash = "pbkdf2-sha256$600000$…$…"
Put that line under [admin] in config.toml and restart the server.Copy the whole password_hash = "…" line under [admin] and restart the
server. The password must be at least 10 characters. Every run gives a
different hash for the same password (the salt is random); that is expected,
and any of them works. Several servers on one machine can share one hash or
have their own. The password itself is stored nowhere: to change it, make a new
hash and replace the line. From a script, the password can be fed on stdin
(one line, asked once):
read -rs PW && printf '%s\n' "$PW" | ./ed2k-server --hash-password; unset PWread -rs keeps the password out of the shell history and off the screen.
bind = "0.0.0.0" or the external IP? Either works for a login from
outside; 0.0.0.0 is the usual choice.
-
0.0.0.0listens on every address of the machine: the external IP, a VPN address and127.0.0.1. The SSH tunnel keeps working, and loopback stays exempt from the global login lock, so you always keep a way in. With HTTPS the certificate coversserver.this_ip(or the detected public address), loopback andtls_names. -
The external IP (
bind = "203.0.113.5") listens on that address only. The SSH tunnel to127.0.0.1:<port>then finds nothing; forward to the external address instead (-L 8080:203.0.113.5:8080). Use this when the panel must not answer on other addresses of the machine. -
A VPN address (
bind = "10.8.0.1") makes the panel reachable only through the VPN — the safest choice when you have one. -
On a cloud VPS whose interface has a private address behind 1:1 NAT, the external IP is not on the machine and cannot be bound: the eD2k server runs, but the panel stays off and the log says
admin web bind failed — UI disabled. Use0.0.0.0there.ip -4 addrshows which addresses the machine has. -
Several servers on one machine each need their own
admin.port;bindcan be the same for all of them. -
Whatever the bind, open the port in the firewall only to the addresses you manage from, if you can.
-
Hash. PBKDF2-HMAC-SHA256, random salt, 600 000 iterations — not the unsalted MD5 that some P2P web interfaces keep in their config. A leaked config does not give the password away cheaply.
-
Session. A login sets an
HttpOnly,SameSite=Strictcookie for 12 hours. Sessions are kept in memory, so a restart logs everybody out. -
Guessing. Five wrong passwords from one address (one /64 for IPv6) lock it out for ten minutes. Fifty wrong passwords from everywhere together stop remote logins for the rest of the ten minutes; the SSH tunnel (loopback) is never held by that, so you always have a way in. Password checks run one at a time, at most four wait, and they never slow down the eD2k side.
-
Behind a reverse proxy on the same host every request comes from 127.0.0.1: the per-address lockout then counts everybody together, and the server key is shown to everybody. Prefer the built-in HTTPS below.
-
Cross-site requests. A request that changes something (reload, config, update) and comes from another site is refused, with or without a login.
-
Server key. The server key, and the update access key derived from it, are shown only to requests from loopback.
-
Host check. The panel answers only when the browser addressed it by IP,
localhostor a name inadmin.tls_names(otherwise 421). This stops DNS rebinding, where a web page re-points its own domain at 127.0.0.1 and talks to the panel through your SSH tunnel. To open the panel by a domain name, put the name intls_names. -
Connection limits. At most 64 connections, 16 per address (per /64 for IPv6), a 10 s TLS handshake, 30 s for each request's headers, a login form of at most 4 KB within 10 s.
-
The SSH tunnel still works. With a password set it asks for the password too, so a reverse proxy on the same host cannot become a way around the login.
Without admin.tls the panel speaks plain HTTP. On anything but loopback
the password (at login) and the session cookie then travel unencrypted, and the
server logs a warning at startup saying so. Turn on HTTPS (below), or use a VPN
(WireGuard, Tailscale) and bind to its address.
[admin]
tls = "self-signed"On first start the server makes an ECDSA P-256 key and a certificate for its
address and writes them to admin-cert.pem / admin-key.pem next to the
config file (key mode 0600). It logs the certificate's SHA-256 fingerprint and
shows it on the Status tab:
admin web UI listening over HTTPS ... fingerprint=A3:8C:CB:...:62:D0
Open https://<server IP>:<admin.port>/. The browser warns once, because no
public authority vouches for the certificate. Open the certificate details,
check that the SHA-256 fingerprint matches the one in the log (read the log over
SSH, not over this connection), and accept. From then on the connection is
encrypted, and a different certificate in the middle would trigger the warning
again.
After that Chrome keeps showing "Not secure" with https struck through.
This only says that the certificate was accepted by hand, not issued by an
authority the browser knows; the traffic is encrypted all the same. What
matters is the fingerprint check the first time: someone in the middle at that
moment would have shown a different one. To get a normal padlock without a
domain, use your own CA (below).
- Addresses covered. The bind address, or
server.this_iponbind = "0.0.0.0", plus127.0.0.1,::1andlocalhostfor the SSH tunnel (https://127.0.0.1:8080/). Add a VPN address or a domain withtls_names = ["10.8.0.1"]. - Lifetime. 397 days. The server replaces the certificate 30 days before it ends, or when the addresses change, without a restart. A replaced self-signed certificate has a new fingerprint and the browser asks once more.
- The cookie is marked
Secureover HTTPS, so the browser never sends it in clear. - Plain HTTP on the HTTPS port is redirected:
http://<IP>:8080/answers with a 302 tohttps://<IP>:8080/on the same port (the first byte tells a TLS handshake from an HTTP request). OnlyGETandHEADwith aHostthe panel answers to are redirected; anything else gets a 400. Nothing but the redirect is ever sent in clear.
Several servers, no warnings: your own CA. Make a CA once:
ed2k-server --make-admin-ca /etc/ed2k-server/ca --admin-ca-names 203.0.113.7,10.8.0.0/24
# admin-ca-cert.pem, admin-ca-key.pem (0600) and the CA fingerprint--admin-ca-names lists what the CA may sign for: your servers' addresses,
networks in CIDR form, DNS names. Loopback is always included. The limit is
written into the CA certificate (X.509 name constraints), so browsers refuse
anything else it signs. An unconstrained CA imported as a trusted root would be
trusted for every site, and its key sits on your servers: whoever took one over
could impersonate any HTTPS site to your PC. Constrained, it can only vouch for
your own servers. A server whose certificate would need a name outside the list
refuses to start and says which.
Import admin-ca-cert.pem into the trusted roots of the PC you manage from
(Windows: certmgr.msc → Trusted Root Certification Authorities → Import;
Firefox keeps its own list: Settings → Privacy & Security → Certificates →
View Certificates → Authorities → Import). On each server:
[admin]
tls = "self-signed"
tls_ca_cert = "/etc/ed2k-server/ca/admin-ca-cert.pem"
tls_ca_key = "/etc/ed2k-server/ca/admin-ca-key.pem"Every server's certificate is then signed by that CA and opens without a
warning, renewals included. The CA lasts ten years. Keep admin-ca-key.pem as
private as an SSH key, and copy it only to servers you run yourself.
With a domain: tls = "files" and the paths certbot writes
(fullchain.pem, privkey.pem). The server re-reads them when they change.
The panel shows live status, connected clients, peers/servers, filter info and
per-range IP-filter hits, blocks, and memory metrics (RSS plus the non-evictable
in-use bytes and per-file cost). The Status tab also carries search counters
(ranking cap hits, unknown words dropped), IPv6 clients and publishers, the
HighID hello-check counters, and the busiest IP: the address with the most
logged-in clients behind it (a NAT or CGNAT), with its open connections, to
check limits.max_clients_per_ip against.
GET /api/memsize breaks memory down by structure. Per connection it reports
the codec buffers (clients_framed_buffers), the obfuscation buffers and keys
(clients_crypt_buffers), the connection tasks (clients_tasks_est: live
tasks × the size of one, measured at spawn) and the push channels of logged-in
clients (clients_push_channels_est).
The Clients tab shows each client's software with its version, as the
client reported it at login: eMule v0.70b, aMule v2.3.3, and the mod in
brackets when the client sends its name (eMule v0.50a [Xtreme 9.1]; stock
eMule and most mods send it only to other clients, not to the server, so the
server cannot show it). Hovering the software shows the shape of the login,
v=0x3c flags=0x0719 tags=01,11,20,fb: CT_VERSION, the server flags and the
tag IDs in the order sent, which tells apart programs that report the same
version. The Client Software chart lists the versions under each family.
The Clients tab searches on the server: free text matches an address, a
nick or a software version (case-insensitive), an IPv4 CIDR such as 82.48.0.0/16 matches by
network, and country, software and High/LowID narrow it further; results are
sorted and paged 100 at a time. The same filter is available as
GET /api/clients/search?q=&country=&software=&id=high|low&sort=connected|files|ip|nick|country|software&dir=asc|desc&offset=&limit=
(limit at most 500). GET /api/clients still returns every connected client
for scripts.
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
everyone at login broke some eMule mods (MorphCA 2.2, ZZUL-TRA 2.6), whose
release builds misread it and drop the connection.
The Peers tab shows each listed server's name, description and version (its
answer to UDP 0xA2), its user and file counts (0x96), and its country and
provider. Each
peer is asked at most once an hour on UDP TCP+4; a peer that answers only
obfuscated UDP shows "no answer".
The Bots tab shows, for each flagged source, its provider and what it sent since it was first seen: searches, source requests with the average number of file hashes per request, the share of obfuscated packets, its UDP port (and how often it changed) and the port of ours it wrote to. A UDP query carries no client name, so the tab also shows the TCP client logged in from the same address, if there is one, with its software version and nick. Behind a NAT that may be a different program than the one sending the queries.
A flood (over 600 qpm, sustained for bots.min_span_secs) is banned for
24 h. A "uniform timing" source is throttled for 24 h instead and still
answered at bots.throttle_qpm: that pattern turned out to be mostly one
client build with broken UDP pacing (about 567 qpm, one hash per packet, a
few dozen downloads), ordinary users. A source that keeps sending through its
ban is a scanner, not a client; past bots.ipfilter_min_dropped dropped
datagrams it is added to the IP filter file (see [bots]).
A detected bot's source requests keep being looked at after its ban, without being answered: the tab counts the datagrams dropped since the ban and when the last one came, and keeps the file hashes it asks for (the last 64, and up to 20 000 distinct). Clicking the count opens those files with their names, sizes and source counts from the index, and the other bots that asked for the same files. A program that checks a list of files one hash at a time, many times a second, and keeps going into a ban, is collecting who shares those files.
For keeping a record, GET /api/bots/export?format=csv (or JSON lines without
format) exports every detection since start, and every detection and every
ban end is appended to a ledger, bot-log.jsonl next to the config file by
default (storage.bot_log_path), which survives restarts and is served at
GET /api/bots/log?tail=N. Both hold hashes, not file names.
GET /api/bots/files lists every file the detected bots asked for since
start, as CSV (UTF-8, opens in Excel): hash, whether we index it, its name,
size and sources, and which bots asked for it, most-asked first.
?ip= keeps one bot, ?indexed=1 only the files we index, ?format=json
gives JSON lines. Links under the Bots table and in a bot's file list.
Length, bitrate and codec of offered files are stored and searchable, as in
Lugdunum: a side table of 8 bytes per media file (about one offer in seven
carries them), shown in /api/memsize as slab_media_cap. Artist, album
and title are not stored, by Lugdunum either; a search on them matches words
of the file name. GET /api/media_stats counts the media tags per offer since
start (how many records carry each, the distinct codecs) and what is stored
now (stored_files, stored_bytes, codec_table).
Country flags are drawn with a font built into the binary (a 78 KB Twemoji subset), because Windows has no flag glyphs and Chrome, Edge and Opera there 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, 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.
Released under the MIT License — see LICENSE.
- A clean-room, independent reimplementation inspired by the original Lugdunum eserver and the broader eMule / eDonkey2000 community.
- GeoIP and server-list data courtesy of emule-security.org and peerates.net.
- IP address data in
ipinfo_lite.mmdb, when used, is powered by IPinfo (CC BY-SA 4.0). - Country flags in the admin panel: Twemoji
graphics (CC-BY 4.0), as subset into the "Twemoji Country Flags" font by
country-flag-emoji-polyfill
(MIT); see
assets/TwemojiCountryFlags-LICENSE.md.