Go port of the original eNode eD2K/eMule server codebase.
Protocol doc: Server <=> Client Communication (OP_ meanings)
- Core eD2K protocol modules (
ed2k/*) - Runtime entrypoint (
cmd/enode/main.go) - YAML config loader (
config/*) - Storage engines:
memorymysql(realdatabase/sql+go-sql-driver/mysql)mongodb(real official MongoDB Go driver)
- Unit tests for core modules
- Dockertest integration tests for real MySQL/Mongo backends
- TCP/UDP opcodes
- TCP/UDP protocol obfuscation
- Obfuscated lowID detection
- Lugdunum/eMule extended protocol
- gzip compression
- LowID callbacks (IPv4, and IPv6 via
OP_CALLBACKREQUESTED_IPV6when the requester is only reachable over IPv6) - NAT traversal server (
OP_VC_NAT_HEADER,OP_NAT_REGISTER,OP_NAT_SYNC2): dual-stack UDP hole-punching for LowID↔LowID and firewalled IPv6↔IPv6 peers (a v4 and a v6 candidate per client, v6 preferred); gated bynatTraversal.ipv6(default on). The registry is login-independent, sonatTraversal.serverIndependent(default on) pairs clients across different servers or no server — cross-server / serverless LowID↔LowID — advertised viaSRV_TCPFLG_NAT_RENDEZVOUS (0x8000)+ anOP_SERVERIDENTNAT-port tag. Seedocs/ipv6-client-implementation-spec.md§9. - Files larger than 4 GiB
- Server-to-server gossip: the Lugdunum
OP_SERVER_LIST_REQ/REShandshake, soOP_SERVERLISTis populated from the live network instead of only a static list. Verified against the original eserver 17.14 binary running beside us on a Docker network — seedocs/server-gossip.mdfor the protocol anddocs/interop-docker-tests.mdfor the test rig. - Access filters:
ipfilter.datranges and MaxMind GeoIP country blocking, both dropping a refused address before any wire parsing. Seedocs/access-filters.md. - Admin dashboard: a self-contained tabbed HTML page served over HTTP with only the
Go standard library (zero third-party deps). The Status tab shows live client / file /
LowID / peer-server counts, listening ports, enabled features, version and uptime;
further tabs list clients, files, peer servers, filters, bans, automated queriers,
refusals, health, runtime, accounts and the effective configuration (secrets hidden).
Default on and bound to
127.0.0.1only; everything beyond the Status tab needs loopback oradmin.username/admin.password. Seedocs/admin-status-dashboard.md. A publicGET /healthzandGET /statusare served by the Meta API HTTP listener; seedocs/meta-api.md. - IPv6 dual-stack: accepts IPv6 client logins, records and verifies each client's
IPv6, publishes IPv6 sources, and advertises IPv6 peer servers in
OP_SERVERLIST(eMuleAI/eMuleQtCT_MOD_*compatible). Seedocs/ipv6-client-implementation-spec.md. Disable withipv6.enabled: falsefor the exact IPv4-only behaviour. - Memory-engine snapshots: the in-RAM index can be persisted to a gob file
periodically and on shutdown, and reloaded at startup, so a restart does not have
to re-learn the index from client re-offers. Off by default; restoring reproduces
what a mysql-backed server looks like after a restart. See
docs/storage-snapshot.md. - Per-client file publish limits: Lugdunum's
softLimit/hardLimit, now configurable and actually enforced. The soft limit is how many distinct files a client may publish at a time: past it a client is warned once and its new files are ignored. The hard limit is how many files one packet may declare: at it the client is told why and disconnected, which a stock eMule, sending 200 files per packet, never is. Seedocs/server-client-communication.md. - Torrent, Usenet, Kad and web results in eD2K search: keywords of every TCP and UDP
search are forwarded to the torrent-crawler / usenet-crawler / kademlia-crawler /
web-crawler daemons over their
MetaIngestservice (gRPC-compatible, via the sharedenodemetacontract), and their releases come back merged into the same reply. Torrent, Usenet and web rows use a pseudo-hash andFT_META_*tags, Kad rows are real eD2K files, and each carries a filename prefix ([torrent]/[usenet]/[kad]/[web]) so stock clients can tell them apart. Each network, its feed subscription and a result cache are optional and off by default. Seedocs/meta-search.md. - Login required before any request: every TCP opcode but
OP_LOGINREQUESTandOP_DISCONNECTis refused on a session that has not logged in, so nothing can reach the index without an identity the server can account for and clean up. Seedocs/server-client-communication.md. - Files identified by
(hash, size), as the protocol intends: all three storage engines key on the pair, so an offer carrying a known hash at a different size gets its own record instead of overwriting someone else's. A record with no usable size is refused outright — it could never be served. Seedocs/server-client-communication.md. - Easy support for multiple storage engines
Runtime config is YAML:
enode.config.yaml
Start command supports custom path:
go run ./cmd/enode -config enode.config.yamlFull config field reference:
name: "eNode-go" # Server name shown to clients
description: "emule-qt.org" # Server description shown to clients
address: "" # Local bind address; default is 0.0.0.0
dynIp: "auto" # Public advertised IP; auto resolves via testUrls
testUrls: # Used only when dynIp=auto, first valid IPv4 wins
- "https://4.ipw.cn"
- "https://ip.3322.net"
- "https://api.ipify.org"
- "https://checkip.amazonaws.com"
messageLowID: "You have LowID." # Message sent to LowID clients
messageLogin: |- # Message sent on login; may span several lines
Welcome to eNode-go!
An experimental ed2k server written in Go.
Get eMule-Qt, the modern open-source ed2k client: https://emule-qt.org/
servers: [] # Peer servers advertised in OP_SERVERLIST; empty is the default.
# - ip: "192.0.2.10" # ip may be IPv4 or a public IPv6 (v6 goes in the trailing v6 block)
# port: 4661
# - ip: "2001:db8::10"
# port: 4661
noAssert: false # Compatibility assert switch (normally false)
logLevel: "debug" # Log level: debug|info|warn|error
logFile: "logs/enode.log" # Log file path
supportCrypt: true # Enable protocol obfuscation support
requestCrypt: true # Request obfuscation from clients
requireCrypt: true # Require obfuscation from clients
auxiliarPort: false # Advertise auxiliary port capability
IPinLogin: false # Include IP in login flow
tcp:
port: 5555 # Main TCP port
portObfuscated: 5565 # Obfuscated TCP port (when supportCrypt=true)
maxConnections: 1000000 # Max concurrent connections
connectionTimeout: 2000 # Connect-stage timeout (ms)
disconnectTimeout: 3600 # Idle disconnect timeout (s)
loginTimeout: 60 # Seconds from accept to a completed login, not extended by traffic
maxConnectionsPerIP: 32 # Concurrent connections per address (IPv6 /64), both TCP ports; 0 = off
allowLowIDs: true # Allow LowID clients
minLowID: 1 # Minimum allocated LowID
maxLowID: 16777215 # Maximum allocated LowID
udp:
port: 5559 # Main UDP port (tcp.port + 4, where eMule pings the plaintext stat)
portObfuscated: 5567 # Obfuscated UDP port; must be tcp.port + 12 for the crypt-ping (see docs/server-udp-crypt-ping.md)
portGossip: 5569 # Obfuscated server-to-server source port / advertised portUDPOBF; tcp.port + 14 on its own socket, open it when gossip is enabled (see docs/server-gossip.md)
getSources: true # Enable UDP source queries
getFiles: true # Enable UDP file queries
serverKey: 0 # 0: per-client UDP obfuscation keys derive from a generated 128-bit secret in data/udp.secret; non-zero: a legacy 32-bit secret (see docs/server-udp-crypt-ping.md)
rateLimitPerIPPerMinute: 120 # UDP searches + source requests per address (IPv6 /64) per minute; 0 = off
files: # Per-client publish caps (see docs/server-client-communication.md)
softLimit: 10000 # Distinct files per client; new files past it are ignored and the client is warned once; 0 = unlimited
hardLimit: 20000 # Files per OP_OFFERFILES packet; at it the client is told why and disconnected; 0 = unlimited
natTraversal:
enabled: true # Enable NAT traversal service
port: 2004 # NAT traversal UDP port
ipv6: true # IPv6 hole-punching (dual-stack candidates); needs ipv6.enabled
serverIndependent: true # Cross-server / serverless LowID↔LowID rendezvous (default on)
registrationTTLSeconds: 30 # NAT registry TTL (seconds)
ipv6: # IPv6 dual-stack; omit the whole block for IPv4-only behaviour
enabled: true # Accept IPv6 logins and run the v6 code paths (default true)
address: "" # Explicit IPv6 bind; "" lets the top-level address govern
dynIp6: "auto" # Public IPv6, "auto" to resolve via testUrls6, or "" to not advertise
publishSources: true # Emit the IPv6 sentinel and honour the OP_*_IPV6 opcodes
probeReachability: true # Verify a client's IPv6 is reachable before publishing it
testUrls6: # Used only when dynIp6=auto, first valid IPv6 wins (temporary ones swapped for a stable one)
- "https://v6.ident.me"
- "https://api64.ipify.org"
admin: # Local admin dashboard (stdlib-only HTTP page)
enabled: true # Default on; an absent block still enables it
bindIP: "127.0.0.1" # Loopback only; "0.0.0.0" / "::" exposes it on all interfaces
port: 4560 # HTTP port
username: "" # Basic-auth login for non-loopback clients; set both or
password: "" # neither. Without them, off-box clients see the status tab only
storage:
engine: memory # Storage engine: memory | mysql | mongodb
cleanup: # Expire offline clients and their sources
enabled: true # Turn the periodic cleanup on
staleAfterHours: 24 # Age after which an offline client is removed
intervalMinutes: 60 # How often the cleanup sweep runs
keepZeroSourceFiles: true # Keep files whose last source went offline (default true)
batchSize: 1000 # Rows deleted per sweep batch
snapshot: # Persist the memory-engine index across restarts
enabled: false # Off by default; memory engine only
file: "data/storage.gob" # Written by the server, hence under data/
intervalMinutes: 15 # How often the snapshot is rewritten
compress: true # gzip the gob; the reader detects either form
mysql:
host: localhost # MySQL host
port: 3306 # MySQL port
user: enode # MySQL user
pass: password # MySQL password
database: enode # MySQL database
connections: 8 # MySQL connection pool cap
deadlockDelay: 100 # Base deadlock retry delay (ms), doubled per retry with jitter, 6 retries
schemaFile: misc/enode.sql # DDL applied on first connect when tables are missing
dialect: mariadb # Full-text search: mariadb (portable) | mysql (ngram substring)
mongodb:
host: 127.0.0.1 # MongoDB host (used when uri is empty)
port: 27017 # MongoDB port (used when uri is empty)
database: enode # MongoDB database name
uri: "" # MongoDB URI; takes precedence when setaddress vs dynIp:
addresscontrols local bind/listen address on this machine.dynIpcontrols the public IP advertised to clients.
For MySQL:
storage:
engine: mysql
mysql:
host: localhost
port: 3306
user: enode
pass: password
database: enode
connections: 8For MongoDB:
storage:
engine: mongodb
mongodb:
uri: mongodb://localhost:27017
database: enodeDownload update.sh
into an empty directory and run bash update.sh. Arguments are passed on to enode, e.g. bash update.sh -daemon to start
in the background or bash update.sh -config config.yaml for another config file.
It installs the latest release for your platform and updates that install on later runs.
When enode is already running from that directory, an update does not disconnect anyone:
the running server hands every connection and its index to the new binary, which carries
on serving them (Linux and macOS). kill -USR2 <pid> does the same by hand. See
docs/server-handoff.md.
The meta search contract module (enodemeta) is a git submodule, so clone with
git clone --recurse-submodules, or run git submodule update --init in an existing
checkout. Go 1.25 or newer.
Run all standard tests:
go test ./...Run real DB integration tests (Docker required):
ENODE_INTEGRATION=1 go test ./storage -run Dockertest -vThis spins temporary MySQL and MongoDB containers, initializes schema/data, and verifies backend behavior end-to-end.
- David Xanatos
- The original Node.js repository remains separate; this directory is the Go implementation.
- If Docker is unavailable, integration tests are skipped unless explicitly enabled.
