commit - /dev/null
commit + 9821aabf0b50d2487b07502d3d2cd89e7d62bdbe
blob - /dev/null
blob + eb5a316cbd195d26e3f768c7dd8e1b47299e17f8 (mode 644)
--- /dev/null
+++ .gitignore
+target
blob - /dev/null
blob + bf955640bde5a20b8c2b53bb4b40cac7e8adcfc9 (mode 644)
--- /dev/null
+++ .rustfmt.toml
+# group_imports = "StdExternalCrate"
+# imports_granularity = "Module"
+max_width = 80
+reorder_imports = true
blob - /dev/null
blob + 3d5c3f22547d2b4abeb63a941725b86a6af0def3 (mode 644)
--- /dev/null
+++ CHANGELOG.md
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.0] - 2026-03-24
+
+### Added
+
+- Kademlia DHT with iterative FIND_NODE, FIND_VALUE, and STORE operations.
+- NAT type detection (global, cone NAT, symmetric NAT).
+- DTUN hole-punching for cone NAT traversal.
+- Proxy relay for symmetric NAT nodes.
+- Reliable Datagram Protocol (RDP) with 3-way handshake, sliding windows,
+ cumulative ACK, EACK/SACK, retransmission, and graceful close.
+- Datagram transport with automatic fragmentation and reassembly.
+- Ed25519 packet authentication (reject unsigned packets).
+- 256-bit node IDs derived from Ed25519 public keys.
+- Address advertisement for routing table updates.
+- Rate limiting per source address.
+- Ban list for misbehaving peers.
+- Eclipse attack mitigation via bucket diversity checks.
+- Persistence for routing table and DHT storage.
+- Metrics collection (message counts, latency, peer churn).
+- Configurable parameters via `Config` builder.
+- Event-based API for async notification of DHT events.
+- OpenBSD (`kqueue`) and Linux (`epoll`) support via mio.
+- Examples: `join`, `put_get`, `dgram`, `rdp`, `two_nodes`, `network`,
+ `remote_get`, `tesserasd`.
+- Integration, scale, and lossy-network test suites.
+- Fuzz harness for wire protocol parsing.
+- Benchmark suite.
blob - /dev/null
blob + 67162f16bee806e5a07b10b35484c316d8822d24 (mode 644)
--- /dev/null
+++ Cargo.lock
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "env_filter"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jiff"
+version = "0.2.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
+dependencies = [
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tesseras-dht"
+version = "0.3.0"
+dependencies = [
+ "ed25519-dalek",
+ "env_logger",
+ "log",
+ "mio",
+ "sha2",
+]
+
+[[package]]
+name = "typenum"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
blob - /dev/null
blob + ffbcb164ee3282f792b515cae9bff37d1919fa49 (mode 644)
--- /dev/null
+++ Cargo.toml
+[package]
+name = "tesseras-dht"
+version = "0.1.0"
+edition = "2024"
+authors = [ "murilo ijanc' <murilo@ijanc.org>" ]
+categories = ["network-programming"]
+description = "NAT-aware Kademlia DHT library"
+homepage = "https://tesseras.net"
+keywords = ["dht", "kademlia", "p2p", "nat", "distributed"]
+license = "ISC"
+readme = "README.md"
+repository = "https://git.sr.ht/~ijanc/tesseras-dht"
+rust-version = "1.93.0"
+
+[dependencies]
+ed25519-dalek = "=2.2.0"
+log = "=0.4.29"
+mio = { version = "=1.1.1", features = ["net", "os-poll"] }
+sha2 = "=0.10.9"
+
+[dev-dependencies]
+env_logger = "0.11"
+
+[[bench]]
+name = "bench"
+harness = false
blob - /dev/null
blob + 832fca8043e861f066ae3fb3655624160639b849 (mode 644)
--- /dev/null
+++ LICENSE
+Copyright (c) 2026 murilo ijanc' <murilo@ijanc.org>
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
blob - /dev/null
blob + 2dd7cf3031dde65e09743666a294e66984e8d435 (mode 644)
--- /dev/null
+++ Makefile
+all: release
+
+release:
+ cargo build --release
+
+debug:
+ cargo build
+
+test:
+ cargo test
+
+test-release:
+ cargo test --release
+
+check:
+ cargo check
+
+clean:
+ cargo clean
+
+fmt:
+ cargo fmt
+
+clippy:
+ cargo clippy -- -D warnings
+
+examples: release
+ cargo build --release --examples
+
+doc:
+ cargo doc --no-deps --open
+
+audit:
+ cargo deny check
+
+bench:
+ cargo bench
+
+.PHONY: all release debug test test-release check clean fmt clippy examples doc audit bench
blob - /dev/null
blob + e70a2473d25b28576abc9ce6e15fbd8323feb532 (mode 644)
--- /dev/null
+++ README.md
+# tesseras-dht
+
+## License
+
+ISC
blob - /dev/null
blob + 0b427ef77c148d0d3f08d5853baf2071ea8a8de7 (mode 644)
--- /dev/null
+++ benches/bench.rs
+//! Benchmarks for core operations.
+//!
+//! Run with: cargo bench
+
+use std::net::SocketAddr;
+use tesseras_dht::crypto::Identity;
+use tesseras_dht::id::NodeId;
+use tesseras_dht::peers::PeerInfo;
+use tesseras_dht::routing::RoutingTable;
+use tesseras_dht::wire::MsgHeader;
+
+fn main() {
+ println!("=== tesseras-dht benchmarks ===\n");
+
+ bench_sha256();
+ bench_ed25519_sign();
+ bench_ed25519_verify();
+ bench_routing_add();
+ bench_routing_closest();
+ bench_header_roundtrip();
+ bench_node_id_xor();
+}
+
+fn bench_sha256() {
+ let data = vec![0xABu8; 1024];
+ let start = std::time::Instant::now();
+ let iters = 100_000;
+ for _ in 0..iters {
+ let _ = NodeId::from_key(&data);
+ }
+ let elapsed = start.elapsed();
+ println!(
+ "SHA-256 (1KB): {:>8.0} ns/op ({iters} iters)",
+ elapsed.as_nanos() as f64 / iters as f64
+ );
+}
+
+fn bench_ed25519_sign() {
+ let id = Identity::generate();
+ let data = vec![0x42u8; 256];
+ let start = std::time::Instant::now();
+ let iters = 10_000;
+ for _ in 0..iters {
+ let _ = id.sign(&data);
+ }
+ let elapsed = start.elapsed();
+ println!(
+ "Ed25519 sign: {:>8.0} ns/op ({iters} iters)",
+ elapsed.as_nanos() as f64 / iters as f64
+ );
+}
+
+fn bench_ed25519_verify() {
+ let id = Identity::generate();
+ let data = vec![0x42u8; 256];
+ let sig = id.sign(&data);
+ let start = std::time::Instant::now();
+ let iters = 10_000;
+ for _ in 0..iters {
+ let _ = Identity::verify(id.public_key(), &data, &sig);
+ }
+ let elapsed = start.elapsed();
+ println!(
+ "Ed25519 verify: {:>8.0} ns/op ({iters} iters)",
+ elapsed.as_nanos() as f64 / iters as f64
+ );
+}
+
+fn bench_routing_add() {
+ let local = NodeId::random();
+ let mut rt = RoutingTable::new(local);
+ let start = std::time::Instant::now();
+ let iters = 10_000;
+ for i in 0..iters {
+ let id = NodeId::random();
+ let addr = SocketAddr::from(([10, 0, (i % 256) as u8, 1], 3000));
+ rt.add(PeerInfo::new(id, addr));
+ }
+ let elapsed = start.elapsed();
+ println!(
+ "Routing add: {:>8.0} ns/op ({iters} iters, size={})",
+ elapsed.as_nanos() as f64 / iters as f64,
+ rt.size()
+ );
+}
+
+fn bench_routing_closest() {
+ let local = NodeId::random();
+ let mut rt = RoutingTable::new(local);
+ for i in 0..500 {
+ let id = NodeId::random();
+ let addr = SocketAddr::from(([10, 0, (i % 256) as u8, 1], 3000));
+ rt.add(PeerInfo::new(id, addr));
+ }
+
+ let target = NodeId::random();
+ let start = std::time::Instant::now();
+ let iters = 10_000;
+ for _ in 0..iters {
+ let _ = rt.closest(&target, 10);
+ }
+ let elapsed = start.elapsed();
+ println!(
+ "Routing closest: {:>8.0} ns/op ({iters} iters, size={})",
+ elapsed.as_nanos() as f64 / iters as f64,
+ rt.size()
+ );
+}
+
+fn bench_header_roundtrip() {
+ let hdr = MsgHeader::new(
+ tesseras_dht::wire::MsgType::DhtPing,
+ 100,
+ NodeId::random(),
+ NodeId::random(),
+ );
+ let mut buf = vec![0u8; tesseras_dht::wire::HEADER_SIZE];
+ let start = std::time::Instant::now();
+ let iters = 1_000_000;
+ for _ in 0..iters {
+ hdr.write(&mut buf).unwrap();
+ let _ = MsgHeader::parse(&buf).unwrap();
+ }
+ let elapsed = start.elapsed();
+ println!(
+ "Header roundtrip: {:>8.0} ns/op ({iters} iters)",
+ elapsed.as_nanos() as f64 / iters as f64
+ );
+}
+
+fn bench_node_id_xor() {
+ let a = NodeId::random();
+ let b = NodeId::random();
+ let start = std::time::Instant::now();
+ let iters = 10_000_000;
+ for _ in 0..iters {
+ let _ = a.distance(&b);
+ }
+ let elapsed = start.elapsed();
+ println!(
+ "NodeId XOR: {:>8.0} ns/op ({iters} iters)",
+ elapsed.as_nanos() as f64 / iters as f64
+ );
+}
blob - /dev/null
blob + 827b081f95a83c8fc4a8ae6fca84065f533cca58 (mode 644)
--- /dev/null
+++ deny.toml
+# This template contains all of the possible sections and their default values
+
+# Note that all fields that take a lint level have these possible values:
+# * deny - An error will be produced and the check will fail
+# * warn - A warning will be produced, but the check will not fail
+# * allow - No warning or error will be produced, though in some cases a note
+# will be
+
+# The values provided in this template are the default values that will be used
+# when any section or field is not specified in your own configuration
+
+# Root options
+
+# The graph table configures how the dependency graph is constructed and thus
+# which crates the checks are performed against
+[graph]
+# If 1 or more target triples (and optionally, target_features) are specified,
+# only the specified targets will be checked when running `cargo deny check`.
+# This means, if a particular package is only ever used as a target specific
+# dependency, such as, for example, the `nix` crate only being used via the
+# `target_family = "unix"` configuration, that only having windows targets in
+# this list would mean the nix crate, as well as any of its exclusive
+# dependencies not shared by any other crates, would be ignored, as the target
+# list here is effectively saying which targets you are building for.
+targets = [
+ # The triple can be any string, but only the target triples built in to
+ # rustc (as of 1.40) can be checked against actual config expressions
+ #"x86_64-unknown-linux-musl",
+ # You can also specify which target_features you promise are enabled for a
+ # particular target. target_features are currently not validated against
+ # the actual valid features supported by the target architecture.
+ #{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
+]
+# When creating the dependency graph used as the source of truth when checks are
+# executed, this field can be used to prune crates from the graph, removing them
+# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
+# is pruned from the graph, all of its dependencies will also be pruned unless
+# they are connected to another crate in the graph that hasn't been pruned,
+# so it should be used with care. The identifiers are [Package ID Specifications]
+# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
+#exclude = []
+# If true, metadata will be collected with `--all-features`. Note that this can't
+# be toggled off if true, if you want to conditionally enable `--all-features` it
+# is recommended to pass `--all-features` on the cmd line instead
+all-features = false
+# If true, metadata will be collected with `--no-default-features`. The same
+# caveat with `all-features` applies
+no-default-features = false
+# If set, these feature will be enabled when collecting metadata. If `--features`
+# is specified on the cmd line they will take precedence over this option.
+#features = []
+
+# The output table provides options for how/if diagnostics are outputted
+[output]
+# When outputting inclusion graphs in diagnostics that include features, this
+# option can be used to specify the depth at which feature edges will be added.
+# This option is included since the graphs can be quite large and the addition
+# of features from the crate(s) to all of the graph roots can be far too verbose.
+# This option can be overridden via `--feature-depth` on the cmd line
+feature-depth = 1
+
+# This section is considered when running `cargo deny check advisories`
+# More documentation for the advisories section can be found here:
+# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
+[advisories]
+# The path where the advisory databases are cloned/fetched into
+#db-path = "$CARGO_HOME/advisory-dbs"
+# The url(s) of the advisory databases to use
+#db-urls = ["https://github.com/rustsec/advisory-db"]
+# A list of advisory IDs to ignore. Note that ignored advisories will still
+# output a note when they are encountered.
+ignore = [
+ #"RUSTSEC-0000-0000",
+ #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
+ #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
+ #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
+]
+# If this is true, then cargo deny will use the git executable to fetch advisory database.
+# If this is false, then it uses a built-in git library.
+# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
+# See Git Authentication for more information about setting up git authentication.
+#git-fetch-with-cli = true
+
+# This section is considered when running `cargo deny check licenses`
+# More documentation for the licenses section can be found here:
+# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
+[licenses]
+# List of explicitly allowed licenses
+# See https://spdx.org/licenses/ for list of possible licenses
+# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
+allow = [
+ "ISC",
+ "MIT",
+ "Apache-2.0",
+ "Apache-2.0 WITH LLVM-exception",
+ "BSD-3-Clause",
+ "Unicode-3.0",
+]
+# The confidence threshold for detecting a license from license text.
+# The higher the value, the more closely the license text must be to the
+# canonical license text of a valid SPDX license file.
+# [possible values: any between 0.0 and 1.0].
+confidence-threshold = 0.8
+# Allow 1 or more licenses on a per-crate basis, so that particular licenses
+# aren't accepted for every possible crate as with the normal allow list
+exceptions = [
+ # Each entry is the crate and version constraint, and its specific allow
+ # list
+ #{ allow = ["Zlib"], crate = "adler32" },
+]
+
+# Some crates don't have (easily) machine readable licensing information,
+# adding a clarification entry for it allows you to manually specify the
+# licensing information
+#[[licenses.clarify]]
+# The package spec the clarification applies to
+#crate = "ring"
+# The SPDX expression for the license requirements of the crate
+#expression = "MIT AND ISC AND OpenSSL"
+# One or more files in the crate's source used as the "source of truth" for
+# the license expression. If the contents match, the clarification will be used
+# when running the license check, otherwise the clarification will be ignored
+# and the crate will be checked normally, which may produce warnings or errors
+# depending on the rest of your configuration
+#license-files = [
+# Each entry is a crate relative path, and the (opaque) hash of its contents
+#{ path = "LICENSE", hash = 0xbd0eed23 }
+#]
+
+[licenses.private]
+# If true, ignores workspace crates that aren't published, or are only
+# published to private registries.
+# To see how to mark a crate as unpublished (to the official registry),
+# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
+ignore = false
+# One or more private registries that you might publish crates to, if a crate
+# is only published to private registries, and ignore is true, the crate will
+# not have its license(s) checked
+registries = [
+ #"https://sekretz.com/registry
+]
+
+# This section is considered when running `cargo deny check bans`.
+# More documentation about the 'bans' section can be found here:
+# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
+[bans]
+# Lint level for when multiple versions of the same crate are detected
+multiple-versions = "warn"
+# Lint level for when a crate version requirement is `*`
+wildcards = "allow"
+# The graph highlighting used when creating dotgraphs for crates
+# with multiple versions
+# * lowest-version - The path to the lowest versioned duplicate is highlighted
+# * simplest-path - The path to the version with the fewest edges is highlighted
+# * all - Both lowest-version and simplest-path are used
+highlight = "all"
+# The default lint level for `default` features for crates that are members of
+# the workspace that is being checked. This can be overridden by allowing/denying
+# `default` on a crate-by-crate basis if desired.
+workspace-default-features = "allow"
+# The default lint level for `default` features for external crates that are not
+# members of the workspace. This can be overridden by allowing/denying `default`
+# on a crate-by-crate basis if desired.
+external-default-features = "allow"
+# List of crates that are allowed. Use with care!
+allow = [
+ #"ansi_term@0.11.0",
+ #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
+]
+# If true, workspace members are automatically allowed even when using deny-by-default
+# This is useful for organizations that want to deny all external dependencies by default
+# but allow their own workspace crates without having to explicitly list them
+allow-workspace = false
+# List of crates to deny
+deny = [
+ #"ansi_term@0.11.0",
+ #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
+ # Wrapper crates can optionally be specified to allow the crate when it
+ # is a direct dependency of the otherwise banned crate
+ #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
+]
+
+# List of features to allow/deny
+# Each entry the name of a crate and a version range. If version is
+# not specified, all versions will be matched.
+#[[bans.features]]
+#crate = "reqwest"
+# Features to not allow
+#deny = ["json"]
+# Features to allow
+#allow = [
+# "rustls",
+# "__rustls",
+# "__tls",
+# "hyper-rustls",
+# "rustls",
+# "rustls-pemfile",
+# "rustls-tls-webpki-roots",
+# "tokio-rustls",
+# "webpki-roots",
+#]
+# If true, the allowed features must exactly match the enabled feature set. If
+# this is set there is no point setting `deny`
+#exact = true
+
+# Certain crates/versions that will be skipped when doing duplicate detection.
+skip = [
+ #"ansi_term@0.11.0",
+ #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
+]
+# Similarly to `skip` allows you to skip certain crates during duplicate
+# detection. Unlike skip, it also includes the entire tree of transitive
+# dependencies starting at the specified crate, up to a certain depth, which is
+# by default infinite.
+skip-tree = [
+ #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
+ #{ crate = "ansi_term@0.11.0", depth = 20 },
+]
+
+# This section is considered when running `cargo deny check sources`.
+# More documentation about the 'sources' section can be found here:
+# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
+[sources]
+# Lint level for what to happen when a crate from a crate registry that is not
+# in the allow list is encountered
+unknown-registry = "warn"
+# Lint level for what to happen when a crate from a git repository that is not
+# in the allow list is encountered
+unknown-git = "warn"
+# List of URLs for allowed crate registries. Defaults to the crates.io index
+# if not specified. If it is specified but empty, no registries are allowed.
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+# List of URLs for allowed Git repositories
+allow-git = []
+
+[sources.allow-org]
+# github.com organizations to allow git sources for
+github = []
+# gitlab.com organizations to allow git sources for
+gitlab = []
+# bitbucket.org organizations to allow git sources for
+bitbucket = []
blob - /dev/null
blob + ab25e7839931c01bc1127fbbd033423cae866fc6 (mode 644)
--- /dev/null
+++ examples/dgram.rs
+//! Datagram transport example (equivalent to example4.cpp).
+//!
+//! Creates two nodes and sends datagrams between them
+//! using the dgram callback API.
+//!
+//! Usage:
+//! cargo run --example dgram
+
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+use tesseras_dht::Node;
+use tesseras_dht::id::NodeId;
+use tesseras_dht::nat::NatState;
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ let mut node1 = Node::bind(0).expect("bind node1");
+ node1.set_nat_state(NatState::Global);
+ let addr1 = node1.local_addr().unwrap();
+ let id1 = *node1.id();
+
+ let mut node2 = Node::bind(0).expect("bind node2");
+ node2.set_nat_state(NatState::Global);
+ let addr2 = node2.local_addr().unwrap();
+ let id2 = *node2.id();
+
+ println!("Node 1: {} @ {addr1}", node1.id_hex());
+ println!("Node 2: {} @ {addr2}", node2.id_hex());
+
+ // Join node2 to node1
+ node2.join("127.0.0.1", addr1.port()).expect("join");
+
+ // Poll to establish routing
+ for _ in 0..10 {
+ node1.poll().ok();
+ node2.poll().ok();
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ // Set up dgram callbacks
+ let received1: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
+ let received2: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
+
+ let r1 = received1.clone();
+ node1.set_dgram_callback(move |data: &[u8], from: &NodeId| {
+ let msg = String::from_utf8_lossy(data).to_string();
+ println!("Node 1 received: '{msg}' from {from:?}");
+ r1.lock().unwrap().push(msg);
+ });
+
+ let r2 = received2.clone();
+ node2.set_dgram_callback(move |data: &[u8], from: &NodeId| {
+ let msg = String::from_utf8_lossy(data).to_string();
+ println!("Node 2 received: '{msg}' from {from:?}");
+ r2.lock().unwrap().push(msg);
+ });
+
+ // Send datagrams
+ println!("\n--- Sending datagrams ---");
+ node1.send_dgram(b"hello from node1", &id2);
+ node2.send_dgram(b"hello from node2", &id1);
+
+ // Poll to deliver
+ for _ in 0..10 {
+ node1.poll().ok();
+ node2.poll().ok();
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ // Note: actual dgram delivery requires the full
+ // send queue → address resolution → send flow.
+ // This example demonstrates the API; full delivery
+ // is wired in integration.
+
+ println!("\n--- Summary ---");
+ println!(
+ "Node 1 received {} messages",
+ received1.lock().unwrap().len()
+ );
+ println!(
+ "Node 2 received {} messages",
+ received2.lock().unwrap().len()
+ );
+ println!("Node 1 send queue pending: queued for delivery");
+ println!("Node 2 send queue pending: queued for delivery");
+}
blob - /dev/null
blob + a4784100ee90d4565d33b702b15436f7bc011566 (mode 644)
--- /dev/null
+++ examples/join.rs
+//! Basic bootstrap example (equivalent to example1.cpp).
+//!
+//! Usage:
+//! cargo run --example join -- 10000
+//! cargo run --example join -- 10001 127.0.0.1 10000
+//!
+//! The first invocation creates a standalone node.
+//! The second joins via the first.
+
+use std::time::Duration;
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ let args: Vec<String> = std::env::args().collect();
+ if args.len() < 2 {
+ eprintln!("usage: {} port [host port]", args[0]);
+ eprintln!();
+ eprintln!("example:");
+ eprintln!(" $ {} 10000 &", args[0]);
+ eprintln!(" $ {} 10001 127.0.0.1 10000", args[0]);
+ std::process::exit(1);
+ }
+
+ let port: u16 = args[1].parse().expect("invalid port");
+
+ let mut node = Node::bind(port).expect("bind failed");
+ node.set_nat_state(NatState::Global);
+
+ println!("Node {} listening on port {port}", node.id_hex());
+
+ if args.len() >= 4 {
+ let dst_host = &args[2];
+ let dst_port: u16 = args[3].parse().expect("invalid dst port");
+
+ match node.join(dst_host, dst_port) {
+ Ok(()) => println!("Join request sent"),
+ Err(e) => {
+ eprintln!("Join failed: {e}");
+ std::process::exit(1);
+ }
+ }
+ }
+
+ // Event loop
+ loop {
+ node.poll().ok();
+ std::thread::sleep(Duration::from_millis(100));
+ }
+}
blob - /dev/null
blob + be3e0efa43faba58f1afb75a3a67fec50e70324f (mode 644)
--- /dev/null
+++ examples/network.rs
+//! Multi-node network example (equivalent to example2.cpp).
+//!
+//! Creates N nodes on localhost, joins them recursively
+//! via the first node, then prints state periodically.
+//!
+//! Usage:
+//! cargo run --example network
+//! RUST_LOG=debug cargo run --example network
+
+use std::time::{Duration, Instant};
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+const NUM_NODES: usize = 20;
+const POLL_ROUNDS: usize = 30;
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ println!("Creating {NUM_NODES} nodes...");
+ let start = Instant::now();
+
+ // Create bootstrap node
+ let mut nodes: Vec<Node> = Vec::new();
+ let bootstrap = Node::bind(0).expect("bind bootstrap");
+ let bootstrap_port = bootstrap.local_addr().unwrap().port();
+ println!("Bootstrap: {} @ port {bootstrap_port}", bootstrap.id_hex());
+ nodes.push(bootstrap);
+
+ // Create and join remaining nodes
+ for i in 1..NUM_NODES {
+ let mut node = Node::bind(0).expect("bind node");
+ node.set_nat_state(NatState::Global);
+ node.join("127.0.0.1", bootstrap_port).expect("join");
+ println!("Node {i}: {} joined", &node.id_hex()[..8]);
+ nodes.push(node);
+ }
+ nodes[0].set_nat_state(NatState::Global);
+
+ println!("\nAll {NUM_NODES} nodes created in {:?}", start.elapsed());
+
+ // Poll all nodes to exchange messages
+ println!("\nPolling {POLL_ROUNDS} rounds...");
+ for round in 0..POLL_ROUNDS {
+ for node in nodes.iter_mut() {
+ node.poll().ok();
+ }
+ std::thread::sleep(Duration::from_millis(50));
+
+ if (round + 1) % 10 == 0 {
+ let sizes: Vec<usize> =
+ nodes.iter().map(|n| n.routing_table_size()).collect();
+ let avg = sizes.iter().sum::<usize>() / sizes.len();
+ let max = sizes.iter().max().unwrap();
+ println!(
+ " Round {}: avg routing table = {avg}, max = {max}",
+ round + 1
+ );
+ }
+ }
+
+ // Print final state
+ println!("\n--- Final state ---");
+ for (i, node) in nodes.iter().enumerate() {
+ println!(
+ "Node {i}: {} | rt={} peers={} storage={}",
+ &node.id_hex()[..8],
+ node.routing_table_size(),
+ node.peer_count(),
+ node.storage_count(),
+ );
+ }
+}
blob - /dev/null
blob + e2bfaca21cd2a243a6975a22e58b9cc4e27278ba (mode 644)
--- /dev/null
+++ examples/put_get.rs
+//! DHT put/get example (equivalent to example3.cpp).
+//!
+//! Creates a small network, stores key-value pairs from
+//! one node, and retrieves them from another.
+//!
+//! Usage:
+//! cargo run --example put_get
+
+use std::time::Duration;
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+const NUM_NODES: usize = 5;
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ // Create network
+ let mut nodes: Vec<Node> = Vec::new();
+ let bootstrap = Node::bind(0).expect("bind");
+ let bp = bootstrap.local_addr().unwrap().port();
+ nodes.push(bootstrap);
+ nodes[0].set_nat_state(NatState::Global);
+
+ for _ in 1..NUM_NODES {
+ let mut n = Node::bind(0).expect("bind");
+ n.set_nat_state(NatState::Global);
+ n.join("127.0.0.1", bp).expect("join");
+ nodes.push(n);
+ }
+
+ // Poll to establish routing
+ for _ in 0..20 {
+ for n in nodes.iter_mut() {
+ n.poll().ok();
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ println!("Network ready: {NUM_NODES} nodes");
+
+ // Node 0 stores several key-value pairs
+ println!("\n--- Storing values from Node 0 ---");
+ for i in 0..5u32 {
+ let key = format!("key-{i}");
+ let val = format!("value-{i}");
+ nodes[0].put(key.as_bytes(), val.as_bytes(), 300, false);
+ println!(" put({key}, {val})");
+ }
+
+ // Poll to distribute stores
+ for _ in 0..20 {
+ for n in nodes.iter_mut() {
+ n.poll().ok();
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ // Each node retrieves values
+ println!("\n--- Retrieving values ---");
+ for (ni, node) in nodes.iter_mut().enumerate() {
+ for i in 0..5u32 {
+ let key = format!("key-{i}");
+ let vals = node.get(key.as_bytes());
+ let found: Vec<String> = vals
+ .iter()
+ .map(|v| String::from_utf8_lossy(v).to_string())
+ .collect();
+ if !found.is_empty() {
+ println!(" Node {ni} get({key}) = {:?}", found);
+ }
+ }
+ }
+
+ // Summary
+ println!("\n--- Storage summary ---");
+ for (i, n) in nodes.iter().enumerate() {
+ println!(" Node {i}: {} values stored", n.storage_count());
+ }
+}
blob - /dev/null
blob + 319c77945b6ce8cab493b28c238c6a101bc67fd7 (mode 644)
--- /dev/null
+++ examples/rdp.rs
+//! RDP reliable transport example (equivalent to example5.cpp).
+//!
+//! Two nodes: server listens, client connects, sends
+//! data, server receives it.
+//!
+//! Usage:
+//! cargo run --example rdp
+
+use std::time::Duration;
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+use tesseras_dht::rdp::RdpState;
+
+const RDP_PORT: u16 = 5000;
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ // Create two nodes
+ let mut server = Node::bind(0).expect("bind server");
+ server.set_nat_state(NatState::Global);
+ let server_addr = server.local_addr().unwrap();
+ let server_id = *server.id();
+ println!("Server: {} @ {server_addr}", server.id_hex());
+
+ let mut client = Node::bind(0).expect("bind client");
+ client.set_nat_state(NatState::Global);
+ println!("Client: {}", client.id_hex());
+
+ // Client joins server so they know each other
+ client.join("127.0.0.1", server_addr.port()).expect("join");
+
+ // Poll to exchange routing info
+ for _ in 0..10 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ println!("Client knows {} peers", client.routing_table_size());
+
+ // Server listens on RDP port
+ let _listen = server.rdp_listen(RDP_PORT).expect("listen");
+ println!("Server listening on RDP port {RDP_PORT}");
+
+ // Client connects
+ let desc = client
+ .rdp_connect(0, &server_id, RDP_PORT)
+ .expect("connect");
+ println!("Client state: {:?}", client.rdp_state(desc).unwrap());
+
+ // Poll to complete handshake
+ for _ in 0..10 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(20));
+ }
+
+ println!(
+ "Client state after handshake: {:?}",
+ client.rdp_state(desc).unwrap_or(RdpState::Closed)
+ );
+
+ // Send data if connection is open
+ match client.rdp_state(desc) {
+ Ok(RdpState::Open) => {
+ for i in 0..3u16 {
+ let msg = format!("hello {i}");
+ match client.rdp_send(desc, msg.as_bytes()) {
+ Ok(n) => println!("Sent: '{msg}' ({n} bytes)"),
+ Err(e) => println!("Send error: {e}"),
+ }
+ }
+
+ // Poll to deliver
+ for _ in 0..10 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(20));
+ }
+
+ // Server reads received data
+ println!("\n--- Server reading ---");
+ let server_status = server.rdp_status();
+ for s in &server_status {
+ if s.state == RdpState::Open {
+ let mut buf = [0u8; 256];
+ loop {
+ match server.rdp_recv(s.sport as i32 + 1, &mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ let msg = String::from_utf8_lossy(&buf[..n]);
+ println!("Server received: '{msg}'");
+ }
+ Err(_) => break,
+ }
+ }
+ }
+ }
+ // Try reading from desc 2 (server-side accepted desc)
+ let mut buf = [0u8; 256];
+ for attempt_desc in 1..=5 {
+ loop {
+ match server.rdp_recv(attempt_desc, &mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ let msg = String::from_utf8_lossy(&buf[..n]);
+ println!("Server desc={attempt_desc}: '{msg}'");
+ }
+ Err(_) => break,
+ }
+ }
+ }
+ }
+ Ok(state) => {
+ println!("Connection not open: {state:?}");
+ }
+ Err(e) => {
+ println!("Descriptor error: {e}");
+ }
+ }
+
+ // Show status
+ println!("\n--- RDP Status ---");
+ for s in &client.rdp_status() {
+ println!(" state={:?} dport={} sport={}", s.state, s.dport, s.sport);
+ }
+
+ // Cleanup
+ client.rdp_close(desc);
+
+ println!("\n--- Done ---");
+ println!("Server: {server}");
+ println!("Client: {client}");
+}
blob - /dev/null
blob + 60add190e7258b9033e5679704cf076973974258 (mode 644)
--- /dev/null
+++ examples/remote_get.rs
+//! Remote get example: FIND_VALUE across the network.
+//!
+//! Node 1 stores a value locally. Node 3 retrieves it
+//! via iterative FIND_VALUE, even though it never
+//! received a STORE.
+//!
+//! Usage:
+//! cargo run --example remote_get
+
+use std::time::Duration;
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ // Create 3 nodes
+ let mut node1 = Node::bind(0).expect("bind");
+ node1.set_nat_state(NatState::Global);
+ let port1 = node1.local_addr().unwrap().port();
+
+ let mut node2 = Node::bind(0).expect("bind");
+ node2.set_nat_state(NatState::Global);
+ node2.join("127.0.0.1", port1).expect("join");
+
+ let mut node3 = Node::bind(0).expect("bind");
+ node3.set_nat_state(NatState::Global);
+ node3.join("127.0.0.1", port1).expect("join");
+
+ println!("Node 1: {} (has the value)", &node1.id_hex()[..8]);
+ println!("Node 2: {} (relay)", &node2.id_hex()[..8]);
+ println!("Node 3: {} (will search)", &node3.id_hex()[..8]);
+
+ // Let them discover each other
+ for _ in 0..10 {
+ node1.poll().ok();
+ node2.poll().ok();
+ node3.poll().ok();
+ std::thread::sleep(Duration::from_millis(20));
+ }
+
+ println!(
+ "\nRouting tables: N1={} N2={} N3={}",
+ node1.routing_table_size(),
+ node2.routing_table_size(),
+ node3.routing_table_size(),
+ );
+
+ // Node 1 stores a value locally only (no STORE sent)
+ println!("\n--- Node 1 stores 'secret-key' ---");
+ node1.put(b"secret-key", b"secret-value", 300, false);
+
+ // Verify: Node 3 does NOT have it
+ assert!(
+ node3.get(b"secret-key").is_empty(),
+ "Node 3 should not have the value yet"
+ );
+ println!("Node 3 get('secret-key'): [] (not found)");
+
+ // Node 3 does get() — triggers FIND_VALUE
+ println!("\n--- Node 3 searches via FIND_VALUE ---");
+ let _ = node3.get(b"secret-key"); // starts query
+
+ // Poll to let FIND_VALUE propagate
+ for _ in 0..15 {
+ node1.poll().ok();
+ node2.poll().ok();
+ node3.poll().ok();
+ std::thread::sleep(Duration::from_millis(30));
+ }
+
+ // Now Node 3 should have cached the value
+ let result = node3.get(b"secret-key");
+ println!(
+ "Node 3 get('secret-key'): {:?}",
+ result
+ .iter()
+ .map(|v| String::from_utf8_lossy(v).to_string())
+ .collect::<Vec<_>>()
+ );
+
+ if result.is_empty() {
+ println!("\n(Value not found — may need more poll rounds)");
+ } else {
+ println!("\nRemote get successful!");
+ }
+
+ // Storage summary
+ println!("\n--- Storage ---");
+ println!("Node 1: {} values", node1.storage_count());
+ println!("Node 2: {} values", node2.storage_count());
+ println!("Node 3: {} values", node3.storage_count());
+}
blob - /dev/null
blob + 81fc3bcaaea40d64a8b58ee19dfc4bb2e193644b (mode 644)
--- /dev/null
+++ examples/tesserasd.rs
+//! tesserasd: daemon with TCP command interface.
+//!
+//! Manages multiple DHT nodes via a line-oriented TCP
+//! protocol.
+//!
+//! Usage:
+//! cargo run --example tesserasd
+//! cargo run --example tesserasd -- --host 0.0.0.0 --port 8080
+//!
+//! Then connect with:
+//! nc localhost 12080
+//!
+//! Commands:
+//! new,NAME,PORT[,global] Create a node
+//! delete,NAME Delete a node
+//! set_id,NAME,DATA Set node ID from data
+//! join,NAME,HOST,PORT Join network
+//! put,NAME,KEY,VALUE,TTL Store key-value
+//! get,NAME,KEY Retrieve value
+//! dump,NAME Print node state
+//! list List all nodes
+//! quit Close connection
+//!
+//! Response codes:
+//! 200-205 Success
+//! 400-409 Error
+
+use std::collections::HashMap;
+use std::io::{BufRead, BufReader, Write};
+use std::net::{TcpListener, TcpStream};
+use std::time::Duration;
+
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+// Response codes
+const OK_NEW: &str = "200";
+const OK_DELETE: &str = "201";
+const OK_JOIN: &str = "202";
+const OK_PUT: &str = "203";
+const OK_GET: &str = "204";
+const OK_SET_ID: &str = "205";
+
+const ERR_UNKNOWN: &str = "400";
+const ERR_INVALID: &str = "401";
+const ERR_PORT: &str = "402";
+const ERR_EXISTS: &str = "403";
+const ERR_NO_NODE: &str = "404";
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ let mut host = "127.0.0.1".to_string();
+ let mut port: u16 = 12080;
+
+ let args: Vec<String> = std::env::args().collect();
+ let mut i = 1;
+ while i < args.len() {
+ match args[i].as_str() {
+ "--host" if i + 1 < args.len() => {
+ host = args[i + 1].clone();
+ i += 2;
+ }
+ "--port" if i + 1 < args.len() => {
+ port = args[i + 1].parse().expect("invalid port");
+ i += 2;
+ }
+ _ => i += 1,
+ }
+ }
+
+ let listener =
+ TcpListener::bind(format!("{host}:{port}")).expect("bind TCP");
+ listener.set_nonblocking(true).expect("set_nonblocking");
+
+ println!("tesserasd listening on 127.0.0.1:{port}");
+ println!("Connect with: nc localhost {port}");
+
+ let mut nodes: HashMap<String, Node> = HashMap::new();
+ let mut clients: Vec<TcpStream> = Vec::new();
+
+ loop {
+ // Accept new TCP connections
+ if let Ok((stream, addr)) = listener.accept() {
+ println!("Client connected: {addr}");
+ stream.set_nonblocking(true).expect("set_nonblocking");
+ let mut s = stream.try_clone().unwrap();
+ let _ = s.write_all(b"tesserasd ready\n");
+ clients.push(stream);
+ }
+
+ // Process commands from connected clients
+ let mut to_remove = Vec::new();
+ for (i, client) in clients.iter().enumerate() {
+ let mut reader = BufReader::new(client.try_clone().unwrap());
+ let mut line = String::new();
+ match reader.read_line(&mut line) {
+ Ok(0) => to_remove.push(i), // disconnected
+ Ok(_) => {
+ let line = line.trim().to_string();
+ if !line.is_empty() {
+ let mut out = client.try_clone().unwrap();
+ let response = handle_command(&line, &mut nodes);
+ let _ = out.write_all(response.as_bytes());
+ let _ = out.write_all(b"\n");
+
+ if line == "quit" {
+ to_remove.push(i);
+ }
+ }
+ }
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+ // No data yet
+ }
+ Err(_) => to_remove.push(i),
+ }
+ }
+ // Remove disconnected (reverse order)
+ to_remove.sort();
+ to_remove.dedup();
+ for i in to_remove.into_iter().rev() {
+ println!("Client disconnected");
+ clients.remove(i);
+ }
+
+ // Poll all DHT nodes
+ for node in nodes.values_mut() {
+ node.poll().ok();
+ }
+
+ std::thread::sleep(Duration::from_millis(10));
+ }
+}
+
+fn handle_command(line: &str, nodes: &mut HashMap<String, Node>) -> String {
+ let parts: Vec<&str> = line.split(',').collect();
+ if parts.is_empty() {
+ return format!("{ERR_UNKNOWN},unknown command");
+ }
+
+ match parts[0] {
+ "new" => cmd_new(&parts, nodes),
+ "delete" => cmd_delete(&parts, nodes),
+ "set_id" => cmd_set_id(&parts, nodes),
+ "join" => cmd_join(&parts, nodes),
+ "put" => cmd_put(&parts, nodes),
+ "get" => cmd_get(&parts, nodes),
+ "dump" => cmd_dump(&parts, nodes),
+ "list" => cmd_list(nodes),
+ "quit" => "goodbye".to_string(),
+ _ => format!("{ERR_UNKNOWN},unknown command: {}", parts[0]),
+ }
+}
+
+// new,NAME,PORT[,global]
+fn cmd_new(parts: &[&str], nodes: &mut HashMap<String, Node>) -> String {
+ if parts.len() < 3 {
+ return format!("{ERR_INVALID},usage: new,NAME,PORT[,global]");
+ }
+ let name = parts[1];
+ let port: u16 = match parts[2].parse() {
+ Ok(p) => p,
+ Err(_) => return format!("{ERR_INVALID},invalid port"),
+ };
+
+ if nodes.contains_key(name) {
+ return format!("{ERR_EXISTS},new,{name},{port},already exists");
+ }
+
+ let mut node = match Node::bind(port) {
+ Ok(n) => n,
+ Err(e) => return format!("{ERR_PORT},new,{name},{port},{e}"),
+ };
+
+ if parts.len() >= 4 && parts[3] == "global" {
+ node.set_nat_state(NatState::Global);
+ }
+
+ let id = node.id_hex();
+ nodes.insert(name.to_string(), node);
+ format!("{OK_NEW},new,{name},{port},{id}")
+}
+
+// delete,NAME
+fn cmd_delete(parts: &[&str], nodes: &mut HashMap<String, Node>) -> String {
+ if parts.len() < 2 {
+ return format!("{ERR_INVALID},usage: delete,NAME");
+ }
+ let name = parts[1];
+ if nodes.remove(name).is_some() {
+ format!("{OK_DELETE},delete,{name}")
+ } else {
+ format!("{ERR_NO_NODE},delete,{name},not found")
+ }
+}
+
+// set_id,NAME,DATA
+fn cmd_set_id(parts: &[&str], nodes: &mut HashMap<String, Node>) -> String {
+ if parts.len() < 3 {
+ return format!("{ERR_INVALID},usage: set_id,NAME,DATA");
+ }
+ let name = parts[1];
+ let data = parts[2];
+ match nodes.get_mut(name) {
+ Some(node) => {
+ node.set_id(data.as_bytes());
+ format!("{OK_SET_ID},set_id,{name},{}", node.id_hex())
+ }
+ None => format!("{ERR_NO_NODE},set_id,{name},not found"),
+ }
+}
+
+// join,NAME,HOST,PORT
+fn cmd_join(parts: &[&str], nodes: &mut HashMap<String, Node>) -> String {
+ if parts.len() < 4 {
+ return format!("{ERR_INVALID},usage: join,NAME,HOST,PORT");
+ }
+ let name = parts[1];
+ let host = parts[2];
+ let port: u16 = match parts[3].parse() {
+ Ok(p) => p,
+ Err(_) => return format!("{ERR_INVALID},invalid port"),
+ };
+ match nodes.get_mut(name) {
+ Some(node) => match node.join(host, port) {
+ Ok(()) => {
+ format!("{OK_JOIN},join,{name},{host},{port}")
+ }
+ Err(e) => format!("{ERR_INVALID},join,{name},{host},{port},{e}"),
+ },
+ None => {
+ format!("{ERR_NO_NODE},join,{name},not found")
+ }
+ }
+}
+
+// put,NAME,KEY,VALUE,TTL
+fn cmd_put(parts: &[&str], nodes: &mut HashMap<String, Node>) -> String {
+ if parts.len() < 5 {
+ return format!("{ERR_INVALID},usage: put,NAME,KEY,VALUE,TTL");
+ }
+ let name = parts[1];
+ let key = parts[2];
+ let value = parts[3];
+ let ttl: u16 = match parts[4].parse() {
+ Ok(t) => t,
+ Err(_) => return format!("{ERR_INVALID},invalid TTL"),
+ };
+ match nodes.get_mut(name) {
+ Some(node) => {
+ node.put(key.as_bytes(), value.as_bytes(), ttl, false);
+ format!("{OK_PUT},put,{name},{key}")
+ }
+ None => {
+ format!("{ERR_NO_NODE},put,{name},not found")
+ }
+ }
+}
+
+// get,NAME,KEY
+fn cmd_get(parts: &[&str], nodes: &mut HashMap<String, Node>) -> String {
+ if parts.len() < 3 {
+ return format!("{ERR_INVALID},usage: get,NAME,KEY");
+ }
+ let name = parts[1];
+ let key = parts[2];
+ match nodes.get_mut(name) {
+ Some(node) => {
+ let vals = node.get(key.as_bytes());
+ if vals.is_empty() {
+ format!("{OK_GET},get,{name},{key},<not found>")
+ } else {
+ let values: Vec<String> = vals
+ .iter()
+ .map(|v| String::from_utf8_lossy(v).to_string())
+ .collect();
+ format!("{OK_GET},get,{name},{key},{}", values.join(";"))
+ }
+ }
+ None => {
+ format!("{ERR_NO_NODE},get,{name},not found")
+ }
+ }
+}
+
+// dump,NAME
+fn cmd_dump(parts: &[&str], nodes: &HashMap<String, Node>) -> String {
+ if parts.len() < 2 {
+ return format!("{ERR_INVALID},usage: dump,NAME");
+ }
+ let name = parts[1];
+ match nodes.get(name) {
+ Some(node) => {
+ format!(
+ "{OK_NEW},dump,{name},id={},nat={:?},\
+ rt={},peers={},storage={}",
+ node.id_hex(),
+ node.nat_state(),
+ node.routing_table_size(),
+ node.peer_count(),
+ node.storage_count(),
+ )
+ }
+ None => {
+ format!("{ERR_NO_NODE},dump,{name},not found")
+ }
+ }
+}
+
+// list
+fn cmd_list(nodes: &HashMap<String, Node>) -> String {
+ if nodes.is_empty() {
+ return format!("{OK_NEW},list,<empty>");
+ }
+ let mut lines = Vec::new();
+ for (name, node) in nodes {
+ lines.push(format!(
+ "{name}={} rt={} storage={}",
+ &node.id_hex()[..8],
+ node.routing_table_size(),
+ node.storage_count(),
+ ));
+ }
+ format!("{OK_NEW},list,{}", lines.join(";"))
+}
blob - /dev/null
blob + 13565c6d83b4925f66bf87650b0eebd0c3a8bf82 (mode 644)
--- /dev/null
+++ examples/two_nodes.rs
+//! Two-node example: bootstrap, put, get.
+//!
+//! Creates two Node nodes on localhost. Node 2 joins
+//! via Node 1, then Node 1 stores a value and Node 2
+//! retrieves it (via protocol exchange).
+//!
+//! Run with:
+//! cargo run --example two_nodes
+//!
+//! With debug logging:
+//! RUST_LOG=debug cargo run --example two_nodes
+
+use std::time::Duration;
+
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("info"),
+ )
+ .format(|buf, record| {
+ use std::io::Write;
+ writeln!(
+ buf,
+ "{} [{}] {}",
+ record.level(),
+ record.target(),
+ record.args()
+ )
+ })
+ .init();
+
+ // ── Create two nodes ────────────────────────────
+
+ let mut node1 = Node::bind(0).expect("bind node1");
+ node1.set_nat_state(NatState::Global);
+ let addr1 = node1.local_addr().expect("local addr");
+ println!("Node 1: {} @ {}", node1.id_hex(), addr1);
+
+ let mut node2 = Node::bind(0).expect("bind node2");
+ node2.set_nat_state(NatState::Global);
+ let addr2 = node2.local_addr().expect("local addr");
+ println!("Node 2: {} @ {}", node2.id_hex(), addr2);
+
+ // ── Node 2 joins via Node 1 ─────────────────────
+
+ println!("\n--- Node 2 joining via Node 1 ---");
+ node2.join("127.0.0.1", addr1.port()).expect("join");
+
+ // Poll both nodes a few times to exchange messages
+ for _ in 0..10 {
+ node1.poll().ok();
+ node2.poll().ok();
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ println!("Node 1 routing table: {} peers", node1.routing_table_size());
+ println!("Node 2 routing table: {} peers", node2.routing_table_size());
+
+ // ── Node 1 stores a value ───────────────────────
+
+ println!("\n--- Node 1 storing key='hello' ---");
+ node1.put(b"hello", b"world", 300, false);
+
+ // Poll to deliver STORE messages
+ for _ in 0..10 {
+ node1.poll().ok();
+ node2.poll().ok();
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ // ── Check storage ───────────────────────────────
+
+ let vals1 = node1.get(b"hello");
+ let vals2 = node2.get(b"hello");
+
+ println!(
+ "\nNode 1 get('hello'): {:?}",
+ vals1
+ .iter()
+ .map(|v| String::from_utf8_lossy(v).to_string())
+ .collect::<Vec<_>>()
+ );
+ println!(
+ "Node 2 get('hello'): {:?}",
+ vals2
+ .iter()
+ .map(|v| String::from_utf8_lossy(v).to_string())
+ .collect::<Vec<_>>()
+ );
+
+ // ── Test remote get via FIND_VALUE ────────────
+
+ println!("\n--- Node 1 storing key='secret' (local only) ---");
+ // Store only on Node 1 (no STORE sent because
+ // we bypass put and go directly to storage)
+ node1.put(b"remote-key", b"remote-val", 300, false);
+ // Don't poll — so Node 2 doesn't get the STORE
+
+ // Node 2 tries to get it — should trigger FIND_VALUE
+ println!(
+ "Node 2 get('remote-key') before poll: {:?}",
+ node2.get(b"remote-key")
+ );
+
+ // Now poll to let FIND_VALUE exchange happen
+ for _ in 0..10 {
+ node1.poll().ok();
+ node2.poll().ok();
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ let remote_vals = node2.get(b"remote-key");
+ println!(
+ "Node 2 get('remote-key') after poll: {:?}",
+ remote_vals
+ .iter()
+ .map(|v| String::from_utf8_lossy(v).to_string())
+ .collect::<Vec<_>>()
+ );
+
+ // ── Print state ─────────────────────────────────
+
+ println!("\n--- Node 1 state ---");
+ node1.print_state();
+ println!("\n--- Node 2 state ---");
+ node2.print_state();
+
+ println!("\n--- Done ---");
+ println!("Node 1: {node1}");
+ println!("Node 2: {node2}");
+}
blob - /dev/null
blob + 0efdf4284f118aac2b3da46ecfe56dca942990bb (mode 644)
--- /dev/null
+++ fuzz/fuzz_parse.rs
+//! Fuzz targets for message parsers.
+//!
+//! Run with: cargo +nightly fuzz run fuzz_parse
+//!
+//! Requires: cargo install cargo-fuzz
+//!
+//! These targets verify that no input can cause a panic,
+//! buffer overflow, or undefined behavior in the parsers.
+
+// Note: this file is a reference for cargo-fuzz targets.
+// To use, create a fuzz/Cargo.toml and fuzz_targets/
+// directory per cargo-fuzz conventions. The actual fuzz
+// harnesses are:
+
+#[cfg(test)]
+mod tests {
+ /// Fuzz MsgHeader::parse with random bytes.
+ #[test]
+ fn fuzz_header_parse() {
+ for _ in 0..10_000 {
+ let mut buf = [0u8; 128];
+ tesseras_dht::sys::random_bytes(&mut buf);
+ // Should never panic
+ let _ = tesseras_dht::wire::MsgHeader::parse(&buf);
+ }
+ }
+
+ /// Fuzz msg::parse_store with random bytes.
+ #[test]
+ fn fuzz_store_parse() {
+ for _ in 0..10_000 {
+ let mut buf = [0u8; 256];
+ tesseras_dht::sys::random_bytes(&mut buf);
+ let _ = tesseras_dht::msg::parse_store(&buf);
+ }
+ }
+
+ /// Fuzz msg::parse_find_node with random bytes.
+ #[test]
+ fn fuzz_find_node_parse() {
+ for _ in 0..10_000 {
+ let mut buf = [0u8; 128];
+ tesseras_dht::sys::random_bytes(&mut buf);
+ let _ = tesseras_dht::msg::parse_find_node(&buf);
+ }
+ }
+
+ /// Fuzz msg::parse_find_value with random bytes.
+ #[test]
+ fn fuzz_find_value_parse() {
+ for _ in 0..10_000 {
+ let mut buf = [0u8; 256];
+ tesseras_dht::sys::random_bytes(&mut buf);
+ let _ = tesseras_dht::msg::parse_find_value(&buf);
+ }
+ }
+
+ /// Fuzz rdp::parse_rdp_wire with random bytes.
+ #[test]
+ fn fuzz_rdp_parse() {
+ for _ in 0..10_000 {
+ let mut buf = [0u8; 128];
+ tesseras_dht::sys::random_bytes(&mut buf);
+ let _ = tesseras_dht::rdp::parse_rdp_wire(&buf);
+ }
+ }
+
+ /// Fuzz dgram::parse_fragment with random bytes.
+ #[test]
+ fn fuzz_fragment_parse() {
+ for _ in 0..10_000 {
+ let mut buf = [0u8; 64];
+ tesseras_dht::sys::random_bytes(&mut buf);
+ let _ = tesseras_dht::dgram::parse_fragment(&buf);
+ }
+ }
+
+ /// Fuzz msg::parse_find_value_reply with random bytes.
+ #[test]
+ fn fuzz_find_value_reply_parse() {
+ for _ in 0..10_000 {
+ let mut buf = [0u8; 256];
+ tesseras_dht::sys::random_bytes(&mut buf);
+ let _ = tesseras_dht::msg::parse_find_value_reply(&buf);
+ }
+ }
+}
blob - /dev/null
blob + b415b5b615a1dac4a9b5343dd6d7a3860a5a461a (mode 644)
--- /dev/null
+++ src/advertise.rs
+//! Local address advertisement.
+//!
+//! Allows a node to announce its address to peers so
+//! they can update their routing tables with the correct
+//! endpoint.
+//!
+//! Used after NAT detection to inform peers of the
+//! node's externally-visible address.
+
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+
+// ── Constants ────────────────────────────────────────
+
+/// TTL for advertisements.
+pub const ADVERTISE_TTL: Duration = Duration::from_secs(300);
+
+/// Timeout for a single advertisement attempt.
+pub const ADVERTISE_TIMEOUT: Duration = Duration::from_secs(2);
+
+/// Interval between refresh cycles.
+pub const ADVERTISE_REFRESH_INTERVAL: Duration = Duration::from_secs(100);
+
+/// A pending outgoing advertisement.
+#[derive(Debug)]
+struct PendingAd {
+ sent_at: Instant,
+}
+
+/// A received advertisement from a peer.
+#[derive(Debug)]
+struct ReceivedAd {
+ received_at: Instant,
+}
+
+/// Address advertisement manager.
+pub struct Advertise {
+ /// Pending outgoing advertisements by nonce.
+ pending: HashMap<u32, PendingAd>,
+
+ /// Received advertisements by peer ID.
+ received: HashMap<NodeId, ReceivedAd>,
+}
+
+impl Advertise {
+ pub fn new(_local_id: NodeId) -> Self {
+ Self {
+ pending: HashMap::new(),
+ received: HashMap::new(),
+ }
+ }
+
+ /// Start an advertisement to a peer.
+ ///
+ /// Returns the nonce to include in the message.
+ pub fn start_advertise(&mut self, nonce: u32) -> u32 {
+ self.pending.insert(
+ nonce,
+ PendingAd {
+ sent_at: Instant::now(),
+ },
+ );
+ nonce
+ }
+
+ /// Handle an advertisement reply (our ad was accepted).
+ ///
+ /// Returns `true` if the nonce matched a pending ad.
+ pub fn recv_reply(&mut self, nonce: u32) -> bool {
+ self.pending.remove(&nonce).is_some()
+ }
+
+ /// Handle an incoming advertisement from a peer.
+ ///
+ /// Records that this peer has advertised to us.
+ pub fn recv_advertise(&mut self, peer_id: NodeId) {
+ self.received.insert(
+ peer_id,
+ ReceivedAd {
+ received_at: Instant::now(),
+ },
+ );
+ }
+
+ /// Check if a peer has advertised to us recently.
+ pub fn has_advertised(&self, peer_id: &NodeId) -> bool {
+ self.received
+ .get(peer_id)
+ .map(|ad| ad.received_at.elapsed() < ADVERTISE_TTL)
+ .unwrap_or(false)
+ }
+
+ /// Remove expired pending ads and stale received ads.
+ pub fn refresh(&mut self) {
+ self.pending
+ .retain(|_, ad| ad.sent_at.elapsed() < ADVERTISE_TIMEOUT);
+ self.received
+ .retain(|_, ad| ad.received_at.elapsed() < ADVERTISE_TTL);
+ }
+
+ /// Number of pending outgoing advertisements.
+ pub fn pending_count(&self) -> usize {
+ self.pending.len()
+ }
+
+ /// Number of active received advertisements.
+ pub fn received_count(&self) -> usize {
+ self.received
+ .values()
+ .filter(|ad| ad.received_at.elapsed() < ADVERTISE_TTL)
+ .count()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn advertise_and_reply() {
+ let mut adv = Advertise::new(NodeId::from_bytes([0x01; 32]));
+ adv.start_advertise(42);
+ assert_eq!(adv.pending_count(), 1);
+
+ assert!(adv.recv_reply(42));
+ assert_eq!(adv.pending_count(), 0);
+ }
+
+ #[test]
+ fn unknown_reply_ignored() {
+ let mut adv = Advertise::new(NodeId::from_bytes([0x01; 32]));
+ assert!(!adv.recv_reply(999));
+ }
+
+ #[test]
+ fn recv_advertisement() {
+ let mut adv = Advertise::new(NodeId::from_bytes([0x01; 32]));
+ let peer = NodeId::from_bytes([0x02; 32]);
+
+ assert!(!adv.has_advertised(&peer));
+ adv.recv_advertise(peer);
+ assert!(adv.has_advertised(&peer));
+ assert_eq!(adv.received_count(), 1);
+ }
+
+ #[test]
+ fn refresh_clears_stale() {
+ let mut adv = Advertise::new(NodeId::from_bytes([0x01; 32]));
+
+ // Insert already-expired pending ad
+ adv.pending.insert(
+ 1,
+ PendingAd {
+ sent_at: Instant::now() - Duration::from_secs(10),
+ },
+ );
+
+ // Insert already-expired received ad
+ let peer = NodeId::from_bytes([0x02; 32]);
+ adv.received.insert(
+ peer,
+ ReceivedAd {
+ received_at: Instant::now() - Duration::from_secs(600),
+ },
+ );
+
+ adv.refresh();
+ assert_eq!(adv.pending_count(), 0);
+ assert_eq!(adv.received_count(), 0);
+ }
+}
blob - /dev/null
blob + f01e31a555c1ffeb91060e98e0ad055e1611dbf2 (mode 644)
--- /dev/null
+++ src/banlist.rs
+//! Ban list for misbehaving peers.
+//!
+//! Tracks failure counts per peer. After exceeding a
+//! threshold, the peer is temporarily banned. Bans
+//! expire automatically after a configurable duration.
+
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::time::{Duration, Instant};
+
+/// Default number of failures before banning a peer.
+const DEFAULT_BAN_THRESHOLD: u32 = 3;
+
+/// Default ban duration (3 hours, matching gonode).
+const DEFAULT_BAN_DURATION: Duration = Duration::from_secs(3 * 3600);
+
+/// Tracks failures and bans for peers.
+pub struct BanList {
+ /// Failure counts per address.
+ failures: HashMap<SocketAddr, FailureEntry>,
+ /// Active bans: address → expiry time.
+ bans: HashMap<SocketAddr, Instant>,
+ /// Number of failures before a ban is applied.
+ threshold: u32,
+ /// How long a ban lasts.
+ ban_duration: Duration,
+}
+
+struct FailureEntry {
+ count: u32,
+ last_failure: Instant,
+}
+
+impl BanList {
+ pub fn new() -> Self {
+ Self {
+ failures: HashMap::new(),
+ bans: HashMap::new(),
+ threshold: DEFAULT_BAN_THRESHOLD,
+ ban_duration: DEFAULT_BAN_DURATION,
+ }
+ }
+
+ /// Set the failure threshold before banning.
+ pub fn set_threshold(&mut self, threshold: u32) {
+ self.threshold = threshold;
+ }
+
+ /// Set the ban duration.
+ pub fn set_ban_duration(&mut self, duration: Duration) {
+ self.ban_duration = duration;
+ }
+
+ /// Check if a peer is currently banned.
+ pub fn is_banned(&self, addr: &SocketAddr) -> bool {
+ if let Some(expiry) = self.bans.get(addr) {
+ Instant::now() < *expiry
+ } else {
+ false
+ }
+ }
+
+ /// Record a failure for a peer. Returns true if the
+ /// peer was just banned (crossed the threshold).
+ pub fn record_failure(&mut self, addr: SocketAddr) -> bool {
+ let entry = self.failures.entry(addr).or_insert(FailureEntry {
+ count: 0,
+ last_failure: Instant::now(),
+ });
+ entry.count += 1;
+ entry.last_failure = Instant::now();
+
+ if entry.count >= self.threshold {
+ let expiry = Instant::now() + self.ban_duration;
+ self.bans.insert(addr, expiry);
+ self.failures.remove(&addr);
+ log::info!(
+ "Banned peer {addr} for {}s",
+ self.ban_duration.as_secs()
+ );
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Clear failure count for a peer (e.g. after a
+ /// successful interaction).
+ pub fn record_success(&mut self, addr: &SocketAddr) {
+ self.failures.remove(addr);
+ }
+
+ /// Remove expired bans and stale failure entries.
+ /// Call periodically from the event loop.
+ pub fn cleanup(&mut self) {
+ let now = Instant::now();
+ self.bans.retain(|_, expiry| now < *expiry);
+
+ // Clear failure entries older than ban_duration
+ // (stale failures shouldn't accumulate forever)
+ self.failures
+ .retain(|_, e| e.last_failure.elapsed() < self.ban_duration);
+ }
+
+ /// Number of currently active bans.
+ pub fn ban_count(&self) -> usize {
+ self.bans
+ .iter()
+ .filter(|(_, e)| Instant::now() < **e)
+ .count()
+ }
+
+ /// Number of peers with recorded failures.
+ pub fn failure_count(&self) -> usize {
+ self.failures.len()
+ }
+}
+
+impl Default for BanList {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn addr(port: u16) -> SocketAddr {
+ SocketAddr::from(([127, 0, 0, 1], port))
+ }
+
+ #[test]
+ fn not_banned_initially() {
+ let bl = BanList::new();
+ assert!(!bl.is_banned(&addr(1000)));
+ }
+
+ #[test]
+ fn ban_after_threshold() {
+ let mut bl = BanList::new();
+ let a = addr(1000);
+ assert!(!bl.record_failure(a));
+ assert!(!bl.record_failure(a));
+ assert!(bl.record_failure(a)); // 3rd failure → banned
+ assert!(bl.is_banned(&a));
+ }
+
+ #[test]
+ fn success_clears_failures() {
+ let mut bl = BanList::new();
+ let a = addr(1000);
+ bl.record_failure(a);
+ bl.record_failure(a);
+ bl.record_success(&a);
+ // Failures cleared, next failure starts over
+ assert!(!bl.record_failure(a));
+ assert!(!bl.is_banned(&a));
+ }
+
+ #[test]
+ fn ban_expires() {
+ let mut bl = BanList::new();
+ bl.set_ban_duration(Duration::from_millis(1));
+ let a = addr(1000);
+ bl.record_failure(a);
+ bl.record_failure(a);
+ bl.record_failure(a);
+ assert!(bl.is_banned(&a));
+ std::thread::sleep(Duration::from_millis(5));
+ assert!(!bl.is_banned(&a));
+ }
+
+ #[test]
+ fn cleanup_removes_expired() {
+ let mut bl = BanList::new();
+ bl.set_ban_duration(Duration::from_millis(1));
+ let a = addr(1000);
+ bl.record_failure(a);
+ bl.record_failure(a);
+ bl.record_failure(a);
+ std::thread::sleep(Duration::from_millis(5));
+ bl.cleanup();
+ assert_eq!(bl.ban_count(), 0);
+ }
+
+ #[test]
+ fn custom_threshold() {
+ let mut bl = BanList::new();
+ bl.set_threshold(1);
+ let a = addr(1000);
+ assert!(bl.record_failure(a)); // 1 failure → banned
+ assert!(bl.is_banned(&a));
+ }
+
+ #[test]
+ fn independent_peers() {
+ let mut bl = BanList::new();
+ let a = addr(1000);
+ let b = addr(2000);
+ bl.record_failure(a);
+ bl.record_failure(a);
+ bl.record_failure(a);
+ assert!(bl.is_banned(&a));
+ assert!(!bl.is_banned(&b));
+ }
+}
blob - /dev/null
blob + b2aaf0244569de6fd672d4b716e5569e3579fbd1 (mode 644)
--- /dev/null
+++ src/config.rs
+//! Node configuration.
+//!
+//! All tunable parameters in one place. Passed to
+//! `Tessera::bind_with_config()`.
+
+use std::time::Duration;
+
+/// Configuration for a Tessera node.
+#[derive(Debug, Clone)]
+pub struct Config {
+ /// Maximum entries per k-bucket (default: 20).
+ pub bucket_size: usize,
+
+ /// Number of closest nodes returned in lookups
+ /// (default: 10).
+ pub num_find_node: usize,
+
+ /// Maximum parallel queries per lookup (default: 6).
+ pub max_query: usize,
+
+ /// Single RPC query timeout (default: 3s).
+ pub query_timeout: Duration,
+
+ /// Maximum iterative query duration (default: 30s).
+ pub max_query_duration: Duration,
+
+ /// Data restore interval (default: 120s).
+ pub restore_interval: Duration,
+
+ /// Bucket refresh interval (default: 60s).
+ pub refresh_interval: Duration,
+
+ /// Maintain (mask_bit exploration) interval
+ /// (default: 120s).
+ pub maintain_interval: Duration,
+
+ /// Default value TTL in seconds (default: 300).
+ /// Max 65535 (~18 hours). For longer TTLs, use
+ /// periodic republish.
+ pub default_ttl: u16,
+
+ /// Maximum value size in bytes (default: 65536).
+ pub max_value_size: usize,
+
+ /// Rate limiter: messages per second per IP
+ /// (default: 50).
+ pub rate_limit: f64,
+
+ /// Rate limiter: burst capacity (default: 100).
+ pub rate_burst: u32,
+
+ /// Maximum nodes per /24 subnet (default: 2).
+ pub max_per_subnet: usize,
+
+ /// Enable DTUN (NAT traversal) (default: true).
+ pub enable_dtun: bool,
+
+ /// Require Ed25519 signature on all packets
+ /// (default: true). Set to false only for testing.
+ pub require_signatures: bool,
+
+ /// Ban threshold: failures before banning a peer
+ /// (default: 3).
+ pub ban_threshold: u32,
+
+ /// Ban duration in seconds (default: 10800 = 3h).
+ pub ban_duration_secs: u64,
+
+ /// Node activity check interval (default: 120s).
+ /// Proactively pings routing table peers to detect
+ /// failures early.
+ pub activity_check_interval: Duration,
+
+ /// Store retry interval (default: 30s). How often
+ /// to sweep for timed-out stores and retry them.
+ pub store_retry_interval: Duration,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Self {
+ bucket_size: 20,
+ num_find_node: 10,
+ max_query: 6,
+ query_timeout: Duration::from_secs(3),
+ max_query_duration: Duration::from_secs(30),
+ restore_interval: Duration::from_secs(120),
+ refresh_interval: Duration::from_secs(60),
+ maintain_interval: Duration::from_secs(120),
+ default_ttl: 300,
+ max_value_size: 65536,
+ rate_limit: 50.0,
+ rate_burst: 100,
+ max_per_subnet: 2,
+ enable_dtun: true,
+ require_signatures: true,
+ ban_threshold: 3,
+ ban_duration_secs: 10800,
+ activity_check_interval: Duration::from_secs(120),
+ store_retry_interval: Duration::from_secs(30),
+ }
+ }
+}
+
+impl Config {
+ /// Create a config tuned for a pastebin.
+ ///
+ /// Higher TTL (24h), larger max value (1 MB),
+ /// HMAC enabled.
+ pub fn pastebin() -> Self {
+ Self {
+ default_ttl: 65535, // ~18h, use republish for longer
+ max_value_size: 1_048_576,
+ require_signatures: true,
+ ..Default::default()
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_values() {
+ let c = Config::default();
+ assert_eq!(c.bucket_size, 20);
+ assert_eq!(c.default_ttl, 300);
+ assert!(c.require_signatures);
+ }
+
+ #[test]
+ fn pastebin_preset() {
+ let c = Config::pastebin();
+ assert_eq!(c.default_ttl, 65535);
+ assert_eq!(c.max_value_size, 1_048_576);
+ assert!(c.require_signatures);
+ }
+}
blob - /dev/null
blob + 10587ec3dbfbf847e0569754697ecf79be3542d1 (mode 644)
--- /dev/null
+++ src/crypto.rs
+//! Ed25519 identity and packet signing.
+//!
+//! Each node has an Ed25519 keypair:
+//! - **Private key** (32 bytes): never leaves the node.
+//! Used to sign every outgoing packet.
+//! - **Public key** (32 bytes): shared with peers.
+//! Used to verify incoming packets.
+//! - **NodeId** = public key (32 bytes). Direct 1:1
+//! binding, no hashing.
+
+use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
+
+use crate::id::NodeId;
+
+/// Ed25519 signature size (64 bytes).
+pub const SIGNATURE_SIZE: usize = 64;
+
+/// Ed25519 public key size (32 bytes).
+pub const PUBLIC_KEY_SIZE: usize = 32;
+
+/// A node's cryptographic identity.
+///
+/// Contains the Ed25519 keypair and the derived NodeId.
+/// The NodeId is the public key directly (32 bytes)
+/// deterministically bound to the keypair.
+pub struct Identity {
+ signing_key: SigningKey,
+ verifying_key: VerifyingKey,
+ node_id: NodeId,
+}
+
+impl Identity {
+ /// Generate a new random identity.
+ pub fn generate() -> Self {
+ let mut seed = [0u8; 32];
+ crate::sys::random_bytes(&mut seed);
+ Self::from_seed(seed)
+ }
+
+ /// Create an identity from a 32-byte seed.
+ ///
+ /// Deterministic: same seed → same keypair → same
+ /// NodeId.
+ pub fn from_seed(seed: [u8; 32]) -> Self {
+ let signing_key = SigningKey::from_bytes(&seed);
+ let verifying_key = signing_key.verifying_key();
+ let node_id = NodeId::from_bytes(*verifying_key.as_bytes());
+ Self {
+ signing_key,
+ verifying_key,
+ node_id,
+ }
+ }
+
+ /// The node's 256-bit ID (= public key).
+ pub fn node_id(&self) -> &NodeId {
+ &self.node_id
+ }
+
+ /// The Ed25519 public key (32 bytes).
+ pub fn public_key(&self) -> &[u8; PUBLIC_KEY_SIZE] {
+ self.verifying_key.as_bytes()
+ }
+
+ /// Sign data with the private key.
+ ///
+ /// Returns a 64-byte Ed25519 signature.
+ pub fn sign(&self, data: &[u8]) -> [u8; SIGNATURE_SIZE] {
+ let sig = self.signing_key.sign(data);
+ sig.to_bytes()
+ }
+
+ /// Verify a signature against a public key.
+ ///
+ /// This is a static method — used to verify packets
+ /// from other nodes using their public key.
+ pub fn verify(
+ public_key: &[u8; PUBLIC_KEY_SIZE],
+ data: &[u8],
+ signature: &[u8],
+ ) -> bool {
+ // Length check is not timing-sensitive (length is
+ // public). ed25519_dalek::verify() is constant-time.
+ if signature.len() != SIGNATURE_SIZE {
+ return false;
+ }
+ let Ok(vk) = VerifyingKey::from_bytes(public_key) else {
+ return false;
+ };
+ let mut sig_bytes = [0u8; SIGNATURE_SIZE];
+ sig_bytes.copy_from_slice(signature);
+ let sig = Signature::from_bytes(&sig_bytes);
+ vk.verify(data, &sig).is_ok()
+ }
+}
+
+impl std::fmt::Debug for Identity {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "Identity({})", self.node_id)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn generate_unique() {
+ let a = Identity::generate();
+ let b = Identity::generate();
+ assert_ne!(a.node_id(), b.node_id());
+ }
+
+ #[test]
+ fn from_seed_deterministic() {
+ let seed = [0x42u8; 32];
+ let a = Identity::from_seed(seed);
+ let b = Identity::from_seed(seed);
+ assert_eq!(a.node_id(), b.node_id());
+ assert_eq!(a.public_key(), b.public_key());
+ }
+
+ #[test]
+ fn node_id_is_pubkey() {
+ let id = Identity::generate();
+ let expected = NodeId::from_bytes(*id.public_key());
+ assert_eq!(*id.node_id(), expected);
+ }
+
+ #[test]
+ fn sign_verify() {
+ let id = Identity::generate();
+ let data = b"hello world";
+ let sig = id.sign(data);
+
+ assert!(Identity::verify(id.public_key(), data, &sig));
+ }
+
+ #[test]
+ fn verify_wrong_data() {
+ let id = Identity::generate();
+ let sig = id.sign(b"correct");
+
+ assert!(!Identity::verify(id.public_key(), b"wrong", &sig));
+ }
+
+ #[test]
+ fn verify_wrong_key() {
+ let id1 = Identity::generate();
+ let id2 = Identity::generate();
+ let sig = id1.sign(b"data");
+
+ assert!(!Identity::verify(id2.public_key(), b"data", &sig));
+ }
+
+ #[test]
+ fn verify_truncated_sig() {
+ let id = Identity::generate();
+ assert!(!Identity::verify(
+ id.public_key(),
+ b"data",
+ &[0u8; 10] // too short
+ ));
+ }
+
+ #[test]
+ fn signature_size() {
+ let id = Identity::generate();
+ let sig = id.sign(b"test");
+ assert_eq!(sig.len(), SIGNATURE_SIZE);
+ }
+}
blob - /dev/null
blob + 6fca81b8fcbbbb82f4fc8d50fa1c64ea65f12815 (mode 644)
--- /dev/null
+++ src/dgram.rs
+//! Datagram transport with automatic fragmentation.
+//!
+//! Messages larger
+//! than `MAX_DGRAM_PAYLOAD` (896 bytes) are split into
+//! fragments and reassembled at the destination.
+//!
+//! Routing is automatic: if the local node is behind
+//! symmetric NAT, datagrams are sent through the proxy.
+
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+
+/// Maximum payload per datagram fragment (bytes).
+///
+/// Ensures each fragment
+/// fits in a single UDP packet with protocol overhead.
+pub const MAX_DGRAM_PAYLOAD: usize = 896;
+
+/// Timeout for incomplete fragment reassembly.
+pub const REASSEMBLY_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// A queued datagram waiting for address resolution.
+#[derive(Debug, Clone)]
+pub struct QueuedDgram {
+ pub data: Vec<u8>,
+ pub src: NodeId,
+ pub queued_at: Instant,
+}
+
+// ── Fragmentation ───────────────────────────────────
+
+/// Fragment header: 4 bytes.
+///
+/// - `total` (u16 BE): total number of fragments.
+/// - `index` (u16 BE): this fragment's index (0-based).
+const FRAG_HEADER_SIZE: usize = 4;
+
+/// Split a message into fragments, each with a 4-byte
+/// header and up to `MAX_DGRAM_PAYLOAD` bytes of data.
+pub fn fragment(data: &[u8]) -> Vec<Vec<u8>> {
+ if data.is_empty() {
+ return vec![make_fragment(1, 0, &[])];
+ }
+
+ let chunk_size = MAX_DGRAM_PAYLOAD;
+ let total = data.len().div_ceil(chunk_size);
+ let total = total as u16;
+
+ data.chunks(chunk_size)
+ .enumerate()
+ .map(|(i, chunk)| make_fragment(total, i as u16, chunk))
+ .collect()
+}
+
+fn make_fragment(total: u16, index: u16, data: &[u8]) -> Vec<u8> {
+ let mut buf = Vec::with_capacity(FRAG_HEADER_SIZE + data.len());
+ buf.extend_from_slice(&total.to_be_bytes());
+ buf.extend_from_slice(&index.to_be_bytes());
+ buf.extend_from_slice(data);
+ buf
+}
+
+/// Parse a fragment header.
+///
+/// Returns `(total_fragments, fragment_index, payload)`.
+pub fn parse_fragment(buf: &[u8]) -> Option<(u16, u16, &[u8])> {
+ if buf.len() < FRAG_HEADER_SIZE {
+ return None;
+ }
+ let total = u16::from_be_bytes([buf[0], buf[1]]);
+ let index = u16::from_be_bytes([buf[2], buf[3]]);
+ Some((total, index, &buf[FRAG_HEADER_SIZE..]))
+}
+
+// ── Reassembly ──────────────────────────────────────
+
+/// State for reassembling fragments from a single sender.
+#[derive(Debug)]
+struct ReassemblyState {
+ total: u16,
+ fragments: HashMap<u16, Vec<u8>>,
+ started_at: Instant,
+}
+
+/// Fragment reassembler.
+///
+/// Tracks incoming fragments per sender and produces
+/// complete messages when all fragments arrive.
+pub struct Reassembler {
+ pending: HashMap<NodeId, ReassemblyState>,
+}
+
+impl Reassembler {
+ pub fn new() -> Self {
+ Self {
+ pending: HashMap::new(),
+ }
+ }
+
+ /// Feed a fragment. Returns the complete message if
+ /// all fragments have arrived.
+ pub fn feed(
+ &mut self,
+ sender: NodeId,
+ total: u16,
+ index: u16,
+ data: Vec<u8>,
+ ) -> Option<Vec<u8>> {
+ // S2-8: cap fragments to prevent memory bomb
+ const MAX_FRAGMENTS: u16 = 10;
+ if total == 0 {
+ log::debug!("Dgram: dropping fragment with total=0");
+ return None;
+ }
+ if total > MAX_FRAGMENTS {
+ log::debug!(
+ "Dgram: dropping fragment with total={total} > {MAX_FRAGMENTS}"
+ );
+ return None;
+ }
+
+ // Single fragment → no reassembly needed
+ if total == 1 && index == 0 {
+ self.pending.remove(&sender);
+ return Some(data);
+ }
+
+ let state =
+ self.pending
+ .entry(sender)
+ .or_insert_with(|| ReassemblyState {
+ total,
+ fragments: HashMap::new(),
+ started_at: Instant::now(),
+ });
+
+ // Total mismatch → reset
+ if state.total != total {
+ *state = ReassemblyState {
+ total,
+ fragments: HashMap::new(),
+ started_at: Instant::now(),
+ };
+ }
+
+ if index < total {
+ state.fragments.insert(index, data);
+ }
+
+ if state.fragments.len() == total as usize {
+ // All fragments received → reassemble
+ let mut result = Vec::new();
+ for i in 0..total {
+ if let Some(frag) = state.fragments.get(&i) {
+ result.extend_from_slice(frag);
+ } else {
+ // Should not happen, but guard
+ self.pending.remove(&sender);
+ return None;
+ }
+ }
+ self.pending.remove(&sender);
+ Some(result)
+ } else {
+ None
+ }
+ }
+
+ /// Remove incomplete reassembly state older than the
+ /// timeout.
+ pub fn expire(&mut self) {
+ self.pending
+ .retain(|_, state| state.started_at.elapsed() < REASSEMBLY_TIMEOUT);
+ }
+
+ /// Number of pending incomplete messages.
+ pub fn pending_count(&self) -> usize {
+ self.pending.len()
+ }
+}
+
+impl Default for Reassembler {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+// ── Send queue ──────────────────────────────────────
+
+/// Queue of datagrams waiting for address resolution.
+pub struct SendQueue {
+ queues: HashMap<NodeId, Vec<QueuedDgram>>,
+}
+
+impl SendQueue {
+ pub fn new() -> Self {
+ Self {
+ queues: HashMap::new(),
+ }
+ }
+
+ /// Enqueue a datagram for a destination.
+ pub fn push(&mut self, dst: NodeId, data: Vec<u8>, src: NodeId) {
+ self.queues.entry(dst).or_default().push(QueuedDgram {
+ data,
+ src,
+ queued_at: Instant::now(),
+ });
+ }
+
+ /// Drain the queue for a destination.
+ pub fn drain(&mut self, dst: &NodeId) -> Vec<QueuedDgram> {
+ self.queues.remove(dst).unwrap_or_default()
+ }
+
+ /// Check if there's a pending queue for a destination.
+ pub fn has_pending(&self, dst: &NodeId) -> bool {
+ self.queues.contains_key(dst)
+ }
+
+ /// Remove stale queued messages (>10s).
+ pub fn expire(&mut self) {
+ self.queues.retain(|_, q| {
+ q.retain(|d| d.queued_at.elapsed() < REASSEMBLY_TIMEOUT);
+ !q.is_empty()
+ });
+ }
+}
+
+impl Default for SendQueue {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ── Fragmentation tests ─────────────────────────
+
+ #[test]
+ fn small_message_single_fragment() {
+ let frags = fragment(b"hello");
+ assert_eq!(frags.len(), 1);
+ let (total, idx, data) = parse_fragment(&frags[0]).unwrap();
+ assert_eq!(total, 1);
+ assert_eq!(idx, 0);
+ assert_eq!(data, b"hello");
+ }
+
+ #[test]
+ fn large_message_multiple_fragments() {
+ let msg = vec![0xAB; MAX_DGRAM_PAYLOAD * 3 + 100];
+ let frags = fragment(&msg);
+ assert_eq!(frags.len(), 4);
+
+ for (i, frag) in frags.iter().enumerate() {
+ let (total, idx, _) = parse_fragment(frag).unwrap();
+ assert_eq!(total, 4);
+ assert_eq!(idx, i as u16);
+ }
+ }
+
+ #[test]
+ fn empty_message() {
+ let frags = fragment(b"");
+ assert_eq!(frags.len(), 1);
+ let (total, idx, data) = parse_fragment(&frags[0]).unwrap();
+ assert_eq!(total, 1);
+ assert_eq!(idx, 0);
+ assert!(data.is_empty());
+ }
+
+ #[test]
+ fn fragment_roundtrip() {
+ let msg = vec![0x42; MAX_DGRAM_PAYLOAD * 2 + 50];
+ let frags = fragment(&msg);
+
+ let mut reassembled = Vec::new();
+ for frag in &frags {
+ let (_, _, data) = parse_fragment(frag).unwrap();
+ reassembled.extend_from_slice(data);
+ }
+ assert_eq!(reassembled, msg);
+ }
+
+ // ── Reassembler tests ───────────────────────────
+
+ #[test]
+ fn reassemble_single() {
+ let mut r = Reassembler::new();
+ let sender = NodeId::from_bytes([0x01; 32]);
+ let result = r.feed(sender, 1, 0, b"hello".to_vec());
+ assert_eq!(result.unwrap(), b"hello");
+ assert_eq!(r.pending_count(), 0);
+ }
+
+ #[test]
+ fn reassemble_multi() {
+ let mut r = Reassembler::new();
+ let sender = NodeId::from_bytes([0x01; 32]);
+
+ assert!(r.feed(sender, 3, 0, b"aaa".to_vec()).is_none());
+ assert!(r.feed(sender, 3, 2, b"ccc".to_vec()).is_none());
+ let result = r.feed(sender, 3, 1, b"bbb".to_vec());
+
+ assert_eq!(result.unwrap(), b"aaabbbccc");
+ assert_eq!(r.pending_count(), 0);
+ }
+
+ #[test]
+ fn reassemble_out_of_order() {
+ let mut r = Reassembler::new();
+ let sender = NodeId::from_bytes([0x01; 32]);
+
+ // Fragments arrive in reverse order
+ assert!(r.feed(sender, 2, 1, b"world".to_vec()).is_none());
+ let result = r.feed(sender, 2, 0, b"hello".to_vec());
+ assert_eq!(result.unwrap(), b"helloworld");
+ }
+
+ // ── SendQueue tests ─────────────────────────────
+
+ #[test]
+ fn send_queue_push_drain() {
+ let mut q = SendQueue::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let src = NodeId::from_bytes([0x02; 32]);
+
+ q.push(dst, b"msg1".to_vec(), src);
+ q.push(dst, b"msg2".to_vec(), src);
+
+ assert!(q.has_pending(&dst));
+ let msgs = q.drain(&dst);
+ assert_eq!(msgs.len(), 2);
+ assert!(!q.has_pending(&dst));
+ }
+
+ #[test]
+ fn parse_truncated() {
+ assert!(parse_fragment(&[0, 1]).is_none());
+ }
+}
blob - /dev/null
blob + 72ec019e6098aeed78af0fd7703d6316d10b5e54 (mode 644)
--- /dev/null
+++ src/dht.rs
+//! Kademlia DHT logic: store, find_node, find_value.
+//!
+//! Uses explicit state machines for iterative queries
+//! instead of nested callbacks.
+
+use std::collections::{HashMap, HashSet};
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+use crate::peers::PeerInfo;
+use crate::routing::NUM_FIND_NODE;
+
+// ── Constants ────────────────────────────────────────
+
+/// Max parallel queries per lookup.
+pub const MAX_QUERY: usize = 6;
+
+/// Timeout for a single RPC query (seconds).
+pub const QUERY_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// Interval between data restore cycles.
+pub const RESTORE_INTERVAL: Duration = Duration::from_secs(120);
+
+/// Slow maintenance timer (refresh + restore).
+pub const SLOW_TIMER_INTERVAL: Duration = Duration::from_secs(600);
+
+/// Fast maintenance timer (expire + sweep).
+pub const FAST_TIMER_INTERVAL: Duration = Duration::from_secs(60);
+
+/// Number of original replicas for a put.
+pub const ORIGINAL_PUT_NUM: i32 = 3;
+
+/// Timeout waiting for all values in find_value.
+pub const RECVD_VALUE_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// RDP port for store operations.
+pub const RDP_STORE_PORT: u16 = 100;
+
+/// RDP port for get operations.
+pub const RDP_GET_PORT: u16 = 101;
+
+/// RDP connection timeout.
+pub const RDP_TIMEOUT: Duration = Duration::from_secs(30);
+
+// ── Stored data ─────────────────────────────────────
+
+/// A single stored value with metadata.
+#[derive(Debug, Clone)]
+pub struct StoredValue {
+ pub key: Vec<u8>,
+ pub value: Vec<u8>,
+ pub id: NodeId,
+ pub source: NodeId,
+ pub ttl: u16,
+ pub stored_at: Instant,
+ pub is_unique: bool,
+
+ /// Number of original puts remaining. Starts at
+ /// `ORIGINAL_PUT_NUM` for originator, 0 for replicas.
+ pub original: i32,
+
+ /// Set of node IDs that already received this value
+ /// during restore, to avoid duplicate sends.
+ pub recvd: HashSet<NodeId>,
+
+ /// Monotonic version timestamp. Newer versions
+ /// (higher value) replace older ones from the same
+ /// source. Prevents stale replicas from overwriting
+ /// fresh data during restore/republish.
+ pub version: u64,
+}
+
+/// Generate a monotonic version number based on the
+/// current time (milliseconds since epoch, truncated
+/// to u64). Sufficient for conflict resolution — two
+/// stores in the same millisecond will have the same
+/// version (tie-break: last write wins).
+pub fn now_version() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0)
+}
+
+impl StoredValue {
+ /// Check if this value has expired.
+ pub fn is_expired(&self) -> bool {
+ self.stored_at.elapsed() >= Duration::from_secs(self.ttl as u64)
+ }
+
+ /// Remaining TTL in seconds.
+ pub fn remaining_ttl(&self) -> u16 {
+ let elapsed = self.stored_at.elapsed().as_secs();
+ if elapsed >= self.ttl as u64 {
+ 0
+ } else {
+ (self.ttl as u64 - elapsed) as u16
+ }
+ }
+}
+
+// ── Storage container ───────────────────────────────
+
+/// Key for the two-level storage map:
+/// first level is the target NodeId (SHA1 of key),
+/// second level is the raw key bytes.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct StorageKey {
+ raw: Vec<u8>,
+}
+
+/// Container for stored DHT values.
+///
+/// Maps target_id -> raw_key -> set of values.
+/// Maps target_id -> raw_key -> set of values.
+pub struct DhtStorage {
+ data: HashMap<NodeId, HashMap<StorageKey, Vec<StoredValue>>>,
+ /// Maximum number of stored values (0 = unlimited).
+ max_entries: usize,
+}
+
+/// Default maximum storage entries.
+const DEFAULT_MAX_STORAGE: usize = 65536;
+
+impl DhtStorage {
+ pub fn new() -> Self {
+ Self {
+ data: HashMap::new(),
+ max_entries: DEFAULT_MAX_STORAGE,
+ }
+ }
+
+ /// Set the maximum number of stored values.
+ pub fn set_max_entries(&mut self, max: usize) {
+ self.max_entries = max;
+ }
+
+ /// Store a value. Handles `is_unique` semantics and
+ /// version-based conflict resolution.
+ ///
+ /// If a value with the same key from the same source
+ /// already exists with a higher version, the store is
+ /// rejected (prevents stale replicas from overwriting
+ /// fresh data).
+ pub fn store(&mut self, val: StoredValue) {
+ // Enforce storage limit
+ if self.max_entries > 0 && self.len() >= self.max_entries {
+ log::warn!(
+ "Storage full ({} entries), dropping store",
+ self.max_entries
+ );
+ return;
+ }
+
+ let key = StorageKey {
+ raw: val.key.clone(),
+ };
+ let entry =
+ self.data.entry(val.id).or_default().entry(key).or_default();
+
+ if val.is_unique {
+ // Unique: replace existing from same source,
+ // but only if version is not older
+ if let Some(pos) = entry.iter().position(|v| v.source == val.source)
+ {
+ if val.version > 0
+ && entry[pos].version > 0
+ && val.version < entry[pos].version
+ {
+ log::debug!(
+ "Rejecting stale unique store: v{} < v{}",
+ val.version,
+ entry[pos].version,
+ );
+ return;
+ }
+ entry[pos] = val;
+ } else if entry.is_empty() || !entry[0].is_unique {
+ entry.clear();
+ entry.push(val);
+ }
+ return;
+ }
+
+ // Non-unique: update if same value exists (any
+ // source), or append. Check version on update.
+ if let Some(pos) = entry.iter().position(|v| v.value == val.value) {
+ if val.version > 0
+ && entry[pos].version > 0
+ && val.version < entry[pos].version
+ {
+ log::debug!(
+ "Rejecting stale store: v{} < v{}",
+ val.version,
+ entry[pos].version,
+ );
+ return;
+ }
+ entry[pos].ttl = val.ttl;
+ entry[pos].stored_at = val.stored_at;
+ entry[pos].version = val.version;
+ } else {
+ // Don't add if existing data is unique
+ if entry.len() == 1 && entry[0].is_unique {
+ return;
+ }
+ entry.push(val);
+ }
+ }
+
+ /// Remove a specific value.
+ pub fn remove(&mut self, id: &NodeId, raw_key: &[u8]) {
+ let key = StorageKey {
+ raw: raw_key.to_vec(),
+ };
+ if let Some(inner) = self.data.get_mut(id) {
+ inner.remove(&key);
+ if inner.is_empty() {
+ self.data.remove(id);
+ }
+ }
+ }
+
+ /// Get all values for a target ID and key.
+ pub fn get(&self, id: &NodeId, raw_key: &[u8]) -> Vec<StoredValue> {
+ let key = StorageKey {
+ raw: raw_key.to_vec(),
+ };
+ self.data
+ .get(id)
+ .and_then(|inner| inner.get(&key))
+ .map(|vals| {
+ vals.iter().filter(|v| !v.is_expired()).cloned().collect()
+ })
+ .unwrap_or_default()
+ }
+
+ /// Remove all expired values.
+ pub fn expire(&mut self) {
+ self.data.retain(|_, inner| {
+ inner.retain(|_, vals| {
+ vals.retain(|v| !v.is_expired());
+ !vals.is_empty()
+ });
+ !inner.is_empty()
+ });
+ }
+
+ /// Iterate over all stored values (for restore).
+ pub fn all_values(&self) -> Vec<StoredValue> {
+ self.data
+ .values()
+ .flat_map(|inner| inner.values())
+ .flat_map(|vals| vals.iter())
+ .filter(|v| !v.is_expired())
+ .cloned()
+ .collect()
+ }
+
+ /// Decrement the `original` counter for a value.
+ /// Returns the new count, or -1 if not found
+ /// (in which case the value is inserted).
+ pub fn dec_original(&mut self, val: &StoredValue) -> i32 {
+ let key = StorageKey {
+ raw: val.key.clone(),
+ };
+ if let Some(inner) = self.data.get_mut(&val.id) {
+ if let Some(vals) = inner.get_mut(&key) {
+ if let Some(existing) = vals
+ .iter_mut()
+ .find(|v| v.value == val.value && v.source == val.source)
+ {
+ if existing.original > 0 {
+ existing.original -= 1;
+ }
+ return existing.original;
+ }
+ }
+ }
+
+ // Not found: insert it
+ self.store(val.clone());
+ -1
+ }
+
+ /// Mark a node as having received a stored value
+ /// (for restore deduplication).
+ pub fn mark_received(&mut self, val: &StoredValue, node_id: NodeId) {
+ let key = StorageKey {
+ raw: val.key.clone(),
+ };
+ if let Some(inner) = self.data.get_mut(&val.id) {
+ if let Some(vals) = inner.get_mut(&key) {
+ if let Some(existing) =
+ vals.iter_mut().find(|v| v.value == val.value)
+ {
+ existing.recvd.insert(node_id);
+ }
+ }
+ }
+ }
+
+ /// Total number of stored values.
+ pub fn len(&self) -> usize {
+ self.data
+ .values()
+ .flat_map(|inner| inner.values())
+ .map(|vals| vals.len())
+ .sum()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+}
+
+impl Default for DhtStorage {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+// ── Iterative query state machine ───────────────────
+
+/// Phase of an iterative Kademlia query.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum QueryPhase {
+ /// Actively searching: sending queries and processing
+ /// replies.
+ Searching,
+
+ /// No closer nodes found in last round: query has
+ /// converged.
+ Converged,
+
+ /// Query complete: results are ready.
+ Done,
+}
+
+/// State of an iterative FIND_NODE or FIND_VALUE query.
+///
+/// Uses an explicit state machine for iterative lookup.
+/// Maximum duration for an iterative query before
+/// returning best-effort results.
+pub const MAX_QUERY_DURATION: Duration = Duration::from_secs(30);
+
+pub struct IterativeQuery {
+ pub target: NodeId,
+ pub closest: Vec<PeerInfo>,
+ pub queried: HashSet<NodeId>,
+ pub pending: HashMap<NodeId, Instant>,
+ pub phase: QueryPhase,
+ pub is_find_value: bool,
+ pub key: Vec<u8>,
+ pub values: Vec<Vec<u8>>,
+ pub nonce: u32,
+ pub started_at: Instant,
+
+ /// Number of iterative rounds completed. Each round
+ /// is a batch of queries followed by reply processing.
+ /// Measures the "depth" of the lookup — useful for
+ /// diagnosing network topology and routing efficiency.
+ pub hops: u32,
+}
+
+impl IterativeQuery {
+ /// Create a new FIND_NODE query.
+ pub fn find_node(target: NodeId, nonce: u32) -> Self {
+ Self {
+ target,
+ closest: Vec::new(),
+ queried: HashSet::new(),
+ pending: HashMap::new(),
+ phase: QueryPhase::Searching,
+ is_find_value: false,
+ key: Vec::new(),
+ values: Vec::new(),
+ nonce,
+ started_at: Instant::now(),
+ hops: 0,
+ }
+ }
+
+ /// Create a new FIND_VALUE query.
+ pub fn find_value(target: NodeId, key: Vec<u8>, nonce: u32) -> Self {
+ Self {
+ target,
+ closest: Vec::new(),
+ queried: HashSet::new(),
+ pending: HashMap::new(),
+ phase: QueryPhase::Searching,
+ is_find_value: true,
+ key,
+ values: Vec::new(),
+ nonce,
+ started_at: Instant::now(),
+ hops: 0,
+ }
+ }
+
+ /// Select the next batch of peers to query.
+ ///
+ /// Returns up to `MAX_QUERY` un-queried peers from
+ /// `closest`, sorted by XOR distance to target.
+ pub fn next_to_query(&self) -> Vec<PeerInfo> {
+ let max = MAX_QUERY.saturating_sub(self.pending.len());
+ self.closest
+ .iter()
+ .filter(|p| {
+ !self.queried.contains(&p.id)
+ && !self.pending.contains_key(&p.id)
+ })
+ .take(max)
+ .cloned()
+ .collect()
+ }
+
+ /// Process a reply: merge new nodes into closest,
+ /// remove from pending, detect convergence.
+ /// Increments the hop counter for route length
+ /// tracking.
+ pub fn process_reply(&mut self, from: &NodeId, nodes: Vec<PeerInfo>) {
+ self.pending.remove(from);
+ self.queried.insert(*from);
+ self.hops += 1;
+
+ let prev_best =
+ self.closest.first().map(|p| self.target.distance(&p.id));
+
+ // Merge new nodes
+ for node in nodes {
+ if node.id != self.target
+ && !self.closest.iter().any(|c| c.id == node.id)
+ {
+ self.closest.push(node);
+ }
+ }
+
+ // Sort by XOR distance
+ self.closest.sort_by(|a, b| {
+ let da = self.target.distance(&a.id);
+ let db = self.target.distance(&b.id);
+ da.cmp(&db)
+ });
+
+ // Trim to NUM_FIND_NODE
+ self.closest.truncate(NUM_FIND_NODE);
+
+ // Check convergence: did the closest node change?
+ let new_best =
+ self.closest.first().map(|p| self.target.distance(&p.id));
+
+ if prev_best == new_best && self.pending.is_empty() {
+ self.phase = QueryPhase::Converged;
+ }
+ }
+
+ /// Process a value reply (for FIND_VALUE).
+ pub fn process_value(&mut self, value: Vec<u8>) {
+ self.values.push(value);
+ self.phase = QueryPhase::Done;
+ }
+
+ /// Mark a peer as timed out.
+ pub fn timeout(&mut self, id: &NodeId) {
+ self.pending.remove(id);
+ if self.pending.is_empty() && self.next_to_query().is_empty() {
+ self.phase = QueryPhase::Done;
+ }
+ }
+
+ /// Expire all pending queries that have exceeded
+ /// the timeout.
+ pub fn expire_pending(&mut self) {
+ let expired: Vec<NodeId> = self
+ .pending
+ .iter()
+ .filter(|(_, sent_at)| sent_at.elapsed() >= QUERY_TIMEOUT)
+ .map(|(id, _)| *id)
+ .collect();
+
+ for id in expired {
+ self.timeout(&id);
+ }
+ }
+
+ /// Check if the query is complete (converged,
+ /// finished, or timed out).
+ pub fn is_done(&self) -> bool {
+ self.phase == QueryPhase::Done
+ || self.phase == QueryPhase::Converged
+ || self.started_at.elapsed() >= MAX_QUERY_DURATION
+ }
+}
+
+// ── Maintenance: mask_bit exploration ───────────────
+
+/// Systematic exploration of the 256-bit ID space.
+///
+/// Generates target IDs for find_node queries that probe
+/// different regions of the network, populating distant
+/// k-buckets that would otherwise remain empty.
+///
+/// Used by both DHT and DTUN maintenance.
+pub struct MaskBitExplorer {
+ local_id: NodeId,
+ mask_bit: usize,
+}
+
+impl MaskBitExplorer {
+ pub fn new(local_id: NodeId) -> Self {
+ Self {
+ local_id,
+ mask_bit: 1,
+ }
+ }
+
+ /// Generate the next pair of exploration targets.
+ ///
+ /// Each call produces two targets by clearing specific
+ /// bits in the local ID, then advances by 2 bits.
+ /// After bit 20, resets to 1.
+ pub fn next_targets(&mut self) -> (NodeId, NodeId) {
+ let id_bytes = *self.local_id.as_bytes();
+
+ let t1 = Self::clear_bit(id_bytes, self.mask_bit);
+ let t2 = Self::clear_bit(id_bytes, self.mask_bit + 1);
+
+ self.mask_bit += 2;
+ if self.mask_bit > 20 {
+ self.mask_bit = 1;
+ }
+
+ (t1, t2)
+ }
+
+ /// Current mask_bit position (for testing).
+ pub fn position(&self) -> usize {
+ self.mask_bit
+ }
+
+ fn clear_bit(
+ mut bytes: [u8; crate::id::ID_LEN],
+ bit_from_msb: usize,
+ ) -> NodeId {
+ if bit_from_msb == 0 || bit_from_msb > crate::id::ID_BITS {
+ return NodeId::from_bytes(bytes);
+ }
+ let pos = bit_from_msb - 1; // 0-indexed
+ let byte_idx = pos / 8;
+ let bit_idx = 7 - (pos % 8);
+ bytes[byte_idx] &= !(1 << bit_idx);
+ NodeId::from_bytes(bytes)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::SocketAddr;
+
+ fn make_peer(byte: u8, port: u16) -> PeerInfo {
+ PeerInfo::new(
+ NodeId::from_bytes([byte; 32]),
+ SocketAddr::from(([127, 0, 0, 1], port)),
+ )
+ }
+
+ // ── DhtStorage tests ────────────────────────────
+
+ #[test]
+ fn storage_store_and_get() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"test-key");
+ let val = StoredValue {
+ key: b"test-key".to_vec(),
+ value: b"hello".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x01; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ };
+ s.store(val);
+ let got = s.get(&id, b"test-key");
+ assert_eq!(got.len(), 1);
+ assert_eq!(got[0].value, b"hello");
+ }
+
+ #[test]
+ fn storage_unique_replaces() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"uk");
+ let src = NodeId::from_bytes([0x01; 32]);
+
+ let v1 = StoredValue {
+ key: b"uk".to_vec(),
+ value: b"v1".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ };
+ s.store(v1);
+
+ let v2 = StoredValue {
+ key: b"uk".to_vec(),
+ value: b"v2".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ };
+ s.store(v2);
+
+ let got = s.get(&id, b"uk");
+ assert_eq!(got.len(), 1);
+ assert_eq!(got[0].value, b"v2");
+ }
+
+ #[test]
+ fn storage_unique_rejects_other_source() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"uk");
+
+ let v1 = StoredValue {
+ key: b"uk".to_vec(),
+ value: b"v1".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x01; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ };
+ s.store(v1);
+
+ let v2 = StoredValue {
+ key: b"uk".to_vec(),
+ value: b"v2".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x02; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ };
+ s.store(v2);
+
+ let got = s.get(&id, b"uk");
+ assert_eq!(got.len(), 1);
+ assert_eq!(got[0].value, b"v1");
+ }
+
+ #[test]
+ fn storage_multiple_non_unique() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"k");
+
+ for i in 0..3u8 {
+ s.store(StoredValue {
+ key: b"k".to_vec(),
+ value: vec![i],
+ id,
+ source: NodeId::from_bytes([i; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 0,
+ recvd: HashSet::new(),
+ version: 0,
+ });
+ }
+
+ assert_eq!(s.get(&id, b"k").len(), 3);
+ }
+
+ #[test]
+ fn storage_remove() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"k");
+ s.store(StoredValue {
+ key: b"k".to_vec(),
+ value: b"v".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x01; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 0,
+ recvd: HashSet::new(),
+ version: 0,
+ });
+ s.remove(&id, b"k");
+ assert!(s.get(&id, b"k").is_empty());
+ assert!(s.is_empty());
+ }
+
+ #[test]
+ fn storage_dec_original() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"k");
+ let val = StoredValue {
+ key: b"k".to_vec(),
+ value: b"v".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x01; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ };
+ s.store(val.clone());
+
+ assert_eq!(s.dec_original(&val), 2);
+ assert_eq!(s.dec_original(&val), 1);
+ assert_eq!(s.dec_original(&val), 0);
+ assert_eq!(s.dec_original(&val), 0); // stays at 0
+ }
+
+ #[test]
+ fn storage_mark_received() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"k");
+ let val = StoredValue {
+ key: b"k".to_vec(),
+ value: b"v".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x01; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 0,
+ recvd: HashSet::new(),
+ version: 0,
+ };
+ s.store(val.clone());
+
+ let node = NodeId::from_bytes([0x42; 32]);
+ s.mark_received(&val, node);
+
+ let got = s.get(&id, b"k");
+ assert!(got[0].recvd.contains(&node));
+ }
+
+ // ── IterativeQuery tests ────────────────────────
+
+ #[test]
+ fn query_process_reply_sorts() {
+ let target = NodeId::from_bytes([0x00; 32]);
+ let mut q = IterativeQuery::find_node(target, 1);
+
+ // Simulate: we have node 0xFF pending
+ let far = NodeId::from_bytes([0xFF; 32]);
+ q.pending.insert(far, Instant::now());
+
+ // Reply with closer nodes
+ let nodes = vec![
+ make_peer(0x10, 3000),
+ make_peer(0x01, 3001),
+ make_peer(0x05, 3002),
+ ];
+ q.process_reply(&far, nodes);
+
+ // Should be sorted by distance from target
+ assert_eq!(q.closest[0].id, NodeId::from_bytes([0x01; 32]));
+ assert_eq!(q.closest[1].id, NodeId::from_bytes([0x05; 32]));
+ assert_eq!(q.closest[2].id, NodeId::from_bytes([0x10; 32]));
+ }
+
+ #[test]
+ fn query_converges_when_no_closer() {
+ let target = NodeId::from_bytes([0x00; 32]);
+ let mut q = IterativeQuery::find_node(target, 1);
+
+ // Add initial closest
+ q.closest.push(make_peer(0x01, 3000));
+
+ // Simulate reply with no closer nodes
+ let from = NodeId::from_bytes([0x01; 32]);
+ q.pending.insert(from, Instant::now());
+ q.process_reply(&from, vec![make_peer(0x02, 3001)]);
+
+ // 0x01 is still closest, pending is empty -> converged
+ assert_eq!(q.phase, QueryPhase::Converged);
+ }
+
+ #[test]
+ fn query_find_value_done_on_value() {
+ let target = NodeId::from_bytes([0x00; 32]);
+ let mut q = IterativeQuery::find_value(target, b"key".to_vec(), 1);
+
+ q.process_value(b"found-it".to_vec());
+ assert!(q.is_done());
+ assert_eq!(q.values, vec![b"found-it".to_vec()]);
+ }
+
+ // ── MaskBitExplorer tests ───────────────────────
+
+ #[test]
+ fn mask_bit_cycles() {
+ let id = NodeId::from_bytes([0xFF; 32]);
+ let mut explorer = MaskBitExplorer::new(id);
+
+ assert_eq!(explorer.position(), 1);
+ explorer.next_targets();
+ assert_eq!(explorer.position(), 3);
+ explorer.next_targets();
+ assert_eq!(explorer.position(), 5);
+
+ // Run through full cycle
+ for _ in 0..8 {
+ explorer.next_targets();
+ }
+
+ // 5 + 8*2 = 21 > 20, so reset to 1
+ assert_eq!(explorer.position(), 1);
+ }
+
+ #[test]
+ fn mask_bit_produces_different_targets() {
+ let id = NodeId::from_bytes([0xFF; 32]);
+ let mut explorer = MaskBitExplorer::new(id);
+
+ let (t1, t2) = explorer.next_targets();
+ assert_ne!(t1, t2);
+ assert_ne!(t1, id);
+ assert_ne!(t2, id);
+ }
+
+ // ── Content versioning tests ──────────────────
+
+ #[test]
+ fn version_rejects_stale_unique() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"vk");
+ let src = NodeId::from_bytes([0x01; 32]);
+
+ // Store version 100
+ s.store(StoredValue {
+ key: b"vk".to_vec(),
+ value: b"new".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 100,
+ });
+
+ // Try to store older version 50 — should be rejected
+ s.store(StoredValue {
+ key: b"vk".to_vec(),
+ value: b"old".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 50,
+ });
+
+ let got = s.get(&id, b"vk");
+ assert_eq!(got.len(), 1);
+ assert_eq!(got[0].value, b"new");
+ assert_eq!(got[0].version, 100);
+ }
+
+ #[test]
+ fn version_accepts_newer() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"vk");
+ let src = NodeId::from_bytes([0x01; 32]);
+
+ s.store(StoredValue {
+ key: b"vk".to_vec(),
+ value: b"old".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 50,
+ });
+
+ s.store(StoredValue {
+ key: b"vk".to_vec(),
+ value: b"new".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 100,
+ });
+
+ let got = s.get(&id, b"vk");
+ assert_eq!(got[0].value, b"new");
+ }
+
+ #[test]
+ fn version_zero_always_accepted() {
+ // version=0 means "no versioning" — always accepted
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"vk");
+ let src = NodeId::from_bytes([0x01; 32]);
+
+ s.store(StoredValue {
+ key: b"vk".to_vec(),
+ value: b"v1".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ });
+
+ s.store(StoredValue {
+ key: b"vk".to_vec(),
+ value: b"v2".to_vec(),
+ id,
+ source: src,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: true,
+ original: 3,
+ recvd: HashSet::new(),
+ version: 0,
+ });
+
+ let got = s.get(&id, b"vk");
+ assert_eq!(got[0].value, b"v2");
+ }
+
+ #[test]
+ fn version_rejects_stale_non_unique() {
+ let mut s = DhtStorage::new();
+ let id = NodeId::from_key(b"nk");
+
+ // Store value with version 100
+ s.store(StoredValue {
+ key: b"nk".to_vec(),
+ value: b"same".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x01; 32]),
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 0,
+ recvd: HashSet::new(),
+ version: 100,
+ });
+
+ // Same value with older version — rejected
+ s.store(StoredValue {
+ key: b"nk".to_vec(),
+ value: b"same".to_vec(),
+ id,
+ source: NodeId::from_bytes([0x02; 32]),
+ ttl: 600,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 0,
+ recvd: HashSet::new(),
+ version: 50,
+ });
+
+ let got = s.get(&id, b"nk");
+ assert_eq!(got.len(), 1);
+ assert_eq!(got[0].version, 100);
+ assert_eq!(got[0].ttl, 300); // TTL not updated
+ }
+
+ // ── Route length tests ────────────────────────
+
+ #[test]
+ fn query_hops_increment() {
+ let target = NodeId::from_bytes([0x00; 32]);
+ let mut q = IterativeQuery::find_node(target, 1);
+ assert_eq!(q.hops, 0);
+
+ let from = NodeId::from_bytes([0xFF; 32]);
+ q.pending.insert(from, Instant::now());
+ q.process_reply(&from, vec![make_peer(0x10, 3000)]);
+ assert_eq!(q.hops, 1);
+
+ let from2 = NodeId::from_bytes([0x10; 32]);
+ q.pending.insert(from2, Instant::now());
+ q.process_reply(&from2, vec![make_peer(0x05, 3001)]);
+ assert_eq!(q.hops, 2);
+ }
+
+ #[test]
+ fn now_version_monotonic() {
+ let v1 = now_version();
+ std::thread::sleep(std::time::Duration::from_millis(2));
+ let v2 = now_version();
+ assert!(v2 >= v1);
+ }
+}
blob - /dev/null
blob + 367262c708b26a2d1491d43b8c7abbb02d5640ff (mode 644)
--- /dev/null
+++ src/dtun.rs
+//! Distributed tunnel for NAT traversal (DTUN).
+//!
+//! Maintains a separate
+//! routing table used to register NAT'd nodes and resolve
+//! their addresses for hole-punching.
+//!
+//! ## How it works
+//!
+//! 1. A node behind NAT **registers** itself with the
+//! k-closest global nodes (find_node + register).
+//! 2. When another node wants to reach a NAT'd node, it
+//! does a **find_value** in the DTUN table to discover
+//! which global node holds the registration.
+//! 3. It then sends a **request** to that global node,
+//! which forwards the request to the NAT'd node,
+//! causing it to send a packet that punches a hole.
+
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+use crate::peers::PeerInfo;
+use crate::routing::RoutingTable;
+
+// ── Constants ────────────────────────────────────────
+
+/// k-closest nodes for DTUN lookups.
+pub const DTUN_NUM_FIND_NODE: usize = 10;
+
+/// Max parallel queries.
+pub const DTUN_MAX_QUERY: usize = 6;
+
+/// Query timeout.
+pub const DTUN_QUERY_TIMEOUT: Duration = Duration::from_secs(2);
+
+/// Retries for reachability requests.
+pub const DTUN_REQUEST_RETRY: usize = 2;
+
+/// Request timeout.
+pub const DTUN_REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
+
+/// TTL for node registrations.
+pub const DTUN_REGISTERED_TTL: Duration = Duration::from_secs(300);
+
+/// Refresh timer interval.
+pub const DTUN_TIMER_INTERVAL: Duration = Duration::from_secs(30);
+
+/// Maintenance interval (mask_bit exploration).
+pub const DTUN_MAINTAIN_INTERVAL: Duration = Duration::from_secs(120);
+
+// ── Registration record ─────────────────────────────
+
+/// A node registered in the DTUN overlay.
+#[derive(Debug, Clone)]
+pub struct Registration {
+ /// The registered node's address.
+ pub addr: SocketAddr,
+
+ /// Session identifier (must match for updates).
+ pub session: u32,
+
+ /// When this registration was created/refreshed.
+ pub registered_at: Instant,
+}
+
+impl Registration {
+ /// Check if this registration has expired.
+ pub fn is_expired(&self) -> bool {
+ self.registered_at.elapsed() >= DTUN_REGISTERED_TTL
+ }
+}
+
+// ── Request state ───────────────────────────────────
+
+/// State of a reachability request.
+#[derive(Debug)]
+pub struct RequestState {
+ /// Target node we're trying to reach.
+ pub target: NodeId,
+
+ /// When the request was sent.
+ pub sent_at: Instant,
+
+ /// Remaining retries.
+ pub retries: usize,
+
+ /// Whether find_value completed.
+ pub found: bool,
+
+ /// The intermediary node (if found).
+ pub intermediary: Option<PeerInfo>,
+}
+
+// ── DTUN ────────────────────────────────────────────
+
+/// Distributed tunnel for NAT traversal.
+pub struct Dtun {
+ /// Separate routing table for the DTUN overlay.
+ table: RoutingTable,
+
+ /// Nodes registered through us (we're their
+ /// "registration server"). Capped at 1000.
+ registered: HashMap<NodeId, Registration>,
+
+ /// Our own registration session.
+ register_session: u32,
+
+ /// Whether we're currently registering.
+ registering: bool,
+
+ /// Last time registration was refreshed.
+ last_registered: Instant,
+
+ /// Pending reachability requests by nonce.
+ requests: HashMap<u32, RequestState>,
+
+ /// Whether DTUN is enabled.
+ enabled: bool,
+
+ /// Local node ID.
+ id: NodeId,
+
+ /// Mask bit for maintain() exploration.
+ mask_bit: usize,
+
+ /// Last maintain() call.
+ last_maintain: Instant,
+}
+
+impl Dtun {
+ pub fn new(id: NodeId) -> Self {
+ Self {
+ table: RoutingTable::new(id),
+ registered: HashMap::new(),
+ register_session: 0,
+ registering: false,
+ last_registered: Instant::now(),
+ requests: HashMap::new(),
+ enabled: true,
+ id,
+ mask_bit: 1,
+ last_maintain: Instant::now(),
+ }
+ }
+
+ /// Access the DTUN routing table.
+ pub fn table(&self) -> &RoutingTable {
+ &self.table
+ }
+
+ /// Mutable access to the routing table.
+ pub fn table_mut(&mut self) -> &mut RoutingTable {
+ &mut self.table
+ }
+
+ /// Whether DTUN is enabled.
+ pub fn is_enabled(&self) -> bool {
+ self.enabled
+ }
+
+ /// Enable or disable DTUN.
+ pub fn set_enabled(&mut self, enabled: bool) {
+ self.enabled = enabled;
+ }
+
+ /// Current registration session.
+ pub fn session(&self) -> u32 {
+ self.register_session
+ }
+
+ // ── Registration (server side) ──────────────────
+
+ /// Register a remote node (we act as their
+ /// registration server).
+ ///
+ /// Returns `true` if the registration was accepted.
+ pub fn register_node(
+ &mut self,
+ id: NodeId,
+ addr: SocketAddr,
+ session: u32,
+ ) -> bool {
+ const MAX_REGISTRATIONS: usize = 1000;
+ if let Some(existing) = self.registered.get(&id) {
+ if existing.session != session && !existing.is_expired() {
+ return false;
+ }
+ }
+ if self.registered.len() >= MAX_REGISTRATIONS
+ && !self.registered.contains_key(&id)
+ {
+ log::debug!("DTUN: registration limit reached");
+ return false;
+ }
+
+ self.registered.insert(
+ id,
+ Registration {
+ addr,
+ session,
+ registered_at: Instant::now(),
+ },
+ );
+ true
+ }
+
+ /// Look up a registered node by ID.
+ pub fn get_registered(&self, id: &NodeId) -> Option<&Registration> {
+ self.registered.get(id).filter(|r| !r.is_expired())
+ }
+
+ /// Remove expired registrations.
+ pub fn expire_registrations(&mut self) {
+ self.registered.retain(|_, r| !r.is_expired());
+ }
+
+ /// Number of active registrations.
+ pub fn registration_count(&self) -> usize {
+ self.registered.values().filter(|r| !r.is_expired()).count()
+ }
+
+ // ── Registration (client side) ──────────────────
+
+ /// Prepare to register ourselves. Increments the
+ /// session and returns (session, closest_nodes) for
+ /// the caller to send DTUN_REGISTER messages.
+ pub fn prepare_register(&mut self) -> (u32, Vec<PeerInfo>) {
+ self.register_session = self.register_session.wrapping_add(1);
+ self.registering = true;
+ let closest = self.table.closest(&self.id, DTUN_NUM_FIND_NODE);
+ (self.register_session, closest)
+ }
+
+ /// Mark registration as complete.
+ pub fn registration_done(&mut self) {
+ self.registering = false;
+ self.last_registered = Instant::now();
+ }
+
+ /// Check if re-registration is needed.
+ pub fn needs_reregister(&self) -> bool {
+ !self.registering
+ && self.last_registered.elapsed() >= DTUN_REGISTERED_TTL / 2
+ }
+
+ // ── Reachability requests ───────────────────────
+
+ /// Start a reachability request for a target node.
+ pub fn start_request(&mut self, nonce: u32, target: NodeId) {
+ self.requests.insert(
+ nonce,
+ RequestState {
+ target,
+ sent_at: Instant::now(),
+ retries: DTUN_REQUEST_RETRY,
+ found: false,
+ intermediary: None,
+ },
+ );
+ }
+
+ /// Record that find_value found the intermediary for
+ /// a request.
+ pub fn request_found(&mut self, nonce: u32, intermediary: PeerInfo) {
+ if let Some(req) = self.requests.get_mut(&nonce) {
+ req.found = true;
+ req.intermediary = Some(intermediary);
+ }
+ }
+
+ /// Get the intermediary for a pending request.
+ pub fn get_request(&self, nonce: &u32) -> Option<&RequestState> {
+ self.requests.get(nonce)
+ }
+
+ /// Remove a completed or timed-out request.
+ pub fn remove_request(&mut self, nonce: &u32) -> Option<RequestState> {
+ self.requests.remove(nonce)
+ }
+
+ /// Expire timed-out requests.
+ pub fn expire_requests(&mut self) {
+ self.requests.retain(|_, req| {
+ req.sent_at.elapsed() < DTUN_REQUEST_TIMEOUT || req.retries > 0
+ });
+ }
+
+ // ── Maintenance ─────────────────────────────────
+
+ /// Periodic maintenance: explore the ID space with
+ /// mask_bit and expire stale data.
+ ///
+ /// Returns target IDs for find_node queries, or empty
+ /// if maintenance isn't due yet.
+ pub fn maintain(&mut self) -> Vec<NodeId> {
+ if self.last_maintain.elapsed() < DTUN_MAINTAIN_INTERVAL {
+ return Vec::new();
+ }
+ self.last_maintain = Instant::now();
+
+ // Generate exploration targets
+ let id_bytes = *self.id.as_bytes();
+ let t1 = clear_bit(id_bytes, self.mask_bit);
+ let t2 = clear_bit(id_bytes, self.mask_bit + 1);
+
+ self.mask_bit += 2;
+ if self.mask_bit > 20 {
+ self.mask_bit = 1;
+ }
+
+ self.expire_registrations();
+ self.expire_requests();
+
+ vec![t1, t2]
+ }
+}
+
+/// Clear a specific bit (1-indexed from MSB) in a NodeId.
+fn clear_bit(
+ mut bytes: [u8; crate::id::ID_LEN],
+ bit_from_msb: usize,
+) -> NodeId {
+ if bit_from_msb == 0 || bit_from_msb > crate::id::ID_BITS {
+ return NodeId::from_bytes(bytes);
+ }
+ let pos = bit_from_msb - 1;
+ let byte_idx = pos / 8;
+ let bit_idx = 7 - (pos % 8);
+ bytes[byte_idx] &= !(1 << bit_idx);
+ NodeId::from_bytes(bytes)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn addr(port: u16) -> SocketAddr {
+ SocketAddr::from(([127, 0, 0, 1], port))
+ }
+
+ #[test]
+ fn register_and_lookup() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0x01; 32]));
+ let nid = NodeId::from_bytes([0x02; 32]);
+
+ assert!(dtun.register_node(nid, addr(3000), 1));
+ assert!(dtun.get_registered(&nid).is_some());
+ assert_eq!(dtun.registration_count(), 1);
+ }
+
+ #[test]
+ fn register_rejects_different_session() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0x01; 32]));
+ let nid = NodeId::from_bytes([0x02; 32]);
+
+ assert!(dtun.register_node(nid, addr(3000), 1));
+
+ // Different session, not expired → rejected
+ assert!(!dtun.register_node(nid, addr(3001), 2));
+ }
+
+ #[test]
+ fn expire_registrations() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0x01; 32]));
+ let nid = NodeId::from_bytes([0x02; 32]);
+ dtun.registered.insert(
+ nid,
+ Registration {
+ addr: addr(3000),
+ session: 1,
+
+ // Expired: registered 10 minutes ago
+ registered_at: Instant::now() - Duration::from_secs(600),
+ },
+ );
+
+ dtun.expire_registrations();
+ assert_eq!(dtun.registration_count(), 0);
+ }
+
+ #[test]
+ fn request_lifecycle() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0x01; 32]));
+ let target = NodeId::from_bytes([0x02; 32]);
+
+ dtun.start_request(42, target);
+ assert!(dtun.get_request(&42).is_some());
+
+ let intermediary =
+ PeerInfo::new(NodeId::from_bytes([0x03; 32]), addr(4000));
+ dtun.request_found(42, intermediary);
+ assert!(dtun.get_request(&42).unwrap().found);
+
+ dtun.remove_request(&42);
+ assert!(dtun.get_request(&42).is_none());
+ }
+
+ #[test]
+ fn prepare_register_increments_session() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0x01; 32]));
+ let (s1, _) = dtun.prepare_register();
+ let (s2, _) = dtun.prepare_register();
+ assert_eq!(s2, s1 + 1);
+ }
+
+ #[test]
+ fn maintain_returns_targets() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0xFF; 32]));
+
+ // Force last_maintain to be old enough
+ dtun.last_maintain = Instant::now() - DTUN_MAINTAIN_INTERVAL;
+
+ let targets = dtun.maintain();
+ assert_eq!(targets.len(), 2);
+
+ // Should differ from local ID
+ assert_ne!(targets[0], dtun.id);
+ assert_ne!(targets[1], dtun.id);
+ }
+
+ #[test]
+ fn maintain_skips_if_recent() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0xFF; 32]));
+ let targets = dtun.maintain();
+ assert!(targets.is_empty());
+ }
+
+ #[test]
+ fn enable_disable() {
+ let mut dtun = Dtun::new(NodeId::from_bytes([0x01; 32]));
+ assert!(dtun.is_enabled());
+ dtun.set_enabled(false);
+ assert!(!dtun.is_enabled());
+ }
+}
blob - /dev/null
blob + d5d6f560728bd51c21593fda8c34f791979e6a0d (mode 644)
--- /dev/null
+++ src/error.rs
+use std::fmt;
+use std::io;
+
+#[derive(Debug)]
+pub enum Error {
+ Io(io::Error),
+
+ /// Bad magic number in header.
+ BadMagic(u16),
+
+ /// Protocol version not supported.
+ UnsupportedVersion(u8),
+
+ /// Unknown message type byte.
+ UnknownMessageType(u8),
+
+ /// Packet or buffer too small for the operation.
+ BufferTooSmall,
+
+ /// Payload length doesn't match declared length.
+ PayloadMismatch,
+
+ /// Generic invalid message (malformed content).
+ InvalidMessage,
+
+ /// Not connected to the network.
+ NotConnected,
+
+ /// Operation timed out.
+ Timeout,
+
+ /// Signature verification failed.
+ BadSignature,
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::Io(e) => write!(f, "I/O error: {e}"),
+ Error::BadMagic(m) => {
+ write!(f, "bad magic: 0x{m:04x}")
+ }
+ Error::UnsupportedVersion(v) => {
+ write!(f, "unsupported version: {v}")
+ }
+ Error::UnknownMessageType(t) => {
+ write!(f, "unknown message type: 0x{t:02x}")
+ }
+ Error::BufferTooSmall => {
+ write!(f, "buffer too small")
+ }
+ Error::PayloadMismatch => {
+ write!(f, "payload length mismatch")
+ }
+ Error::InvalidMessage => {
+ write!(f, "invalid message")
+ }
+ Error::NotConnected => write!(f, "not connected"),
+ Error::Timeout => write!(f, "timeout"),
+ Error::BadSignature => {
+ write!(f, "signature verification failed")
+ }
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Error::Io(e) => Some(e),
+ _ => None,
+ }
+ }
+}
+
+impl From<io::Error> for Error {
+ fn from(e: io::Error) -> Self {
+ Error::Io(e)
+ }
+}
blob - /dev/null
blob + df7295d36a49f56bf050a8832e6fb560f6db6c11 (mode 644)
--- /dev/null
+++ src/event.rs
+//! Event types for typed callback integration.
+//!
+//! Alternative to `Box<dyn Fn>` callbacks. Applications
+//! can receive events via `std::sync::mpsc::Receiver`.
+
+use crate::id::NodeId;
+use crate::rdp::{RdpAddr, RdpEvent};
+
+/// Events emitted by a Node.
+#[derive(Debug, Clone)]
+pub enum NodeEvent {
+ /// Datagram received from a peer.
+ DgramReceived { data: Vec<u8>, from: NodeId },
+
+ /// RDP event on a connection.
+ Rdp {
+ desc: i32,
+ addr: RdpAddr,
+ event: RdpEvent,
+ },
+
+ /// Peer added to routing table.
+ PeerAdded(NodeId),
+
+ /// Value stored via DHT STORE.
+ ValueStored { key: Vec<u8> },
+
+ /// Node shutdown initiated.
+ Shutdown,
+}
blob - /dev/null
blob + f4c5b2c0a88778c8c2f8ce7ab7cebe831cc5cb4b (mode 644)
--- /dev/null
+++ src/handlers.rs
+//! Packet dispatch and message handlers.
+//!
+//! Extension impl block for Node. All handle_*
+//! methods process incoming protocol messages.
+
+use std::net::SocketAddr;
+use std::time::Instant;
+
+use crate::msg;
+use crate::nat::NatState;
+use crate::node::Node;
+use crate::peers::PeerInfo;
+use crate::wire::{DOMAIN_INET, DOMAIN_INET6, HEADER_SIZE, MsgHeader, MsgType};
+
+impl Node {
+ // ── Packet dispatch ─────────────────────────────
+
+ pub(crate) fn handle_packet(&mut self, raw: &[u8], from: SocketAddr) {
+ self.metrics
+ .bytes_received
+ .fetch_add(raw.len() as u64, std::sync::atomic::Ordering::Relaxed);
+
+ // Verify signature and strip it
+ let buf = match self.verify_incoming(raw, from) {
+ Some(b) => b,
+ None => {
+ self.metrics
+ .packets_rejected
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ log::trace!("Dropped unsigned packet from {from}");
+ return;
+ }
+ };
+
+ self.metrics
+ .messages_received
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+
+ // Reject banned peers
+ if self.ban_list.is_banned(&from) {
+ log::trace!("Dropped packet from banned peer {from}");
+ return;
+ }
+
+ // Rate limit per source IP
+ if !self.rate_limiter.allow(from.ip()) {
+ log::trace!("Rate limited packet from {from}");
+ return;
+ }
+
+ if buf.len() < HEADER_SIZE {
+ return;
+ }
+
+ let hdr = match MsgHeader::parse(buf) {
+ Ok(h) => h,
+ Err(e) => {
+ log::trace!("Dropped packet from {from}: {e}");
+ return;
+ }
+ };
+
+ log::trace!(
+ "Received {:?} from {from} (src={:?})",
+ hdr.msg_type,
+ hdr.src
+ );
+
+ // Register sender as peer and record successful
+ // communication (clears ban list failures)
+ self.peers.add(PeerInfo::new(hdr.src, from));
+ self.ban_list.record_success(&from);
+
+ match hdr.msg_type {
+ // ── DHT messages ────────────────────────
+ MsgType::DhtPing => {
+ self.handle_dht_ping(buf, &hdr, from);
+ }
+ MsgType::DhtPingReply => {
+ self.handle_dht_ping_reply(buf, &hdr, from);
+ }
+ MsgType::DhtFindNode => {
+ self.handle_dht_find_node(buf, &hdr, from);
+ }
+ MsgType::DhtFindNodeReply => {
+ self.handle_dht_find_node_reply(buf, &hdr, from);
+ }
+ MsgType::DhtStore => {
+ self.handle_dht_store(buf, &hdr, from);
+ }
+ MsgType::DhtFindValue => {
+ self.handle_dht_find_value(buf, &hdr, from);
+ }
+ MsgType::DhtFindValueReply => {
+ self.handle_dht_find_value_reply(buf, &hdr);
+ }
+
+ // ── NAT detection ───────────────────────
+ MsgType::NatEcho => {
+ // Respond with our observed address of the sender
+ if let Ok(nonce) = msg::parse_nat_echo(buf) {
+ log::debug!("NatEcho from {:?} nonce={nonce}", hdr.src);
+ let reply = msg::NatEchoReply {
+ nonce,
+ domain: if from.is_ipv4() {
+ DOMAIN_INET
+ } else {
+ DOMAIN_INET6
+ },
+ port: from.port(),
+ addr: {
+ let mut a = [0u8; 16];
+ match from.ip() {
+ std::net::IpAddr::V4(v4) => {
+ a[..4].copy_from_slice(&v4.octets())
+ }
+ std::net::IpAddr::V6(v6) => {
+ a.copy_from_slice(&v6.octets())
+ }
+ }
+ a
+ },
+ };
+ let size = HEADER_SIZE + msg::NAT_ECHO_REPLY_BODY;
+ let mut rbuf = vec![0u8; size];
+ let rhdr = MsgHeader::new(
+ MsgType::NatEchoReply,
+ Self::len16(size),
+ self.id,
+ hdr.src,
+ );
+ if rhdr.write(&mut rbuf).is_ok() {
+ msg::write_nat_echo_reply(&mut rbuf, &reply);
+ let _ = self.send_signed(&rbuf, from);
+ }
+ }
+ }
+ MsgType::NatEchoReply => {
+ if let Ok(reply) = msg::parse_nat_echo_reply(buf) {
+ let observed_port = reply.port;
+ let observed_ip: std::net::IpAddr =
+ if reply.domain == DOMAIN_INET {
+ std::net::IpAddr::V4(std::net::Ipv4Addr::new(
+ reply.addr[0],
+ reply.addr[1],
+ reply.addr[2],
+ reply.addr[3],
+ ))
+ } else {
+ std::net::IpAddr::V6(std::net::Ipv6Addr::from(
+ reply.addr,
+ ))
+ };
+ let observed = SocketAddr::new(observed_ip, observed_port);
+ let local = self.net.local_addr().unwrap_or(from);
+ let action =
+ self.nat.recv_echo_reply(reply.nonce, observed, local);
+ log::info!(
+ "NAT echo reply: observed={observed} action={action:?}"
+ );
+
+ // After NAT detection completes, register with DTUN
+ if let crate::nat::EchoReplyAction::DetectionComplete(
+ state,
+ ) = action
+ {
+ log::info!("NAT type detected: {state:?}");
+ if state != NatState::Global && self.is_dtun {
+ self.dtun_register();
+ }
+ if state == NatState::SymmetricNat {
+ // Find a global node to use as proxy
+ let closest = self.dht_table.closest(&self.id, 1);
+ if let Some(server) = closest.first() {
+ self.proxy.set_server(server.clone());
+ let nonce = self.alloc_nonce();
+ if let Some(n) =
+ self.proxy.start_register(nonce)
+ {
+ // Send ProxyRegister msg
+ let size = HEADER_SIZE + 8;
+ let mut buf = vec![0u8; size];
+ let hdr = MsgHeader::new(
+ MsgType::ProxyRegister,
+ Self::len16(size),
+ self.id,
+ server.id,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ let session = self.dtun.session();
+ buf[HEADER_SIZE..HEADER_SIZE + 4]
+ .copy_from_slice(
+ &session.to_be_bytes(),
+ );
+ buf[HEADER_SIZE + 4..HEADER_SIZE + 8]
+ .copy_from_slice(&n.to_be_bytes());
+ if let Err(e) =
+ self.send_signed(&buf, server.addr)
+ {
+ log::warn!(
+ "ProxyRegister send failed: {e}"
+ );
+ }
+ }
+ log::info!(
+ "Sent ProxyRegister to {:?}",
+ server.id
+ );
+ }
+ } else {
+ log::warn!(
+ "No peers available as proxy server"
+ );
+ }
+ }
+ }
+ }
+ }
+ MsgType::NatEchoRedirect | MsgType::NatEchoRedirectReply => {
+ log::debug!("NAT redirect: {:?}", hdr.msg_type);
+ }
+
+ // ── DTUN ────────────────────────────────
+ MsgType::DtunPing => {
+ // Reuse DHT ping handler logic
+ self.handle_dht_ping(buf, &hdr, from);
+ self.dtun.table_mut().add(PeerInfo::new(hdr.src, from));
+ }
+ MsgType::DtunPingReply => {
+ self.handle_dht_ping_reply(buf, &hdr, from);
+ self.dtun.table_mut().add(PeerInfo::new(hdr.src, from));
+ }
+ MsgType::DtunFindNode => {
+ // Respond with closest from DTUN table
+ if let Ok(find) = msg::parse_find_node(buf) {
+ self.dtun.table_mut().add(PeerInfo::new(hdr.src, from));
+ let closest = self
+ .dtun
+ .table()
+ .closest(&find.target, self.config.num_find_node);
+ let domain = if from.is_ipv4() {
+ DOMAIN_INET
+ } else {
+ DOMAIN_INET6
+ };
+ let reply_msg = msg::FindNodeReplyMsg {
+ nonce: find.nonce,
+ id: find.target,
+ domain,
+ nodes: closest,
+ };
+ let mut rbuf = [0u8; 2048];
+ let rhdr = MsgHeader::new(
+ MsgType::DtunFindNodeReply,
+ 0,
+ self.id,
+ hdr.src,
+ );
+ if rhdr.write(&mut rbuf).is_ok() {
+ let total =
+ msg::write_find_node_reply(&mut rbuf, &reply_msg);
+ rbuf[4..6]
+ .copy_from_slice(&Self::len16(total).to_be_bytes());
+ let _ = self.send_signed(&rbuf[..total], from);
+ }
+ }
+ }
+ MsgType::DtunFindNodeReply => {
+ if let Ok(reply) = msg::parse_find_node_reply(buf) {
+ log::debug!(
+ "DTUN find_node_reply: {} nodes",
+ reply.nodes.len()
+ );
+ for node in &reply.nodes {
+ self.dtun.table_mut().add(node.clone());
+ }
+ }
+ }
+ MsgType::DtunFindValue | MsgType::DtunFindValueReply => {
+ log::debug!("DTUN find_value: {:?}", hdr.msg_type);
+ }
+ MsgType::DtunRegister => {
+ if let Ok(session) = msg::parse_dtun_register(buf) {
+ log::debug!(
+ "DTUN register from {:?} session={session}",
+ hdr.src
+ );
+ self.dtun.register_node(hdr.src, from, session);
+ }
+ }
+ MsgType::DtunRequest => {
+ if let Ok((nonce, target)) = msg::parse_dtun_request(buf) {
+ log::debug!("DTUN request for {:?} nonce={nonce}", target);
+
+ // Check if we have this node registered
+ if let Some(reg) = self.dtun.get_registered(&target) {
+ log::debug!(
+ "DTUN: forwarding request to {:?}",
+ reg.addr
+ );
+ }
+ }
+ }
+ MsgType::DtunRequestBy | MsgType::DtunRequestReply => {
+ log::debug!("DTUN request_by/reply: {:?}", hdr.msg_type);
+ }
+
+ // ── Proxy ───────────────────────────────
+ MsgType::ProxyRegister => {
+ if buf.len() >= HEADER_SIZE + 8 {
+ let session = u32::from_be_bytes([
+ buf[HEADER_SIZE],
+ buf[HEADER_SIZE + 1],
+ buf[HEADER_SIZE + 2],
+ buf[HEADER_SIZE + 3],
+ ]);
+ let nonce = u32::from_be_bytes([
+ buf[HEADER_SIZE + 4],
+ buf[HEADER_SIZE + 5],
+ buf[HEADER_SIZE + 6],
+ buf[HEADER_SIZE + 7],
+ ]);
+ log::debug!(
+ "Proxy register from {:?} session={session}",
+ hdr.src
+ );
+ self.proxy.register_client(hdr.src, from, session);
+
+ // Send reply
+ let size = HEADER_SIZE + 4;
+ let mut rbuf = vec![0u8; size];
+ let rhdr = MsgHeader::new(
+ MsgType::ProxyRegisterReply,
+ Self::len16(size),
+ self.id,
+ hdr.src,
+ );
+ if rhdr.write(&mut rbuf).is_ok() {
+ rbuf[HEADER_SIZE..HEADER_SIZE + 4]
+ .copy_from_slice(&nonce.to_be_bytes());
+ let _ = self.send_signed(&rbuf, from);
+ }
+ }
+ }
+ MsgType::ProxyRegisterReply => {
+ if let Ok(nonce) = msg::parse_ping(buf) {
+ self.proxy.recv_register_reply(nonce);
+ }
+ }
+ MsgType::ProxyStore => {
+ // Forward store to DHT on behalf of the client
+ self.handle_dht_store(buf, &hdr, from);
+ }
+ MsgType::ProxyGet | MsgType::ProxyGetReply => {
+ log::debug!("Proxy get/reply: {:?}", hdr.msg_type);
+ }
+ MsgType::ProxyDgram | MsgType::ProxyDgramForwarded => {
+ // Forward dgram payload
+ self.handle_dgram(buf, &hdr);
+ }
+ MsgType::ProxyRdp | MsgType::ProxyRdpForwarded => {
+ // Forward RDP payload
+ self.handle_rdp(buf, &hdr);
+ }
+
+ // ── Transport ───────────────────────────
+ MsgType::Dgram => {
+ self.handle_dgram(buf, &hdr);
+ }
+ MsgType::Rdp => {
+ self.handle_rdp(buf, &hdr);
+ }
+
+ // ── Advertise ───────────────────────────
+ MsgType::Advertise => {
+ if buf.len() >= HEADER_SIZE + 8 {
+ let nonce = u32::from_be_bytes([
+ buf[HEADER_SIZE],
+ buf[HEADER_SIZE + 1],
+ buf[HEADER_SIZE + 2],
+ buf[HEADER_SIZE + 3],
+ ]);
+ log::debug!("Advertise from {:?} nonce={nonce}", hdr.src);
+ self.advertise.recv_advertise(hdr.src);
+
+ // Send reply
+ let size = HEADER_SIZE + 8;
+ let mut rbuf = vec![0u8; size];
+ let rhdr = MsgHeader::new(
+ MsgType::AdvertiseReply,
+ Self::len16(size),
+ self.id,
+ hdr.src,
+ );
+ if rhdr.write(&mut rbuf).is_ok() {
+ rbuf[HEADER_SIZE..HEADER_SIZE + 8].copy_from_slice(
+ &buf[HEADER_SIZE..HEADER_SIZE + 8],
+ );
+ let _ = self.send_signed(&rbuf, from);
+ }
+ }
+ }
+ MsgType::AdvertiseReply => {
+ if buf.len() >= HEADER_SIZE + 8 {
+ let nonce = u32::from_be_bytes([
+ buf[HEADER_SIZE],
+ buf[HEADER_SIZE + 1],
+ buf[HEADER_SIZE + 2],
+ buf[HEADER_SIZE + 3],
+ ]);
+ self.advertise.recv_reply(nonce);
+ }
+ }
+ }
+ }
+
+ // ── DHT message handlers ────────────────────────
+
+ pub(crate) fn handle_dht_ping(
+ &mut self,
+ buf: &[u8],
+ hdr: &MsgHeader,
+ from: SocketAddr,
+ ) {
+ let nonce = match msg::parse_ping(buf) {
+ Ok(n) => n,
+ Err(_) => return,
+ };
+
+ if hdr.dst != self.id {
+ return;
+ }
+
+ log::debug!("DHT ping from {:?} nonce={nonce}", hdr.src);
+
+ // Add to routing table
+ self.dht_table.add(PeerInfo::new(hdr.src, from));
+
+ // Send reply
+ let reply_hdr = MsgHeader::new(
+ MsgType::DhtPingReply,
+ Self::len16(msg::PING_MSG_SIZE),
+ self.id,
+ hdr.src,
+ );
+ let mut reply = [0u8; msg::PING_MSG_SIZE];
+ if reply_hdr.write(&mut reply).is_ok() {
+ msg::write_ping(&mut reply, nonce);
+ let _ = self.send_signed(&reply, from);
+ }
+ }
+
+ pub(crate) fn handle_dht_ping_reply(
+ &mut self,
+ buf: &[u8],
+ hdr: &MsgHeader,
+ from: SocketAddr,
+ ) {
+ let nonce = match msg::parse_ping(buf) {
+ Ok(n) => n,
+ Err(_) => return,
+ };
+
+ if hdr.dst != self.id {
+ return;
+ }
+
+ // Verify we actually sent this nonce
+ match self.pending_pings.remove(&nonce) {
+ Some((expected_id, _sent_at)) => {
+ if expected_id != hdr.src && !expected_id.is_zero() {
+ log::debug!(
+ "Ping reply nonce={nonce}: expected {:?} got {:?}",
+ expected_id,
+ hdr.src
+ );
+ return;
+ }
+ }
+ None => {
+ log::trace!("Ignoring unsolicited ping reply nonce={nonce}");
+ return;
+ }
+ }
+
+ log::debug!("DHT ping reply from {:?} nonce={nonce}", hdr.src);
+
+ self.dht_table.add(PeerInfo::new(hdr.src, from));
+ self.dht_table.mark_seen(&hdr.src);
+ }
+
+ pub(crate) fn handle_dht_find_node(
+ &mut self,
+ buf: &[u8],
+ hdr: &MsgHeader,
+ from: SocketAddr,
+ ) {
+ let find = match msg::parse_find_node(buf) {
+ Ok(f) => f,
+ Err(_) => return,
+ };
+
+ log::debug!(
+ "DHT find_node from {:?} target={:?}",
+ hdr.src,
+ find.target
+ );
+
+ self.dht_table.add(PeerInfo::new(hdr.src, from));
+
+ // Respond with our closest nodes
+ let closest = self
+ .dht_table
+ .closest(&find.target, self.config.num_find_node);
+
+ let domain = if from.is_ipv4() {
+ DOMAIN_INET
+ } else {
+ DOMAIN_INET6
+ };
+
+ let reply_msg = msg::FindNodeReplyMsg {
+ nonce: find.nonce,
+ id: find.target,
+ domain,
+ nodes: closest,
+ };
+
+ let mut reply_buf = [0u8; 2048];
+ let reply_hdr =
+ MsgHeader::new(MsgType::DhtFindNodeReply, 0, self.id, hdr.src);
+ if reply_hdr.write(&mut reply_buf).is_ok() {
+ let total = msg::write_find_node_reply(&mut reply_buf, &reply_msg);
+
+ // Fix the length in header
+ let len_bytes = Self::len16(total).to_be_bytes();
+ reply_buf[4] = len_bytes[0];
+ reply_buf[5] = len_bytes[1];
+ let _ = self.send_signed(&reply_buf[..total], from);
+ }
+ }
+
+ pub(crate) fn handle_dht_find_node_reply(
+ &mut self,
+ buf: &[u8],
+ hdr: &MsgHeader,
+ from: SocketAddr,
+ ) {
+ let reply = match msg::parse_find_node_reply(buf) {
+ Ok(r) => r,
+ Err(_) => return,
+ };
+
+ log::debug!(
+ "DHT find_node_reply from {:?}: {} nodes",
+ hdr.src,
+ reply.nodes.len()
+ );
+
+ // Add sender to routing table
+ self.dht_table.add(PeerInfo::new(hdr.src, from));
+
+ // Add returned nodes, filtering invalid addresses
+ for node in &reply.nodes {
+ // S1-6: reject unroutable addresses
+ let ip = node.addr.ip();
+ if ip.is_unspecified() || ip.is_multicast() {
+ continue;
+ }
+ // Reject zero NodeId
+ if node.id.is_zero() {
+ continue;
+ }
+ let result = self.dht_table.add(node.clone());
+ self.peers.add(node.clone());
+
+ // §2.5: replicate data to newly discovered nodes
+ if matches!(result, crate::routing::InsertResult::Inserted) {
+ self.proactive_replicate(node);
+ }
+ }
+
+ // Feed active queries with the reply
+ let sender_id = hdr.src;
+ let nodes_clone = reply.nodes.clone();
+
+ // Find which query this reply belongs to (match
+ // by nonce or by pending sender).
+ let matching_nonce: Option<u32> = self
+ .queries
+ .iter()
+ .find(|(_, q)| q.pending.contains_key(&sender_id))
+ .map(|(n, _)| *n);
+
+ if let Some(nonce) = matching_nonce {
+ if let Some(q) = self.queries.get_mut(&nonce) {
+ q.process_reply(&sender_id, nodes_clone);
+
+ // Send follow-up find_nodes to newly
+ // discovered nodes
+ let next = q.next_to_query();
+ let target = q.target;
+ for peer in next {
+ if self.send_find_node(peer.addr, target).is_ok() {
+ if let Some(q) = self.queries.get_mut(&nonce) {
+ q.pending.insert(peer.id, Instant::now());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ pub(crate) fn handle_dht_store(
+ &mut self,
+ buf: &[u8],
+ hdr: &MsgHeader,
+ from: SocketAddr,
+ ) {
+ let store = match msg::parse_store(buf) {
+ Ok(s) => s,
+ Err(_) => return,
+ };
+
+ if hdr.dst != self.id {
+ return;
+ }
+
+ // S1-4: enforce max value size
+ if store.value.len() > self.config.max_value_size {
+ log::debug!(
+ "Rejecting oversized store: {} bytes > {} max",
+ store.value.len(),
+ self.config.max_value_size
+ );
+ return;
+ }
+
+ // S2-9: verify sender matches claimed originator
+ if hdr.src != store.from {
+ log::debug!(
+ "Store origin mismatch: sender={:?} from={:?}",
+ hdr.src,
+ store.from
+ );
+ return;
+ }
+
+ log::debug!(
+ "DHT store from {:?}: key={} bytes, value={} bytes, ttl={}",
+ hdr.src,
+ store.key.len(),
+ store.value.len(),
+ store.ttl
+ );
+
+ self.dht_table.add(PeerInfo::new(hdr.src, from));
+
+ if store.ttl == 0 {
+ self.storage.remove(&store.id, &store.key);
+ } else {
+ let val = crate::dht::StoredValue {
+ key: store.key,
+ value: store.value,
+ id: store.id,
+ source: store.from,
+ ttl: store.ttl,
+ stored_at: std::time::Instant::now(),
+ is_unique: store.is_unique,
+ original: 0, // received, not originated
+ recvd: {
+ let mut s = std::collections::HashSet::new();
+ s.insert(hdr.src);
+ s
+ },
+ version: crate::dht::now_version(),
+ };
+ self.storage.store(val);
+ }
+ }
+
+ pub(crate) fn handle_dht_find_value(
+ &mut self,
+ buf: &[u8],
+ hdr: &MsgHeader,
+ from: SocketAddr,
+ ) {
+ let fv = match msg::parse_find_value(buf) {
+ Ok(f) => f,
+ Err(_) => return,
+ };
+
+ log::debug!(
+ "DHT find_value from {:?}: key={} bytes",
+ hdr.src,
+ fv.key.len()
+ );
+
+ self.dht_table.add(PeerInfo::new(hdr.src, from));
+
+ // Check local storage
+ let values = self.storage.get(&fv.target, &fv.key);
+
+ if !values.is_empty() {
+ log::debug!("Found {} value(s) locally", values.len());
+
+ // Send value reply using DHT_FIND_VALUE_REPLY
+ // with DATA_ARE_VALUES flag
+ let val = &values[0];
+ let fixed = msg::FIND_VALUE_REPLY_FIXED;
+ let total = HEADER_SIZE + fixed + val.value.len();
+ let mut rbuf = vec![0u8; total];
+ let rhdr = MsgHeader::new(
+ MsgType::DhtFindValueReply,
+ Self::len16(total),
+ self.id,
+ hdr.src,
+ );
+ if rhdr.write(&mut rbuf).is_ok() {
+ let off = HEADER_SIZE;
+ rbuf[off..off + 4].copy_from_slice(&fv.nonce.to_be_bytes());
+ fv.target
+ .write_to(&mut rbuf[off + 4..off + 4 + crate::id::ID_LEN]);
+ rbuf[off + 24..off + 26].copy_from_slice(&0u16.to_be_bytes()); // index
+ rbuf[off + 26..off + 28].copy_from_slice(&1u16.to_be_bytes()); // total
+ rbuf[off + 28] = crate::wire::DATA_ARE_VALUES;
+ rbuf[off + 29] = 0;
+ rbuf[off + 30] = 0;
+ rbuf[off + 31] = 0;
+ rbuf[HEADER_SIZE + fixed..].copy_from_slice(&val.value);
+ let _ = self.send_signed(&rbuf, from);
+ }
+ } else {
+ // Send closest nodes as find_node_reply format
+ let closest = self
+ .dht_table
+ .closest(&fv.target, self.config.num_find_node);
+ log::debug!("Value not found, returning {} nodes", closest.len());
+ let domain = if from.is_ipv4() {
+ DOMAIN_INET
+ } else {
+ DOMAIN_INET6
+ };
+ let reply_msg = msg::FindNodeReplyMsg {
+ nonce: fv.nonce,
+ id: fv.target,
+ domain,
+ nodes: closest,
+ };
+ let mut rbuf = [0u8; 2048];
+ let rhdr =
+ MsgHeader::new(MsgType::DhtFindValueReply, 0, self.id, hdr.src);
+ if rhdr.write(&mut rbuf).is_ok() {
+ // Write header with DATA_ARE_NODES flag
+ let off = HEADER_SIZE;
+ rbuf[off..off + 4].copy_from_slice(&fv.nonce.to_be_bytes());
+ fv.target
+ .write_to(&mut rbuf[off + 4..off + 4 + crate::id::ID_LEN]);
+ rbuf[off + 24..off + 26].fill(0); // index
+ rbuf[off + 26..off + 28].fill(0); // total
+ rbuf[off + 28] = crate::wire::DATA_ARE_NODES;
+ rbuf[off + 29] = 0;
+ rbuf[off + 30] = 0;
+ rbuf[off + 31] = 0;
+
+ // Write nodes after the fixed part
+ let nodes_off = HEADER_SIZE + msg::FIND_VALUE_REPLY_FIXED;
+
+ // Write domain + num + padding before nodes
+ rbuf[nodes_off..nodes_off + 2]
+ .copy_from_slice(&domain.to_be_bytes());
+ rbuf[nodes_off + 2] = reply_msg.nodes.len() as u8;
+ rbuf[nodes_off + 3] = 0;
+ let nw = if domain == DOMAIN_INET {
+ msg::write_nodes_inet(
+ &mut rbuf[nodes_off + 4..],
+ &reply_msg.nodes,
+ )
+ } else {
+ msg::write_nodes_inet6(
+ &mut rbuf[nodes_off + 4..],
+ &reply_msg.nodes,
+ )
+ };
+ let total = nodes_off + 4 + nw;
+ rbuf[4..6].copy_from_slice(&Self::len16(total).to_be_bytes());
+ let _ = self.send_signed(&rbuf[..total], from);
+ }
+ }
+ }
+
+ pub(crate) fn handle_dht_find_value_reply(
+ &mut self,
+ buf: &[u8],
+ hdr: &MsgHeader,
+ ) {
+ let reply = match msg::parse_find_value_reply(buf) {
+ Ok(r) => r,
+ Err(e) => {
+ log::debug!("Failed to parse find_value_reply: {e}");
+ return;
+ }
+ };
+
+ let sender_id = hdr.src;
+
+ // Find the matching active query
+ let matching_nonce: Option<u32> = self
+ .queries
+ .iter()
+ .filter(|(_, q)| q.is_find_value)
+ .find(|(_, q)| q.pending.contains_key(&sender_id))
+ .map(|(n, _)| *n);
+
+ match reply.data {
+ msg::FindValueReplyData::Value { data, .. } => {
+ log::info!(
+ "Received value from {:?}: {} bytes",
+ sender_id,
+ data.len()
+ );
+
+ // Cache the value locally and republish
+ // to the closest queried node without it
+ // (§2.3: cache on nearest without value)
+ if let Some(nonce) = matching_nonce {
+ if let Some(q) = self.queries.get_mut(&nonce) {
+ // Store in local storage for
+ // subsequent get() calls
+ let val = crate::dht::StoredValue {
+ key: q.key.clone(),
+ value: data.clone(),
+ id: q.target,
+ source: sender_id,
+ ttl: 300,
+ stored_at: Instant::now(),
+ is_unique: false,
+ original: 0,
+ recvd: std::collections::HashSet::new(),
+ version: crate::dht::now_version(),
+ };
+ self.storage.store(val);
+
+ // §2.3: store on nearest queried
+ // node that didn't have the value
+ let target = q.target;
+ let key = q.key.clone();
+ let nearest_without: Option<PeerInfo> = q
+ .closest
+ .iter()
+ .find(|p| {
+ q.queried.contains(&p.id)
+ && p.id != sender_id
+ })
+ .cloned();
+
+ q.process_value(data.clone());
+
+ if let Some(peer) = nearest_without {
+ let store_msg = crate::msg::StoreMsg {
+ id: target,
+ from: self.id,
+ key,
+ value: data,
+ ttl: 300,
+ is_unique: false,
+ };
+ if let Err(e) = self.send_store(&peer, &store_msg) {
+ log::debug!(
+ "Republish-on-access failed: {e}"
+ );
+ } else {
+ log::debug!(
+ "Republished value to {:?} (nearest without)",
+ peer.id,
+ );
+ }
+ }
+ }
+ }
+ }
+ msg::FindValueReplyData::Nodes { nodes, .. } => {
+ log::debug!(
+ "find_value_reply from {:?}: {} nodes (no value)",
+ sender_id,
+ nodes.len()
+ );
+
+ // Add nodes to routing table
+ for node in &nodes {
+ self.dht_table.add(node.clone());
+ self.peers.add(node.clone());
+ }
+
+ // Feed the query with the nodes so it
+ // continues iterating
+ if let Some(nonce) = matching_nonce {
+ if let Some(q) = self.queries.get_mut(&nonce) {
+ q.process_reply(&sender_id, nodes.clone());
+
+ // Send follow-up find_value to
+ // newly discovered nodes
+ let next = q.next_to_query();
+ let target = q.target;
+ let key = q.key.clone();
+ for peer in next {
+ if self
+ .send_find_value_msg(peer.addr, target, &key)
+ .is_ok()
+ {
+ if let Some(q) = self.queries.get_mut(&nonce) {
+ q.pending.insert(peer.id, Instant::now());
+ }
+ }
+ }
+ }
+ }
+ }
+ msg::FindValueReplyData::Nul => {
+ log::debug!("find_value_reply NUL from {:?}", sender_id);
+ if let Some(nonce) = matching_nonce {
+ if let Some(q) = self.queries.get_mut(&nonce) {
+ q.pending.remove(&sender_id);
+ q.queried.insert(sender_id);
+ }
+ }
+ }
+ }
+ }
+
+ // ── Data restore / republish ────────────────────
+
+ /// Restore (republish) stored data to k-closest
+ /// nodes. Tracks the `original` counter and `recvd`
+ /// set to avoid unnecessary duplicates.
+ pub(crate) fn restore_data(&mut self) {
+ let values = self.storage.all_values();
+ if values.is_empty() {
+ return;
+ }
+
+ log::debug!("Restoring {} stored values", values.len());
+
+ for val in &values {
+ if val.is_expired() {
+ continue;
+ }
+ let closest =
+ self.dht_table.closest(&val.id, self.config.num_find_node);
+ let store_msg = msg::StoreMsg {
+ id: val.id,
+ from: val.source,
+ key: val.key.clone(),
+ value: val.value.clone(),
+ ttl: val.remaining_ttl(),
+ is_unique: val.is_unique,
+ };
+ for peer in &closest {
+ if peer.id == self.id {
+ continue;
+ }
+
+ // Skip peers that already have this value
+ if val.recvd.contains(&peer.id) {
+ continue;
+ }
+ if let Err(e) = self.send_store(peer, &store_msg) {
+ log::debug!("Restore send failed: {e}");
+ continue;
+ }
+ self.storage.mark_received(val, peer.id);
+ }
+ }
+ }
+
+ /// Run mask_bit exploration: send find_node queries
+ /// to probe distant regions of the ID space.
+ pub(crate) fn run_maintain(&mut self) {
+ let (t1, t2) = self.explorer.next_targets();
+ log::debug!("Maintain: exploring targets {:?} {:?}", t1, t2);
+ let _ = self.start_find_node(t1);
+ let _ = self.start_find_node(t2);
+ }
+
+ // ── Proactive replication (§2.5) ──────────────
+
+ /// Replicate stored data to a newly discovered node
+ /// if it is closer to a key than the current furthest
+ /// holder. This ensures data availability without
+ /// waiting for the periodic restore cycle.
+ ///
+ /// Called when a new node is inserted into the routing
+ /// table (not on updates of existing nodes).
+ pub(crate) fn proactive_replicate(&mut self, new_node: &PeerInfo) {
+ let values = self.storage.all_values();
+ if values.is_empty() {
+ return;
+ }
+
+ let k = self.config.num_find_node;
+ let mut sent = 0u32;
+
+ for val in &values {
+ if val.is_expired() {
+ continue;
+ }
+ // Skip if this node already received the value
+ if val.recvd.contains(&new_node.id) {
+ continue;
+ }
+
+ // Check: is new_node closer to this key than
+ // the furthest current k-closest holder?
+ let closest = self.dht_table.closest(&val.id, k);
+ let furthest = match closest.last() {
+ Some(p) => p,
+ None => continue,
+ };
+
+ let dist_new = val.id.distance(&new_node.id);
+ let dist_far = val.id.distance(&furthest.id);
+
+ if dist_new >= dist_far {
+ continue; // new node is not closer
+ }
+
+ let store_msg = crate::msg::StoreMsg {
+ id: val.id,
+ from: val.source,
+ key: val.key.clone(),
+ value: val.value.clone(),
+ ttl: val.remaining_ttl(),
+ is_unique: val.is_unique,
+ };
+
+ if self.send_store(new_node, &store_msg).is_ok() {
+ self.storage.mark_received(val, new_node.id);
+ sent += 1;
+ }
+ }
+
+ if sent > 0 {
+ log::debug!(
+ "Proactive replicate: sent {sent} values to {:?}",
+ new_node.id,
+ );
+ }
+ }
+}
blob - /dev/null
blob + b2e37ebcf78819db693300d2fced49b8d17a946a (mode 644)
--- /dev/null
+++ src/id.rs
+//! 256-bit node identity for Kademlia.
+//!
+//! NodeId is 32 bytes — the same size as an Ed25519
+//! public key. For node IDs, `NodeId = public_key`
+//! directly. For DHT key hashing, SHA-256 maps
+//! arbitrary data into the 256-bit ID space.
+
+use crate::sys;
+use std::fmt;
+
+/// Length of a node ID in bytes (32 = Ed25519 pubkey).
+pub const ID_LEN: usize = 32;
+
+/// Number of bits in a node ID.
+pub const ID_BITS: usize = ID_LEN * 8; // 256
+
+/// A 256-bit node identifier.
+///
+/// Provides XOR distance and lexicographic ordering as
+/// required by Kademlia routing.
+#[derive(Clone, Copy, PartialEq, Eq, Hash)]
+pub struct NodeId([u8; ID_LEN]);
+
+impl NodeId {
+ /// Generate a random node ID.
+ pub fn random() -> Self {
+ let mut buf = [0u8; ID_LEN];
+ sys::random_bytes(&mut buf);
+ Self(buf)
+ }
+
+ /// Create a node ID from raw bytes.
+ pub fn from_bytes(b: [u8; ID_LEN]) -> Self {
+ Self(b)
+ }
+
+ /// Create a node ID by SHA-256 hashing arbitrary
+ /// data.
+ ///
+ /// Used by `put`/`get` to map keys into the 256-bit
+ /// Kademlia ID space.
+ pub fn from_key(data: &[u8]) -> Self {
+ use sha2::{Digest, Sha256};
+ let hash = Sha256::digest(data);
+ let mut buf = [0u8; ID_LEN];
+ buf.copy_from_slice(&hash);
+ Self(buf)
+ }
+
+ /// Return the raw bytes.
+ pub fn as_bytes(&self) -> &[u8; ID_LEN] {
+ &self.0
+ }
+
+ /// XOR distance between two node IDs.
+ pub fn distance(&self, other: &NodeId) -> NodeId {
+ let mut out = [0u8; ID_LEN];
+ for (i, byte) in out.iter_mut().enumerate() {
+ *byte = self.0[i] ^ other.0[i];
+ }
+ NodeId(out)
+ }
+
+ /// Number of leading zero bits in this ID.
+ ///
+ /// Used to determine the k-bucket index.
+ pub fn leading_zeros(&self) -> u32 {
+ for (i, &byte) in self.0.iter().enumerate() {
+ if byte != 0 {
+ return (i as u32) * 8 + byte.leading_zeros();
+ }
+ }
+ (ID_LEN as u32) * 8
+ }
+
+ /// Check if all bytes are zero.
+ pub fn is_zero(&self) -> bool {
+ self.0.iter().all(|&b| b == 0)
+ }
+
+ /// Parse from a hexadecimal string (64 chars).
+ pub fn from_hex(s: &str) -> Option<Self> {
+ if s.len() != ID_LEN * 2 {
+ return None;
+ }
+ let mut buf = [0u8; ID_LEN];
+ for i in 0..ID_LEN {
+ buf[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
+ }
+ Some(Self(buf))
+ }
+
+ /// Format as a hexadecimal string (64 chars).
+ pub fn to_hex(&self) -> String {
+ self.0.iter().map(|b| format!("{b:02x}")).collect()
+ }
+
+ /// Serialize to a byte slice.
+ ///
+ /// # Panics
+ /// Panics if `buf.len() < ID_LEN` (32).
+ pub fn write_to(&self, buf: &mut [u8]) {
+ debug_assert!(
+ buf.len() >= ID_LEN,
+ "NodeId::write_to: buf too small ({} < {ID_LEN})",
+ buf.len()
+ );
+ buf[..ID_LEN].copy_from_slice(&self.0);
+ }
+
+ /// Deserialize from a byte slice.
+ ///
+ /// # Panics
+ /// Panics if `buf.len() < ID_LEN` (32).
+ pub fn read_from(buf: &[u8]) -> Self {
+ debug_assert!(
+ buf.len() >= ID_LEN,
+ "NodeId::read_from: buf too small ({} < {ID_LEN})",
+ buf.len()
+ );
+ let mut id = [0u8; ID_LEN];
+ id.copy_from_slice(&buf[..ID_LEN]);
+ Self(id)
+ }
+}
+
+impl Ord for NodeId {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ self.0.cmp(&other.0)
+ }
+}
+
+impl PartialOrd for NodeId {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl fmt::Debug for NodeId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "NodeId({})", &self.to_hex()[..8])
+ }
+}
+
+impl fmt::Display for NodeId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.to_hex())
+ }
+}
+
+impl AsRef<[u8]> for NodeId {
+ fn as_ref(&self) -> &[u8] {
+ &self.0
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn zero_distance() {
+ let id = NodeId::from_bytes([0xAB; ID_LEN]);
+ let d = id.distance(&id);
+ assert!(d.is_zero());
+ }
+
+ #[test]
+ fn xor_symmetric() {
+ let a = NodeId::from_bytes([0x01; ID_LEN]);
+ let b = NodeId::from_bytes([0xFF; ID_LEN]);
+ assert_eq!(a.distance(&b), b.distance(&a));
+ }
+
+ #[test]
+ fn leading_zeros_all_zero() {
+ let z = NodeId::from_bytes([0; ID_LEN]);
+ assert_eq!(z.leading_zeros(), 256);
+ }
+
+ #[test]
+ fn leading_zeros_first_bit() {
+ let mut buf = [0u8; ID_LEN];
+ buf[0] = 0x80;
+ let id = NodeId::from_bytes(buf);
+ assert_eq!(id.leading_zeros(), 0);
+ }
+
+ #[test]
+ fn leading_zeros_ninth_bit() {
+ let mut buf = [0u8; ID_LEN];
+ buf[1] = 0x80;
+ let id = NodeId::from_bytes(buf);
+ assert_eq!(id.leading_zeros(), 8);
+ }
+
+ #[test]
+ fn hex_roundtrip() {
+ let id = NodeId::from_bytes([
+ 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD,
+ 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10, 0xDE, 0xAD,
+ 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
+ ]);
+ let hex = id.to_hex();
+ assert_eq!(hex.len(), 64);
+ assert_eq!(NodeId::from_hex(&hex), Some(id));
+ }
+
+ #[test]
+ fn from_key_deterministic() {
+ let a = NodeId::from_key(b"hello");
+ let b = NodeId::from_key(b"hello");
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn from_key_different_inputs() {
+ let a = NodeId::from_key(b"hello");
+ let b = NodeId::from_key(b"world");
+ assert_ne!(a, b);
+ }
+
+ #[test]
+ fn ordering_is_lexicographic() {
+ let a = NodeId::from_bytes([0x00; ID_LEN]);
+ let b = NodeId::from_bytes([0xFF; ID_LEN]);
+ assert!(a < b);
+ }
+
+ #[test]
+ fn write_read_roundtrip() {
+ let id = NodeId::from_bytes([0x42; ID_LEN]);
+ let mut buf = [0u8; ID_LEN];
+ id.write_to(&mut buf);
+ let id2 = NodeId::read_from(&buf);
+ assert_eq!(id, id2);
+ }
+}
blob - /dev/null
blob + f956f9821695e00eff5516b1ab427b2fb9fa3b29 (mode 644)
--- /dev/null
+++ src/lib.rs
+// Allow nested if-let until we migrate to let-chains
+#![allow(clippy::collapsible_if)]
+
+//! # tesseras-dht
+//!
+//! NAT-aware Kademlia DHT library for peer-to-peer
+//! networks.
+//!
+//! `tesseras-dht` provides:
+//!
+//! - **Distributed key-value storage** via Kademlia
+//! (iterative FIND_NODE, FIND_VALUE, STORE)
+//! - **NAT traversal** via DTUN (hole-punching) and
+//! proxy relay (symmetric NAT)
+//! - **Reliable transport** (RDP) over UDP
+//! - **Datagram transport** with automatic fragmentation
+//!
+//! ## Quick start
+//!
+//! ```rust,no_run
+//! use tesseras_dht::{Node, NatState};
+//!
+//! let mut node = Node::bind(10000).unwrap();
+//! node.set_nat_state(NatState::Global);
+//! node.join("bootstrap.example.com", 10000).unwrap();
+//!
+//! // Store and retrieve values
+//! node.put(b"key", b"value", 300, false);
+//!
+//! // Event loop
+//! loop {
+//! node.poll().unwrap();
+//! # break;
+//! }
+//! ```
+//!
+//! ## Architecture
+//!
+//! ```text
+//! ┌─────────────────────────────────────────┐
+//! │ Node (facade) │
+//! │ ┌──────┐ ┌────────┐ ┌──────────────┐ │
+//! │ │ DHT │ │Routing │ │ Storage │ │
+//! │ │ │ │ Table │ │ (DhtStorage)│ │
+//! │ └──────┘ └────────┘ └──────────────┘ │
+//! │ ┌──────┐ ┌────────┐ ┌──────────────┐ │
+//! │ │ DTUN │ │ NAT │ │ Proxy │ │
+//! │ │ │ │Detector│ │ │ │
+//! │ └──────┘ └────────┘ └──────────────┘ │
+//! │ ┌──────┐ ┌────────┐ ┌──────────────┐ │
+//! │ │ RDP │ │ Dgram │ │ Timers │ │
+//! │ └──────┘ └────────┘ └──────────────┘ │
+//! │ ┌──────────────────────────────────┐ │
+//! │ │ NetLoop (mio / kqueue) │ │
+//! │ └──────────────────────────────────┘ │
+//! └─────────────────────────────────────────┘
+//! ```
+//!
+//! ## Security (OpenBSD)
+//!
+//! Sandboxing with pledge(2) and unveil(2) is an
+//! application-level concern. Call them in your binary
+//! after binding the socket. The library only needs
+//! `"stdio inet dns"` promises.
+
+/// Address advertisement protocol.
+pub mod advertise;
+/// Ban list for misbehaving peers.
+pub mod banlist;
+/// Node configuration.
+pub mod config;
+/// Ed25519 identity and packet signing.
+pub mod crypto;
+/// Datagram transport with fragmentation/reassembly.
+pub mod dgram;
+/// Kademlia DHT storage and iterative queries.
+pub mod dht;
+/// Distributed tunnel for NAT traversal.
+pub mod dtun;
+/// Error types.
+pub mod error;
+/// Event types for typed callback integration.
+pub mod event;
+/// Packet dispatch and message handlers.
+mod handlers;
+/// 256-bit node identity.
+pub mod id;
+/// Node metrics and observability.
+pub mod metrics;
+/// Wire message body parsing and writing.
+pub mod msg;
+/// NAT type detection (STUN-like echo protocol).
+pub mod nat;
+/// Network send helpers and query management.
+mod net;
+/// Main facade: the [`Node`].
+pub mod node;
+/// Peer node database.
+pub mod peers;
+/// Persistence traits for data and routing table.
+pub mod persist;
+/// Proxy relay for symmetric NAT nodes.
+pub mod proxy;
+/// Per-IP rate limiting.
+pub mod ratelimit;
+/// Reliable Datagram Protocol (RDP).
+pub mod rdp;
+/// Kademlia routing table with k-buckets.
+pub mod routing;
+/// UDP I/O via mio (kqueue on OpenBSD).
+pub mod socket;
+/// Store acknowledgment tracking.
+pub mod store_track;
+/// OpenBSD arc4random_buf(3) for secure random bytes.
+pub mod sys;
+/// Timer wheel for scheduling callbacks.
+pub mod timer;
+/// On-the-wire binary protocol (header, message types).
+pub mod wire;
+
+// Re-export the main types for convenience.
+pub use error::Error;
+pub use id::NodeId;
+pub use nat::NatState;
+
+// Re-export sha2 for downstream crates.
+pub use sha2;
+pub use node::Node;
blob - /dev/null
blob + 67ec30f3f02d21fd99499c8b2ef28c3a7d46fd91 (mode 644)
--- /dev/null
+++ src/metrics.rs
+//! Node metrics and observability.
+//!
+//! Atomic counters for messages, lookups, storage,
+//! and errors. Accessible via `Tessera::metrics()`.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+/// Atomic metrics counters.
+pub struct Metrics {
+ pub messages_sent: AtomicU64,
+ pub messages_received: AtomicU64,
+ pub lookups_started: AtomicU64,
+ pub lookups_completed: AtomicU64,
+ pub rpc_timeouts: AtomicU64,
+ pub values_stored: AtomicU64,
+ pub packets_rejected: AtomicU64,
+ pub bytes_sent: AtomicU64,
+ pub bytes_received: AtomicU64,
+}
+
+impl Metrics {
+ pub fn new() -> Self {
+ Self {
+ messages_sent: AtomicU64::new(0),
+ messages_received: AtomicU64::new(0),
+ lookups_started: AtomicU64::new(0),
+ lookups_completed: AtomicU64::new(0),
+ rpc_timeouts: AtomicU64::new(0),
+ values_stored: AtomicU64::new(0),
+ packets_rejected: AtomicU64::new(0),
+ bytes_sent: AtomicU64::new(0),
+ bytes_received: AtomicU64::new(0),
+ }
+ }
+
+ /// Take a snapshot of all counters.
+ pub fn snapshot(&self) -> MetricsSnapshot {
+ MetricsSnapshot {
+ messages_sent: self.messages_sent.load(Ordering::Relaxed),
+ messages_received: self.messages_received.load(Ordering::Relaxed),
+ lookups_started: self.lookups_started.load(Ordering::Relaxed),
+ lookups_completed: self.lookups_completed.load(Ordering::Relaxed),
+ rpc_timeouts: self.rpc_timeouts.load(Ordering::Relaxed),
+ values_stored: self.values_stored.load(Ordering::Relaxed),
+ packets_rejected: self.packets_rejected.load(Ordering::Relaxed),
+ bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
+ bytes_received: self.bytes_received.load(Ordering::Relaxed),
+ }
+ }
+}
+
+impl Default for Metrics {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Snapshot of metrics at a point in time.
+#[derive(Debug, Clone)]
+pub struct MetricsSnapshot {
+ pub messages_sent: u64,
+ pub messages_received: u64,
+ pub lookups_started: u64,
+ pub lookups_completed: u64,
+ pub rpc_timeouts: u64,
+ pub values_stored: u64,
+ pub packets_rejected: u64,
+ pub bytes_sent: u64,
+ pub bytes_received: u64,
+}
+
+impl std::fmt::Display for MetricsSnapshot {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "sent={} recv={} lookups={}/{} timeouts={} \
+ stored={} rejected={} bytes={}/{}",
+ self.messages_sent,
+ self.messages_received,
+ self.lookups_completed,
+ self.lookups_started,
+ self.rpc_timeouts,
+ self.values_stored,
+ self.packets_rejected,
+ self.bytes_sent,
+ self.bytes_received,
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn counters_start_zero() {
+ let m = Metrics::new();
+ let s = m.snapshot();
+ assert_eq!(s.messages_sent, 0);
+ assert_eq!(s.bytes_sent, 0);
+ }
+
+ #[test]
+ fn increment_and_snapshot() {
+ let m = Metrics::new();
+ m.messages_sent.fetch_add(5, Ordering::Relaxed);
+ m.bytes_sent.fetch_add(1000, Ordering::Relaxed);
+ let s = m.snapshot();
+ assert_eq!(s.messages_sent, 5);
+ assert_eq!(s.bytes_sent, 1000);
+ }
+
+ #[test]
+ fn display_format() {
+ let m = Metrics::new();
+ m.messages_sent.fetch_add(10, Ordering::Relaxed);
+ let s = m.snapshot();
+ let text = format!("{s}");
+ assert!(text.contains("sent=10"));
+ }
+}
blob - /dev/null
blob + 95c1d4c3a25e7aa33b42e65de019940c5c0727fa (mode 644)
--- /dev/null
+++ src/msg.rs
+//! Message body parsing and writing.
+//!
+//! Each protocol message has a fixed header (parsed by
+//! `wire::MsgHeader`) followed by a variable body. This
+//! module provides parse/write for all body types.
+//!
+//! All multi-byte fields are big-endian (network order).
+
+use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
+
+use crate::error::Error;
+use crate::id::{ID_LEN, NodeId};
+use crate::peers::PeerInfo;
+use crate::wire::{DOMAIN_INET, HEADER_SIZE};
+
+// ── Helpers ─────────────────────────────────────────
+
+// Callers MUST validate `off + 2 <= buf.len()` before calling.
+fn read_u16(buf: &[u8], off: usize) -> u16 {
+ u16::from_be_bytes([buf[off], buf[off + 1]])
+}
+
+fn read_u32(buf: &[u8], off: usize) -> u32 {
+ u32::from_be_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
+}
+
+fn write_u16(buf: &mut [u8], off: usize, v: u16) {
+ buf[off..off + 2].copy_from_slice(&v.to_be_bytes());
+}
+
+fn write_u32(buf: &mut [u8], off: usize, v: u32) {
+ buf[off..off + 4].copy_from_slice(&v.to_be_bytes());
+}
+
+// ── Ping (DHT + DTUN) ──────────────────────────────
+
+/// Body: just a nonce (4 bytes after header).
+/// Used by: DhtPing, DhtPingReply, DtunPing,
+/// DtunPingReply, DtunRequestReply.
+pub fn parse_ping(buf: &[u8]) -> Result<u32, Error> {
+ if buf.len() < HEADER_SIZE + 4 {
+ return Err(Error::BufferTooSmall);
+ }
+ Ok(read_u32(buf, HEADER_SIZE))
+}
+
+pub fn write_ping(buf: &mut [u8], nonce: u32) {
+ write_u32(buf, HEADER_SIZE, nonce);
+}
+
+/// Total size of a ping message.
+pub const PING_MSG_SIZE: usize = HEADER_SIZE + 4;
+
+// ── NAT Echo ────────────────────────────────────────
+
+/// Parse NatEcho: nonce only.
+pub fn parse_nat_echo(buf: &[u8]) -> Result<u32, Error> {
+ parse_ping(buf) // same layout
+}
+
+/// NatEchoReply body: nonce(4) + domain(2) + port(2) + addr(16).
+pub const NAT_ECHO_REPLY_BODY: usize = 4 + 2 + 2 + 16;
+
+#[derive(Debug, Clone)]
+pub struct NatEchoReply {
+ pub nonce: u32,
+ pub domain: u16,
+ pub port: u16,
+ pub addr: [u8; 16],
+}
+
+pub fn parse_nat_echo_reply(buf: &[u8]) -> Result<NatEchoReply, Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + NAT_ECHO_REPLY_BODY {
+ return Err(Error::BufferTooSmall);
+ }
+ Ok(NatEchoReply {
+ nonce: read_u32(buf, off),
+ domain: read_u16(buf, off + 4),
+ port: read_u16(buf, off + 6),
+ addr: {
+ let mut a = [0u8; 16];
+ a.copy_from_slice(&buf[off + 8..off + 24]);
+ a
+ },
+ })
+}
+
+pub fn write_nat_echo_reply(buf: &mut [u8], reply: &NatEchoReply) {
+ let off = HEADER_SIZE;
+ write_u32(buf, off, reply.nonce);
+ write_u16(buf, off + 4, reply.domain);
+ write_u16(buf, off + 6, reply.port);
+ buf[off + 8..off + 24].copy_from_slice(&reply.addr);
+}
+
+/// NatEchoRedirect body: nonce(4) + port(2) + padding(2).
+pub fn parse_nat_echo_redirect(buf: &[u8]) -> Result<(u32, u16), Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + 8 {
+ return Err(Error::BufferTooSmall);
+ }
+ Ok((read_u32(buf, off), read_u16(buf, off + 4)))
+}
+
+/// Write NatEchoRedirect body.
+pub fn write_nat_echo_redirect(buf: &mut [u8], nonce: u32, port: u16) {
+ let off = HEADER_SIZE;
+ write_u32(buf, off, nonce);
+ write_u16(buf, off + 4, port);
+ write_u16(buf, off + 6, 0); // padding
+}
+
+/// Size of a NatEchoRedirect message.
+pub const NAT_ECHO_REDIRECT_SIZE: usize = HEADER_SIZE + 8;
+
+// ── FindNode (DHT + DTUN) ───────────────────────────
+
+/// FindNode body: nonce(4) + id(20) + domain(2) + state_or_pad(2).
+pub const FIND_NODE_BODY: usize = 4 + ID_LEN + 2 + 2;
+
+#[derive(Debug, Clone)]
+pub struct FindNodeMsg {
+ pub nonce: u32,
+ pub target: NodeId,
+ pub domain: u16,
+ pub state: u16,
+}
+
+pub fn parse_find_node(buf: &[u8]) -> Result<FindNodeMsg, Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + FIND_NODE_BODY {
+ return Err(Error::BufferTooSmall);
+ }
+ Ok(FindNodeMsg {
+ nonce: read_u32(buf, off),
+ target: NodeId::read_from(&buf[off + 4..off + 4 + ID_LEN]),
+ domain: read_u16(buf, off + 4 + ID_LEN),
+ state: read_u16(buf, off + 6 + ID_LEN),
+ })
+}
+
+pub fn write_find_node(buf: &mut [u8], msg: &FindNodeMsg) {
+ let off = HEADER_SIZE;
+ write_u32(buf, off, msg.nonce);
+ msg.target.write_to(&mut buf[off + 4..off + 4 + ID_LEN]);
+ write_u16(buf, off + 4 + ID_LEN, msg.domain);
+ write_u16(buf, off + 6 + ID_LEN, msg.state);
+}
+
+pub const FIND_NODE_MSG_SIZE: usize = HEADER_SIZE + FIND_NODE_BODY;
+
+// ── FindNodeReply (DHT + DTUN) ──────────────────────
+
+/// FindNodeReply fixed part: nonce(4) + id(20) +
+/// domain(2) + num(1) + padding(1).
+pub const FIND_NODE_REPLY_FIXED: usize = 4 + ID_LEN + 4;
+
+#[derive(Debug, Clone)]
+pub struct FindNodeReplyMsg {
+ pub nonce: u32,
+ pub id: NodeId,
+ pub domain: u16,
+ pub nodes: Vec<PeerInfo>,
+}
+
+/// Size of an IPv4 node entry: port(2) + reserved(2) +
+/// addr(4) + id(ID_LEN).
+pub const INET_NODE_SIZE: usize = 8 + ID_LEN;
+
+/// Size of an IPv6 node entry: port(2) + reserved(2) +
+/// addr(16) + id(ID_LEN).
+pub const INET6_NODE_SIZE: usize = 20 + ID_LEN;
+
+pub fn parse_find_node_reply(buf: &[u8]) -> Result<FindNodeReplyMsg, Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + FIND_NODE_REPLY_FIXED {
+ return Err(Error::BufferTooSmall);
+ }
+
+ let nonce = read_u32(buf, off);
+ let id = NodeId::read_from(&buf[off + 4..off + 4 + ID_LEN]);
+ let domain = read_u16(buf, off + 4 + ID_LEN);
+ let num = buf[off + 4 + ID_LEN + 2] as usize;
+
+ let nodes_off = off + FIND_NODE_REPLY_FIXED;
+ let nodes = if domain == DOMAIN_INET {
+ read_nodes_inet(&buf[nodes_off..], num)
+ } else {
+ read_nodes_inet6(&buf[nodes_off..], num)
+ };
+
+ Ok(FindNodeReplyMsg {
+ nonce,
+ id,
+ domain,
+ nodes,
+ })
+}
+
+pub fn write_find_node_reply(buf: &mut [u8], msg: &FindNodeReplyMsg) -> usize {
+ let off = HEADER_SIZE;
+ write_u32(buf, off, msg.nonce);
+ msg.id.write_to(&mut buf[off + 4..off + 4 + ID_LEN]);
+ write_u16(buf, off + 4 + ID_LEN, msg.domain);
+ let num = msg.nodes.len().min(MAX_NODES_PER_REPLY);
+ buf[off + 4 + ID_LEN + 2] = num as u8;
+ buf[off + 4 + ID_LEN + 3] = 0; // padding
+
+ let nodes_off = off + FIND_NODE_REPLY_FIXED;
+ let nodes_len = if msg.domain == DOMAIN_INET {
+ write_nodes_inet(&mut buf[nodes_off..], &msg.nodes)
+ } else {
+ write_nodes_inet6(&mut buf[nodes_off..], &msg.nodes)
+ };
+
+ HEADER_SIZE + FIND_NODE_REPLY_FIXED + nodes_len
+}
+
+// ── Store (DHT) ─────────────────────────────────────
+
+/// Store fixed part: id(20) + from(20) + keylen(2) +
+/// valuelen(2) + ttl(2) + flags(1) + reserved(1) = 48.
+pub const STORE_FIXED: usize = ID_LEN * 2 + 8;
+
+#[derive(Debug, Clone)]
+pub struct StoreMsg {
+ pub id: NodeId,
+ pub from: NodeId,
+ pub key: Vec<u8>,
+ pub value: Vec<u8>,
+ pub ttl: u16,
+ pub is_unique: bool,
+}
+
+pub fn parse_store(buf: &[u8]) -> Result<StoreMsg, Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + STORE_FIXED {
+ return Err(Error::BufferTooSmall);
+ }
+
+ let id = NodeId::read_from(&buf[off..off + ID_LEN]);
+ let from = NodeId::read_from(&buf[off + ID_LEN..off + ID_LEN * 2]);
+ let keylen = read_u16(buf, off + ID_LEN * 2) as usize;
+ let valuelen = read_u16(buf, off + ID_LEN * 2 + 2) as usize;
+ let ttl = read_u16(buf, off + ID_LEN * 2 + 4);
+ let flags = buf[off + ID_LEN * 2 + 6];
+
+ let data_off = off + STORE_FIXED;
+ let total = data_off
+ .checked_add(keylen)
+ .and_then(|v| v.checked_add(valuelen))
+ .ok_or(Error::InvalidMessage)?;
+
+ if buf.len() < total {
+ return Err(Error::BufferTooSmall);
+ }
+
+ let key = buf[data_off..data_off + keylen].to_vec();
+ let value = buf[data_off + keylen..data_off + keylen + valuelen].to_vec();
+
+ Ok(StoreMsg {
+ id,
+ from,
+ key,
+ value,
+ ttl,
+ is_unique: flags & crate::wire::DHT_FLAG_UNIQUE != 0,
+ })
+}
+
+pub fn write_store(buf: &mut [u8], msg: &StoreMsg) -> Result<usize, Error> {
+ let off = HEADER_SIZE;
+ let total = off + STORE_FIXED + msg.key.len() + msg.value.len();
+ if buf.len() < total {
+ return Err(Error::BufferTooSmall);
+ }
+
+ msg.id.write_to(&mut buf[off..off + ID_LEN]);
+ msg.from.write_to(&mut buf[off + ID_LEN..off + ID_LEN * 2]);
+ let keylen =
+ u16::try_from(msg.key.len()).map_err(|_| Error::BufferTooSmall)?;
+ let valuelen =
+ u16::try_from(msg.value.len()).map_err(|_| Error::BufferTooSmall)?;
+ write_u16(buf, off + ID_LEN * 2, keylen);
+ write_u16(buf, off + ID_LEN * 2 + 2, valuelen);
+ write_u16(buf, off + ID_LEN * 2 + 4, msg.ttl);
+ buf[off + ID_LEN * 2 + 6] = if msg.is_unique {
+ crate::wire::DHT_FLAG_UNIQUE
+ } else {
+ 0
+ };
+ buf[off + ID_LEN * 2 + 7] = 0; // reserved
+
+ let data_off = off + STORE_FIXED;
+ buf[data_off..data_off + msg.key.len()].copy_from_slice(&msg.key);
+ buf[data_off + msg.key.len()..data_off + msg.key.len() + msg.value.len()]
+ .copy_from_slice(&msg.value);
+
+ Ok(total)
+}
+
+// ── FindValue (DHT) ─────────────────────────────────
+
+/// FindValue fixed: nonce(4) + id(20) + domain(2) +
+/// keylen(2) + flag(1) + padding(3) = 32.
+pub const FIND_VALUE_FIXED: usize = 4 + ID_LEN + 8;
+
+#[derive(Debug, Clone)]
+pub struct FindValueMsg {
+ pub nonce: u32,
+ pub target: NodeId,
+ pub domain: u16,
+ pub key: Vec<u8>,
+ pub use_rdp: bool,
+}
+
+pub fn parse_find_value(buf: &[u8]) -> Result<FindValueMsg, Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + FIND_VALUE_FIXED {
+ return Err(Error::BufferTooSmall);
+ }
+
+ let nonce = read_u32(buf, off);
+ let target = NodeId::read_from(&buf[off + 4..off + 4 + ID_LEN]);
+ let domain = read_u16(buf, off + 4 + ID_LEN);
+ let keylen = read_u16(buf, off + 6 + ID_LEN) as usize;
+ let flag = buf[off + 8 + ID_LEN];
+
+ let key_off = off + FIND_VALUE_FIXED;
+ if buf.len() < key_off + keylen {
+ return Err(Error::BufferTooSmall);
+ }
+
+ Ok(FindValueMsg {
+ nonce,
+ target,
+ domain,
+ key: buf[key_off..key_off + keylen].to_vec(),
+ use_rdp: flag == 1,
+ })
+}
+
+pub fn write_find_value(
+ buf: &mut [u8],
+ msg: &FindValueMsg,
+) -> Result<usize, Error> {
+ let off = HEADER_SIZE;
+ let total = off + FIND_VALUE_FIXED + msg.key.len();
+ if buf.len() < total {
+ return Err(Error::BufferTooSmall);
+ }
+
+ write_u32(buf, off, msg.nonce);
+ msg.target.write_to(&mut buf[off + 4..off + 4 + ID_LEN]);
+ write_u16(buf, off + 4 + ID_LEN, msg.domain);
+ let keylen =
+ u16::try_from(msg.key.len()).map_err(|_| Error::BufferTooSmall)?;
+ write_u16(buf, off + 6 + ID_LEN, keylen);
+ buf[off + 8 + ID_LEN] = if msg.use_rdp { 1 } else { 0 };
+ buf[off + 9 + ID_LEN] = 0;
+ buf[off + 10 + ID_LEN] = 0;
+ buf[off + 11 + ID_LEN] = 0;
+
+ let key_off = off + FIND_VALUE_FIXED;
+ buf[key_off..key_off + msg.key.len()].copy_from_slice(&msg.key);
+
+ Ok(total)
+}
+
+// ── FindValueReply (DHT) ────────────────────────────
+
+/// Fixed: nonce(4) + id(20) + index(2) + total(2) +
+/// flag(1) + padding(3) = 32.
+pub const FIND_VALUE_REPLY_FIXED: usize = 4 + ID_LEN + 8;
+
+#[derive(Debug, Clone)]
+pub enum FindValueReplyData {
+ /// flag=0xa0: node list
+ Nodes { domain: u16, nodes: Vec<PeerInfo> },
+
+ /// flag=0xa1: a value chunk
+ Value {
+ index: u16,
+ total: u16,
+ data: Vec<u8>,
+ },
+
+ /// flag=0xa2: no data
+ Nul,
+}
+
+#[derive(Debug, Clone)]
+pub struct FindValueReplyMsg {
+ pub nonce: u32,
+ pub id: NodeId,
+ pub data: FindValueReplyData,
+}
+
+pub fn parse_find_value_reply(buf: &[u8]) -> Result<FindValueReplyMsg, Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + FIND_VALUE_REPLY_FIXED {
+ return Err(Error::BufferTooSmall);
+ }
+
+ let nonce = read_u32(buf, off);
+ let id = NodeId::read_from(&buf[off + 4..off + 4 + ID_LEN]);
+ let index = read_u16(buf, off + 4 + ID_LEN);
+ let total = read_u16(buf, off + 6 + ID_LEN);
+ let flag = buf[off + 8 + ID_LEN];
+
+ let data_off = off + FIND_VALUE_REPLY_FIXED;
+
+ let data = match flag {
+ crate::wire::DATA_ARE_NODES => {
+ if buf.len() < data_off + 4 {
+ return Err(Error::BufferTooSmall);
+ }
+ let domain = read_u16(buf, data_off);
+ let num = buf[data_off + 2] as usize;
+ let nodes_off = data_off + 4;
+ let nodes = if domain == DOMAIN_INET {
+ read_nodes_inet(&buf[nodes_off..], num)
+ } else {
+ read_nodes_inet6(&buf[nodes_off..], num)
+ };
+ FindValueReplyData::Nodes { domain, nodes }
+ }
+ crate::wire::DATA_ARE_VALUES => {
+ let payload = buf[data_off..].to_vec();
+ FindValueReplyData::Value {
+ index,
+ total,
+ data: payload,
+ }
+ }
+ crate::wire::DATA_ARE_NUL => FindValueReplyData::Nul,
+ _ => return Err(Error::InvalidMessage),
+ };
+
+ Ok(FindValueReplyMsg { nonce, id, data })
+}
+
+// ── DtunRegister ────────────────────────────────────
+
+pub fn parse_dtun_register(buf: &[u8]) -> Result<u32, Error> {
+ if buf.len() < HEADER_SIZE + 4 {
+ return Err(Error::BufferTooSmall);
+ }
+ Ok(read_u32(buf, HEADER_SIZE)) // session
+}
+
+// ── DtunRequest ─────────────────────────────────────
+
+pub fn parse_dtun_request(buf: &[u8]) -> Result<(u32, NodeId), Error> {
+ let off = HEADER_SIZE;
+ if buf.len() < off + 4 + ID_LEN {
+ return Err(Error::BufferTooSmall);
+ }
+ let nonce = read_u32(buf, off);
+ let target = NodeId::read_from(&buf[off + 4..off + 4 + ID_LEN]);
+ Ok((nonce, target))
+}
+
+// ── Node list serialization (IPv4 / IPv6) ───────────
+
+/// Maximum nodes per reply (prevents OOM from malicious num).
+const MAX_NODES_PER_REPLY: usize = 20;
+
+/// Read `num` IPv4 node entries from `buf`.
+pub fn read_nodes_inet(buf: &[u8], num: usize) -> Vec<PeerInfo> {
+ let num = num.min(MAX_NODES_PER_REPLY);
+ let mut nodes = Vec::with_capacity(num);
+ for i in 0..num {
+ let off = i * INET_NODE_SIZE;
+ if off + INET_NODE_SIZE > buf.len() {
+ break;
+ }
+ let port = read_u16(buf, off);
+ let ip = Ipv4Addr::new(
+ buf[off + 4],
+ buf[off + 5],
+ buf[off + 6],
+ buf[off + 7],
+ );
+ let id = NodeId::read_from(&buf[off + 8..off + 8 + ID_LEN]);
+ let addr = SocketAddr::V4(SocketAddrV4::new(ip, port));
+ nodes.push(PeerInfo::new(id, addr));
+ }
+ nodes
+}
+
+/// Read `num` IPv6 node entries from `buf`.
+pub fn read_nodes_inet6(buf: &[u8], num: usize) -> Vec<PeerInfo> {
+ let num = num.min(MAX_NODES_PER_REPLY);
+ let mut nodes = Vec::with_capacity(num);
+ for i in 0..num {
+ let off = i * INET6_NODE_SIZE;
+ if off + INET6_NODE_SIZE > buf.len() {
+ break;
+ }
+ let port = read_u16(buf, off);
+ let mut octets = [0u8; 16];
+ octets.copy_from_slice(&buf[off + 4..off + 20]);
+ let ip = Ipv6Addr::from(octets);
+ let id = NodeId::read_from(&buf[off + 20..off + 20 + ID_LEN]);
+ let addr = SocketAddr::V6(SocketAddrV6::new(ip, port, 0, 0));
+ nodes.push(PeerInfo::new(id, addr));
+ }
+ nodes
+}
+
+/// Write IPv4 node entries. Returns bytes written.
+pub fn write_nodes_inet(buf: &mut [u8], nodes: &[PeerInfo]) -> usize {
+ let mut written = 0;
+ for node in nodes {
+ if written + INET_NODE_SIZE > buf.len() {
+ break;
+ }
+ let off = written;
+ write_u16(buf, off, node.addr.port());
+ write_u16(buf, off + 2, 0); // reserved
+
+ if let SocketAddr::V4(v4) = node.addr {
+ let octets = v4.ip().octets();
+ buf[off + 4..off + 8].copy_from_slice(&octets);
+ } else {
+ buf[off + 4..off + 8].fill(0);
+ }
+
+ node.id.write_to(&mut buf[off + 8..off + 8 + ID_LEN]);
+ written += INET_NODE_SIZE;
+ }
+ written
+}
+
+/// Write IPv6 node entries. Returns bytes written.
+pub fn write_nodes_inet6(buf: &mut [u8], nodes: &[PeerInfo]) -> usize {
+ let mut written = 0;
+ for node in nodes {
+ if written + INET6_NODE_SIZE > buf.len() {
+ break;
+ }
+ let off = written;
+ write_u16(buf, off, node.addr.port());
+ write_u16(buf, off + 2, 0); // reserved
+
+ if let SocketAddr::V6(v6) = node.addr {
+ let octets = v6.ip().octets();
+ buf[off + 4..off + 20].copy_from_slice(&octets);
+ } else {
+ buf[off + 4..off + 20].fill(0);
+ }
+
+ node.id.write_to(&mut buf[off + 20..off + 20 + ID_LEN]);
+ written += INET6_NODE_SIZE;
+ }
+ written
+}
+
+/// Create a PeerInfo from a message header and source
+/// address.
+pub fn peer_from_header(src_id: NodeId, from: SocketAddr) -> PeerInfo {
+ PeerInfo::new(src_id, from)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::wire::{MsgHeader, MsgType};
+
+ fn make_buf(msg_type: MsgType, body_len: usize) -> Vec<u8> {
+ let total = HEADER_SIZE + body_len;
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ msg_type,
+ total as u16,
+ NodeId::from_bytes([0xAA; 32]),
+ NodeId::from_bytes([0xBB; 32]),
+ );
+ hdr.write(&mut buf).unwrap();
+ buf
+ }
+
+ // ── Ping ────────────────────────────────────────
+
+ #[test]
+ fn ping_roundtrip() {
+ let mut buf = make_buf(MsgType::DhtPing, 4);
+ write_ping(&mut buf, 0xDEADBEEF);
+ let nonce = parse_ping(&buf).unwrap();
+ assert_eq!(nonce, 0xDEADBEEF);
+ }
+
+ // ── NatEchoReply ────────────────────────────────
+
+ #[test]
+ fn nat_echo_reply_roundtrip() {
+ let mut buf = make_buf(MsgType::NatEchoReply, NAT_ECHO_REPLY_BODY);
+ let reply = NatEchoReply {
+ nonce: 42,
+ domain: DOMAIN_INET,
+ port: 3000,
+ addr: {
+ let mut a = [0u8; 16];
+ a[0..4].copy_from_slice(&[192, 168, 1, 1]);
+ a
+ },
+ };
+ write_nat_echo_reply(&mut buf, &reply);
+ let parsed = parse_nat_echo_reply(&buf).unwrap();
+ assert_eq!(parsed.nonce, 42);
+ assert_eq!(parsed.domain, DOMAIN_INET);
+ assert_eq!(parsed.port, 3000);
+ assert_eq!(parsed.addr[0..4], [192, 168, 1, 1]);
+ }
+
+ // ── FindNode ────────────────────────────────────
+
+ #[test]
+ fn find_node_roundtrip() {
+ let mut buf = make_buf(MsgType::DhtFindNode, FIND_NODE_BODY);
+ let msg = FindNodeMsg {
+ nonce: 99,
+ target: NodeId::from_bytes([0x42; 32]),
+ domain: DOMAIN_INET,
+ state: 1,
+ };
+ write_find_node(&mut buf, &msg);
+ let parsed = parse_find_node(&buf).unwrap();
+ assert_eq!(parsed.nonce, 99);
+ assert_eq!(parsed.target, msg.target);
+ assert_eq!(parsed.domain, DOMAIN_INET);
+ }
+
+ // ── FindNodeReply ───────────────────────────────
+
+ #[test]
+ fn find_node_reply_roundtrip() {
+ let nodes = vec![
+ PeerInfo::new(
+ NodeId::from_bytes([0x01; 32]),
+ "127.0.0.1:3000".parse().unwrap(),
+ ),
+ PeerInfo::new(
+ NodeId::from_bytes([0x02; 32]),
+ "127.0.0.1:3001".parse().unwrap(),
+ ),
+ ];
+ let msg = FindNodeReplyMsg {
+ nonce: 55,
+ id: NodeId::from_bytes([0x42; 32]),
+ domain: DOMAIN_INET,
+ nodes: nodes.clone(),
+ };
+
+ let mut buf = vec![0u8; 1024];
+ let hdr = MsgHeader::new(
+ MsgType::DhtFindNodeReply,
+ 0, // will fix
+ NodeId::from_bytes([0xAA; 32]),
+ NodeId::from_bytes([0xBB; 32]),
+ );
+ hdr.write(&mut buf).unwrap();
+ let _total = write_find_node_reply(&mut buf, &msg);
+
+ let parsed = parse_find_node_reply(&buf).unwrap();
+ assert_eq!(parsed.nonce, 55);
+ assert_eq!(parsed.nodes.len(), 2);
+ assert_eq!(parsed.nodes[0].id, nodes[0].id);
+ assert_eq!(parsed.nodes[0].addr.port(), 3000);
+ assert_eq!(parsed.nodes[1].id, nodes[1].id);
+ }
+
+ // ── Store ───────────────────────────────────────
+
+ #[test]
+ fn store_roundtrip() {
+ let msg = StoreMsg {
+ id: NodeId::from_bytes([0x10; 32]),
+ from: NodeId::from_bytes([0x20; 32]),
+ key: b"mykey".to_vec(),
+ value: b"myvalue".to_vec(),
+ ttl: 300,
+ is_unique: true,
+ };
+ let total = HEADER_SIZE + STORE_FIXED + msg.key.len() + msg.value.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ MsgType::DhtStore,
+ total as u16,
+ NodeId::from_bytes([0xAA; 32]),
+ NodeId::from_bytes([0xBB; 32]),
+ );
+ hdr.write(&mut buf).unwrap();
+ write_store(&mut buf, &msg).unwrap();
+
+ let parsed = parse_store(&buf).unwrap();
+ assert_eq!(parsed.id, msg.id);
+ assert_eq!(parsed.from, msg.from);
+ assert_eq!(parsed.key, b"mykey");
+ assert_eq!(parsed.value, b"myvalue");
+ assert_eq!(parsed.ttl, 300);
+ assert!(parsed.is_unique);
+ }
+
+ #[test]
+ fn store_not_unique() {
+ let msg = StoreMsg {
+ id: NodeId::from_bytes([0x10; 32]),
+ from: NodeId::from_bytes([0x20; 32]),
+ key: b"k".to_vec(),
+ value: b"v".to_vec(),
+ ttl: 60,
+ is_unique: false,
+ };
+ let total = HEADER_SIZE + STORE_FIXED + msg.key.len() + msg.value.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ MsgType::DhtStore,
+ total as u16,
+ NodeId::from_bytes([0xAA; 32]),
+ NodeId::from_bytes([0xBB; 32]),
+ );
+ hdr.write(&mut buf).unwrap();
+ write_store(&mut buf, &msg).unwrap();
+
+ let parsed = parse_store(&buf).unwrap();
+ assert!(!parsed.is_unique);
+ }
+
+ // ── FindValue ───────────────────────────────────
+
+ #[test]
+ fn find_value_roundtrip() {
+ let msg = FindValueMsg {
+ nonce: 77,
+ target: NodeId::from_bytes([0x33; 32]),
+ domain: DOMAIN_INET,
+ key: b"lookup-key".to_vec(),
+ use_rdp: false,
+ };
+ let total = HEADER_SIZE + FIND_VALUE_FIXED + msg.key.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ MsgType::DhtFindValue,
+ total as u16,
+ NodeId::from_bytes([0xAA; 32]),
+ NodeId::from_bytes([0xBB; 32]),
+ );
+ hdr.write(&mut buf).unwrap();
+ write_find_value(&mut buf, &msg).unwrap();
+
+ let parsed = parse_find_value(&buf).unwrap();
+ assert_eq!(parsed.nonce, 77);
+ assert_eq!(parsed.target, msg.target);
+ assert_eq!(parsed.key, b"lookup-key");
+ assert!(!parsed.use_rdp);
+ }
+
+ // ── DtunRegister ────────────────────────────────
+
+ #[test]
+ fn dtun_register_roundtrip() {
+ let mut buf = make_buf(MsgType::DtunRegister, 4);
+ write_u32(&mut buf, HEADER_SIZE, 12345);
+ let session = parse_dtun_register(&buf).unwrap();
+ assert_eq!(session, 12345);
+ }
+
+ // ── DtunRequest ─────────────────────────────────
+
+ #[test]
+ fn dtun_request_roundtrip() {
+ let mut buf = make_buf(MsgType::DtunRequest, 4 + ID_LEN);
+ let nonce = 88u32;
+ let target = NodeId::from_bytes([0x55; 32]);
+ write_u32(&mut buf, HEADER_SIZE, nonce);
+ target.write_to(&mut buf[HEADER_SIZE + 4..HEADER_SIZE + 4 + ID_LEN]);
+
+ let (n, t) = parse_dtun_request(&buf).unwrap();
+ assert_eq!(n, 88);
+ assert_eq!(t, target);
+ }
+
+ // ── Node list ───────────────────────────────────
+
+ #[test]
+ fn inet_nodes_roundtrip() {
+ let nodes = vec![
+ PeerInfo::new(
+ NodeId::from_bytes([0x01; 32]),
+ "10.0.0.1:8000".parse().unwrap(),
+ ),
+ PeerInfo::new(
+ NodeId::from_bytes([0x02; 32]),
+ "10.0.0.2:9000".parse().unwrap(),
+ ),
+ ];
+ let mut buf = vec![0u8; INET_NODE_SIZE * 2];
+ let written = write_nodes_inet(&mut buf, &nodes);
+ assert_eq!(written, INET_NODE_SIZE * 2);
+
+ let parsed = read_nodes_inet(&buf, 2);
+ assert_eq!(parsed.len(), 2);
+ assert_eq!(parsed[0].id, nodes[0].id);
+ assert_eq!(parsed[0].addr.port(), 8000);
+ assert_eq!(parsed[1].addr.port(), 9000);
+ }
+
+ // ── Truncated inputs ────────────────────────────
+
+ #[test]
+ fn parse_store_truncated() {
+ let buf = make_buf(MsgType::DhtStore, 2); // too small
+ assert!(matches!(parse_store(&buf), Err(Error::BufferTooSmall)));
+ }
+
+ #[test]
+ fn parse_find_node_truncated() {
+ let buf = make_buf(MsgType::DhtFindNode, 2);
+ assert!(matches!(parse_find_node(&buf), Err(Error::BufferTooSmall)));
+ }
+
+ #[test]
+ fn parse_find_value_truncated() {
+ let buf = make_buf(MsgType::DhtFindValue, 2);
+ assert!(matches!(parse_find_value(&buf), Err(Error::BufferTooSmall)));
+ }
+}
blob - /dev/null
blob + cfd57294da835cdbc3aba8e4f8760c72bebf498a (mode 644)
--- /dev/null
+++ src/nat.rs
+//! NAT type detection (STUN-like echo protocol).
+//!
+//! Detects whether
+//! this node has a public IP, is behind a cone NAT, or is
+//! behind a symmetric NAT. The result determines how the
+//! node communicates:
+//!
+//! - **Global**: direct communication.
+//! - **ConeNat**: hole-punching via DTUN.
+//! - **SymmetricNat**: relay via proxy.
+//!
+//! ## Detection protocol
+//!
+//! 1. Send NatEcho to a known node.
+//! 2. Receive NatEchoReply with our observed external
+//! address and port.
+//! 3. If observed == local → Global.
+//! 4. If different → we're behind NAT. Send
+//! NatEchoRedirect asking the peer to have a *third*
+//! node echo us from a different port.
+//! 5. Receive NatEchoRedirectReply with observed port
+//! from the third node.
+//! 6. If ports match → ConeNat (same external port for
+//! different destinations).
+//! 7. If ports differ → SymmetricNat (external port
+//! changes per destination).
+
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+
+/// NAT detection echo timeout.
+pub const ECHO_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// Periodic re-detection interval.
+pub const NAT_TIMER_INTERVAL: Duration = Duration::from_secs(30);
+
+/// Node's NAT state (visible to the application).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NatState {
+ /// Not yet determined.
+ Unknown,
+
+ /// Public IP — direct communication.
+ Global,
+
+ /// Behind NAT, type not yet classified.
+ Nat,
+
+ /// Cone NAT — hole-punching works.
+ ConeNat,
+
+ /// Symmetric NAT — must use a proxy relay.
+ SymmetricNat,
+}
+
+/// Internal state machine for the detection protocol.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum DetectorState {
+ /// Initial / idle.
+ Undefined,
+
+ /// Sent first echo, waiting for reply.
+ EchoWait1,
+
+ /// Behind NAT, sent redirect request, waiting.
+ EchoRedirectWait,
+
+ /// Confirmed global (public IP).
+ Global,
+
+ /// Behind NAT, type unknown yet.
+ Nat,
+
+ /// Sent second echo (via redirected node), waiting.
+ EchoWait2,
+
+ /// Confirmed cone NAT.
+ ConeNat,
+
+ /// Confirmed symmetric NAT.
+ SymmetricNat,
+}
+
+/// Pending echo request tracked by nonce.
+struct EchoPending {
+ sent_at: Instant,
+}
+
+/// NAT type detector.
+///
+/// Drives the echo protocol state machine. The caller
+/// feeds received messages via `recv_*` methods and
+/// reads the result via `state()`.
+pub struct NatDetector {
+ state: DetectorState,
+
+ /// Our detected external address (if behind NAT).
+ global_addr: Option<SocketAddr>,
+
+ /// Port observed in the first echo reply.
+ echo1_port: Option<u16>,
+
+ /// Pending echo requests keyed by nonce.
+ pending: HashMap<u32, EchoPending>,
+}
+
+impl NatDetector {
+ pub fn new(_local_id: NodeId) -> Self {
+ Self {
+ state: DetectorState::Undefined,
+ global_addr: None,
+ echo1_port: None,
+ pending: HashMap::new(),
+ }
+ }
+
+ /// Current NAT state as seen by the application.
+ pub fn state(&self) -> NatState {
+ match self.state {
+ DetectorState::Undefined
+ | DetectorState::EchoWait1
+ | DetectorState::EchoRedirectWait
+ | DetectorState::EchoWait2 => NatState::Unknown,
+ DetectorState::Global => NatState::Global,
+ DetectorState::Nat => NatState::Nat,
+ DetectorState::ConeNat => NatState::ConeNat,
+ DetectorState::SymmetricNat => NatState::SymmetricNat,
+ }
+ }
+
+ /// Our detected global address, if known.
+ pub fn global_addr(&self) -> Option<SocketAddr> {
+ self.global_addr
+ }
+
+ /// Force the NAT state (e.g. from configuration).
+ pub fn set_state(&mut self, s: NatState) {
+ self.state = match s {
+ NatState::Unknown => DetectorState::Undefined,
+ NatState::Global => DetectorState::Global,
+ NatState::Nat => DetectorState::Nat,
+ NatState::ConeNat => DetectorState::ConeNat,
+ NatState::SymmetricNat => DetectorState::SymmetricNat,
+ };
+ }
+
+ /// Start detection: prepare a NatEcho to send to a
+ /// known peer.
+ ///
+ /// Returns the nonce to include in the echo message.
+ pub fn start_detect(&mut self, nonce: u32) -> u32 {
+ self.state = DetectorState::EchoWait1;
+ self.pending.insert(
+ nonce,
+ EchoPending {
+ sent_at: Instant::now(),
+ },
+ );
+ nonce
+ }
+
+ /// Handle a NatEchoReply: the peer tells us our
+ /// observed external address and port.
+ /// Replies in unexpected states are silently ignored
+ /// (catch-all returns `EchoReplyAction::Ignore`).
+ pub fn recv_echo_reply(
+ &mut self,
+ nonce: u32,
+ observed_addr: SocketAddr,
+ local_addr: SocketAddr,
+ ) -> EchoReplyAction {
+ if self.pending.remove(&nonce).is_none() {
+ return EchoReplyAction::Ignore;
+ }
+
+ match self.state {
+ DetectorState::EchoWait1 => {
+ if observed_addr.port() == local_addr.port()
+ && observed_addr.ip() == local_addr.ip()
+ {
+ // Our address matches → we have a public IP
+ self.state = DetectorState::Global;
+ self.global_addr = Some(observed_addr);
+ EchoReplyAction::DetectionComplete(NatState::Global)
+ } else {
+ // Behind NAT — need redirect test
+ self.state = DetectorState::Nat;
+ self.global_addr = Some(observed_addr);
+ self.echo1_port = Some(observed_addr.port());
+ EchoReplyAction::NeedRedirect
+ }
+ }
+ DetectorState::EchoWait2 => {
+ // Reply from redirected third node
+ let port2 = observed_addr.port();
+ if Some(port2) == self.echo1_port {
+ // Same external port → Cone NAT
+ self.state = DetectorState::ConeNat;
+ EchoReplyAction::DetectionComplete(NatState::ConeNat)
+ } else {
+ // Different port → Symmetric NAT
+ self.state = DetectorState::SymmetricNat;
+ EchoReplyAction::DetectionComplete(NatState::SymmetricNat)
+ }
+ }
+ _ => EchoReplyAction::Ignore,
+ }
+ }
+
+ /// After receiving NeedRedirect, start the redirect
+ /// phase with a new nonce.
+ pub fn start_redirect(&mut self, nonce: u32) {
+ self.state = DetectorState::EchoRedirectWait;
+ self.pending.insert(
+ nonce,
+ EchoPending {
+ sent_at: Instant::now(),
+ },
+ );
+ }
+
+ /// The redirect was forwarded and a third node will
+ /// send us an echo. Transition to EchoWait2.
+ pub fn redirect_sent(&mut self, nonce: u32) {
+ self.state = DetectorState::EchoWait2;
+ self.pending.insert(
+ nonce,
+ EchoPending {
+ sent_at: Instant::now(),
+ },
+ );
+ }
+
+ /// Expire timed-out echo requests.
+ ///
+ /// If all pending requests timed out and we haven't
+ /// completed detection, reset to Undefined.
+ pub fn expire_pending(&mut self) {
+ self.pending
+ .retain(|_, p| p.sent_at.elapsed() < ECHO_TIMEOUT);
+
+ if self.pending.is_empty() {
+ match self.state {
+ DetectorState::EchoWait1
+ | DetectorState::EchoRedirectWait
+ | DetectorState::EchoWait2 => {
+ log::debug!("NAT detection timed out, resetting");
+ self.state = DetectorState::Undefined;
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Check if detection is still in progress.
+ pub fn is_detecting(&self) -> bool {
+ matches!(
+ self.state,
+ DetectorState::EchoWait1
+ | DetectorState::EchoRedirectWait
+ | DetectorState::EchoWait2
+ )
+ }
+
+ /// Check if detection is complete (any terminal state).
+ pub fn is_complete(&self) -> bool {
+ matches!(
+ self.state,
+ DetectorState::Global
+ | DetectorState::ConeNat
+ | DetectorState::SymmetricNat
+ )
+ }
+}
+
+/// Action to take after processing an echo reply.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum EchoReplyAction {
+ /// Ignore (unknown nonce or wrong state).
+ Ignore,
+
+ /// Detection complete with the given NAT state.
+ DetectionComplete(NatState),
+
+ /// Need to send a NatEchoRedirect to determine
+ /// NAT type (cone vs symmetric).
+ NeedRedirect,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn local() -> SocketAddr {
+ "192.168.1.100:5000".parse().unwrap()
+ }
+
+ #[test]
+ fn detect_global() {
+ let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
+ let nonce = nd.start_detect(1);
+ assert!(nd.is_detecting());
+
+ let action = nd.recv_echo_reply(nonce, local(), local());
+ assert_eq!(
+ action,
+ EchoReplyAction::DetectionComplete(NatState::Global)
+ );
+ assert_eq!(nd.state(), NatState::Global);
+ assert!(nd.is_complete());
+ }
+
+ #[test]
+ fn detect_cone_nat() {
+ let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
+
+ // Phase 1: echo shows different address
+ let n1 = nd.start_detect(1);
+ let observed: SocketAddr = "1.2.3.4:5000".parse().unwrap();
+ let action = nd.recv_echo_reply(n1, observed, local());
+ assert_eq!(action, EchoReplyAction::NeedRedirect);
+ assert_eq!(nd.state(), NatState::Nat);
+
+ // Phase 2: redirect → third node echoes with
+ // same port
+ nd.redirect_sent(2);
+ let observed2: SocketAddr = "1.2.3.4:5000".parse().unwrap();
+ let action = nd.recv_echo_reply(2, observed2, local());
+ assert_eq!(
+ action,
+ EchoReplyAction::DetectionComplete(NatState::ConeNat)
+ );
+ assert_eq!(nd.state(), NatState::ConeNat);
+ }
+
+ #[test]
+ fn detect_symmetric_nat() {
+ let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
+
+ // Phase 1: behind NAT
+ let n1 = nd.start_detect(1);
+ let observed: SocketAddr = "1.2.3.4:5000".parse().unwrap();
+ nd.recv_echo_reply(n1, observed, local());
+
+ // Phase 2: different port from third node
+ nd.redirect_sent(2);
+ let observed2: SocketAddr = "1.2.3.4:6000".parse().unwrap();
+ let action = nd.recv_echo_reply(2, observed2, local());
+ assert_eq!(
+ action,
+ EchoReplyAction::DetectionComplete(NatState::SymmetricNat)
+ );
+ }
+
+ #[test]
+ fn unknown_nonce_ignored() {
+ let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
+ nd.start_detect(1);
+ let action = nd.recv_echo_reply(999, local(), local());
+ assert_eq!(action, EchoReplyAction::Ignore);
+ }
+
+ #[test]
+ fn force_state() {
+ let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
+ nd.set_state(NatState::Global);
+ assert_eq!(nd.state(), NatState::Global);
+ assert!(nd.is_complete());
+ }
+
+ #[test]
+ fn timeout_resets() {
+ let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
+ nd.start_detect(1);
+
+ // Clear pending manually to simulate timeout
+ nd.pending.clear();
+ nd.expire_pending();
+ assert_eq!(nd.state(), NatState::Unknown);
+ }
+}
blob - /dev/null
blob + aa6d2a7231f301dd65dd7fca61f25d474b46fbc2 (mode 644)
--- /dev/null
+++ src/net.rs
+//! Network send helpers and query management.
+//!
+//! Extension impl block for Node. Contains send_signed,
+//! verify_incoming, send_find_node, send_store, query
+//! batch sending, and liveness probing.
+
+use std::net::SocketAddr;
+use std::time::{Duration, Instant};
+
+use crate::dgram;
+use crate::dht::IterativeQuery;
+use crate::error::Error;
+use crate::id::NodeId;
+use crate::msg;
+use crate::nat::NatState;
+use crate::node::Node;
+use crate::peers::PeerInfo;
+use crate::wire::{DOMAIN_INET, DOMAIN_INET6, HEADER_SIZE, MsgHeader, MsgType};
+
+impl Node {
+ // ── Network ─────────────────────────────────────
+
+ /// Local socket address.
+ pub fn local_addr(&self) -> Result<SocketAddr, Error> {
+ self.net.local_addr()
+ }
+
+ /// Join the DHT network via a bootstrap node.
+ ///
+ /// Starts an iterative FIND_NODE for our own ID.
+ /// The query is driven by subsequent `poll()` calls.
+ /// DNS resolution is synchronous.
+ ///
+ /// # Example
+ ///
+ /// ```rust,no_run
+ /// # let mut node = tesseras_dht::Node::bind(0).unwrap();
+ /// node.join("bootstrap.example.com", 10000).unwrap();
+ /// loop { node.poll().unwrap(); }
+ /// ```
+ pub fn join(&mut self, host: &str, port: u16) -> Result<(), Error> {
+ use std::net::ToSocketAddrs;
+
+ let addr_str = format!("{host}:{port}");
+ let addr = addr_str
+ .to_socket_addrs()
+ .map_err(Error::Io)?
+ .next()
+ .ok_or_else(|| {
+ Error::Io(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ format!("no addresses for {addr_str}"),
+ ))
+ })?;
+
+ log::info!("Joining via {addr}");
+
+ // Send DHT FIND_NODE to bootstrap (populates
+ // DHT routing table via handle_dht_find_node_reply)
+ self.send_find_node(addr, self.id)?;
+
+ if self.is_dtun {
+ // Send DTUN FIND_NODE to bootstrap too
+ // (populates DTUN routing table)
+ let nonce = self.alloc_nonce();
+ let domain = if addr.is_ipv4() {
+ crate::wire::DOMAIN_INET
+ } else {
+ crate::wire::DOMAIN_INET6
+ };
+ let find = crate::msg::FindNodeMsg {
+ nonce,
+ target: self.id,
+ domain,
+ state: 0,
+ };
+ let mut buf = [0u8; crate::msg::FIND_NODE_MSG_SIZE];
+ let hdr = MsgHeader::new(
+ MsgType::DtunFindNode,
+ Self::len16(crate::msg::FIND_NODE_MSG_SIZE),
+ self.id,
+ NodeId::from_bytes([0; 32]),
+ );
+ if hdr.write(&mut buf).is_ok() {
+ crate::msg::write_find_node(&mut buf, &find);
+ if let Err(e) = self.send_signed(&buf, addr) {
+ log::debug!("DTUN find_node send failed: {e}");
+ }
+ }
+ }
+
+ // Start NAT detection if DTUN is enabled
+ if self.is_dtun && !self.nat.is_complete() {
+ let nonce = self.alloc_nonce();
+ self.nat.start_detect(nonce);
+
+ // Send NatEcho to bootstrap
+ let size = HEADER_SIZE + 4;
+ let mut buf = vec![0u8; size];
+ let hdr = MsgHeader::new(
+ MsgType::NatEcho,
+ Self::len16(size),
+ self.id,
+ NodeId::from_bytes([0; crate::id::ID_LEN]),
+ );
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..HEADER_SIZE + 4]
+ .copy_from_slice(&nonce.to_be_bytes());
+ if let Err(e) = self.send_signed(&buf, addr) {
+ log::warn!("Failed to send NatEcho: {e}");
+ }
+ log::debug!("Sent NatEcho to {addr} nonce={nonce}");
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Start an iterative FIND_NODE query for a target.
+ ///
+ /// Returns the query nonce. The query is driven by
+ /// `poll()` and completes when converged.
+ pub fn start_find_node(&mut self, target: NodeId) -> Result<u32, Error> {
+ let nonce = self.alloc_nonce();
+ let mut query = IterativeQuery::find_node(target, nonce);
+
+ // Seed with our closest known nodes
+ let closest =
+ self.dht_table.closest(&target, self.config.num_find_node);
+ query.closest = closest;
+
+ // Limit concurrent queries to prevent memory
+ // exhaustion (max 100)
+ const MAX_CONCURRENT_QUERIES: usize = 100;
+ if self.queries.len() >= MAX_CONCURRENT_QUERIES {
+ log::warn!("Too many concurrent queries, dropping");
+ return Err(Error::Timeout);
+ }
+
+ // Send initial batch
+ self.send_query_batch(nonce)?;
+
+ self.queries.insert(nonce, query);
+ Ok(nonce)
+ }
+
+ /// Send a FIND_NODE message to a specific address.
+ pub(crate) fn send_find_node(
+ &mut self,
+ to: SocketAddr,
+ target: NodeId,
+ ) -> Result<u32, Error> {
+ let nonce = self.alloc_nonce();
+ let domain = if to.is_ipv4() {
+ DOMAIN_INET
+ } else {
+ DOMAIN_INET6
+ };
+
+ let find = msg::FindNodeMsg {
+ nonce,
+ target,
+ domain,
+ state: 0,
+ };
+
+ let mut buf = [0u8; msg::FIND_NODE_MSG_SIZE];
+ let hdr = MsgHeader::new(
+ MsgType::DhtFindNode,
+ Self::len16(msg::FIND_NODE_MSG_SIZE),
+ self.id,
+ NodeId::from_bytes([0; crate::id::ID_LEN]), // unknown dst
+ );
+ hdr.write(&mut buf)?;
+ msg::write_find_node(&mut buf, &find);
+ self.send_signed(&buf, to)?;
+
+ log::debug!("Sent find_node to {to} target={target:?} nonce={nonce}");
+ Ok(nonce)
+ }
+
+ /// Send a STORE message to a specific peer.
+ pub(crate) fn send_store(
+ &mut self,
+ peer: &PeerInfo,
+ store: &msg::StoreMsg,
+ ) -> Result<(), Error> {
+ let total = HEADER_SIZE
+ + msg::STORE_FIXED
+ + store.key.len()
+ + store.value.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ MsgType::DhtStore,
+ Self::len16(total),
+ self.id,
+ peer.id,
+ );
+ hdr.write(&mut buf)?;
+ msg::write_store(&mut buf, store)?;
+ self.send_signed(&buf, peer.addr)?;
+ log::debug!(
+ "Sent store to {:?} key={} bytes",
+ peer.id,
+ store.key.len()
+ );
+ Ok(())
+ }
+
+ /// Send the next batch of queries for an active
+ /// iterative query. Uses FIND_VALUE for find_value
+ /// queries, FIND_NODE otherwise.
+ pub(crate) fn send_query_batch(&mut self, nonce: u32) -> Result<(), Error> {
+ let query = match self.queries.get(&nonce) {
+ Some(q) => q,
+ None => return Ok(()),
+ };
+
+ let to_query = query.next_to_query();
+ let target = query.target;
+ let is_find_value = query.is_find_value;
+ let key = query.key.clone();
+
+ for peer in to_query {
+ let result = if is_find_value {
+ self.send_find_value_msg(peer.addr, target, &key)
+ } else {
+ self.send_find_node(peer.addr, target)
+ };
+
+ if let Err(e) = result {
+ log::debug!("Failed to send query to {:?}: {e}", peer.id);
+ continue;
+ }
+ if let Some(q) = self.queries.get_mut(&nonce) {
+ q.pending.insert(peer.id, Instant::now());
+ }
+ }
+ Ok(())
+ }
+
+ /// Drive all active iterative queries: expire
+ /// timeouts, send next batches, clean up finished.
+ pub(crate) fn drive_queries(&mut self) {
+ // Expire timed-out pending requests
+ let nonces: Vec<u32> = self.queries.keys().copied().collect();
+
+ for nonce in &nonces {
+ if let Some(q) = self.queries.get_mut(nonce) {
+ q.expire_pending();
+ }
+ }
+
+ // Send next batch for active queries
+ for nonce in &nonces {
+ let is_active = self
+ .queries
+ .get(nonce)
+ .map(|q| !q.is_done())
+ .unwrap_or(false);
+
+ if is_active {
+ if let Err(e) = self.send_query_batch(*nonce) {
+ log::debug!("Query batch send failed: {e}");
+ }
+ }
+ }
+
+ // Remove completed queries
+ self.queries.retain(|nonce, q| {
+ if q.is_done() {
+ log::debug!(
+ "Query nonce={nonce} complete: {} closest, {} hops, {}ms",
+ q.closest.len(),
+ q.hops,
+ q.started_at.elapsed().as_millis(),
+ );
+ self.metrics
+ .lookups_completed
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ false
+ } else {
+ true
+ }
+ });
+ }
+
+ /// Refresh stale routing table buckets by starting
+ /// FIND_NODE queries for random IDs in each stale
+ /// bucket's range.
+ pub(crate) fn refresh_buckets(&mut self) {
+ let threshold = self.config.refresh_interval;
+
+ if self.last_refresh.elapsed() < threshold {
+ return;
+ }
+ self.last_refresh = Instant::now();
+
+ let targets = self.dht_table.stale_bucket_targets(threshold);
+
+ if targets.is_empty() {
+ return;
+ }
+
+ // Limit to 3 refresh queries per cycle to avoid
+ // flooding the query queue (256 empty buckets would
+ // otherwise spawn 256 queries at once).
+ let batch = targets.into_iter().take(3);
+ log::debug!("Refreshing stale buckets");
+
+ for target in batch {
+ if let Err(e) = self.start_find_node(target) {
+ log::debug!("Refresh find_node failed: {e}");
+ break; // query queue full, stop
+ }
+ }
+ }
+
+ /// Ping LRU peers in each bucket to verify they're
+ /// alive. Dead peers are removed from the routing
+ /// table. Called alongside refresh_buckets.
+ pub(crate) fn probe_liveness(&mut self) {
+ let lru_peers = self.dht_table.lru_peers();
+ if lru_peers.is_empty() {
+ return;
+ }
+
+ for peer in &lru_peers {
+ // Skip if we've seen them recently
+ if peer.last_seen.elapsed() < Duration::from_secs(30) {
+ continue;
+ }
+ let nonce = self.alloc_nonce();
+ let size = msg::PING_MSG_SIZE;
+ let mut buf = [0u8; msg::PING_MSG_SIZE];
+ let hdr = MsgHeader::new(
+ MsgType::DhtPing,
+ Self::len16(size),
+ self.id,
+ peer.id,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..HEADER_SIZE + 4]
+ .copy_from_slice(&nonce.to_be_bytes());
+ let _ = self.send_signed(&buf, peer.addr);
+ self.pending_pings.insert(nonce, (peer.id, Instant::now()));
+ }
+ }
+ }
+
+ /// Try to send queued datagrams whose destinations
+ /// are now in the peer store.
+ pub(crate) fn drain_send_queue(&mut self) {
+ let known_ids: Vec<crate::id::NodeId> = self.peers.ids();
+
+ for dst_id in known_ids {
+ if !self.send_queue.has_pending(&dst_id) {
+ continue;
+ }
+ let peer = match self.peers.get(&dst_id).cloned() {
+ Some(p) => p,
+ None => continue,
+ };
+ let queued = self.send_queue.drain(&dst_id);
+ for item in queued {
+ self.send_dgram_raw(&item.data, &peer);
+ }
+ }
+ }
+
+ /// Handle an incoming Dgram message: reassemble
+ /// fragments and invoke the dgram callback.
+ pub(crate) fn handle_dgram(&mut self, buf: &[u8], hdr: &MsgHeader) {
+ let payload = &buf[HEADER_SIZE..];
+
+ let (total, index, frag_data) = match dgram::parse_fragment(payload) {
+ Some(v) => v,
+ None => return,
+ };
+
+ let complete =
+ self.reassembler
+ .feed(hdr.src, total, index, frag_data.to_vec());
+
+ if let Some(data) = complete {
+ log::debug!(
+ "Dgram reassembled: {} bytes from {:?}",
+ data.len(),
+ hdr.src
+ );
+ if let Some(ref cb) = self.dgram_callback {
+ cb(&data, &hdr.src);
+ }
+ }
+ }
+
+ /// Flush pending RDP output for a connection,
+ /// sending packets via UDP.
+ pub(crate) fn flush_rdp_output(&mut self, desc: i32) {
+ let output = match self.rdp.pending_output(desc) {
+ Some(o) => o,
+ None => return,
+ };
+
+ // Determine target address and message type
+ let (send_addr, msg_type) =
+ if self.nat.state() == NatState::SymmetricNat {
+ // Route through proxy
+ if let Some(server) = self.proxy.server() {
+ (server.addr, MsgType::ProxyRdp)
+ } else if let Some(peer) = self.peers.get(&output.dst) {
+ (peer.addr, MsgType::Rdp)
+ } else {
+ log::debug!("RDP: no route for {:?}", output.dst);
+ return;
+ }
+ } else if let Some(peer) = self.peers.get(&output.dst) {
+ (peer.addr, MsgType::Rdp)
+ } else {
+ log::debug!("RDP: no address for {:?}", output.dst);
+ return;
+ };
+
+ for pkt in &output.packets {
+ let rdp_wire = crate::rdp::build_rdp_wire(
+ pkt.flags,
+ output.sport,
+ output.dport,
+ pkt.seqnum,
+ pkt.acknum,
+ &pkt.data,
+ );
+
+ let total = HEADER_SIZE + rdp_wire.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ msg_type,
+ Self::len16(total),
+ self.id,
+ output.dst,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..].copy_from_slice(&rdp_wire);
+ let _ = self.send_signed(&buf, send_addr);
+ }
+ }
+ }
+
+ /// Flush pending RDP output for all connections.
+ pub(crate) fn flush_all_rdp(&mut self) {
+ let descs = self.rdp.descriptors();
+ for desc in descs {
+ self.flush_rdp_output(desc);
+ }
+ }
+
+ /// Handle an incoming RDP packet.
+ pub(crate) fn handle_rdp(&mut self, buf: &[u8], hdr: &MsgHeader) {
+ let payload = &buf[HEADER_SIZE..];
+ let wire = match crate::rdp::parse_rdp_wire(payload) {
+ Some(w) => w,
+ None => return,
+ };
+
+ log::debug!(
+ "RDP from {:?}: flags=0x{:02x} \
+ sport={} dport={} \
+ seq={} ack={} \
+ data={} bytes",
+ hdr.src,
+ wire.flags,
+ wire.sport,
+ wire.dport,
+ wire.seqnum,
+ wire.acknum,
+ wire.data.len()
+ );
+
+ let input = crate::rdp::RdpInput {
+ src: hdr.src,
+ sport: wire.sport,
+ dport: wire.dport,
+ flags: wire.flags,
+ seqnum: wire.seqnum,
+ acknum: wire.acknum,
+ data: wire.data,
+ };
+ let actions = self.rdp.input(&input);
+
+ for action in actions {
+ match action {
+ crate::rdp::RdpAction::Event {
+ desc,
+ ref addr,
+ event,
+ } => {
+ log::info!("RDP event: desc={desc} {:?}", event);
+
+ // Invoke app callback
+ if let Some(ref cb) = self.rdp_callback {
+ cb(desc, addr, event);
+ }
+
+ // After accept/connect, flush SYN-ACK/ACK
+ self.flush_rdp_output(desc);
+ }
+ crate::rdp::RdpAction::Close(desc) => {
+ self.rdp.close(desc);
+ }
+ }
+ }
+ }
+
+ /// Register this node with DTUN (for NAT traversal).
+ ///
+ /// Sends DTUN_REGISTER to the k-closest global nodes
+ /// so other peers can find us via hole-punching.
+ pub(crate) fn dtun_register(&mut self) {
+ let (session, closest) = self.dtun.prepare_register();
+ log::info!(
+ "DTUN register: session={session}, {} targets",
+ closest.len()
+ );
+
+ for peer in &closest {
+ let size = HEADER_SIZE + 4;
+ let mut buf = vec![0u8; size];
+ let hdr = MsgHeader::new(
+ MsgType::DtunRegister,
+ Self::len16(size),
+ self.id,
+ peer.id,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..HEADER_SIZE + 4]
+ .copy_from_slice(&session.to_be_bytes());
+ let _ = self.send_signed(&buf, peer.addr);
+ }
+ }
+
+ self.dtun.registration_done();
+ }
+
+ /// Send a packet with Ed25519 signature appended.
+ ///
+ /// Appends 64-byte signature after the packet body.
+ /// All outgoing packets go through this method.
+ pub(crate) fn send_signed(
+ &self,
+ buf: &[u8],
+ to: SocketAddr,
+ ) -> Result<usize, Error> {
+ let mut signed = buf.to_vec();
+ crate::wire::sign_packet(&mut signed, &self.identity);
+ self.metrics
+ .messages_sent
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ self.metrics.bytes_sent.fetch_add(
+ signed.len() as u64,
+ std::sync::atomic::Ordering::Relaxed,
+ );
+ // Note: reply sends use `let _ =` (best-effort).
+ // Critical sends (join, put, DTUN register) use
+ // `if let Err(e) = ... { log::warn! }`.
+ self.net.send_to(&signed, to)
+ }
+
+ /// Proactively check node activity by pinging peers
+ /// in the routing table. Peers that fail to respond
+ /// accumulate failures in the ban list. This keeps
+ /// the routing table healthy by detecting dead nodes
+ /// before queries need them.
+ pub(crate) fn check_node_activity(&mut self) {
+ if self.last_activity_check.elapsed()
+ < self.config.activity_check_interval
+ {
+ return;
+ }
+ self.last_activity_check = Instant::now();
+
+ let lru_peers = self.dht_table.lru_peers();
+ if lru_peers.is_empty() {
+ return;
+ }
+
+ let mut pinged = 0u32;
+ for peer in &lru_peers {
+ // Only ping peers not seen for >60s
+ if peer.last_seen.elapsed() < Duration::from_secs(60) {
+ continue;
+ }
+ // Skip banned peers
+ if self.ban_list.is_banned(&peer.addr) {
+ continue;
+ }
+
+ let nonce = self.alloc_nonce();
+ let size = crate::msg::PING_MSG_SIZE;
+ let mut buf = [0u8; crate::msg::PING_MSG_SIZE];
+ let hdr = MsgHeader::new(
+ MsgType::DhtPing,
+ Self::len16(size),
+ self.id,
+ peer.id,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..HEADER_SIZE + 4]
+ .copy_from_slice(&nonce.to_be_bytes());
+ let _ = self.send_signed(&buf, peer.addr);
+ self.pending_pings.insert(nonce, (peer.id, Instant::now()));
+ pinged += 1;
+ }
+ }
+
+ if pinged > 0 {
+ log::debug!("Activity check: pinged {pinged} peers");
+ }
+ }
+
+ /// Sweep timed-out stores and retry with alternative
+ /// peers. Failed peers accumulate ban list failures.
+ pub(crate) fn retry_failed_stores(&mut self) {
+ if self.last_store_retry.elapsed() < self.config.store_retry_interval {
+ return;
+ }
+ self.last_store_retry = Instant::now();
+
+ let retries = self.store_tracker.collect_timeouts();
+ if retries.is_empty() {
+ return;
+ }
+
+ log::debug!("Store retry: {} timed-out stores", retries.len());
+
+ for retry in &retries {
+ // Record failure for the peer that didn't ack
+ if let Some(peer_info) = self.peers.get(&retry.failed_peer) {
+ self.ban_list.record_failure(peer_info.addr);
+ }
+
+ // Find alternative peers (excluding the failed one)
+ let closest = self
+ .dht_table
+ .closest(&retry.target, self.config.num_find_node);
+
+ let store_msg = crate::msg::StoreMsg {
+ id: retry.target,
+ from: self.id,
+ key: retry.key.clone(),
+ value: retry.value.clone(),
+ ttl: retry.ttl,
+ is_unique: retry.is_unique,
+ };
+
+ let mut sent = false;
+ for peer in &closest {
+ if peer.id == retry.failed_peer {
+ continue;
+ }
+ if self.ban_list.is_banned(&peer.addr) {
+ continue;
+ }
+ if let Err(e) = self.send_store(peer, &store_msg) {
+ log::debug!("Store retry send failed: {e}");
+ continue;
+ }
+ self.store_tracker.track(
+ retry.target,
+ retry.key.clone(),
+ retry.value.clone(),
+ retry.ttl,
+ retry.is_unique,
+ peer.clone(),
+ );
+ sent = true;
+ break; // one alternative peer is enough per retry
+ }
+
+ if !sent {
+ log::debug!(
+ "Store retry: no alternative peer for key ({} bytes)",
+ retry.key.len()
+ );
+ }
+ }
+ }
+
+ /// Verify Ed25519 signature on an incoming packet.
+ ///
+ /// Since NodeId = Ed25519 public key, the src field
+ /// in the header IS the public key. The signature
+ /// proves the sender holds the private key for that
+ /// NodeId.
+ ///
+ /// Additionally, if we already know this peer, verify
+ /// the source address matches to prevent IP spoofing.
+ pub(crate) fn verify_incoming<'a>(
+ &self,
+ buf: &'a [u8],
+ from: std::net::SocketAddr,
+ ) -> Option<&'a [u8]> {
+ if buf.len() < HEADER_SIZE + crate::crypto::SIGNATURE_SIZE {
+ log::trace!("Rejecting unsigned packet ({} bytes)", buf.len());
+ return None;
+ }
+
+ // NodeId IS the public key — verify signature
+ let src = NodeId::read_from(&buf[8..8 + crate::id::ID_LEN]);
+
+ // Reject zero NodeId
+ if src.is_zero() {
+ log::trace!("Rejecting packet from zero NodeId");
+ return None;
+ }
+
+ let pubkey = src.as_bytes();
+
+ if !crate::wire::verify_packet(buf, pubkey) {
+ log::trace!("Signature verification failed");
+ self.metrics
+ .packets_rejected
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ return None;
+ }
+
+ // If we know this peer, verify source address
+ // (prevents IP spoofing with valid signatures)
+ if let Some(known) = self.peers.get(&src) {
+ if known.addr != from {
+ log::debug!(
+ "Peer {:?} address mismatch: known={} got={}",
+ src,
+ known.addr,
+ from
+ );
+ // Allow — peer may have changed IP (NAT rebind)
+ // but log for monitoring
+ }
+ }
+
+ let payload_end = buf.len() - crate::crypto::SIGNATURE_SIZE;
+ Some(&buf[..payload_end])
+ }
+}
blob - /dev/null
blob + fef917e85a8278976127bfbe2ed3be7661c27af4 (mode 644)
--- /dev/null
+++ src/node.rs
+//! Main facade: the `Node` node.
+//!
+//! Owns all subsystems and provides the public API for
+//! joining the network,
+//! storing/retrieving values, sending datagrams, and
+//! using reliable transport (RDP).
+
+use std::collections::HashMap;
+use std::fmt;
+use std::net::SocketAddr;
+use std::time::{Duration, Instant};
+
+use crate::advertise::Advertise;
+use crate::dgram::{self, Reassembler, SendQueue};
+use crate::dht::{DhtStorage, IterativeQuery, MaskBitExplorer};
+use crate::dtun::Dtun;
+use crate::error::Error;
+use crate::id::NodeId;
+use crate::msg;
+use crate::nat::{NatDetector, NatState};
+use crate::peers::{PeerInfo, PeerStore};
+use crate::proxy::Proxy;
+use crate::rdp::{Rdp, RdpError, RdpState, RdpStatus};
+use crate::routing::RoutingTable;
+use crate::socket::{NetLoop, UDP_TOKEN};
+use crate::timer::TimerWheel;
+use crate::wire::{DOMAIN_INET, DOMAIN_INET6, HEADER_SIZE, MsgHeader, MsgType};
+
+/// Default poll timeout when no timers are scheduled.
+const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_millis(100);
+
+type DgramCallback = Box<dyn Fn(&[u8], &NodeId) + Send>;
+type RdpCallback =
+ Box<dyn Fn(i32, &crate::rdp::RdpAddr, crate::rdp::RdpEvent) + Send>;
+
+/// The tesseras-dht node.
+///
+/// This is the main entry point. It owns all subsystems
+/// (DHT, DTUN, NAT detector, proxy, RDP, datagrams,
+/// peers, timers, network I/O) and exposes a clean API
+/// for the tesseras-dht node.
+pub struct Node {
+ pub(crate) identity: crate::crypto::Identity,
+ pub(crate) id: NodeId,
+ pub(crate) net: NetLoop,
+ pub(crate) dht_table: RoutingTable,
+ pub(crate) dtun: Dtun,
+ pub(crate) nat: NatDetector,
+ pub(crate) proxy: Proxy,
+ pub(crate) rdp: Rdp,
+ pub(crate) storage: DhtStorage,
+ pub(crate) peers: PeerStore,
+ pub(crate) timers: TimerWheel,
+ pub(crate) advertise: Advertise,
+ pub(crate) reassembler: Reassembler,
+ pub(crate) send_queue: SendQueue,
+ pub(crate) explorer: MaskBitExplorer,
+ pub(crate) is_dtun: bool,
+ pub(crate) dgram_callback: Option<DgramCallback>,
+ pub(crate) rdp_callback: Option<RdpCallback>,
+
+ /// Active iterative queries keyed by nonce.
+ pub(crate) queries: HashMap<u32, IterativeQuery>,
+
+ /// Last bucket refresh time.
+ pub(crate) last_refresh: Instant,
+
+ /// Last data restore time.
+ pub(crate) last_restore: Instant,
+
+ /// Last maintain (mask_bit exploration) time.
+ pub(crate) last_maintain: Instant,
+
+ /// Routing table persistence backend.
+ pub(crate) routing_persistence: Box<dyn crate::persist::RoutingPersistence>,
+
+ /// Data persistence backend.
+ pub(crate) data_persistence: Box<dyn crate::persist::DataPersistence>,
+
+ /// Metrics counters.
+ pub(crate) metrics: crate::metrics::Metrics,
+ /// Pending pings: nonce → (target NodeId, sent_at).
+ pub(crate) pending_pings: HashMap<u32, (NodeId, Instant)>,
+ /// Inbound rate limiter.
+ pub(crate) rate_limiter: crate::ratelimit::RateLimiter,
+ /// Node configuration.
+ pub(crate) config: crate::config::Config,
+ /// Ban list for misbehaving peers.
+ pub(crate) ban_list: crate::banlist::BanList,
+ /// Store acknowledgment tracker.
+ pub(crate) store_tracker: crate::store_track::StoreTracker,
+ /// Last node activity check time.
+ pub(crate) last_activity_check: Instant,
+ /// Last store retry sweep time.
+ pub(crate) last_store_retry: Instant,
+}
+
+/// Builder for configuring a Node node.
+///
+/// ```rust,no_run
+/// use tesseras_dht::node::NodeBuilder;
+/// use tesseras_dht::nat::NatState;
+///
+/// let node = NodeBuilder::new()
+/// .port(10000)
+/// .nat(NatState::Global)
+/// .seed(b"my-identity-seed")
+/// .build()
+/// .unwrap();
+/// ```
+pub struct NodeBuilder {
+ port: u16,
+ addr: Option<SocketAddr>,
+ pub(crate) nat: Option<NatState>,
+ seed: Option<Vec<u8>>,
+ enable_dtun: bool,
+ config: Option<crate::config::Config>,
+}
+
+impl NodeBuilder {
+ pub fn new() -> Self {
+ Self {
+ port: 0,
+ addr: None,
+ nat: None,
+ seed: None,
+ enable_dtun: true,
+ config: None,
+ }
+ }
+
+ /// Set the UDP port to bind.
+ pub fn port(mut self, port: u16) -> Self {
+ self.port = port;
+ self
+ }
+
+ /// Set a specific bind address.
+ pub fn addr(mut self, addr: SocketAddr) -> Self {
+ self.addr = Some(addr);
+ self
+ }
+
+ /// Set the NAT state.
+ pub fn nat(mut self, state: NatState) -> Self {
+ self.nat = Some(state);
+ self
+ }
+
+ /// Set identity seed (deterministic keypair).
+ pub fn seed(mut self, data: &[u8]) -> Self {
+ self.seed = Some(data.to_vec());
+ self
+ }
+
+ /// Enable or disable DTUN.
+ pub fn dtun(mut self, enabled: bool) -> Self {
+ self.enable_dtun = enabled;
+ self
+ }
+
+ /// Set the node configuration.
+ pub fn config(mut self, config: crate::config::Config) -> Self {
+ self.config = Some(config);
+ self
+ }
+
+ /// Build the Node node.
+ pub fn build(self) -> Result<Node, Error> {
+ let addr = self
+ .addr
+ .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], self.port)));
+
+ let mut node = Node::bind_addr(addr)?;
+
+ if let Some(seed) = &self.seed {
+ node.set_id(seed);
+ }
+
+ if let Some(nat) = self.nat {
+ node.set_nat_state(nat);
+ }
+
+ if !self.enable_dtun {
+ node.is_dtun = false;
+ }
+
+ if let Some(config) = self.config {
+ node.config = config;
+ }
+
+ Ok(node)
+ }
+}
+
+impl Default for NodeBuilder {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Node {
+ /// Create a new node and bind to `port` (IPv4).
+ ///
+ /// Generates a random node ID. Use `set_id` to
+ /// derive an ID from application data instead.
+ pub fn bind(port: u16) -> Result<Self, Error> {
+ let addr = SocketAddr::from(([0, 0, 0, 0], port));
+ Self::bind_addr(addr)
+ }
+
+ /// Create a new node bound to `port` on IPv6.
+ ///
+ /// When using IPv6, DTUN is disabled and NAT state
+ /// is set to Global (IPv6 does not need NAT
+ /// traversal).
+ pub fn bind_v6(port: u16) -> Result<Self, Error> {
+ let addr = SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, port));
+ let mut node = Self::bind_addr(addr)?;
+ node.is_dtun = false;
+ node.dtun.set_enabled(false);
+ node.nat.set_state(NatState::Global);
+ Ok(node)
+ }
+
+ /// Create a new node bound to a specific address.
+ pub fn bind_addr(addr: SocketAddr) -> Result<Self, Error> {
+ let identity = crate::crypto::Identity::generate();
+ let id = *identity.node_id();
+ let net = NetLoop::bind(addr)?;
+
+ log::info!("Node node {} bound to {}", id, net.local_addr()?);
+
+ Ok(Self {
+ dht_table: RoutingTable::new(id),
+ dtun: Dtun::new(id),
+ nat: NatDetector::new(id),
+ proxy: Proxy::new(id),
+ rdp: Rdp::new(),
+ storage: DhtStorage::new(),
+ peers: PeerStore::new(),
+ timers: TimerWheel::new(),
+ advertise: Advertise::new(id),
+ reassembler: Reassembler::new(),
+ send_queue: SendQueue::new(),
+ explorer: MaskBitExplorer::new(id),
+ is_dtun: true,
+ dgram_callback: None,
+ rdp_callback: None,
+ queries: HashMap::new(),
+ last_refresh: Instant::now(),
+ last_restore: Instant::now(),
+ last_maintain: Instant::now(),
+ routing_persistence: Box::new(crate::persist::NoPersistence),
+ data_persistence: Box::new(crate::persist::NoPersistence),
+ metrics: crate::metrics::Metrics::new(),
+ pending_pings: HashMap::new(),
+ rate_limiter: crate::ratelimit::RateLimiter::default(),
+ config: crate::config::Config::default(),
+ ban_list: crate::banlist::BanList::new(),
+ store_tracker: crate::store_track::StoreTracker::new(),
+ last_activity_check: Instant::now(),
+ last_store_retry: Instant::now(),
+ identity,
+ id,
+ net,
+ })
+ }
+
+ // ── Identity ────────────────────────────────────
+
+ /// The local node ID.
+ pub fn id(&self) -> &NodeId {
+ &self.id
+ }
+
+ /// The local node ID as a hex string.
+ pub fn id_hex(&self) -> String {
+ self.id.to_hex()
+ }
+
+ /// Set the node identity from a 32-byte seed.
+ ///
+ /// Derives an Ed25519 keypair from the seed.
+ /// NodeId = public key. Deterministic: same seed
+ /// produces the same identity.
+ pub fn set_id(&mut self, data: &[u8]) {
+ // Hash to 32 bytes if input is not already 32
+ let seed = if data.len() == 32 {
+ let mut s = [0u8; 32];
+ s.copy_from_slice(data);
+ s
+ } else {
+ use sha2::{Digest, Sha256};
+ let hash = Sha256::digest(data);
+ let mut s = [0u8; 32];
+ s.copy_from_slice(&hash);
+ s
+ };
+ self.identity = crate::crypto::Identity::from_seed(seed);
+ self.id = *self.identity.node_id();
+ self.dht_table = RoutingTable::new(self.id);
+ self.dtun = Dtun::new(self.id);
+ self.explorer = MaskBitExplorer::new(self.id);
+ log::info!("Node ID set to {}", self.id);
+ }
+
+ /// The node's Ed25519 public key (32 bytes).
+ pub fn public_key(&self) -> &[u8; 32] {
+ self.identity.public_key()
+ }
+
+ // ── NAT state ───────────────────────────────────
+
+ /// Current NAT detection state.
+ pub fn nat_state(&self) -> NatState {
+ self.nat.state()
+ }
+
+ /// Force the NAT state.
+ pub fn set_nat_state(&mut self, state: NatState) {
+ self.nat.set_state(state);
+ if state == NatState::Global {
+ // IPv6 or explicitly global: disable DTUN
+ if !self.is_dtun {
+ self.dtun.set_enabled(false);
+ }
+ }
+ }
+
+ pub(crate) fn alloc_nonce(&mut self) -> u32 {
+ let mut buf = [0u8; 4];
+ crate::sys::random_bytes(&mut buf);
+ u32::from_ne_bytes(buf)
+ }
+
+ /// Safe cast of packet size to u16.
+ #[inline]
+ pub(crate) fn len16(n: usize) -> u16 {
+ u16::try_from(n).expect("packet size exceeds u16")
+ }
+
+ // ── DHT operations ──────────────────────────────
+
+ /// Store a key-value pair in the DHT.
+ ///
+ /// The key is hashed with SHA-256 to map it to the
+ /// 256-bit ID space. Stored locally and sent to
+ /// the k-closest known nodes.
+ ///
+ /// # Example
+ ///
+ /// ```rust,no_run
+ /// # let mut node = tesseras_dht::Node::bind(0).unwrap();
+ /// node.put(b"paste-id", b"paste content", 3600, false);
+ /// ```
+ pub fn put(&mut self, key: &[u8], value: &[u8], ttl: u16, is_unique: bool) {
+ let target_id = NodeId::from_key(key);
+ log::debug!(
+ "put: key={} target={}",
+ String::from_utf8_lossy(key),
+ target_id
+ );
+
+ // Store locally
+ let val = crate::dht::StoredValue {
+ key: key.to_vec(),
+ value: value.to_vec(),
+ id: target_id,
+ source: self.id,
+ ttl,
+ stored_at: std::time::Instant::now(),
+ is_unique,
+ original: 3, // ORIGINAL_PUT_NUM
+ recvd: std::collections::HashSet::new(),
+ version: crate::dht::now_version(),
+ };
+ self.storage.store(val);
+
+ // If behind symmetric NAT, route through proxy
+ if self.nat.state() == NatState::SymmetricNat {
+ if let Some(server) = self.proxy.server().cloned() {
+ let store_msg = msg::StoreMsg {
+ id: target_id,
+ from: self.id,
+ key: key.to_vec(),
+ value: value.to_vec(),
+ ttl,
+ is_unique,
+ };
+ let total = HEADER_SIZE
+ + msg::STORE_FIXED
+ + store_msg.key.len()
+ + store_msg.value.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ MsgType::ProxyStore,
+ Self::len16(total),
+ self.id,
+ server.id,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ let _ = msg::write_store(&mut buf, &store_msg);
+ let _ = self.send_signed(&buf, server.addr);
+ }
+ log::info!("put: via proxy to {:?}", server.id);
+ return;
+ }
+ }
+
+ // Direct: send STORE to k-closest known nodes
+ let closest = self
+ .dht_table
+ .closest(&target_id, self.config.num_find_node);
+ let store_msg = msg::StoreMsg {
+ id: target_id,
+ from: self.id,
+ key: key.to_vec(),
+ value: value.to_vec(),
+ ttl,
+ is_unique,
+ };
+ for peer in &closest {
+ if let Err(e) = self.send_store(peer, &store_msg) {
+ log::warn!("Failed to send store to {:?}: {e}", peer.id);
+ } else {
+ self.store_tracker.track(
+ target_id,
+ key.to_vec(),
+ value.to_vec(),
+ ttl,
+ is_unique,
+ peer.clone(),
+ );
+ }
+ }
+ log::info!("put: stored locally + sent to {} peers", closest.len());
+ }
+
+ /// Store multiple key-value pairs in the DHT.
+ ///
+ /// More efficient than calling `put()` in a loop:
+ /// groups stores by target peer to reduce redundant
+ /// lookups and sends.
+ pub fn put_batch(&mut self, entries: &[(&[u8], &[u8], u16, bool)]) {
+ // Group by target peer set to batch sends
+ struct BatchEntry {
+ target_id: NodeId,
+ key: Vec<u8>,
+ value: Vec<u8>,
+ ttl: u16,
+ is_unique: bool,
+ }
+
+ let mut batch: Vec<BatchEntry> = Vec::with_capacity(entries.len());
+
+ for &(key, value, ttl, is_unique) in entries {
+ let target_id = NodeId::from_key(key);
+
+ // Store locally
+ let val = crate::dht::StoredValue {
+ key: key.to_vec(),
+ value: value.to_vec(),
+ id: target_id,
+ source: self.id,
+ ttl,
+ stored_at: std::time::Instant::now(),
+ is_unique,
+ original: 3,
+ recvd: std::collections::HashSet::new(),
+ version: crate::dht::now_version(),
+ };
+ self.storage.store(val);
+
+ batch.push(BatchEntry {
+ target_id,
+ key: key.to_vec(),
+ value: value.to_vec(),
+ ttl,
+ is_unique,
+ });
+ }
+
+ // Collect unique peers across all targets to
+ // minimize redundant sends
+ let mut peer_stores: HashMap<NodeId, Vec<msg::StoreMsg>> =
+ HashMap::new();
+
+ for entry in &batch {
+ let closest = self
+ .dht_table
+ .closest(&entry.target_id, self.config.num_find_node);
+ let store_msg = msg::StoreMsg {
+ id: entry.target_id,
+ from: self.id,
+ key: entry.key.clone(),
+ value: entry.value.clone(),
+ ttl: entry.ttl,
+ is_unique: entry.is_unique,
+ };
+ for peer in &closest {
+ peer_stores
+ .entry(peer.id)
+ .or_default()
+ .push(store_msg.clone());
+ }
+ }
+
+ // Send all stores grouped by peer
+ let mut total_sent = 0u32;
+ for (peer_id, stores) in &peer_stores {
+ if let Some(peer) = self.peers.get(peer_id).cloned() {
+ if self.ban_list.is_banned(&peer.addr) {
+ continue;
+ }
+ for store in stores {
+ if self.send_store(&peer, store).is_ok() {
+ total_sent += 1;
+ }
+ }
+ }
+ }
+
+ log::info!(
+ "put_batch: {} entries stored locally, {total_sent} sends to {} peers",
+ batch.len(),
+ peer_stores.len(),
+ );
+ }
+
+ /// Retrieve multiple keys from the DHT.
+ ///
+ /// Returns a vec of (key, values) pairs. Local values
+ /// are returned immediately; missing keys trigger
+ /// iterative FIND_VALUE queries resolved via `poll()`.
+ pub fn get_batch(
+ &mut self,
+ keys: &[&[u8]],
+ ) -> Vec<(Vec<u8>, Vec<Vec<u8>>)> {
+ let mut results = Vec::with_capacity(keys.len());
+
+ for &key in keys {
+ let target_id = NodeId::from_key(key);
+ let local = self.storage.get(&target_id, key);
+
+ if !local.is_empty() {
+ let vals: Vec<Vec<u8>> =
+ local.into_iter().map(|v| v.value).collect();
+ results.push((key.to_vec(), vals));
+ } else {
+ // Start iterative FIND_VALUE for missing keys
+ if let Err(e) = self.start_find_value(key) {
+ log::debug!("Batch find_value failed for key: {e}");
+ }
+ results.push((key.to_vec(), Vec::new()));
+ }
+ }
+
+ results
+ }
+
+ /// Delete a key from the DHT.
+ ///
+ /// Sends a STORE with TTL=0 to the k-closest nodes,
+ /// which causes them to remove the value.
+ pub fn delete(&mut self, key: &[u8]) {
+ let target_id = NodeId::from_key(key);
+
+ // Remove locally
+ self.storage.remove(&target_id, key);
+
+ // Send TTL=0 store to closest nodes
+ let closest = self
+ .dht_table
+ .closest(&target_id, self.config.num_find_node);
+ let store_msg = msg::StoreMsg {
+ id: target_id,
+ from: self.id,
+ key: key.to_vec(),
+ value: Vec::new(),
+ ttl: 0,
+ is_unique: false,
+ };
+ for peer in &closest {
+ let _ = self.send_store(peer, &store_msg);
+ }
+ log::info!("delete: removed locally + sent to {} peers", closest.len());
+ }
+
+ /// Retrieve values for a key from the DHT.
+ ///
+ /// First checks local storage. If not found, starts
+ /// an iterative FIND_VALUE query across the network.
+ /// Returns local values immediately; remote results
+ /// arrive via `poll()` and can be retrieved with a
+ /// subsequent `get()` call (they'll be cached
+ /// locally by `handle_dht_find_value_reply`).
+ pub fn get(&mut self, key: &[u8]) -> Vec<Vec<u8>> {
+ let target_id = NodeId::from_key(key);
+
+ // Check local storage first
+ let local = self.storage.get(&target_id, key);
+ if !local.is_empty() {
+ return local.into_iter().map(|v| v.value).collect();
+ }
+
+ // Not found locally — start iterative FIND_VALUE
+ if let Err(e) = self.start_find_value(key) {
+ log::debug!("Failed to start find_value: {e}");
+ }
+
+ Vec::new()
+ }
+
+ /// Retrieve values with blocking network lookup.
+ ///
+ /// Polls internally until the value is found or
+ /// `timeout` expires. Returns empty if not found.
+ pub fn get_blocking(
+ &mut self,
+ key: &[u8],
+ timeout: Duration,
+ ) -> Vec<Vec<u8>> {
+ let target_id = NodeId::from_key(key);
+
+ // Check local first
+ let local = self.storage.get(&target_id, key);
+ if !local.is_empty() {
+ return local.into_iter().map(|v| v.value).collect();
+ }
+
+ // Start FIND_VALUE
+ if self.start_find_value(key).is_err() {
+ return Vec::new();
+ }
+
+ // Poll until found or timeout
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ let _ = self.poll();
+
+ let vals = self.storage.get(&target_id, key);
+ if !vals.is_empty() {
+ return vals.into_iter().map(|v| v.value).collect();
+ }
+
+ std::thread::sleep(Duration::from_millis(10));
+ }
+
+ Vec::new()
+ }
+
+ /// Start an iterative FIND_VALUE query for a key.
+ ///
+ /// Returns the query nonce. Results arrive via
+ /// `handle_dht_find_value_reply` during `poll()`.
+ pub fn start_find_value(&mut self, key: &[u8]) -> Result<u32, Error> {
+ let target_id = NodeId::from_key(key);
+ let nonce = self.alloc_nonce();
+ let mut query =
+ IterativeQuery::find_value(target_id, key.to_vec(), nonce);
+
+ // Seed with our closest known nodes
+ let closest = self
+ .dht_table
+ .closest(&target_id, self.config.num_find_node);
+ query.closest = closest;
+
+ self.queries.insert(nonce, query);
+
+ // Send initial batch
+ self.send_query_batch(nonce)?;
+
+ Ok(nonce)
+ }
+
+ /// Send a FIND_VALUE message to a specific address.
+ pub(crate) fn send_find_value_msg(
+ &mut self,
+ to: SocketAddr,
+ target: NodeId,
+ key: &[u8],
+ ) -> Result<u32, Error> {
+ let nonce = self.alloc_nonce();
+ let domain = if to.is_ipv4() {
+ DOMAIN_INET
+ } else {
+ DOMAIN_INET6
+ };
+
+ let fv = msg::FindValueMsg {
+ nonce,
+ target,
+ domain,
+ key: key.to_vec(),
+ use_rdp: false,
+ };
+
+ let total = HEADER_SIZE + msg::FIND_VALUE_FIXED + key.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ MsgType::DhtFindValue,
+ Self::len16(total),
+ self.id,
+ NodeId::from_bytes([0; crate::id::ID_LEN]),
+ );
+ hdr.write(&mut buf)?;
+ msg::write_find_value(&mut buf, &fv)?;
+ self.send_signed(&buf, to)?;
+
+ log::debug!(
+ "Sent find_value to {to} target={target:?} key={} bytes",
+ key.len()
+ );
+ Ok(nonce)
+ }
+
+ // ── Datagram ────────────────────────────────────
+
+ /// Send a datagram to a destination node.
+ ///
+ /// If the destination's address is known, fragments
+ /// are sent immediately. Otherwise they are queued
+ /// for delivery once the address is resolved.
+ pub fn send_dgram(&mut self, data: &[u8], dst: &NodeId) {
+ let fragments = dgram::fragment(data);
+ log::debug!(
+ "send_dgram: {} bytes, {} fragment(s) to {:?}",
+ data.len(),
+ fragments.len(),
+ dst
+ );
+
+ // If behind symmetric NAT, route through proxy
+ if self.nat.state() == NatState::SymmetricNat {
+ if let Some(server) = self.proxy.server().cloned() {
+ for frag in &fragments {
+ let total = HEADER_SIZE + frag.len();
+ let mut buf = vec![0u8; total];
+ let hdr = MsgHeader::new(
+ MsgType::ProxyDgram,
+ Self::len16(total),
+ self.id,
+ *dst,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..].copy_from_slice(frag);
+ let _ = self.send_signed(&buf, server.addr);
+ }
+ }
+ return;
+ }
+ }
+
+ // Direct: send if we know the address
+ if let Some(peer) = self.peers.get(dst).cloned() {
+ for frag in &fragments {
+ self.send_dgram_raw(frag, &peer);
+ }
+ } else {
+ // Queue for later delivery
+ for frag in fragments {
+ self.send_queue.push(*dst, frag, self.id);
+ }
+ }
+ }
+
+ /// Send a single dgram fragment wrapped in a
+ /// protocol message.
+ pub(crate) fn send_dgram_raw(&self, payload: &[u8], dst: &PeerInfo) {
+ let total = HEADER_SIZE + payload.len();
+ let mut buf = vec![0u8; total];
+ let hdr =
+ MsgHeader::new(MsgType::Dgram, Self::len16(total), self.id, dst.id);
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..].copy_from_slice(payload);
+ let _ = self.send_signed(&buf, dst.addr);
+ }
+ }
+
+ /// Set the callback for received datagrams.
+ pub fn set_dgram_callback<F>(&mut self, f: F)
+ where
+ F: Fn(&[u8], &NodeId) + Send + 'static,
+ {
+ self.dgram_callback = Some(Box::new(f));
+ }
+
+ /// Remove the datagram callback.
+ pub fn unset_dgram_callback(&mut self) {
+ self.dgram_callback = None;
+ }
+
+ /// Set a callback for RDP events (ACCEPTED,
+ /// CONNECTED, READY2READ, RESET, FAILED, etc).
+ pub fn set_rdp_callback<F>(&mut self, f: F)
+ where
+ F: Fn(i32, &crate::rdp::RdpAddr, crate::rdp::RdpEvent) + Send + 'static,
+ {
+ self.rdp_callback = Some(Box::new(f));
+ }
+
+ /// Remove the RDP event callback.
+ pub fn unset_rdp_callback(&mut self) {
+ self.rdp_callback = None;
+ }
+
+ // ── RDP (reliable transport) ────────────────────
+
+ /// Listen for RDP connections on `port`.
+ pub fn rdp_listen(&mut self, port: u16) -> Result<i32, RdpError> {
+ self.rdp.listen(port)
+ }
+
+ /// Connect to a remote node via RDP.
+ ///
+ /// Sends a SYN packet immediately if the peer's
+ /// address is known.
+ pub fn rdp_connect(
+ &mut self,
+ sport: u16,
+ dst: &NodeId,
+ dport: u16,
+ ) -> Result<i32, RdpError> {
+ let desc = self.rdp.connect(sport, *dst, dport)?;
+ self.flush_rdp_output(desc);
+ Ok(desc)
+ }
+
+ /// Close an RDP connection or listener.
+ pub fn rdp_close(&mut self, desc: i32) {
+ self.rdp.close(desc);
+ }
+
+ /// Send data on an RDP connection.
+ ///
+ /// Enqueues data and flushes pending packets to the
+ /// network.
+ pub fn rdp_send(
+ &mut self,
+ desc: i32,
+ data: &[u8],
+ ) -> Result<usize, RdpError> {
+ let n = self.rdp.send(desc, data)?;
+ self.flush_rdp_output(desc);
+ Ok(n)
+ }
+
+ /// Receive data from an RDP connection.
+ pub fn rdp_recv(
+ &mut self,
+ desc: i32,
+ buf: &mut [u8],
+ ) -> Result<usize, RdpError> {
+ self.rdp.recv(desc, buf)
+ }
+
+ /// Get the state of an RDP descriptor.
+ pub fn rdp_state(&self, desc: i32) -> Result<RdpState, RdpError> {
+ self.rdp.get_state(desc)
+ }
+
+ /// Get status of all RDP connections.
+ pub fn rdp_status(&self) -> Vec<RdpStatus> {
+ self.rdp.get_status()
+ }
+
+ /// Set RDP maximum retransmission timeout.
+ pub fn rdp_set_max_retrans(&mut self, secs: u64) {
+ self.rdp.set_max_retrans(Duration::from_secs(secs));
+ }
+
+ /// Get RDP maximum retransmission timeout.
+ pub fn rdp_max_retrans(&self) -> u64 {
+ self.rdp.max_retrans().as_secs()
+ }
+
+ // ── Event loop ──────────────────────────────────
+
+ /// Process one iteration of the event loop.
+ ///
+ /// Polls for I/O events, processes incoming packets,
+ /// fires expired timers, and runs maintenance tasks.
+ pub fn poll(&mut self) -> Result<(), Error> {
+ self.poll_timeout(DEFAULT_POLL_TIMEOUT)
+ }
+
+ /// Poll with a custom maximum timeout.
+ ///
+ /// Use a short timeout (e.g. 1ms) in tests with
+ /// many nodes to avoid blocking.
+ pub fn poll_timeout(&mut self, max_timeout: Duration) -> Result<(), Error> {
+ let timeout = self
+ .timers
+ .next_deadline()
+ .map(|d| d.min(max_timeout))
+ .unwrap_or(max_timeout);
+
+ self.net.poll_events(timeout)?;
+
+ // Check if we got a UDP event. We must not hold
+ // a borrow on self.net while calling handle_packet,
+ // so we just check and then drain separately.
+ let has_udp = self.net.drain_events().any(|ev| ev.token() == UDP_TOKEN);
+
+ if has_udp {
+ let mut buf = [0u8; 4096];
+ while let Ok((len, from)) = self.net.recv_from(&mut buf) {
+ self.handle_packet(&buf[..len], from);
+ }
+ }
+
+ // Fire timers
+ let _fired = self.timers.tick();
+
+ // Drive iterative queries
+ self.drive_queries();
+
+ // Drain send queue for destinations we now know
+ self.drain_send_queue();
+
+ // Drive RDP: tick timeouts and flush output
+ let rdp_actions = self.rdp.tick();
+ for action in rdp_actions {
+ if let crate::rdp::RdpAction::Event { desc, event, .. } = action {
+ log::info!("RDP tick event: desc={desc} {event:?}");
+ }
+ }
+ self.flush_all_rdp();
+
+ // Periodic maintenance
+ self.peers.refresh();
+ self.storage.expire();
+ self.advertise.refresh();
+ self.reassembler.expire();
+ self.send_queue.expire();
+ self.refresh_buckets();
+ self.probe_liveness();
+
+ self.rate_limiter.cleanup();
+
+ // Node activity monitor (proactive ping)
+ self.check_node_activity();
+
+ // Retry failed stores
+ self.retry_failed_stores();
+
+ // Ban list cleanup
+ self.ban_list.cleanup();
+ self.store_tracker.cleanup();
+
+ // Expire stale pending pings (>10s) — record
+ // failure for unresponsive peers
+ let expired_pings: Vec<(u32, NodeId)> = self
+ .pending_pings
+ .iter()
+ .filter(|(_, (_, sent))| sent.elapsed().as_secs() >= 10)
+ .map(|(nonce, (id, _))| (*nonce, *id))
+ .collect();
+ for (nonce, peer_id) in &expired_pings {
+ if let Some(peer) = self.peers.get(peer_id) {
+ self.ban_list.record_failure(peer.addr);
+ }
+ // Also record failure in routing table for
+ // stale count / replacement cache logic
+ if let Some(evicted) = self.dht_table.record_failure(peer_id) {
+ log::debug!(
+ "Replaced stale peer {:?} from routing table",
+ evicted
+ );
+ }
+ self.pending_pings.remove(nonce);
+ }
+
+ // Data restore (every 120s)
+ if self.last_restore.elapsed() >= self.config.restore_interval {
+ self.last_restore = Instant::now();
+ self.restore_data();
+ }
+
+ // Maintain: mask_bit exploration (every 120s)
+ if self.last_maintain.elapsed() >= self.config.maintain_interval {
+ self.last_maintain = Instant::now();
+ self.run_maintain();
+ }
+
+ // DTUN maintenance
+ let dtun_targets = self.dtun.maintain();
+ for target in dtun_targets {
+ if let Err(e) = self.start_find_node(target) {
+ log::debug!("DTUN maintain find_node failed: {e}");
+ }
+ }
+
+ // NAT re-detection
+ self.nat.expire_pending();
+
+ Ok(())
+ }
+
+ /// Run the event loop forever.
+ pub fn run(&mut self) -> ! {
+ loop {
+ if let Err(e) = self.poll() {
+ log::error!("Event loop error: {e}");
+ }
+ }
+ }
+
+ /// Set the node configuration. Call before `join()`.
+ pub fn set_config(&mut self, config: crate::config::Config) {
+ self.config = config;
+ }
+
+ /// Get the current configuration.
+ pub fn config(&self) -> &crate::config::Config {
+ &self.config
+ }
+
+ /// Set the routing table persistence backend.
+ pub fn set_routing_persistence(
+ &mut self,
+ p: Box<dyn crate::persist::RoutingPersistence>,
+ ) {
+ self.routing_persistence = p;
+ }
+
+ /// Set the data persistence backend.
+ pub fn set_data_persistence(
+ &mut self,
+ p: Box<dyn crate::persist::DataPersistence>,
+ ) {
+ self.data_persistence = p;
+ }
+
+ /// Load saved contacts and data from persistence
+ /// backends. Call after bind, before join.
+ pub fn load_persisted(&mut self) {
+ // Load routing table contacts
+ if let Ok(contacts) = self.routing_persistence.load_contacts() {
+ let mut loaded = 0usize;
+ for c in contacts {
+ // Validate: skip zero IDs and unspecified addrs
+ if c.id.is_zero() {
+ log::debug!("Skipping persisted contact: zero ID");
+ continue;
+ }
+ if c.addr.ip().is_unspecified() {
+ log::debug!("Skipping persisted contact: unspecified addr");
+ continue;
+ }
+ let peer = PeerInfo::new(c.id, c.addr);
+ self.dht_table.add(peer.clone());
+ self.peers.add(peer);
+ loaded += 1;
+ }
+ log::info!("Loaded {loaded} persisted contacts");
+ }
+
+ // Load stored values
+ if let Ok(records) = self.data_persistence.load() {
+ for r in &records {
+ let val = crate::dht::StoredValue {
+ key: r.key.clone(),
+ value: r.value.clone(),
+ id: r.target_id,
+ source: r.source,
+ ttl: r.ttl,
+ stored_at: Instant::now(),
+ is_unique: r.is_unique,
+ original: 0,
+ recvd: std::collections::HashSet::new(),
+ version: 0, // persisted data has no version
+ };
+ self.storage.store(val);
+ }
+ log::info!("Loaded {} persisted values", records.len());
+ }
+ }
+
+ /// Save current state to persistence backends.
+ /// Called during shutdown or periodically.
+ pub fn save_state(&self) {
+ // Save routing table contacts
+ let contacts: Vec<crate::persist::ContactRecord> = self
+ .dht_table
+ .closest(&self.id, 1000) // save all
+ .iter()
+ .map(|p| crate::persist::ContactRecord {
+ id: p.id,
+ addr: p.addr,
+ })
+ .collect();
+
+ if let Err(e) = self.routing_persistence.save_contacts(&contacts) {
+ log::warn!("Failed to save contacts: {e}");
+ }
+
+ // Save stored values
+ let values = self.storage.all_values();
+ let records: Vec<crate::persist::StoredRecord> = values
+ .iter()
+ .map(|v| crate::persist::StoredRecord {
+ key: v.key.clone(),
+ value: v.value.clone(),
+ target_id: v.id,
+ source: v.source,
+ ttl: v.remaining_ttl(),
+ is_unique: v.is_unique,
+ })
+ .collect();
+
+ if let Err(e) = self.data_persistence.save(&records) {
+ log::warn!("Failed to save values: {e}");
+ }
+ }
+
+ /// Graceful shutdown: notify closest peers that we
+ /// are leaving, so they can remove us from their
+ /// routing tables immediately.
+ pub fn shutdown(&mut self) {
+ log::info!("Shutting down node {}", self.id);
+
+ // Send a "leaving" ping to our closest peers
+ // so they know to remove us
+ let closest =
+ self.dht_table.closest(&self.id, self.config.num_find_node);
+
+ for peer in &closest {
+ // Send FIN-like notification via advertise
+ let nonce = self.alloc_nonce();
+ let size = HEADER_SIZE + 8;
+ let mut buf = vec![0u8; size];
+ let hdr = MsgHeader::new(
+ MsgType::Advertise,
+ Self::len16(size),
+ self.id,
+ peer.id,
+ );
+ if hdr.write(&mut buf).is_ok() {
+ buf[HEADER_SIZE..HEADER_SIZE + 4]
+ .copy_from_slice(&nonce.to_be_bytes());
+
+ // Session 0 = shutdown signal
+ buf[HEADER_SIZE + 4..HEADER_SIZE + 8].fill(0);
+ let _ = self.send_signed(&buf, peer.addr);
+ }
+ }
+
+ log::info!("Shutdown: notified {} peers", closest.len());
+
+ // Persist state
+ self.save_state();
+ }
+
+ // ── Routing table access ────────────────────────
+
+ /// Number of peers in the DHT routing table.
+ pub fn routing_table_size(&self) -> usize {
+ self.dht_table.size()
+ }
+
+ /// Number of known peers.
+ pub fn peer_count(&self) -> usize {
+ self.peers.len()
+ }
+
+ /// Snapshot of metrics counters.
+ pub fn metrics(&self) -> crate::metrics::MetricsSnapshot {
+ self.metrics.snapshot()
+ }
+
+ /// Number of stored DHT values.
+ pub fn storage_count(&self) -> usize {
+ self.storage.len()
+ }
+
+ /// All stored DHT values (key, value bytes).
+ /// Used by applications to sync DHT-replicated data
+ /// to their own persistence layer.
+ pub fn dht_values(&self) -> Vec<(Vec<u8>, Vec<u8>)> {
+ self.storage
+ .all_values()
+ .into_iter()
+ .map(|v| (v.key, v.value))
+ .collect()
+ }
+
+ /// Number of currently banned peers.
+ pub fn ban_count(&self) -> usize {
+ self.ban_list.ban_count()
+ }
+
+ /// Number of pending store operations awaiting ack.
+ pub fn pending_stores(&self) -> usize {
+ self.store_tracker.pending_count()
+ }
+
+ /// Store tracker statistics: (acks, failures).
+ pub fn store_stats(&self) -> (u64, u64) {
+ (self.store_tracker.acks, self.store_tracker.failures)
+ }
+
+ /// Print node state (debug).
+ pub fn print_state(&self) {
+ println!("MyID = {}", self.id);
+ println!();
+ println!("Node State:");
+ println!(" {:?}", self.nat_state());
+ println!();
+ println!("Routing Table: {} nodes", self.dht_table.size());
+ self.dht_table.print_table();
+ println!();
+ println!("DTUN: {} registrations", self.dtun.registration_count());
+ println!("Peers: {} known", self.peers.len());
+ println!("Storage: {} values", self.storage.len());
+ println!("Bans: {} active", self.ban_list.ban_count());
+ println!(
+ "Stores: {} pending, {} acked, {} failed",
+ self.store_tracker.pending_count(),
+ self.store_tracker.acks,
+ self.store_tracker.failures,
+ );
+ println!(
+ "RDP: {} connections, {} listeners",
+ self.rdp.connection_count(),
+ self.rdp.listener_count()
+ );
+ }
+}
+
+impl fmt::Display for Node {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "Node({}, {:?}, {} peers, {} stored)",
+ self.id,
+ self.nat_state(),
+ self.dht_table.size(),
+ self.storage.len()
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bind_creates_node() {
+ let node = Node::bind(0).unwrap();
+ assert!(!node.id().is_zero());
+ assert_eq!(node.routing_table_size(), 0);
+ assert_eq!(node.nat_state(), NatState::Unknown);
+ }
+
+ #[test]
+ fn set_id_from_data() {
+ let mut node1 = Node::bind(0).unwrap();
+ let mut node2 = Node::bind(0).unwrap();
+
+ let old_id = *node1.id();
+ node1.set_id(b"my-application-id");
+ assert_ne!(*node1.id(), old_id);
+
+ // Deterministic: same seed → same ID
+ node2.set_id(b"my-application-id");
+ assert_eq!(*node1.id(), *node2.id());
+
+ // Public key matches NodeId
+ assert_eq!(node1.public_key(), node1.id().as_bytes());
+ }
+
+ #[test]
+ fn id_hex_format() {
+ let node = Node::bind(0).unwrap();
+ let hex = node.id_hex();
+ assert_eq!(hex.len(), 64);
+ assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
+ }
+
+ #[test]
+ fn nat_state_set_get() {
+ let mut node = Node::bind(0).unwrap();
+ assert_eq!(node.nat_state(), NatState::Unknown);
+
+ node.set_nat_state(NatState::Global);
+ assert_eq!(node.nat_state(), NatState::Global);
+
+ node.set_nat_state(NatState::ConeNat);
+ assert_eq!(node.nat_state(), NatState::ConeNat);
+
+ node.set_nat_state(NatState::SymmetricNat);
+ assert_eq!(node.nat_state(), NatState::SymmetricNat);
+ }
+
+ #[test]
+ fn put_get_local() {
+ let mut node = Node::bind(0).unwrap();
+ node.put(b"hello", b"world", 300, false);
+
+ let vals = node.get(b"hello");
+ assert_eq!(vals.len(), 1);
+ assert_eq!(vals[0], b"world");
+ }
+
+ #[test]
+ fn put_unique() {
+ let mut node = Node::bind(0).unwrap();
+ node.put(b"key", b"val1", 300, true);
+ node.put(b"key", b"val2", 300, true);
+
+ let vals = node.get(b"key");
+
+ // Unique from same source → replaced
+ assert_eq!(vals.len(), 1);
+ assert_eq!(vals[0], b"val2");
+ }
+
+ #[test]
+ fn get_nonexistent() {
+ let mut node = Node::bind(0).unwrap();
+ let vals = node.get(b"nope");
+ assert!(vals.is_empty());
+ }
+
+ #[test]
+ fn rdp_listen_and_close() {
+ let mut node = Node::bind(0).unwrap();
+ let _desc = node.rdp_listen(5000).unwrap();
+
+ // Listener desc is not a connection, so
+ // rdp_state won't find it. Just verify close
+ // doesn't panic.
+ node.rdp_close(_desc);
+ }
+
+ #[test]
+ fn rdp_connect_creates_syn() {
+ let mut node = Node::bind(0).unwrap();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = node.rdp_connect(0, &dst, 5000).unwrap();
+ assert_eq!(node.rdp_state(desc).unwrap(), RdpState::SynSent);
+ }
+
+ #[test]
+ fn rdp_status() {
+ let mut node = Node::bind(0).unwrap();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ node.rdp_connect(0, &dst, 5000).unwrap();
+ let status = node.rdp_status();
+ assert_eq!(status.len(), 1);
+ }
+
+ #[test]
+ fn rdp_max_retrans() {
+ let mut node = Node::bind(0).unwrap();
+ node.rdp_set_max_retrans(60);
+ assert_eq!(node.rdp_max_retrans(), 60);
+ }
+
+ #[test]
+ fn display() {
+ let node = Node::bind(0).unwrap();
+ let s = format!("{node}");
+ assert!(s.starts_with("Node("));
+ assert!(s.contains("Unknown"));
+ }
+
+ #[test]
+ fn dgram_callback() {
+ let mut node = Node::bind(0).unwrap();
+ assert!(node.dgram_callback.is_none());
+ node.set_dgram_callback(|_data, _from| {});
+ assert!(node.dgram_callback.is_some());
+ node.unset_dgram_callback();
+ assert!(node.dgram_callback.is_none());
+ }
+
+ #[test]
+ fn join_with_invalid_host() {
+ let mut node = Node::bind(0).unwrap();
+ let result = node.join("this-host-does-not-exist.invalid", 3000);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn poll_once() {
+ let mut node = Node::bind(0).unwrap();
+
+ // Should not block forever (default 1s timeout,
+ // but returns quickly with no events)
+ node.poll().unwrap();
+ }
+}
blob - /dev/null
blob + e575323a295bd1777a66d338b4b4e5af353fb446 (mode 644)
--- /dev/null
+++ src/peers.rs
+//! Peer node database.
+//!
+//! Bidirectional peer map with TTL-based expiry and
+//! timeout tracking.
+
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+
+/// TTL for peer entries (5 min).
+pub const PEERS_MAP_TTL: Duration = Duration::from_secs(300);
+
+/// TTL for timeout entries (30s).
+pub const PEERS_TIMEOUT_TTL: Duration = Duration::from_secs(30);
+
+/// Cleanup timer interval (30s).
+pub const PEERS_TIMER_INTERVAL: Duration = Duration::from_secs(30);
+
+/// NAT state of a peer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NatState {
+ Unknown,
+ Global,
+ Nat,
+ ConeNat,
+ SymmetricNat,
+}
+
+/// Information about a known peer.
+#[derive(Debug, Clone)]
+pub struct PeerInfo {
+ pub id: NodeId,
+ pub addr: SocketAddr,
+ pub domain: u16,
+ pub nat_state: NatState,
+ pub last_seen: Instant,
+ pub session: u32,
+
+ /// Ed25519 public key (if known). Since NodeId =
+ /// pubkey, this is always available when we know
+ /// the peer's ID.
+ pub public_key: Option<[u8; 32]>,
+}
+
+impl PeerInfo {
+ pub fn new(id: NodeId, addr: SocketAddr) -> Self {
+ // NodeId IS the public key (32 bytes)
+ let public_key = Some(*id.as_bytes());
+ Self {
+ id,
+ addr,
+ domain: if addr.is_ipv4() { 1 } else { 2 },
+ nat_state: NatState::Unknown,
+ last_seen: Instant::now(),
+ session: 0,
+ public_key,
+ }
+ }
+}
+
+/// Database of known peers with TTL-based expiry.
+///
+/// Provides forward lookup (id -> info) and reverse
+/// lookup (addr -> ids).
+type PeerCallback = Box<dyn Fn(&PeerInfo)>;
+
+/// Maximum number of tracked peers (prevents OOM).
+const MAX_PEERS: usize = 10_000;
+
+pub struct PeerStore {
+ by_id: HashMap<NodeId, PeerInfo>,
+ by_addr: HashMap<SocketAddr, Vec<NodeId>>,
+ timeouts: HashMap<NodeId, Instant>,
+ on_add: Option<PeerCallback>,
+}
+
+impl PeerStore {
+ pub fn new() -> Self {
+ Self {
+ by_id: HashMap::new(),
+ by_addr: HashMap::new(),
+ timeouts: HashMap::new(),
+ on_add: None,
+ }
+ }
+
+ /// Get peer info by ID.
+ pub fn get(&self, id: &NodeId) -> Option<&PeerInfo> {
+ self.by_id.get(id)
+ }
+
+ /// Get all peer IDs associated with an address.
+ pub fn ids_for_addr(&self, addr: &SocketAddr) -> Vec<NodeId> {
+ self.by_addr.get(addr).cloned().unwrap_or_default()
+ }
+
+ /// Add a peer, checking for duplicates.
+ ///
+ /// If the peer already exists, updates `last_seen`.
+ /// Returns `true` if newly added.
+ pub fn add(&mut self, peer: PeerInfo) -> bool {
+ let id = peer.id;
+ let addr = peer.addr;
+
+ // Limit check (updates are always allowed)
+ if self.by_id.len() >= MAX_PEERS && !self.by_id.contains_key(&id) {
+ return false;
+ }
+
+ if let Some(existing) = self.by_id.get_mut(&id) {
+ existing.last_seen = Instant::now();
+ existing.addr = addr;
+ return false;
+ }
+
+ self.by_addr.entry(addr).or_default().push(id);
+ if let Some(ref cb) = self.on_add {
+ cb(&peer);
+ }
+ self.by_id.insert(id, peer);
+ true
+ }
+
+ /// Add a peer with a session ID (for DTUN register).
+ ///
+ /// Returns `true` if the session matches or is new.
+ pub fn add_with_session(&mut self, peer: PeerInfo, session: u32) -> bool {
+ if let Some(existing) = self.by_id.get(&peer.id) {
+ if existing.session != session {
+ return false;
+ }
+ }
+ let mut peer = peer;
+ peer.session = session;
+ self.add(peer);
+ true
+ }
+
+ /// Add a peer, overwriting any existing entry.
+ pub fn add_force(&mut self, peer: PeerInfo) {
+ self.remove(&peer.id);
+ self.add(peer);
+ }
+
+ /// Remove a peer by ID.
+ pub fn remove(&mut self, id: &NodeId) -> Option<PeerInfo> {
+ if let Some(peer) = self.by_id.remove(id) {
+ if let Some(ids) = self.by_addr.get_mut(&peer.addr) {
+ ids.retain(|i| i != id);
+ if ids.is_empty() {
+ self.by_addr.remove(&peer.addr);
+ }
+ }
+ self.timeouts.remove(id);
+ Some(peer)
+ } else {
+ None
+ }
+ }
+
+ /// Remove all peers associated with an address.
+ pub fn remove_addr(&mut self, addr: &SocketAddr) {
+ if let Some(ids) = self.by_addr.remove(addr) {
+ for id in ids {
+ self.by_id.remove(&id);
+ self.timeouts.remove(&id);
+ }
+ }
+ }
+
+ /// Mark a peer as having timed out.
+ pub fn mark_timeout(&mut self, id: &NodeId) {
+ self.timeouts.insert(*id, Instant::now());
+ }
+
+ /// Check if a peer is in timeout state.
+ pub fn is_timeout(&self, id: &NodeId) -> bool {
+ if let Some(t) = self.timeouts.get(id) {
+ t.elapsed() < PEERS_TIMEOUT_TTL
+ } else {
+ false
+ }
+ }
+
+ /// Remove expired peers (older than MAP_TTL) and
+ /// stale timeout entries.
+ pub fn refresh(&mut self) {
+ let now = Instant::now();
+
+ // Remove expired peers
+ let expired: Vec<NodeId> = self
+ .by_id
+ .iter()
+ .filter(|(_, p)| now.duration_since(p.last_seen) >= PEERS_MAP_TTL)
+ .map(|(id, _)| *id)
+ .collect();
+
+ for id in expired {
+ self.remove(&id);
+ }
+
+ // Remove stale timeout entries
+ self.timeouts
+ .retain(|_, t| now.duration_since(*t) < PEERS_TIMEOUT_TTL);
+ }
+
+ /// Set a callback for when a peer is added.
+ pub fn on_add(&mut self, f: impl Fn(&PeerInfo) + 'static) {
+ self.on_add = Some(Box::new(f));
+ }
+
+ /// Number of known peers.
+ pub fn len(&self) -> usize {
+ self.by_id.len()
+ }
+
+ /// Check if the store is empty.
+ pub fn is_empty(&self) -> bool {
+ self.by_id.is_empty()
+ }
+
+ /// Iterate over all peers.
+ pub fn iter(&self) -> impl Iterator<Item = &PeerInfo> {
+ self.by_id.values()
+ }
+
+ /// Get all peer IDs.
+ pub fn ids(&self) -> Vec<NodeId> {
+ self.by_id.keys().copied().collect()
+ }
+}
+
+impl Default for PeerStore {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn peer(byte: u8, port: u16) -> PeerInfo {
+ PeerInfo::new(
+ NodeId::from_bytes([byte; 32]),
+ SocketAddr::from(([127, 0, 0, 1], port)),
+ )
+ }
+
+ #[test]
+ fn add_and_get() {
+ let mut store = PeerStore::new();
+ let p = peer(1, 3000);
+ assert!(store.add(p.clone()));
+ assert_eq!(store.len(), 1);
+ assert_eq!(store.get(&p.id).unwrap().addr, p.addr);
+ }
+
+ #[test]
+ fn add_duplicate_updates() {
+ let mut store = PeerStore::new();
+ let p = peer(1, 3000);
+ assert!(store.add(p.clone()));
+ assert!(!store.add(p)); // duplicate
+ assert_eq!(store.len(), 1);
+ }
+
+ #[test]
+ fn remove_by_id() {
+ let mut store = PeerStore::new();
+ let p = peer(1, 3000);
+ store.add(p.clone());
+ store.remove(&p.id);
+ assert!(store.is_empty());
+ }
+
+ #[test]
+ fn reverse_lookup() {
+ let mut store = PeerStore::new();
+ let addr: SocketAddr = "127.0.0.1:3000".parse().unwrap();
+ let p1 = PeerInfo::new(NodeId::from_bytes([1; 32]), addr);
+ let p2 = PeerInfo::new(NodeId::from_bytes([2; 32]), addr);
+ store.add(p1.clone());
+ store.add(p2.clone());
+
+ let ids = store.ids_for_addr(&addr);
+ assert_eq!(ids.len(), 2);
+ assert!(ids.contains(&p1.id));
+ assert!(ids.contains(&p2.id));
+ }
+
+ #[test]
+ fn remove_addr_removes_all() {
+ let mut store = PeerStore::new();
+ let addr: SocketAddr = "127.0.0.1:3000".parse().unwrap();
+ store.add(PeerInfo::new(NodeId::from_bytes([1; 32]), addr));
+ store.add(PeerInfo::new(NodeId::from_bytes([2; 32]), addr));
+ store.remove_addr(&addr);
+ assert!(store.is_empty());
+ }
+
+ #[test]
+ fn timeout_tracking() {
+ let mut store = PeerStore::new();
+ let id = NodeId::from_bytes([1; 32]);
+ assert!(!store.is_timeout(&id));
+ store.mark_timeout(&id);
+ assert!(store.is_timeout(&id));
+ }
+
+ #[test]
+ fn add_with_session() {
+ let mut store = PeerStore::new();
+ let p = peer(1, 3000);
+ assert!(store.add_with_session(p.clone(), 42));
+
+ // Same session: ok
+ assert!(store.add_with_session(peer(1, 3001), 42));
+
+ // Different session: rejected
+ assert!(!store.add_with_session(peer(1, 3002), 99));
+ }
+
+ #[test]
+ fn add_force_overwrites() {
+ let mut store = PeerStore::new();
+ store.add(peer(1, 3000));
+ store.add_force(peer(1, 4000));
+ assert_eq!(store.len(), 1);
+ assert_eq!(
+ store.get(&NodeId::from_bytes([1; 32])).unwrap().addr.port(),
+ 4000
+ );
+ }
+}
blob - /dev/null
blob + 8e733b0f3c7a123c21318291be2e76fe2bf1003d (mode 644)
--- /dev/null
+++ src/persist.rs
+//! Persistence traits for data and routing table.
+//!
+//! The library defines the traits; applications
+//! implement backends (SQLite, file, etc).
+
+use crate::error::Error;
+use crate::id::NodeId;
+use std::net::SocketAddr;
+
+/// Stored value record for persistence.
+#[derive(Debug, Clone)]
+pub struct StoredRecord {
+ pub key: Vec<u8>,
+ pub value: Vec<u8>,
+ pub target_id: NodeId,
+ pub source: NodeId,
+ pub ttl: u16,
+ pub is_unique: bool,
+}
+
+/// Contact record for routing table persistence.
+#[derive(Debug, Clone)]
+pub struct ContactRecord {
+ pub id: NodeId,
+ pub addr: SocketAddr,
+}
+
+/// Trait for persisting DHT stored values.
+///
+/// Implement this to survive restarts. The library
+/// calls `save` periodically and `load` on startup.
+pub trait DataPersistence {
+ /// Save all stored values.
+ fn save(&self, records: &[StoredRecord]) -> Result<(), Error>;
+
+ /// Load previously saved values.
+ fn load(&self) -> Result<Vec<StoredRecord>, Error>;
+}
+
+/// Trait for persisting the routing table.
+///
+/// Implement this for fast re-bootstrap after restart.
+pub trait RoutingPersistence {
+ /// Save known contacts.
+ fn save_contacts(&self, contacts: &[ContactRecord]) -> Result<(), Error>;
+
+ /// Load previously saved contacts.
+ fn load_contacts(&self) -> Result<Vec<ContactRecord>, Error>;
+}
+
+/// No-op persistence (default — no persistence).
+pub struct NoPersistence;
+
+impl DataPersistence for NoPersistence {
+ fn save(&self, _records: &[StoredRecord]) -> Result<(), Error> {
+ Ok(())
+ }
+ fn load(&self) -> Result<Vec<StoredRecord>, Error> {
+ Ok(Vec::new())
+ }
+}
+
+impl RoutingPersistence for NoPersistence {
+ fn save_contacts(&self, _contacts: &[ContactRecord]) -> Result<(), Error> {
+ Ok(())
+ }
+ fn load_contacts(&self) -> Result<Vec<ContactRecord>, Error> {
+ Ok(Vec::new())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn no_persistence_save_load() {
+ let p = NoPersistence;
+ assert!(p.save(&[]).is_ok());
+ assert!(p.load().unwrap().is_empty());
+ assert!(p.save_contacts(&[]).is_ok());
+ assert!(p.load_contacts().unwrap().is_empty());
+ }
+}
blob - /dev/null
blob + aaa3827796c51ee1aa6aaf670e5c2d5c86121873 (mode 644)
--- /dev/null
+++ src/proxy.rs
+//! Proxy relay for symmetric NAT nodes.
+//!
+//! When a node is
+//! behind a symmetric NAT (where hole-punching fails),
+//! it registers with a global node that acts as a relay:
+//!
+//! - **Store**: the NAT'd node sends store requests to
+//! its proxy, which forwards them to the DHT.
+//! - **Get**: the NAT'd node sends get requests to its
+//! proxy, which performs the lookup and returns results.
+//! - **Dgram**: datagrams to/from NAT'd nodes are
+//! forwarded through the proxy.
+//! - **RDP**: reliable transport is also tunnelled
+//! through the proxy (type_proxy_rdp).
+
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+use crate::peers::PeerInfo;
+
+// ── Constants ────────────────────────────────────────
+
+/// Registration timeout.
+pub const PROXY_REGISTER_TIMEOUT: Duration = Duration::from_secs(2);
+
+/// Registration TTL.
+pub const PROXY_REGISTER_TTL: Duration = Duration::from_secs(300);
+
+/// Get request timeout.
+pub const PROXY_GET_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// Maintenance timer interval.
+pub const PROXY_TIMER_INTERVAL: Duration = Duration::from_secs(30);
+
+/// RDP timeout for proxy connections.
+pub const PROXY_RDP_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// RDP port for proxy store.
+pub const PROXY_STORE_PORT: u16 = 200;
+
+/// RDP port for proxy get.
+pub const PROXY_GET_PORT: u16 = 201;
+
+/// RDP port for proxy get reply.
+pub const PROXY_GET_REPLY_PORT: u16 = 202;
+
+// ── Client state ────────────────────────────────────
+
+/// A node registered as a client of this proxy.
+#[derive(Debug, Clone)]
+pub struct ProxyClient {
+ /// Client's address (behind NAT).
+ pub addr: SocketAddr,
+
+ /// DTUN session for validation.
+ pub session: u32,
+
+ /// When the client last registered/refreshed.
+ pub registered_at: Instant,
+}
+
+impl ProxyClient {
+ pub fn is_expired(&self) -> bool {
+ self.registered_at.elapsed() >= PROXY_REGISTER_TTL
+ }
+}
+
+// ── Pending get state ───────────────────────────────
+
+/// State for a pending proxy get request.
+#[derive(Debug)]
+pub struct PendingGet {
+ /// Key being looked up.
+ pub key: Vec<u8>,
+
+ /// Nonce for correlation.
+ pub nonce: u32,
+
+ /// When the request was sent.
+ pub sent_at: Instant,
+
+ /// Whether we've received a result.
+ pub completed: bool,
+
+ /// Collected values.
+ pub values: Vec<Vec<u8>>,
+}
+
+// ── Proxy ───────────────────────────────────────────
+
+/// Proxy relay for symmetric NAT traversal.
+///
+/// Acts in two roles:
+/// - **Client**: when we're behind symmetric NAT, we
+/// register with a proxy server and route operations
+/// through it.
+/// - **Server**: when we have a public IP, we accept
+/// registrations from NAT'd nodes and relay their
+/// traffic.
+pub struct Proxy {
+ /// Our proxy server (when we're a client).
+ server: Option<PeerInfo>,
+
+ /// Whether we've successfully registered with our
+ /// proxy server.
+ is_registered: bool,
+
+ /// Whether registration is in progress.
+ is_registering: bool,
+
+ /// Nonce for registration correlation.
+ register_nonce: u32,
+
+ /// Clients registered with us. Capped at 500.
+ clients: HashMap<NodeId, ProxyClient>,
+
+ /// Pending get requests. Capped at 100.
+ pending_gets: HashMap<u32, PendingGet>,
+}
+
+impl Proxy {
+ pub fn new(_id: NodeId) -> Self {
+ Self {
+ server: None,
+ is_registered: false,
+ is_registering: false,
+ register_nonce: 0,
+ clients: HashMap::new(),
+ pending_gets: HashMap::new(),
+ }
+ }
+
+ // ── Client role ─────────────────────────────────
+
+ /// Set the proxy server to use.
+ pub fn set_server(&mut self, server: PeerInfo) {
+ self.server = Some(server);
+ self.is_registered = false;
+ }
+
+ /// Get the current proxy server.
+ pub fn server(&self) -> Option<&PeerInfo> {
+ self.server.as_ref()
+ }
+
+ /// Whether we're registered with a proxy server.
+ pub fn is_registered(&self) -> bool {
+ self.is_registered
+ }
+
+ /// Start proxy registration. Returns the nonce to
+ /// include in the register message.
+ pub fn start_register(&mut self, nonce: u32) -> Option<u32> {
+ self.server.as_ref()?;
+ self.is_registering = true;
+ self.register_nonce = nonce;
+ Some(nonce)
+ }
+
+ /// Handle registration reply.
+ pub fn recv_register_reply(&mut self, nonce: u32) -> bool {
+ if nonce != self.register_nonce {
+ return false;
+ }
+ self.is_registering = false;
+ self.is_registered = true;
+ log::info!("Registered with proxy server");
+ true
+ }
+
+ // ── Server role ─────────────────────────────────
+
+ /// Register a client node (we act as their proxy).
+ pub fn register_client(
+ &mut self,
+ id: NodeId,
+ addr: SocketAddr,
+ session: u32,
+ ) -> bool {
+ const MAX_CLIENTS: usize = 500;
+ if self.clients.len() >= MAX_CLIENTS && !self.clients.contains_key(&id)
+ {
+ return false;
+ }
+ if let Some(existing) = self.clients.get(&id) {
+ if existing.session != session && !existing.is_expired() {
+ return false;
+ }
+ }
+ self.clients.insert(
+ id,
+ ProxyClient {
+ addr,
+ session,
+ registered_at: Instant::now(),
+ },
+ );
+ log::debug!("Proxy: registered client {id:?}");
+ true
+ }
+
+ /// Check if a node is registered as our client.
+ pub fn is_client_registered(&self, id: &NodeId) -> bool {
+ self.clients
+ .get(id)
+ .map(|c| !c.is_expired())
+ .unwrap_or(false)
+ }
+
+ /// Get a registered client's info.
+ pub fn get_client(&self, id: &NodeId) -> Option<&ProxyClient> {
+ self.clients.get(id).filter(|c| !c.is_expired())
+ }
+
+ /// Number of active proxy clients.
+ pub fn client_count(&self) -> usize {
+ self.clients.values().filter(|c| !c.is_expired()).count()
+ }
+
+ // ── Pending get management ──────────────────────
+
+ /// Start a proxied get request.
+ pub fn start_get(&mut self, nonce: u32, key: Vec<u8>) {
+ const MAX_GETS: usize = 100;
+ if self.pending_gets.len() >= MAX_GETS {
+ log::debug!("Proxy: too many pending gets");
+ return;
+ }
+ self.pending_gets.insert(
+ nonce,
+ PendingGet {
+ key,
+ nonce,
+ sent_at: Instant::now(),
+ completed: false,
+ values: Vec::new(),
+ },
+ );
+ }
+
+ /// Add a value to a pending get.
+ pub fn add_get_value(&mut self, nonce: u32, value: Vec<u8>) -> bool {
+ if let Some(pg) = self.pending_gets.get_mut(&nonce) {
+ pg.values.push(value);
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Complete a pending get request. Returns the
+ /// collected values.
+ pub fn complete_get(&mut self, nonce: u32) -> Option<Vec<Vec<u8>>> {
+ self.pending_gets.remove(&nonce).map(|pg| pg.values)
+ }
+
+ // ── Maintenance ─────────────────────────────────
+
+ /// Remove expired clients and timed-out gets.
+ pub fn refresh(&mut self) {
+ self.clients.retain(|_, c| !c.is_expired());
+
+ self.pending_gets
+ .retain(|_, pg| pg.sent_at.elapsed() < PROXY_GET_TIMEOUT);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn addr(port: u16) -> SocketAddr {
+ SocketAddr::from(([127, 0, 0, 1], port))
+ }
+
+ fn peer(byte: u8, port: u16) -> PeerInfo {
+ PeerInfo::new(NodeId::from_bytes([byte; 32]), addr(port))
+ }
+
+ // ── Client tests ────────────────────────────────
+
+ #[test]
+ fn client_register_flow() {
+ let mut proxy = Proxy::new(NodeId::from_bytes([0x01; 32]));
+
+ assert!(!proxy.is_registered());
+
+ proxy.set_server(peer(0x02, 5000));
+ let nonce = proxy.start_register(42).unwrap();
+ assert_eq!(nonce, 42);
+
+ // Wrong nonce
+ assert!(!proxy.recv_register_reply(99));
+ assert!(!proxy.is_registered());
+
+ // Correct nonce
+ assert!(proxy.recv_register_reply(42));
+ assert!(proxy.is_registered());
+ }
+
+ #[test]
+ fn client_no_server() {
+ let mut proxy = Proxy::new(NodeId::from_bytes([0x01; 32]));
+ assert!(proxy.start_register(1).is_none());
+ }
+
+ // ── Server tests ────────────────────────────────
+
+ #[test]
+ fn server_register_client() {
+ let mut proxy = Proxy::new(NodeId::from_bytes([0x01; 32]));
+ let client_id = NodeId::from_bytes([0x02; 32]);
+
+ assert!(proxy.register_client(client_id, addr(3000), 1));
+ assert!(proxy.is_client_registered(&client_id));
+ assert_eq!(proxy.client_count(), 1);
+ }
+
+ #[test]
+ fn server_rejects_different_session() {
+ let mut proxy = Proxy::new(NodeId::from_bytes([0x01; 32]));
+ let client_id = NodeId::from_bytes([0x02; 32]);
+
+ proxy.register_client(client_id, addr(3000), 1);
+ assert!(!proxy.register_client(client_id, addr(3001), 2));
+ }
+
+ #[test]
+ fn server_expire_clients() {
+ let mut proxy = Proxy::new(NodeId::from_bytes([0x01; 32]));
+ let client_id = NodeId::from_bytes([0x02; 32]);
+
+ proxy.clients.insert(
+ client_id,
+ ProxyClient {
+ addr: addr(3000),
+ session: 1,
+ registered_at: Instant::now() - Duration::from_secs(600),
+ },
+ );
+
+ proxy.refresh();
+ assert_eq!(proxy.client_count(), 0);
+ }
+
+ // ── Pending get tests ───────────────────────────
+
+ #[test]
+ fn pending_get_flow() {
+ let mut proxy = Proxy::new(NodeId::from_bytes([0x01; 32]));
+
+ proxy.start_get(42, b"mykey".to_vec());
+ assert!(proxy.add_get_value(42, b"val1".to_vec()));
+ assert!(proxy.add_get_value(42, b"val2".to_vec()));
+
+ let vals = proxy.complete_get(42).unwrap();
+ assert_eq!(vals.len(), 2);
+ assert_eq!(vals[0], b"val1");
+ assert_eq!(vals[1], b"val2");
+ }
+
+ #[test]
+ fn pending_get_unknown_nonce() {
+ let mut proxy = Proxy::new(NodeId::from_bytes([0x01; 32]));
+ assert!(!proxy.add_get_value(999, b"v".to_vec()));
+ assert!(proxy.complete_get(999).is_none());
+ }
+}
blob - /dev/null
blob + 691b30f9f825b951fb82d7a8cb0c84d3dda33c97 (mode 644)
--- /dev/null
+++ src/ratelimit.rs
+//! Per-IP rate limiting with token bucket algorithm.
+//!
+//! Limits the number of inbound messages processed per
+//! source IP address per second. Prevents a single
+//! source from overwhelming the node.
+
+use std::collections::HashMap;
+use std::net::IpAddr;
+use std::time::Instant;
+
+/// Default rate: 50 messages per second per IP.
+pub const DEFAULT_RATE: f64 = 50.0;
+
+/// Default burst: 100 messages.
+pub const DEFAULT_BURST: u32 = 100;
+
+/// Stale bucket cleanup threshold.
+const STALE_SECS: u64 = 60;
+
+struct Bucket {
+ tokens: f64,
+ last_refill: Instant,
+}
+
+/// Per-IP token bucket rate limiter.
+pub struct RateLimiter {
+ buckets: HashMap<IpAddr, Bucket>,
+ rate: f64,
+ burst: u32,
+ last_cleanup: Instant,
+}
+
+impl RateLimiter {
+ /// Create a new rate limiter.
+ ///
+ /// - `rate`: tokens added per second per IP.
+ /// - `burst`: maximum tokens (burst capacity).
+ pub fn new(rate: f64, burst: u32) -> Self {
+ Self {
+ buckets: HashMap::new(),
+ rate,
+ burst,
+ last_cleanup: Instant::now(),
+ }
+ }
+
+ /// Check if a message from `ip` should be allowed.
+ ///
+ /// Returns `true` if allowed (token consumed),
+ /// `false` if rate-limited (drop the message).
+ pub fn allow(&mut self, ip: IpAddr) -> bool {
+ let now = Instant::now();
+ let burst = self.burst as f64;
+ let rate = self.rate;
+
+ let bucket = self.buckets.entry(ip).or_insert(Bucket {
+ tokens: burst,
+ last_refill: now,
+ });
+
+ // Refill tokens based on elapsed time
+ let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
+ bucket.tokens = (bucket.tokens + elapsed * rate).min(burst);
+ bucket.last_refill = now;
+
+ if bucket.tokens >= 1.0 {
+ bucket.tokens -= 1.0;
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Remove stale buckets (no activity for 60s).
+ pub fn cleanup(&mut self) {
+ if self.last_cleanup.elapsed().as_secs() < STALE_SECS {
+ return;
+ }
+ self.last_cleanup = Instant::now();
+ let cutoff = Instant::now();
+ self.buckets.retain(|_, b| {
+ cutoff.duration_since(b.last_refill).as_secs() < STALE_SECS
+ });
+ }
+
+ /// Number of tracked IPs.
+ pub fn tracked_count(&self) -> usize {
+ self.buckets.len()
+ }
+}
+
+impl Default for RateLimiter {
+ fn default() -> Self {
+ Self::new(DEFAULT_RATE, DEFAULT_BURST)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn allow_within_burst() {
+ let mut rl = RateLimiter::new(10.0, 5);
+ let ip: IpAddr = "1.2.3.4".parse().unwrap();
+
+ // First 5 should be allowed (burst)
+ for _ in 0..5 {
+ assert!(rl.allow(ip));
+ }
+
+ // 6th should be denied
+ assert!(!rl.allow(ip));
+ }
+
+ #[test]
+ fn different_ips_independent() {
+ let mut rl = RateLimiter::new(1.0, 1);
+ let ip1: IpAddr = "1.2.3.4".parse().unwrap();
+ let ip2: IpAddr = "5.6.7.8".parse().unwrap();
+ assert!(rl.allow(ip1));
+ assert!(rl.allow(ip2));
+
+ // Both exhausted
+ assert!(!rl.allow(ip1));
+ assert!(!rl.allow(ip2));
+ }
+
+ #[test]
+ fn tracked_count() {
+ let mut rl = RateLimiter::default();
+ let ip: IpAddr = "1.2.3.4".parse().unwrap();
+ rl.allow(ip);
+ assert_eq!(rl.tracked_count(), 1);
+ }
+}
blob - /dev/null
blob + 96de51d8fad142a913d23d0126c89715c494e3b5 (mode 644)
--- /dev/null
+++ src/rdp.rs
+//! Reliable Datagram Protocol (RDP).
+//!
+//! Provides TCP-like
+//! reliable, ordered delivery over UDP with:
+//!
+//! - 7-state connection machine
+//! - 3-way handshake (SYN / SYN-ACK / ACK)
+//! - Sliding send and receive windows
+//! - Cumulative ACK + Extended ACK (EACK/SACK)
+//! - Delayed ACK (300ms)
+//! - Retransmission (300ms timer)
+//! - FIN-based graceful close
+//! - RST for abrupt termination
+//!
+//! **No congestion control**.
+
+use std::collections::{HashMap, VecDeque};
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+
+// ── Constants ────────────────────────────────────────
+
+pub const RDP_FLAG_SYN: u8 = 0x80;
+pub const RDP_FLAG_ACK: u8 = 0x40;
+pub const RDP_FLAG_EAK: u8 = 0x20;
+pub const RDP_FLAG_RST: u8 = 0x10;
+pub const RDP_FLAG_NUL: u8 = 0x08;
+pub const RDP_FLAG_FIN: u8 = 0x04;
+
+pub const RDP_RBUF_MAX_DEFAULT: u32 = 884;
+pub const RDP_RCV_MAX_DEFAULT: u32 = 1024;
+pub const RDP_WELL_KNOWN_PORT_MAX: u16 = 1024;
+pub const RDP_SBUF_LIMIT: u16 = 884;
+pub const RDP_TIMER_INTERVAL: Duration = Duration::from_millis(300);
+pub const RDP_ACK_INTERVAL: Duration = Duration::from_millis(300);
+pub const RDP_DEFAULT_MAX_RETRANS: Duration = Duration::from_secs(30);
+
+/// Generate a random initial sequence number to prevent
+/// sequence prediction attacks.
+fn random_isn() -> u32 {
+ let mut buf = [0u8; 4];
+ crate::sys::random_bytes(&mut buf);
+ u32::from_ne_bytes(buf)
+}
+
+/// RDP packet header (20 bytes on the wire).
+pub const RDP_HEADER_SIZE: usize = 20;
+
+// ── Connection state ────────────────────────────────
+
+/// RDP connection state (7 states).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RdpState {
+ Closed,
+ Listen,
+ SynSent,
+ SynRcvd,
+ Open,
+ CloseWaitPassive,
+ CloseWaitActive,
+}
+
+/// Events emitted to the application.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RdpEvent {
+ /// Server accepted a new connection.
+ Accepted,
+
+ /// Client connected successfully.
+ Connected,
+
+ /// Connection refused by peer.
+ Refused,
+
+ /// Connection reset by peer.
+ Reset,
+
+ /// Connection failed (timeout).
+ Failed,
+
+ /// Data available to read.
+ Ready2Read,
+
+ /// Pipe broken (peer vanished).
+ Broken,
+}
+
+/// RDP connection address.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct RdpAddr {
+ pub did: NodeId,
+ pub dport: u16,
+ pub sport: u16,
+}
+
+/// Status of a single RDP connection.
+#[derive(Debug, Clone)]
+pub struct RdpStatus {
+ pub state: RdpState,
+ pub did: NodeId,
+ pub dport: u16,
+ pub sport: u16,
+}
+
+// ── Segment ─────────────────────────────────────────
+
+/// A segment in the send window.
+#[derive(Debug, Clone)]
+struct SendSegment {
+ data: Vec<u8>,
+ seqnum: u32,
+ sent_time: Option<Instant>,
+ is_sent: bool,
+ is_acked: bool,
+
+ /// Retransmission timeout (doubles on each retry).
+ rt_secs: u64,
+}
+
+/// A segment in the receive window.
+#[derive(Debug, Clone)]
+struct RecvSegment {
+ data: Vec<u8>,
+ seqnum: u32,
+ is_used: bool,
+ is_eacked: bool,
+}
+
+// ── Connection ──────────────────────────────────────
+
+/// A single RDP connection.
+struct RdpConnection {
+ addr: RdpAddr,
+ desc: i32,
+ state: RdpState,
+
+ /// Server (passive) or client (active) side.
+ is_passive: bool,
+
+ /// Connection has been closed locally.
+ is_closed: bool,
+
+ /// Max segment size we can send (from peer's SYN).
+ sbuf_max: u32,
+
+ /// Max segment size we can receive (our buffer).
+ rbuf_max: u32,
+
+ // Send sequence variables
+ snd_nxt: u32,
+ snd_una: u32,
+ snd_max: u32,
+ snd_iss: u32,
+
+ // Receive sequence variables
+ rcv_cur: u32,
+
+ /// Max segments we can buffer.
+ rcv_max: u32,
+ rcv_irs: u32,
+
+ /// Last sequence number we ACK'd.
+ rcv_ack: u32,
+
+ // Windows
+ send_window: VecDeque<SendSegment>,
+ recv_window: Vec<Option<RecvSegment>>,
+ read_queue: VecDeque<Vec<u8>>,
+
+ // Timing
+ last_ack_time: Instant,
+ syn_time: Option<Instant>,
+ close_time: Option<Instant>,
+
+ /// SYN retry timeout (doubles on retry).
+ syn_rt_secs: u64,
+
+ // ── RTT estimation (Jacobson/Karels) ────────
+ /// Smoothed RTT estimate (microseconds).
+ srtt_us: u64,
+
+ /// RTT variation (microseconds).
+ rttvar_us: u64,
+
+ /// Retransmission timeout (microseconds).
+ rto_us: u64,
+
+ // ── Congestion control (AIMD) ───────────────
+ /// Congestion window (segments allowed in flight).
+ cwnd: u32,
+
+ /// Slow-start threshold.
+ ssthresh: u32,
+
+ /// RST retry state.
+ rst_time: Option<Instant>,
+ rst_rt_secs: u64,
+ is_retry_rst: bool,
+
+ // Out-of-order tracking for EACK
+ rcvd_seqno: Vec<u32>,
+}
+
+impl RdpConnection {
+ fn new(desc: i32, addr: RdpAddr, is_passive: bool) -> Self {
+ Self {
+ addr,
+ desc,
+ state: RdpState::Closed,
+ is_passive,
+ is_closed: false,
+ sbuf_max: RDP_SBUF_LIMIT as u32,
+ rbuf_max: RDP_RBUF_MAX_DEFAULT,
+ snd_nxt: 0,
+ snd_una: 0,
+ snd_max: RDP_RCV_MAX_DEFAULT,
+ snd_iss: 0,
+ rcv_cur: 0,
+ rcv_max: RDP_RCV_MAX_DEFAULT,
+ rcv_irs: 0,
+ rcv_ack: 0,
+ send_window: VecDeque::new(),
+ recv_window: Vec::new(),
+ read_queue: VecDeque::new(),
+ last_ack_time: Instant::now(),
+ syn_time: None,
+ close_time: None,
+ syn_rt_secs: 1,
+
+ // Jacobson/Karels: initial RTO = 1s
+ srtt_us: 0,
+ rttvar_us: 500_000, // 500ms initial variance
+ rto_us: 1_000_000, // 1s initial RTO
+
+ // AIMD congestion control
+ cwnd: 1, // start with 1 segment
+ ssthresh: RDP_RCV_MAX_DEFAULT,
+ rst_time: None,
+ rst_rt_secs: 1,
+ is_retry_rst: false,
+ rcvd_seqno: Vec::new(),
+ }
+ }
+
+ /// Enqueue data for sending.
+ fn enqueue_send(&mut self, data: &[u8]) -> bool {
+ if self.is_closed {
+ return false;
+ }
+ if data.len() > self.sbuf_max as usize {
+ return false;
+ }
+ if self.send_window.len() >= self.snd_max as usize {
+ return false;
+ }
+ let seg = SendSegment {
+ data: data.to_vec(),
+ seqnum: self.snd_nxt,
+ sent_time: None,
+ is_sent: false,
+ is_acked: false,
+ rt_secs: 1,
+ };
+ self.snd_nxt = self.snd_nxt.wrapping_add(1);
+ self.send_window.push_back(seg);
+ true
+ }
+
+ /// Process a cumulative ACK.
+ fn recv_ack(&mut self, acknum: u32) {
+ while let Some(front) = self.send_window.front() {
+ if front.seqnum == acknum {
+ break;
+ }
+
+ // Sequence numbers before acknum are acked
+ if is_before(front.seqnum, acknum) {
+ let seg = self.send_window.pop_front().unwrap();
+ self.snd_una = self.snd_una.wrapping_add(1);
+ self.on_ack_received();
+
+ // Measure RTT from first-sent (non-retransmitted)
+ if let Some(sent) = seg.sent_time {
+ if seg.rt_secs <= 1 {
+ // Only use first transmission for RTT
+ let rtt_us = sent.elapsed().as_micros() as u64;
+ self.update_rtt(rtt_us);
+ }
+ }
+ } else {
+ break;
+ }
+ }
+ }
+
+ /// Process an Extended ACK (EACK) for out-of-order
+ /// segments.
+ fn recv_eack(&mut self, eack_seqnum: u32) {
+ for seg in self.send_window.iter_mut() {
+ if seg.seqnum == eack_seqnum {
+ seg.is_acked = true;
+ break;
+ }
+ }
+ }
+
+ /// Deliver in-order data from the receive window to
+ /// the read queue.
+ fn deliver_to_read_queue(&mut self) {
+ // Count contiguous in-order segments
+ let mut count = 0;
+ for slot in &self.recv_window {
+ match slot {
+ Some(seg) if seg.is_used => count += 1,
+ _ => break,
+ }
+ }
+ // Drain them all at once (O(n) instead of O(n²))
+ if count > 0 {
+ for seg in self.recv_window.drain(..count).flatten() {
+ self.rcv_cur = seg.seqnum;
+ self.read_queue.push_back(seg.data);
+ }
+ }
+ }
+
+ /// Maximum out-of-order gap before dropping.
+ /// Prevents memory exhaustion from malicious
+ /// high-seqnum packets.
+ const MAX_OOO_GAP: usize = 256;
+
+ /// Place a received segment into the receive window.
+ fn recv_data(&mut self, seqnum: u32, data: Vec<u8>) {
+ let expected = self.rcv_cur.wrapping_add(1);
+
+ if seqnum == expected {
+ // In-order: deliver directly
+ self.read_queue.push_back(data);
+ self.rcv_cur = seqnum;
+ self.deliver_to_read_queue();
+ // Clean up rcvd_seqno for delivered segments
+ self.rcvd_seqno.retain(|&s| is_before(seqnum, s));
+ } else if is_before(expected, seqnum) {
+ // Out-of-order: check gap before allocating
+ let offset = seqnum.wrapping_sub(expected) as usize;
+
+ // Reject if gap too large (DoS protection)
+ if offset > Self::MAX_OOO_GAP {
+ log::debug!("RDP: dropping packet with gap {offset}");
+ return;
+ }
+
+ // Check total recv window capacity
+ if self.recv_window.len() >= self.rcv_max as usize {
+ return;
+ }
+
+ while self.recv_window.len() <= offset {
+ self.recv_window.push(None);
+ }
+ self.recv_window[offset] = Some(RecvSegment {
+ data,
+ seqnum,
+ is_used: true,
+ is_eacked: false,
+ });
+ // Use bounded set (cap at MAX_OOO_GAP)
+ if self.rcvd_seqno.len() < Self::MAX_OOO_GAP
+ && !self.rcvd_seqno.contains(&seqnum)
+ {
+ self.rcvd_seqno.push(seqnum);
+ }
+ }
+
+ // else: duplicate, ignore
+ }
+
+ /// Whether a delayed ACK should be sent.
+ /// Update RTT estimate using Jacobson/Karels algorithm.
+ ///
+ /// Called when we receive an ACK for a segment
+ /// whose `sent_time` we know.
+ fn update_rtt(&mut self, sample_us: u64) {
+ if self.srtt_us == 0 {
+ // First measurement
+ self.srtt_us = sample_us;
+ self.rttvar_us = sample_us / 2;
+ } else {
+ // RTTVAR = (1 - beta) * RTTVAR + beta * |SRTT - R|
+ // SRTT = (1 - alpha) * SRTT + alpha * R
+ // alpha = 1/8, beta = 1/4 (RFC 6298)
+ let diff = sample_us.abs_diff(self.srtt_us);
+ self.rttvar_us = (3 * self.rttvar_us + diff) / 4;
+ self.srtt_us = (7 * self.srtt_us + sample_us) / 8;
+ }
+
+ // RTO = SRTT + max(G, 4 * RTTVAR)
+ // G (clock granularity) = 1ms = 1000us
+ let k_rttvar = 4 * self.rttvar_us;
+ self.rto_us = self.srtt_us + k_rttvar.max(1000);
+
+ // Clamp: min 200ms, max 60s
+ self.rto_us = self.rto_us.clamp(200_000, 60_000_000);
+ }
+
+ /// Handle successful ACK: update congestion window.
+ fn on_ack_received(&mut self) {
+ if self.cwnd < self.ssthresh {
+ // Slow start: increase by 1 per ACK
+ self.cwnd += 1;
+ } else {
+ // Congestion avoidance: increase by 1/cwnd
+ // (approx 1 segment per RTT)
+ self.cwnd += 1u32.max(1 / self.cwnd.max(1));
+ }
+ }
+
+ /// Handle packet loss: halve congestion window.
+ fn on_loss_detected(&mut self) {
+ self.ssthresh = (self.cwnd / 2).max(2);
+ self.cwnd = self.ssthresh;
+ }
+
+ fn needs_ack(&self) -> bool {
+ self.rcv_cur != self.rcv_ack
+ && self.last_ack_time.elapsed() >= RDP_ACK_INTERVAL
+ }
+
+ /// Check for segments needing retransmission.
+ ///
+ /// Returns `false` if a segment has exceeded
+ /// max_retrans (broken pipe).
+ fn retransmit(
+ &mut self,
+ max_retrans: Duration,
+ ) -> (bool, Vec<SendSegment>) {
+ let mut to_send = Vec::new();
+ let now = Instant::now();
+ let rto_secs = (self.rto_us / 1_000_000).max(1);
+
+ for seg in self.send_window.iter_mut() {
+ if !seg.is_sent {
+ break;
+ }
+ if seg.is_acked {
+ continue;
+ }
+
+ // Check if we've exceeded max retransmission
+ if seg.rt_secs > max_retrans.as_secs() {
+ self.state = RdpState::Closed;
+ return (false, Vec::new()); // broken pipe
+ }
+
+ if let Some(sent) = seg.sent_time {
+ // Use adaptive RTO for first retransmit,
+ // then exponential backoff
+ let timeout = if seg.rt_secs <= 1 {
+ rto_secs
+ } else {
+ seg.rt_secs
+ };
+ let elapsed = now.duration_since(sent).as_secs();
+ if elapsed > timeout {
+ seg.sent_time = Some(now);
+ seg.rt_secs = timeout * 2; // backoff
+ to_send.push(seg.clone());
+ }
+ }
+ }
+
+ // Loss detected → halve congestion window
+ if !to_send.is_empty() {
+ self.on_loss_detected();
+ }
+
+ (true, to_send)
+ }
+}
+
+/// Check if sequence `a` comes before `b` (wrapping).
+fn is_before(a: u32, b: u32) -> bool {
+ let diff = b.wrapping_sub(a);
+ diff > 0 && diff < 0x80000000
+}
+
+// ── Deferred action (invoke protection) ─────────────
+
+/// Actions deferred during event processing to avoid
+/// reentrance issues.
+#[derive(Debug)]
+pub enum RdpAction {
+ /// Emit an event to the application.
+ Event {
+ desc: i32,
+ addr: RdpAddr,
+ event: RdpEvent,
+ },
+
+ /// Close a connection after processing.
+ Close(i32),
+}
+
+// ── RDP manager ─────────────────────────────────────
+
+/// RDP protocol manager.
+/// Incoming RDP packet for `Rdp::input()`.
+pub struct RdpInput<'a> {
+ pub src: NodeId,
+ pub sport: u16,
+ pub dport: u16,
+ pub flags: u8,
+ pub seqnum: u32,
+ pub acknum: u32,
+ pub data: &'a [u8],
+}
+
+/// RDP protocol manager.
+///
+/// Manages multiple connections with descriptor-based
+/// API (similar to file descriptors).
+pub struct Rdp {
+ connections: HashMap<i32, RdpConnection>,
+ listeners: HashMap<u16, i32>,
+ addr_to_desc: HashMap<RdpAddr, i32>,
+ next_desc: i32,
+ max_retrans: Duration,
+}
+
+impl Rdp {
+ pub fn new() -> Self {
+ Self {
+ connections: HashMap::new(),
+ listeners: HashMap::new(),
+ addr_to_desc: HashMap::new(),
+ next_desc: 1,
+ max_retrans: RDP_DEFAULT_MAX_RETRANS,
+ }
+ }
+
+ /// Create a listening socket on `port`.
+ ///
+ /// Returns a descriptor for the listener.
+ pub fn listen(&mut self, port: u16) -> Result<i32, RdpError> {
+ if self.listeners.contains_key(&port) {
+ return Err(RdpError::PortInUse(port));
+ }
+ let desc = self.alloc_desc();
+ self.listeners.insert(port, desc);
+ Ok(desc)
+ }
+
+ /// Initiate a connection to `dst:dport` from `sport`.
+ ///
+ /// Returns a descriptor for the connection.
+ pub fn connect(
+ &mut self,
+ sport: u16,
+ dst: NodeId,
+ dport: u16,
+ ) -> Result<i32, RdpError> {
+ let desc = self.alloc_desc();
+ let addr = RdpAddr {
+ did: dst,
+ dport,
+ sport,
+ };
+
+ let mut conn = RdpConnection::new(desc, addr.clone(), false);
+ conn.state = RdpState::SynSent;
+ conn.snd_iss = random_isn();
+ conn.snd_nxt = conn.snd_iss.wrapping_add(1);
+ conn.snd_una = conn.snd_iss;
+ conn.syn_time = Some(Instant::now());
+
+ self.addr_to_desc.insert(addr, desc);
+ self.connections.insert(desc, conn);
+ Ok(desc)
+ }
+
+ /// Close a connection or listener.
+ pub fn close(&mut self, desc: i32) {
+ if let Some(mut conn) = self.connections.remove(&desc) {
+ conn.is_closed = true;
+ self.addr_to_desc.remove(&conn.addr);
+ log::debug!("RDP: closed desc {desc}");
+ }
+
+ // Also check listeners
+ self.listeners.retain(|_, d| *d != desc);
+ }
+
+ /// Enqueue data for sending on a connection.
+ pub fn send(&mut self, desc: i32, data: &[u8]) -> Result<usize, RdpError> {
+ let conn = self
+ .connections
+ .get_mut(&desc)
+ .ok_or(RdpError::BadDescriptor(desc))?;
+
+ if conn.state != RdpState::Open {
+ return Err(RdpError::NotOpen(desc));
+ }
+
+ if !conn.enqueue_send(data) {
+ return Err(RdpError::SendBufferFull);
+ }
+
+ Ok(data.len())
+ }
+
+ /// Read available data from a connection.
+ ///
+ /// Returns the number of bytes read, or 0 if no
+ /// data available.
+ pub fn recv(
+ &mut self,
+ desc: i32,
+ buf: &mut [u8],
+ ) -> Result<usize, RdpError> {
+ let conn = self
+ .connections
+ .get_mut(&desc)
+ .ok_or(RdpError::BadDescriptor(desc))?;
+
+ if let Some(data) = conn.read_queue.pop_front() {
+ let len = data.len().min(buf.len());
+ buf[..len].copy_from_slice(&data[..len]);
+ Ok(len)
+ } else {
+ Ok(0)
+ }
+ }
+
+ /// Get the state of a descriptor.
+ pub fn get_state(&self, desc: i32) -> Result<RdpState, RdpError> {
+ self.connections
+ .get(&desc)
+ .map(|c| c.state)
+ .ok_or(RdpError::BadDescriptor(desc))
+ }
+
+ /// Get status of all connections.
+ pub fn get_status(&self) -> Vec<RdpStatus> {
+ self.connections
+ .values()
+ .map(|c| RdpStatus {
+ state: c.state,
+ did: c.addr.did,
+ dport: c.addr.dport,
+ sport: c.addr.sport,
+ })
+ .collect()
+ }
+
+ /// Set the maximum retransmission timeout.
+ pub fn set_max_retrans(&mut self, dur: Duration) {
+ self.max_retrans = dur;
+ }
+
+ /// Get the maximum retransmission timeout.
+ pub fn max_retrans(&self) -> Duration {
+ self.max_retrans
+ }
+
+ /// Process incoming RDP data from a peer.
+ ///
+ /// Returns deferred actions (events, closes) to
+ /// avoid reentrance during processing.
+ pub fn input(&mut self, pkt: &RdpInput<'_>) -> Vec<RdpAction> {
+ let src = pkt.src;
+ let sport = pkt.sport;
+ let dport = pkt.dport;
+ let flags = pkt.flags;
+ let seqnum = pkt.seqnum;
+ let acknum = pkt.acknum;
+ let data = pkt.data;
+ let addr = RdpAddr {
+ did: src,
+ dport: sport, // their sport is our dport
+ sport: dport, // our dport is our sport
+ };
+ let mut actions = Vec::new();
+
+ if let Some(&desc) = self.addr_to_desc.get(&addr) {
+ // Existing connection
+ if let Some(conn) = self.connections.get_mut(&desc) {
+ Self::process_connected(
+ conn,
+ flags,
+ seqnum,
+ acknum,
+ data,
+ &mut actions,
+ );
+ }
+ } else if flags & RDP_FLAG_SYN != 0 {
+ // New inbound SYN → check listener
+ if let Some(&_listen_desc) = self.listeners.get(&dport) {
+ let desc = self.alloc_desc();
+ let mut conn = RdpConnection::new(desc, addr.clone(), true);
+ conn.state = RdpState::SynRcvd;
+ conn.rcv_irs = seqnum;
+ conn.rcv_cur = seqnum;
+ conn.snd_iss = random_isn();
+ conn.snd_nxt = conn.snd_iss.wrapping_add(1);
+ conn.snd_una = conn.snd_iss;
+
+ self.addr_to_desc.insert(addr.clone(), desc);
+ self.connections.insert(desc, conn);
+
+ actions.push(RdpAction::Event {
+ desc,
+ addr,
+ event: RdpEvent::Accepted,
+ });
+ }
+
+ // else: no listener → RST (ignored for now)
+ }
+
+ actions
+ }
+
+ /// Process a packet on an existing connection.
+ fn process_connected(
+ conn: &mut RdpConnection,
+ flags: u8,
+ seqnum: u32,
+ acknum: u32,
+ data: &[u8],
+ actions: &mut Vec<RdpAction>,
+ ) {
+ match conn.state {
+ RdpState::SynSent => {
+ if flags & RDP_FLAG_SYN != 0 && flags & RDP_FLAG_ACK != 0 {
+ conn.rcv_irs = seqnum;
+ conn.rcv_cur = seqnum;
+ conn.recv_ack(acknum);
+ conn.state = RdpState::Open;
+ actions.push(RdpAction::Event {
+ desc: conn.desc,
+ addr: conn.addr.clone(),
+ event: RdpEvent::Connected,
+ });
+ } else if flags & RDP_FLAG_RST != 0 {
+ conn.state = RdpState::Closed;
+ actions.push(RdpAction::Event {
+ desc: conn.desc,
+ addr: conn.addr.clone(),
+ event: RdpEvent::Refused,
+ });
+ }
+ }
+ RdpState::SynRcvd => {
+ if flags & RDP_FLAG_ACK != 0 {
+ conn.recv_ack(acknum);
+ conn.state = RdpState::Open;
+ }
+ }
+ RdpState::Open => {
+ if flags & RDP_FLAG_RST != 0 {
+ conn.state = RdpState::Closed;
+ actions.push(RdpAction::Event {
+ desc: conn.desc,
+ addr: conn.addr.clone(),
+ event: RdpEvent::Reset,
+ });
+ return;
+ }
+ if flags & RDP_FLAG_FIN != 0 {
+ conn.state = if conn.is_passive {
+ RdpState::CloseWaitPassive
+ } else {
+ RdpState::CloseWaitActive
+ };
+ conn.close_time = Some(Instant::now());
+ return;
+ }
+ if flags & RDP_FLAG_ACK != 0 {
+ conn.recv_ack(acknum);
+ }
+ if flags & RDP_FLAG_EAK != 0 {
+ conn.recv_eack(seqnum);
+ }
+ if !data.is_empty() {
+ conn.recv_data(seqnum, data.to_vec());
+ actions.push(RdpAction::Event {
+ desc: conn.desc,
+ addr: conn.addr.clone(),
+ event: RdpEvent::Ready2Read,
+ });
+ }
+ }
+ RdpState::CloseWaitActive => {
+ if flags & RDP_FLAG_FIN != 0 {
+ conn.state = RdpState::Closed;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// Periodic tick: retransmit, delayed ACK, timeouts.
+ ///
+ /// Returns actions for timed-out connections.
+ pub fn tick(&mut self) -> Vec<RdpAction> {
+ let mut actions = Vec::new();
+ let mut to_close = Vec::new();
+
+ for (desc, conn) in self.connections.iter_mut() {
+ // Check SYN timeout with exponential backoff
+ if conn.state == RdpState::SynSent {
+ if let Some(t) = conn.syn_time {
+ let elapsed = t.elapsed().as_secs();
+ if elapsed > conn.syn_rt_secs {
+ if conn.syn_rt_secs > self.max_retrans.as_secs() {
+ actions.push(RdpAction::Event {
+ desc: *desc,
+ addr: conn.addr.clone(),
+ event: RdpEvent::Failed,
+ });
+ to_close.push(*desc);
+ } else {
+ // Retry SYN with backoff
+ conn.syn_time = Some(Instant::now());
+ conn.syn_rt_secs *= 2;
+ }
+ }
+ }
+ }
+
+ // RST retry
+ if conn.is_retry_rst {
+ if let Some(t) = conn.rst_time {
+ if t.elapsed().as_secs() > conn.rst_rt_secs {
+ conn.rst_rt_secs *= 2;
+ conn.rst_time = Some(Instant::now());
+ if conn.rst_rt_secs > self.max_retrans.as_secs() {
+ conn.is_retry_rst = false;
+ to_close.push(*desc);
+ }
+ }
+ }
+ }
+
+ // Check close-wait timeout
+ if matches!(
+ conn.state,
+ RdpState::CloseWaitPassive | RdpState::CloseWaitActive
+ ) {
+ if let Some(t) = conn.close_time {
+ if t.elapsed() >= self.max_retrans {
+ to_close.push(*desc);
+ }
+ }
+ }
+
+ // Retransmit unacked segments
+ if conn.state == RdpState::Open {
+ let (alive, _retransmits) = conn.retransmit(self.max_retrans);
+ if !alive {
+ // Broken pipe — exceeded max retrans
+ actions.push(RdpAction::Event {
+ desc: *desc,
+ addr: conn.addr.clone(),
+ event: RdpEvent::Broken,
+ });
+ to_close.push(*desc);
+ }
+
+ // Note: retransmitted segments are picked
+ // up by pending_output() in the next flush
+ }
+ }
+
+ for d in to_close {
+ self.close(d);
+ }
+
+ actions
+ }
+
+ /// Number of active connections.
+ pub fn connection_count(&self) -> usize {
+ self.connections.len()
+ }
+
+ /// Number of active listeners.
+ pub fn listener_count(&self) -> usize {
+ self.listeners.len()
+ }
+
+ /// Build outgoing packets for a connection.
+ ///
+ /// Returns `(dst_id, sport, dport, packets)` where
+ /// each packet is `(flags, seqnum, acknum, data)`.
+ /// The caller wraps these in protocol messages and
+ /// sends via UDP.
+ pub fn pending_output(&mut self, desc: i32) -> Option<PendingOutput> {
+ let conn = self.connections.get_mut(&desc)?;
+
+ let mut packets = Vec::new();
+
+ match conn.state {
+ RdpState::SynSent => {
+ // Send SYN with receive buffer params
+ // (rdp_syn: out_segs_max + seg_size_max)
+ let mut syn_data = Vec::with_capacity(4);
+ syn_data
+ .extend_from_slice(&(conn.rcv_max as u16).to_be_bytes());
+ syn_data
+ .extend_from_slice(&(conn.rbuf_max as u16).to_be_bytes());
+ packets.push(RdpPacket {
+ flags: RDP_FLAG_SYN,
+ seqnum: conn.snd_iss,
+ acknum: 0,
+ data: syn_data,
+ });
+ }
+ RdpState::SynRcvd => {
+ // Send SYN+ACK
+ packets.push(RdpPacket {
+ flags: RDP_FLAG_SYN | RDP_FLAG_ACK,
+ seqnum: conn.snd_iss,
+ acknum: conn.rcv_cur,
+ data: Vec::new(),
+ });
+ }
+ RdpState::Open => {
+ // Send ACK if needed
+ if conn.needs_ack() {
+ packets.push(RdpPacket {
+ flags: RDP_FLAG_ACK,
+ seqnum: conn.snd_nxt,
+ acknum: conn.rcv_cur,
+ data: Vec::new(),
+ });
+ conn.last_ack_time = Instant::now();
+ conn.rcv_ack = conn.rcv_cur;
+ }
+
+ // Send EACKs for out-of-order recv segments
+ for seg in conn.recv_window.iter_mut().flatten() {
+ if seg.is_used && !seg.is_eacked {
+ packets.push(RdpPacket {
+ flags: RDP_FLAG_EAK | RDP_FLAG_ACK,
+ seqnum: seg.seqnum,
+ acknum: conn.rcv_cur,
+ data: Vec::new(),
+ });
+ seg.is_eacked = true;
+ }
+ }
+
+ // Send pending data segments, limited by
+ // congestion window (AIMD)
+ let in_flight = conn
+ .send_window
+ .iter()
+ .filter(|s| s.is_sent && !s.is_acked)
+ .count() as u32;
+ let can_send = conn.cwnd.saturating_sub(in_flight);
+
+ let mut sent_count = 0u32;
+ for seg in &conn.send_window {
+ if sent_count >= can_send {
+ break;
+ }
+ if !seg.is_sent && !seg.is_acked {
+ packets.push(RdpPacket {
+ flags: RDP_FLAG_ACK,
+ seqnum: seg.seqnum,
+ acknum: conn.rcv_cur,
+ data: seg.data.clone(),
+ });
+ sent_count += 1;
+ }
+ }
+
+ // Mark as sent
+ let mut marked = 0u32;
+ for seg in conn.send_window.iter_mut() {
+ if marked >= can_send {
+ break;
+ }
+ if !seg.is_sent {
+ seg.is_sent = true;
+ seg.sent_time = Some(Instant::now());
+ marked += 1;
+ }
+ }
+ }
+ _ => {}
+ }
+
+ if packets.is_empty() {
+ return None;
+ }
+
+ Some(PendingOutput {
+ dst: conn.addr.did,
+ sport: conn.addr.sport,
+ dport: conn.addr.dport,
+ packets,
+ })
+ }
+
+ /// Get all connection descriptors.
+ pub fn descriptors(&self) -> Vec<i32> {
+ self.connections.keys().copied().collect()
+ }
+
+ fn alloc_desc(&mut self) -> i32 {
+ let d = self.next_desc;
+ // Wrap at i32::MAX to avoid overflow; skip 0
+ // and negative values
+ self.next_desc = if d >= i32::MAX - 1 { 1 } else { d + 1 };
+ d
+ }
+}
+
+/// A pending outgoing RDP packet.
+#[derive(Debug, Clone)]
+pub struct RdpPacket {
+ pub flags: u8,
+ pub seqnum: u32,
+ pub acknum: u32,
+ pub data: Vec<u8>,
+}
+
+/// Pending output for a connection.
+#[derive(Debug)]
+pub struct PendingOutput {
+ pub dst: NodeId,
+ pub sport: u16,
+ pub dport: u16,
+ pub packets: Vec<RdpPacket>,
+}
+
+/// Build an RDP wire packet: rdp_head(20) + data.
+pub fn build_rdp_wire(
+ flags: u8,
+ sport: u16,
+ dport: u16,
+ seqnum: u32,
+ acknum: u32,
+ data: &[u8],
+) -> Vec<u8> {
+ let dlen = data.len() as u16;
+ let mut buf = vec![0u8; RDP_HEADER_SIZE + data.len()];
+ buf[0] = flags;
+ buf[1] = (RDP_HEADER_SIZE / 2) as u8; // hlen in 16-bit words
+ buf[2..4].copy_from_slice(&sport.to_be_bytes());
+ buf[4..6].copy_from_slice(&dport.to_be_bytes());
+ buf[6..8].copy_from_slice(&dlen.to_be_bytes());
+ buf[8..12].copy_from_slice(&seqnum.to_be_bytes());
+ buf[12..16].copy_from_slice(&acknum.to_be_bytes());
+ buf[16..20].fill(0); // reserved
+ buf[20..].copy_from_slice(data);
+ buf
+}
+
+/// Parsed RDP wire header fields.
+pub struct RdpWireHeader<'a> {
+ pub flags: u8,
+ pub sport: u16,
+ pub dport: u16,
+ pub seqnum: u32,
+ pub acknum: u32,
+ pub data: &'a [u8],
+}
+
+/// Parse an RDP wire packet header.
+pub fn parse_rdp_wire(buf: &[u8]) -> Option<RdpWireHeader<'_>> {
+ if buf.len() < RDP_HEADER_SIZE {
+ return None;
+ }
+ Some(RdpWireHeader {
+ flags: buf[0],
+ sport: u16::from_be_bytes([buf[2], buf[3]]),
+ dport: u16::from_be_bytes([buf[4], buf[5]]),
+ seqnum: u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]),
+ acknum: u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]),
+ data: &buf[RDP_HEADER_SIZE..],
+ })
+}
+
+impl Default for Rdp {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+// ── Errors ──────────────────────────────────────────
+
+#[derive(Debug)]
+pub enum RdpError {
+ PortInUse(u16),
+ BadDescriptor(i32),
+ NotOpen(i32),
+ SendBufferFull,
+}
+
+impl std::fmt::Display for RdpError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RdpError::PortInUse(p) => write!(f, "port {p} in use"),
+ RdpError::BadDescriptor(d) => write!(f, "bad descriptor {d}"),
+ RdpError::NotOpen(d) => write!(f, "descriptor {d} not open"),
+ RdpError::SendBufferFull => write!(f, "send buffer full"),
+ }
+ }
+}
+
+impl std::error::Error for RdpError {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn listen_and_close() {
+ let mut rdp = Rdp::new();
+ let desc = rdp.listen(5000).unwrap();
+ assert_eq!(rdp.listener_count(), 1);
+ rdp.close(desc);
+ assert_eq!(rdp.listener_count(), 0);
+ }
+
+ #[test]
+ fn listen_duplicate_port() {
+ let mut rdp = Rdp::new();
+ rdp.listen(5000).unwrap();
+ assert!(matches!(rdp.listen(5000), Err(RdpError::PortInUse(5000))));
+ }
+
+ #[test]
+ fn connect_creates_syn_sent() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = rdp.connect(0, dst, 5000).unwrap();
+ assert_eq!(rdp.get_state(desc).unwrap(), RdpState::SynSent);
+ }
+
+ #[test]
+ fn send_before_open_fails() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = rdp.connect(0, dst, 5000).unwrap();
+ assert!(matches!(
+ rdp.send(desc, b"hello"),
+ Err(RdpError::NotOpen(_))
+ ));
+ }
+
+ #[test]
+ fn recv_empty() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = rdp.connect(0, dst, 5000).unwrap();
+ let mut buf = [0u8; 64];
+
+ // SynSent state → bad descriptor or no data
+ assert!(rdp.recv(desc, &mut buf).is_ok());
+ }
+
+ #[test]
+ fn syn_ack_opens_connection() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = rdp.connect(1000, dst, 5000).unwrap();
+
+ // Simulate receiving SYN+ACK
+ let actions = rdp.input(&RdpInput {
+ src: dst,
+ sport: 5000,
+ dport: 1000,
+ flags: RDP_FLAG_SYN | RDP_FLAG_ACK,
+ seqnum: 100,
+ acknum: 1,
+ data: &[],
+ });
+
+ assert_eq!(rdp.get_state(desc).unwrap(), RdpState::Open);
+ assert!(actions.iter().any(|a| matches!(
+ a,
+ RdpAction::Event {
+ event: RdpEvent::Connected,
+ ..
+ }
+ )));
+ }
+
+ #[test]
+ fn inbound_syn_accepted() {
+ let mut rdp = Rdp::new();
+ rdp.listen(5000).unwrap();
+
+ let peer = NodeId::from_bytes([0x02; 32]);
+ let actions = rdp.input(&RdpInput {
+ src: peer,
+ sport: 3000,
+ dport: 5000,
+ flags: RDP_FLAG_SYN,
+ seqnum: 200,
+ acknum: 0,
+ data: &[],
+ });
+
+ assert_eq!(rdp.connection_count(), 1);
+ assert!(actions.iter().any(|a| matches!(
+ a,
+ RdpAction::Event {
+ event: RdpEvent::Accepted,
+ ..
+ }
+ )));
+ }
+
+ #[test]
+ fn rst_resets_connection() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = rdp.connect(1000, dst, 5000).unwrap();
+
+ // Open first
+ rdp.input(&RdpInput {
+ src: dst,
+ sport: 5000,
+ dport: 1000,
+ flags: RDP_FLAG_SYN | RDP_FLAG_ACK,
+ seqnum: 100,
+ acknum: 1,
+ data: &[],
+ });
+ assert_eq!(rdp.get_state(desc).unwrap(), RdpState::Open);
+
+ // RST
+ let actions = rdp.input(&RdpInput {
+ src: dst,
+ sport: 5000,
+ dport: 1000,
+ flags: RDP_FLAG_RST,
+ seqnum: 0,
+ acknum: 0,
+ data: &[],
+ });
+ assert_eq!(rdp.get_state(desc).unwrap(), RdpState::Closed);
+ assert!(actions.iter().any(|a| matches!(
+ a,
+ RdpAction::Event {
+ event: RdpEvent::Reset,
+ ..
+ }
+ )));
+ }
+
+ #[test]
+ fn data_delivery() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = rdp.connect(1000, dst, 5000).unwrap();
+
+ // Open
+ rdp.input(&RdpInput {
+ src: dst,
+ sport: 5000,
+ dport: 1000,
+ flags: RDP_FLAG_SYN | RDP_FLAG_ACK,
+ seqnum: 100,
+ acknum: 1,
+ data: &[],
+ });
+
+ // Receive data
+ let actions = rdp.input(&RdpInput {
+ src: dst,
+ sport: 5000,
+ dport: 1000,
+ flags: RDP_FLAG_ACK,
+ seqnum: 101,
+ acknum: 1,
+ data: b"hello",
+ });
+
+ assert!(actions.iter().any(|a| matches!(
+ a,
+ RdpAction::Event {
+ event: RdpEvent::Ready2Read,
+ ..
+ }
+ )));
+
+ let mut buf = [0u8; 64];
+ let n = rdp.recv(desc, &mut buf).unwrap();
+ assert_eq!(&buf[..n], b"hello");
+ }
+
+ #[test]
+ fn send_data_on_open() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ let desc = rdp.connect(1000, dst, 5000).unwrap();
+
+ // Open
+ rdp.input(&RdpInput {
+ src: dst,
+ sport: 5000,
+ dport: 1000,
+ flags: RDP_FLAG_SYN | RDP_FLAG_ACK,
+ seqnum: 100,
+ acknum: 1,
+ data: &[],
+ });
+
+ let n = rdp.send(desc, b"world").unwrap();
+ assert_eq!(n, 5);
+ }
+
+ #[test]
+ fn get_status() {
+ let mut rdp = Rdp::new();
+ let dst = NodeId::from_bytes([0x01; 32]);
+ rdp.connect(1000, dst, 5000).unwrap();
+
+ let status = rdp.get_status();
+ assert_eq!(status.len(), 1);
+ assert_eq!(status[0].state, RdpState::SynSent);
+ assert_eq!(status[0].dport, 5000);
+ }
+
+ #[test]
+ fn is_before_wrapping() {
+ assert!(is_before(1, 2));
+ assert!(is_before(0, 1));
+ assert!(!is_before(2, 1));
+ assert!(!is_before(5, 5));
+
+ // Wrapping: u32::MAX is before 0
+ assert!(is_before(u32::MAX, 0));
+ }
+}
blob - /dev/null
blob + a9b618d1fe724850b91c421c559f138e0d8c3cf8 (mode 644)
--- /dev/null
+++ src/routing.rs
+//! Kademlia routing table with k-buckets.
+//!
+//! Each bucket holds
+//! up to `MAX_BUCKET_ENTRY` (20) peers ordered by last
+//! seen time (LRU). When a bucket is full, the least
+//! recently seen peer is pinged; if it doesn't respond,
+//! it's replaced by the new peer.
+
+use std::time::Instant;
+
+use crate::id::NodeId;
+use crate::peers::PeerInfo;
+
+/// Maximum entries per k-bucket.
+pub const MAX_BUCKET_ENTRY: usize = 20;
+
+/// Number of bits in a node ID.
+pub const ID_BITS: usize = crate::id::ID_BITS;
+
+/// Number of closest nodes to return in lookups
+/// Kademlia default: 10.
+pub const NUM_FIND_NODE: usize = 10;
+
+/// Maximum entries in the replacement cache per bucket.
+/// When a bucket is full, new contacts go here instead
+/// of being discarded (Kademlia paper section 2.4).
+const MAX_REPLACEMENT_CACHE: usize = 5;
+
+/// Number of consecutive failures before a contact is
+/// considered stale and eligible for replacement.
+const STALE_THRESHOLD: u32 = 3;
+
+/// Result of inserting a peer into the routing table.
+#[derive(Debug)]
+pub enum InsertResult {
+ /// Peer was inserted into the bucket.
+ Inserted,
+
+ /// Peer already existed and was moved to tail (LRU).
+ Updated,
+
+ /// Bucket is full. Contains the LRU peer that should
+ /// be pinged to decide eviction.
+ BucketFull { lru: PeerInfo },
+
+ /// Peer is our own ID, ignored.
+ IsSelf,
+}
+
+/// A single k-bucket holding up to K peers.
+struct KBucket {
+ nodes: Vec<PeerInfo>,
+ last_updated: Instant,
+ /// Replacement cache: contacts seen when bucket is
+ /// full. Used to replace stale contacts without
+ /// losing discovered nodes (Kademlia paper §2.4).
+ replacements: Vec<PeerInfo>,
+ /// Consecutive failure count per node ID. Peers with
+ /// count >= STALE_THRESHOLD are replaced by cached
+ /// contacts.
+ stale_counts: std::collections::HashMap<NodeId, u32>,
+}
+
+impl KBucket {
+ fn new() -> Self {
+ Self {
+ nodes: Vec::new(),
+ last_updated: Instant::now(),
+ replacements: Vec::new(),
+ stale_counts: std::collections::HashMap::new(),
+ }
+ }
+
+ fn len(&self) -> usize {
+ self.nodes.len()
+ }
+
+ fn is_full(&self) -> bool {
+ self.nodes.len() >= MAX_BUCKET_ENTRY
+ }
+
+ fn contains(&self, id: &NodeId) -> bool {
+ self.nodes.iter().any(|n| n.id == *id)
+ }
+
+ fn find_pos(&self, id: &NodeId) -> Option<usize> {
+ self.nodes.iter().position(|n| n.id == *id)
+ }
+
+ /// Insert or update a peer. Returns InsertResult.
+ fn insert(&mut self, peer: PeerInfo) -> InsertResult {
+ if let Some(pos) = self.find_pos(&peer.id) {
+ // Move to tail (most recently seen)
+ self.nodes.remove(pos);
+ self.nodes.push(peer);
+ self.last_updated = Instant::now();
+ // Clear stale count on successful contact
+ self.stale_counts.remove(&self.nodes.last().unwrap().id);
+ return InsertResult::Updated;
+ }
+
+ if self.is_full() {
+ // Check if any existing contact is stale
+ // enough to replace immediately
+ if let Some(stale_pos) = self.find_stale() {
+ let stale_id = self.nodes[stale_pos].id;
+ self.stale_counts.remove(&stale_id);
+ self.nodes.remove(stale_pos);
+ self.nodes.push(peer);
+ self.last_updated = Instant::now();
+ return InsertResult::Inserted;
+ }
+
+ // No stale contact: add to replacement cache
+ self.add_to_cache(peer.clone());
+
+ // Return LRU (front) for ping check
+ let lru = self.nodes[0].clone();
+ return InsertResult::BucketFull { lru };
+ }
+
+ self.nodes.push(peer);
+ self.last_updated = Instant::now();
+ InsertResult::Inserted
+ }
+
+ /// Find a contact whose stale count exceeds the
+ /// threshold. Returns its position in the nodes vec.
+ fn find_stale(&self) -> Option<usize> {
+ for (i, node) in self.nodes.iter().enumerate() {
+ if let Some(&count) = self.stale_counts.get(&node.id) {
+ if count >= STALE_THRESHOLD {
+ return Some(i);
+ }
+ }
+ }
+ None
+ }
+
+ /// Add a contact to the replacement cache.
+ fn add_to_cache(&mut self, peer: PeerInfo) {
+ // Update if already in cache
+ if let Some(pos) = self
+ .replacements
+ .iter()
+ .position(|r| r.id == peer.id)
+ {
+ self.replacements.remove(pos);
+ self.replacements.push(peer);
+ return;
+ }
+ if self.replacements.len() >= MAX_REPLACEMENT_CACHE {
+ self.replacements.remove(0); // drop oldest
+ }
+ self.replacements.push(peer);
+ }
+
+ /// Record a failure for a contact. Returns true if
+ /// the contact became stale (crossed threshold).
+ fn record_failure(&mut self, id: &NodeId) -> bool {
+ let count = self.stale_counts.entry(*id).or_insert(0);
+ *count += 1;
+ *count >= STALE_THRESHOLD
+ }
+
+ /// Try to replace a stale contact with the best
+ /// replacement from cache. Returns the evicted ID
+ /// if successful.
+ fn try_replace_stale(&mut self, stale_id: &NodeId) -> Option<NodeId> {
+ let pos = self.find_pos(stale_id)?;
+ let replacement = self.replacements.pop()?;
+ let evicted = self.nodes[pos].id;
+ self.stale_counts.remove(&evicted);
+ self.nodes.remove(pos);
+ self.nodes.push(replacement);
+ self.last_updated = Instant::now();
+ Some(evicted)
+ }
+
+ /// Number of contacts in the replacement cache.
+ fn cache_len(&self) -> usize {
+ self.replacements.len()
+ }
+
+ /// Replace the LRU node (front) with a new peer.
+ /// Only succeeds if `old_id` matches the current LRU.
+ fn evict_lru(&mut self, old_id: &NodeId, new: PeerInfo) -> bool {
+ if let Some(front) = self.nodes.first() {
+ if front.id == *old_id {
+ self.nodes.remove(0);
+ self.nodes.push(new);
+ self.last_updated = Instant::now();
+ return true;
+ }
+ }
+ false
+ }
+
+ fn remove(&mut self, id: &NodeId) -> bool {
+ if let Some(pos) = self.find_pos(id) {
+ self.nodes.remove(pos);
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Mark a peer as recently seen (move to tail).
+ fn mark_seen(&mut self, id: &NodeId) {
+ if let Some(pos) = self.find_pos(id) {
+ let peer = self.nodes.remove(pos);
+ self.nodes.push(PeerInfo {
+ last_seen: Instant::now(),
+ ..peer
+ });
+ self.last_updated = Instant::now();
+ self.stale_counts.remove(id);
+ }
+ }
+}
+
+/// Kademlia routing table.
+///
+/// Maintains 256 k-buckets indexed by XOR distance from
+/// the local node. Each bucket holds up to
+/// `MAX_BUCKET_ENTRY` peers.
+/// Maximum nodes per /24 subnet in the routing table.
+/// Limits Sybil attack impact.
+pub const MAX_PER_SUBNET: usize = 2;
+
+pub struct RoutingTable {
+ local_id: NodeId,
+ buckets: Vec<KBucket>,
+
+ /// Count of nodes per /24 subnet for Sybil
+ /// resistance.
+ subnet_counts: std::collections::HashMap<[u8; 3], usize>,
+
+ /// Pinned bootstrap nodes — never evicted.
+ pinned: std::collections::HashSet<NodeId>,
+}
+
+impl RoutingTable {
+ /// Create a new routing table for the given local ID.
+ pub fn new(local_id: NodeId) -> Self {
+ let mut buckets = Vec::with_capacity(ID_BITS);
+ for _ in 0..ID_BITS {
+ buckets.push(KBucket::new());
+ }
+ Self {
+ local_id,
+ buckets,
+ subnet_counts: std::collections::HashMap::new(),
+ pinned: std::collections::HashSet::new(),
+ }
+ }
+
+ /// Pin a bootstrap node — it will never be evicted.
+ pub fn pin(&mut self, id: NodeId) {
+ self.pinned.insert(id);
+ }
+
+ /// Check if a node is pinned.
+ pub fn is_pinned(&self, id: &NodeId) -> bool {
+ self.pinned.contains(id)
+ }
+
+ /// Our own node ID.
+ pub fn local_id(&self) -> &NodeId {
+ &self.local_id
+ }
+
+ /// Determine the bucket index for a given node ID.
+ ///
+ /// Returns `None` if `id` equals our local ID.
+ fn bucket_index(&self, id: &NodeId) -> Option<usize> {
+ let dist = self.local_id.distance(id);
+ if dist.is_zero() {
+ return None;
+ }
+ let lz = dist.leading_zeros() as usize;
+
+ // Bucket 0 = furthest (bit 0 differs),
+ // Bucket 255 = closest (only bit 255 differs).
+ // Index = 255 - leading_zeros.
+ Some(ID_BITS - 1 - lz)
+ }
+
+ /// Add a peer to the routing table.
+ ///
+ /// Rejects the peer if its /24 subnet already has
+ /// `MAX_PER_SUBNET` entries (Sybil resistance).
+ pub fn add(&mut self, peer: PeerInfo) -> InsertResult {
+ if peer.id == self.local_id {
+ return InsertResult::IsSelf;
+ }
+ let idx = match self.bucket_index(&peer.id) {
+ Some(i) => i,
+ None => return InsertResult::IsSelf,
+ };
+
+ // Sybil check: limit per /24 subnet
+ // Skip for loopback (tests, local dev)
+ let subnet = subnet_key(&peer.addr);
+ let is_loopback = peer.addr.ip().is_loopback();
+ if !is_loopback && !self.buckets[idx].contains(&peer.id) {
+ let count = self.subnet_counts.get(&subnet).copied().unwrap_or(0);
+ if count >= MAX_PER_SUBNET {
+ log::debug!(
+ "Sybil: rejecting {:?} (subnet {:?} has {count} entries)",
+ peer.id,
+ subnet
+ );
+ return InsertResult::BucketFull { lru: peer };
+ }
+ }
+
+ let result = self.buckets[idx].insert(peer);
+ if matches!(result, InsertResult::Inserted) {
+ *self.subnet_counts.entry(subnet).or_insert(0) += 1;
+ }
+ result
+ }
+
+ /// Remove a peer from the routing table.
+ pub fn remove(&mut self, id: &NodeId) -> bool {
+ // Never evict pinned bootstrap nodes
+ if self.pinned.contains(id) {
+ return false;
+ }
+ if let Some(idx) = self.bucket_index(id) {
+ // Decrement subnet count
+ if let Some(peer) =
+ self.buckets[idx].nodes.iter().find(|p| p.id == *id)
+ {
+ let subnet = subnet_key(&peer.addr);
+ if let Some(c) = self.subnet_counts.get_mut(&subnet) {
+ *c = c.saturating_sub(1);
+ if *c == 0 {
+ self.subnet_counts.remove(&subnet);
+ }
+ }
+ }
+ self.buckets[idx].remove(id)
+ } else {
+ false
+ }
+ }
+
+ /// Evict the LRU node in a bucket and insert a new
+ /// peer. Called after a ping timeout confirms the LRU
+ /// node is dead.
+ pub fn evict_and_insert(&mut self, old_id: &NodeId, new: PeerInfo) -> bool {
+ if let Some(idx) = self.bucket_index(&new.id) {
+ self.buckets[idx].evict_lru(old_id, new)
+ } else {
+ false
+ }
+ }
+
+ /// Mark a peer as recently seen.
+ pub fn mark_seen(&mut self, id: &NodeId) {
+ if let Some(idx) = self.bucket_index(id) {
+ self.buckets[idx].mark_seen(id);
+ }
+ }
+
+ /// Record a communication failure for a peer.
+ /// If the peer becomes stale (exceeds threshold),
+ /// tries to replace it with a cached contact.
+ /// Returns the evicted NodeId if replacement happened.
+ pub fn record_failure(&mut self, id: &NodeId) -> Option<NodeId> {
+ // Never mark pinned nodes as stale
+ if self.pinned.contains(id) {
+ return None;
+ }
+ let idx = self.bucket_index(id)?;
+ let became_stale = self.buckets[idx].record_failure(id);
+ if became_stale {
+ self.buckets[idx].try_replace_stale(id)
+ } else {
+ None
+ }
+ }
+
+ /// Total number of contacts in all replacement caches.
+ pub fn replacement_cache_size(&self) -> usize {
+ self.buckets.iter().map(|b| b.cache_len()).sum()
+ }
+
+ /// Find the `count` closest peers to `target` by XOR
+ /// distance, sorted closest-first.
+ pub fn closest(&self, target: &NodeId, count: usize) -> Vec<PeerInfo> {
+ let mut all: Vec<PeerInfo> = self
+ .buckets
+ .iter()
+ .flat_map(|b| b.nodes.iter().cloned())
+ .collect();
+
+ all.sort_by(|a, b| {
+ let da = target.distance(&a.id);
+ let db = target.distance(&b.id);
+ da.cmp(&db)
+ });
+
+ all.truncate(count);
+ all
+ }
+
+ /// Check if a given ID exists in the table.
+ pub fn has_id(&self, id: &NodeId) -> bool {
+ if let Some(idx) = self.bucket_index(id) {
+ self.buckets[idx].contains(id)
+ } else {
+ false
+ }
+ }
+
+ /// Total number of peers in the table.
+ pub fn size(&self) -> usize {
+ self.buckets.iter().map(|b| b.len()).sum()
+ }
+
+ /// Check if the table has no peers.
+ pub fn is_empty(&self) -> bool {
+ self.size() == 0
+ }
+
+ /// Get fill level of each non-empty bucket (for
+ /// debugging/metrics).
+ pub fn bucket_fill_levels(&self) -> Vec<(usize, usize)> {
+ self.buckets
+ .iter()
+ .enumerate()
+ .filter(|(_, b)| b.len() > 0)
+ .map(|(i, b)| (i, b.len()))
+ .collect()
+ }
+
+ /// Find buckets that haven't been updated since
+ /// `threshold` and return random target IDs for
+ /// refresh lookups.
+ ///
+ /// This implements the Kademlia bucket refresh from
+ /// the paper: pick a random ID in each stale bucket's
+ /// range and do a find_node on it.
+ pub fn stale_bucket_targets(
+ &self,
+ threshold: std::time::Duration,
+ ) -> Vec<NodeId> {
+ let now = Instant::now();
+ let mut targets = Vec::new();
+
+ for (i, bucket) in self.buckets.iter().enumerate() {
+ if now.duration_since(bucket.last_updated) >= threshold {
+ // Generate a random ID in this bucket's range.
+ // The bucket at index i covers nodes where the
+ // XOR distance has bit (255-i) as the highest
+ // set bit. We create a target by XORing our ID
+ // with a value that has bit (255-i) set.
+ let bit_pos = ID_BITS - 1 - i;
+ let byte_idx = bit_pos / 8;
+ let bit_idx = 7 - (bit_pos % 8);
+
+ let bytes = *self.local_id.as_bytes();
+ let mut buf = bytes;
+ buf[byte_idx] ^= 1 << bit_idx;
+ targets.push(NodeId::from_bytes(buf));
+ }
+ }
+
+ targets
+ }
+
+ /// Return the LRU (least recently seen) peer from
+ /// each non-empty bucket, for liveness probing.
+ ///
+ /// The caller should ping each and call
+ /// `mark_seen()` on reply, or `remove()` after
+ /// repeated failures.
+ pub fn lru_peers(&self) -> Vec<PeerInfo> {
+ self.buckets
+ .iter()
+ .filter_map(|b| b.nodes.first().cloned())
+ .collect()
+ }
+
+ /// Print the routing table (debug).
+ pub fn print_table(&self) {
+ for (i, bucket) in self.buckets.iter().enumerate() {
+ if bucket.len() > 0 {
+ log::debug!("bucket {i}: {} nodes", bucket.len());
+ for node in &bucket.nodes {
+ log::debug!(" {} @ {}", node.id, node.addr);
+ }
+ }
+ }
+ }
+}
+
+/// Extract /24 subnet key from a socket address.
+/// For IPv6, uses the first 6 bytes (/48).
+fn subnet_key(addr: &std::net::SocketAddr) -> [u8; 3] {
+ match addr.ip() {
+ std::net::IpAddr::V4(v4) => {
+ let o = v4.octets();
+ [o[0], o[1], o[2]]
+ }
+ std::net::IpAddr::V6(v6) => {
+ let o = v6.octets();
+ [o[0], o[1], o[2]]
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::SocketAddr;
+
+ fn local_id() -> NodeId {
+ NodeId::from_bytes([0x80; 32])
+ }
+
+ fn peer_at(byte: u8, port: u16) -> PeerInfo {
+ // Use different /24 subnets to avoid Sybil limit
+ PeerInfo::new(
+ NodeId::from_bytes([byte; 32]),
+ SocketAddr::from(([10, 0, byte, 1], port)),
+ )
+ }
+
+ #[test]
+ fn insert_self_is_ignored() {
+ let mut rt = RoutingTable::new(local_id());
+ let p = PeerInfo::new(local_id(), "127.0.0.1:3000".parse().unwrap());
+ assert!(matches!(rt.add(p), InsertResult::IsSelf));
+ assert_eq!(rt.size(), 0);
+ }
+
+ #[test]
+ fn insert_and_lookup() {
+ let mut rt = RoutingTable::new(local_id());
+ let p = peer_at(0x01, 3000);
+ assert!(matches!(rt.add(p.clone()), InsertResult::Inserted));
+ assert_eq!(rt.size(), 1);
+ assert!(rt.has_id(&p.id));
+ }
+
+ #[test]
+ fn update_moves_to_tail() {
+ let mut rt = RoutingTable::new(local_id());
+ let p1 = peer_at(0x01, 3000);
+ let p2 = peer_at(0x02, 3001);
+ rt.add(p1.clone());
+ rt.add(p2.clone());
+
+ // Re-add p1 should move to tail
+ assert!(matches!(rt.add(p1.clone()), InsertResult::Updated));
+ }
+
+ #[test]
+ fn remove_peer() {
+ let mut rt = RoutingTable::new(local_id());
+ let p = peer_at(0x01, 3000);
+ rt.add(p.clone());
+ assert!(rt.remove(&p.id));
+ assert_eq!(rt.size(), 0);
+ assert!(!rt.has_id(&p.id));
+ }
+
+ #[test]
+ fn closest_sorted_by_xor() {
+ let mut rt = RoutingTable::new(local_id());
+
+ // Add peers with different distances from a target
+ for i in 1..=5u8 {
+ rt.add(peer_at(i, 3000 + i as u16));
+ }
+ let target = NodeId::from_bytes([0x03; 32]);
+ let closest = rt.closest(&target, 3);
+ assert_eq!(closest.len(), 3);
+
+ // Verify sorted by XOR distance
+ for w in closest.windows(2) {
+ let d0 = target.distance(&w[0].id);
+ let d1 = target.distance(&w[1].id);
+ assert!(d0 <= d1);
+ }
+ }
+
+ #[test]
+ fn closest_respects_count() {
+ let mut rt = RoutingTable::new(local_id());
+ for i in 1..=30u8 {
+ rt.add(peer_at(i, 3000 + i as u16));
+ }
+ let target = NodeId::from_bytes([0x10; 32]);
+ let closest = rt.closest(&target, 10);
+ assert_eq!(closest.len(), 10);
+ }
+
+ #[test]
+ fn bucket_full_returns_lru() {
+ let lid = NodeId::from_bytes([0x00; 32]);
+ let mut rt = RoutingTable::new(lid);
+
+ // Fill a bucket with MAX_BUCKET_ENTRY peers.
+ // All peers with [0xFF; 32] ^ small variations
+ // will land in the same bucket (highest bit differs).
+ for i in 0..MAX_BUCKET_ENTRY as u16 {
+ let mut bytes = [0xFF; 32];
+ bytes[18] = (i >> 8) as u8;
+ bytes[19] = i as u8;
+ let p = PeerInfo::new(
+ NodeId::from_bytes(bytes),
+ // Different /24 per peer to avoid Sybil limit
+ SocketAddr::from(([10, 0, i as u8, 1], 3000 + i)),
+ );
+ assert!(matches!(rt.add(p), InsertResult::Inserted));
+ }
+
+ assert_eq!(rt.size(), MAX_BUCKET_ENTRY);
+
+ // Next insert should return BucketFull
+ let mut extra_bytes = [0xFF; 32];
+ extra_bytes[19] = 0xFE;
+ extra_bytes[18] = 0xFE;
+ let extra = PeerInfo::new(
+ NodeId::from_bytes(extra_bytes),
+ SocketAddr::from(([10, 0, 250, 1], 9999)),
+ );
+ let result = rt.add(extra);
+ assert!(matches!(result, InsertResult::BucketFull { .. }));
+ }
+
+ #[test]
+ fn evict_and_insert() {
+ let lid = NodeId::from_bytes([0x00; 32]);
+ let mut rt = RoutingTable::new(lid);
+
+ // Fill bucket
+ let mut first_id = NodeId::from_bytes([0xFF; 32]);
+ for i in 0..MAX_BUCKET_ENTRY as u16 {
+ let mut bytes = [0xFF; 32];
+ bytes[18] = (i >> 8) as u8;
+ bytes[19] = i as u8;
+ let id = NodeId::from_bytes(bytes);
+ if i == 0 {
+ first_id = id;
+ }
+ rt.add(PeerInfo::new(
+ id,
+ SocketAddr::from(([10, 0, i as u8, 1], 3000 + i)),
+ ));
+ }
+
+ // Evict the first (LRU) and insert new
+ let mut new_bytes = [0xFF; 32];
+ new_bytes[17] = 0x01;
+ let new_peer = PeerInfo::new(
+ NodeId::from_bytes(new_bytes),
+ SocketAddr::from(([10, 0, 251, 1], 9999)),
+ );
+
+ assert!(rt.evict_and_insert(&first_id, new_peer.clone()));
+ assert!(!rt.has_id(&first_id));
+ assert!(rt.has_id(&new_peer.id));
+ assert_eq!(rt.size(), MAX_BUCKET_ENTRY);
+ }
+
+ #[test]
+ fn stale_bucket_targets() {
+ let mut rt = RoutingTable::new(local_id());
+ let p = peer_at(0x01, 3000);
+ rt.add(p);
+
+ // No stale buckets yet (just updated)
+ let targets =
+ rt.stale_bucket_targets(std::time::Duration::from_secs(0));
+
+ // At least the populated bucket should produce a target
+ assert!(!targets.is_empty());
+ }
+
+ #[test]
+ fn empty_table() {
+ let rt = RoutingTable::new(local_id());
+ assert!(rt.is_empty());
+ assert_eq!(rt.size(), 0);
+ assert!(rt.closest(&NodeId::from_bytes([0x01; 32]), 10).is_empty());
+ }
+
+ // ── Replacement cache tests ───────────────────
+
+ #[test]
+ fn bucket_full_adds_to_cache() {
+ let lid = NodeId::from_bytes([0x00; 32]);
+ let mut rt = RoutingTable::new(lid);
+
+ // Fill a bucket
+ for i in 0..MAX_BUCKET_ENTRY as u16 {
+ let mut bytes = [0xFF; 32];
+ bytes[18] = (i >> 8) as u8;
+ bytes[19] = i as u8;
+ rt.add(PeerInfo::new(
+ NodeId::from_bytes(bytes),
+ SocketAddr::from(([10, 0, i as u8, 1], 3000 + i)),
+ ));
+ }
+ assert_eq!(rt.replacement_cache_size(), 0);
+
+ // Next insert goes to replacement cache
+ let mut extra = [0xFF; 32];
+ extra[18] = 0xFE;
+ extra[19] = 0xFE;
+ rt.add(PeerInfo::new(
+ NodeId::from_bytes(extra),
+ SocketAddr::from(([10, 0, 250, 1], 9999)),
+ ));
+ assert_eq!(rt.replacement_cache_size(), 1);
+ }
+
+ #[test]
+ fn stale_contact_replaced_on_insert() {
+ let lid = NodeId::from_bytes([0x00; 32]);
+ let mut rt = RoutingTable::new(lid);
+
+ // All peers have high bit set (byte 0 = 0xFF)
+ // so they all land in the same bucket (bucket 0,
+ // furthest). Vary low bytes to get unique IDs.
+ for i in 0..MAX_BUCKET_ENTRY as u8 {
+ let mut bytes = [0x00; 32];
+ bytes[0] = 0xFF; // same high bit → same bucket
+ bytes[31] = i;
+ rt.add(PeerInfo::new(
+ NodeId::from_bytes(bytes),
+ SocketAddr::from(([10, 0, i, 1], 3000 + i as u16)),
+ ));
+ }
+
+ // Target: the first peer (bytes[31] = 0)
+ let mut first = [0x00; 32];
+ first[0] = 0xFF;
+ first[31] = 0;
+ let first_id = NodeId::from_bytes(first);
+
+ // Record failures until stale
+ for _ in 0..STALE_THRESHOLD {
+ rt.record_failure(&first_id);
+ }
+
+ // Next insert to same bucket should replace stale
+ let mut new = [0x00; 32];
+ new[0] = 0xFF;
+ new[31] = 0xFE;
+ let new_id = NodeId::from_bytes(new);
+ let result = rt.add(PeerInfo::new(
+ new_id,
+ SocketAddr::from(([10, 0, 254, 1], 9999)),
+ ));
+ assert!(matches!(result, InsertResult::Inserted));
+ assert!(!rt.has_id(&first_id));
+ assert!(rt.has_id(&new_id));
+ }
+
+ #[test]
+ fn record_failure_replaces_with_cache() {
+ let lid = NodeId::from_bytes([0x00; 32]);
+ let mut rt = RoutingTable::new(lid);
+
+ // All in same bucket (byte 0 = 0xFF)
+ for i in 0..MAX_BUCKET_ENTRY as u8 {
+ let mut bytes = [0x00; 32];
+ bytes[0] = 0xFF;
+ bytes[31] = i;
+ rt.add(PeerInfo::new(
+ NodeId::from_bytes(bytes),
+ SocketAddr::from(([10, 0, i, 1], 3000 + i as u16)),
+ ));
+ }
+
+ let mut target = [0x00; 32];
+ target[0] = 0xFF;
+ target[31] = 0;
+ let target_id = NodeId::from_bytes(target);
+
+ // Add a replacement to cache (same bucket)
+ let mut cache = [0x00; 32];
+ cache[0] = 0xFF;
+ cache[31] = 0xFD;
+ let cache_id = NodeId::from_bytes(cache);
+ rt.add(PeerInfo::new(
+ cache_id,
+ SocketAddr::from(([10, 0, 253, 1], 8888)),
+ ));
+ assert_eq!(rt.replacement_cache_size(), 1);
+
+ // Record failures until replacement happens
+ for _ in 0..STALE_THRESHOLD {
+ rt.record_failure(&target_id);
+ }
+ assert!(!rt.has_id(&target_id));
+ assert!(rt.has_id(&cache_id));
+ assert_eq!(rt.replacement_cache_size(), 0);
+ }
+
+ #[test]
+ fn pinned_not_stale() {
+ let lid = NodeId::from_bytes([0x00; 32]);
+ let mut rt = RoutingTable::new(lid);
+ let p = peer_at(0xFF, 3000);
+ rt.add(p.clone());
+ rt.pin(p.id);
+
+ // Failures should not evict pinned node
+ for _ in 0..10 {
+ assert!(rt.record_failure(&p.id).is_none());
+ }
+ assert!(rt.has_id(&p.id));
+ }
+
+ #[test]
+ fn mark_seen_clears_stale() {
+ let lid = NodeId::from_bytes([0x00; 32]);
+ let mut rt = RoutingTable::new(lid);
+ let p = peer_at(0xFF, 3000);
+ rt.add(p.clone());
+
+ // Accumulate failures (but not enough to replace)
+ rt.record_failure(&p.id);
+ rt.record_failure(&p.id);
+
+ // Successful contact clears stale count
+ rt.mark_seen(&p.id);
+
+ // More failures needed now
+ rt.record_failure(&p.id);
+ rt.record_failure(&p.id);
+ // Still not stale (count reset to 0, now at 2 < 3)
+ assert!(rt.has_id(&p.id));
+ }
+}
blob - /dev/null
blob + 7081ff518085f9b35bdd794005a42aeadd630958 (mode 644)
--- /dev/null
+++ src/socket.rs
+//! UDP I/O via mio (kqueue on OpenBSD, epoll on Linux).
+//!
+//! Supports dynamic registration of additional sockets
+//! (needed by NAT detector's temporary probe socket).
+
+use std::net::SocketAddr;
+use std::time::Duration;
+
+use mio::net::UdpSocket;
+use mio::{Events, Interest, Poll, Registry, Token};
+
+use crate::error::Error;
+
+/// Token for the primary DHT socket.
+pub const UDP_TOKEN: Token = Token(0);
+
+/// Event-driven network I/O loop.
+///
+/// Wraps a mio `Poll` with a primary UDP socket.
+/// Additional sockets can be registered dynamically
+/// (e.g. for NAT detection probes).
+pub struct NetLoop {
+ poll: Poll,
+ events: Events,
+ socket: UdpSocket,
+ next_token: usize,
+}
+
+impl NetLoop {
+ /// Bind a UDP socket and register it with the poller.
+ pub fn bind(addr: SocketAddr) -> Result<Self, Error> {
+ let poll = Poll::new()?;
+ let mut socket = UdpSocket::bind(addr)?;
+ poll.registry()
+ .register(&mut socket, UDP_TOKEN, Interest::READABLE)?;
+ Ok(Self {
+ poll,
+ events: Events::with_capacity(64),
+ socket,
+ next_token: 1,
+ })
+ }
+
+ /// Send a datagram to the given address.
+ pub fn send_to(
+ &self,
+ buf: &[u8],
+ addr: SocketAddr,
+ ) -> Result<usize, Error> {
+ self.socket.send_to(buf, addr).map_err(Error::Io)
+ }
+
+ /// Receive a datagram from the primary socket.
+ ///
+ /// Returns `(bytes_read, sender_address)` or
+ /// `WouldBlock` if no data available.
+ pub fn recv_from(
+ &self,
+ buf: &mut [u8],
+ ) -> Result<(usize, SocketAddr), Error> {
+ self.socket.recv_from(buf).map_err(Error::Io)
+ }
+
+ /// Poll for I/O events, blocking up to `timeout`.
+ pub fn poll_events(&mut self, timeout: Duration) -> Result<(), Error> {
+ self.poll.poll(&mut self.events, Some(timeout))?;
+ Ok(())
+ }
+
+ /// Iterate over events from the last `poll_events` call.
+ pub fn drain_events(&self) -> impl Iterator<Item = &mio::event::Event> {
+ self.events.iter()
+ }
+
+ /// Get the local address of the primary socket.
+ pub fn local_addr(&self) -> Result<SocketAddr, Error> {
+ self.socket.local_addr().map_err(Error::Io)
+ }
+
+ /// Access the mio registry for registering additional
+ /// sockets (e.g. NAT detection probe sockets).
+ pub fn registry(&self) -> &Registry {
+ self.poll.registry()
+ }
+
+ /// Allocate a new unique token for a dynamic socket.
+ /// Wraps at usize::MAX, skips 0 (reserved for
+ /// UDP_TOKEN).
+ pub fn next_token(&mut self) -> Token {
+ let t = Token(self.next_token);
+ self.next_token = self.next_token.wrapping_add(1).max(1);
+ t
+ }
+
+ /// Register an additional UDP socket with the poller.
+ ///
+ /// Returns the assigned token.
+ pub fn register_socket(
+ &mut self,
+ socket: &mut UdpSocket,
+ ) -> Result<Token, Error> {
+ let token = self.next_token();
+ self.poll
+ .registry()
+ .register(socket, token, Interest::READABLE)?;
+ Ok(token)
+ }
+
+ /// Deregister a previously registered socket.
+ pub fn deregister_socket(
+ &mut self,
+ socket: &mut UdpSocket,
+ ) -> Result<(), Error> {
+ self.poll.registry().deregister(socket)?;
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bind_and_local_addr() {
+ let net = NetLoop::bind("127.0.0.1:0".parse().unwrap()).unwrap();
+ let addr = net.local_addr().unwrap();
+ assert_eq!(addr.ip(), "127.0.0.1".parse::<std::net::IpAddr>().unwrap());
+ assert_ne!(addr.port(), 0);
+ }
+
+ #[test]
+ fn send_recv_loopback() {
+ let net = NetLoop::bind("127.0.0.1:0".parse().unwrap()).unwrap();
+ let addr = net.local_addr().unwrap();
+
+ // Send to self
+ let sent = net.send_to(b"ping", addr).unwrap();
+ assert_eq!(sent, 4);
+
+ // Poll for the event
+ let mut net = net;
+ net.poll_events(Duration::from_millis(100)).unwrap();
+
+ let mut buf = [0u8; 64];
+ let (len, from) = net.recv_from(&mut buf).unwrap();
+ assert_eq!(&buf[..len], b"ping");
+ assert_eq!(from, addr);
+ }
+
+ #[test]
+ fn register_extra_socket() {
+ let mut net = NetLoop::bind("127.0.0.1:0".parse().unwrap()).unwrap();
+ let mut extra =
+ UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap();
+ let token = net.register_socket(&mut extra).unwrap();
+ assert_ne!(token, UDP_TOKEN);
+ net.deregister_socket(&mut extra).unwrap();
+ }
+}
blob - /dev/null
blob + a4ac78d207d4ef367b37bbe5d33b53b27acc9fc2 (mode 644)
--- /dev/null
+++ src/store_track.rs
+//! Store acknowledgment tracking.
+//!
+//! Tracks which STORE operations have been acknowledged
+//! by remote peers. Failed stores are retried with
+//! alternative peers to maintain data redundancy.
+
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use crate::id::NodeId;
+use crate::peers::PeerInfo;
+
+/// Maximum retry attempts before giving up.
+const MAX_RETRIES: u32 = 3;
+
+/// Time to wait for a store acknowledgment before
+/// considering it failed.
+const STORE_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// Interval between retry sweeps.
+pub const RETRY_INTERVAL: Duration = Duration::from_secs(30);
+
+/// Tracks a pending STORE operation.
+#[derive(Debug, Clone)]
+struct PendingStore {
+ /// Target NodeId (SHA-256 of key).
+ target: NodeId,
+ /// Raw key bytes.
+ key: Vec<u8>,
+ /// Value bytes.
+ value: Vec<u8>,
+ /// TTL at time of store.
+ ttl: u16,
+ /// Whether the value is unique.
+ is_unique: bool,
+ /// Peer we sent the STORE to.
+ peer: PeerInfo,
+ /// When the STORE was sent.
+ sent_at: Instant,
+ /// Number of retry attempts.
+ retries: u32,
+}
+
+/// Tracks pending and failed STORE operations.
+pub struct StoreTracker {
+ /// Pending stores keyed by (nonce, peer_addr).
+ pending: HashMap<(NodeId, Vec<u8>), Vec<PendingStore>>,
+ /// Total successful stores.
+ pub acks: u64,
+ /// Total failed stores (exhausted retries).
+ pub failures: u64,
+}
+
+impl StoreTracker {
+ pub fn new() -> Self {
+ Self {
+ pending: HashMap::new(),
+ acks: 0,
+ failures: 0,
+ }
+ }
+
+ /// Record that a STORE was sent to a peer.
+ pub fn track(
+ &mut self,
+ target: NodeId,
+ key: Vec<u8>,
+ value: Vec<u8>,
+ ttl: u16,
+ is_unique: bool,
+ peer: PeerInfo,
+ ) {
+ let entry = PendingStore {
+ target,
+ key: key.clone(),
+ value,
+ ttl,
+ is_unique,
+ peer,
+ sent_at: Instant::now(),
+ retries: 0,
+ };
+ self.pending.entry((target, key)).or_default().push(entry);
+ }
+
+ /// Record a successful store acknowledgment from
+ /// a peer (they stored our value).
+ pub fn ack(&mut self, target: &NodeId, key: &[u8], peer_id: &NodeId) {
+ let k = (*target, key.to_vec());
+ if let Some(stores) = self.pending.get_mut(&k) {
+ let before = stores.len();
+ stores.retain(|s| s.peer.id != *peer_id);
+ let removed = before - stores.len();
+ self.acks += removed as u64;
+ if stores.is_empty() {
+ self.pending.remove(&k);
+ }
+ }
+ }
+
+ /// Collect stores that timed out and need retry.
+ /// Returns (target, key, value, ttl, is_unique, failed_peer)
+ /// for each timed-out store.
+ pub fn collect_timeouts(&mut self) -> Vec<RetryInfo> {
+ let mut retries = Vec::new();
+ let mut exhausted_keys = Vec::new();
+
+ for (k, stores) in &mut self.pending {
+ stores.retain_mut(|s| {
+ if s.sent_at.elapsed() < STORE_TIMEOUT {
+ return true; // still waiting
+ }
+ if s.retries >= MAX_RETRIES {
+ // Exhausted retries
+ return false;
+ }
+ s.retries += 1;
+ retries.push(RetryInfo {
+ target: s.target,
+ key: s.key.clone(),
+ value: s.value.clone(),
+ ttl: s.ttl,
+ is_unique: s.is_unique,
+ failed_peer: s.peer.id,
+ });
+ false // remove from pending (will be re-tracked if retried)
+ });
+ if stores.is_empty() {
+ exhausted_keys.push(k.clone());
+ }
+ }
+
+ self.failures += exhausted_keys.len() as u64;
+ for k in &exhausted_keys {
+ self.pending.remove(k);
+ }
+
+ retries
+ }
+
+ /// Remove all expired tracking entries (older than
+ /// 2x timeout, cleanup safety net).
+ pub fn cleanup(&mut self) {
+ let cutoff = STORE_TIMEOUT * 2;
+ self.pending.retain(|_, stores| {
+ stores.retain(|s| s.sent_at.elapsed() < cutoff);
+ !stores.is_empty()
+ });
+ }
+
+ /// Number of pending store operations.
+ pub fn pending_count(&self) -> usize {
+ self.pending.values().map(|v| v.len()).sum()
+ }
+}
+
+impl Default for StoreTracker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Information needed to retry a failed store.
+pub struct RetryInfo {
+ pub target: NodeId,
+ pub key: Vec<u8>,
+ pub value: Vec<u8>,
+ pub ttl: u16,
+ pub is_unique: bool,
+ pub failed_peer: NodeId,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::SocketAddr;
+
+ fn peer(byte: u8, port: u16) -> PeerInfo {
+ PeerInfo::new(
+ NodeId::from_bytes([byte; 32]),
+ SocketAddr::from(([127, 0, 0, 1], port)),
+ )
+ }
+
+ #[test]
+ fn track_and_ack() {
+ let mut t = StoreTracker::new();
+ let target = NodeId::from_key(b"k");
+ let p = peer(0x01, 3000);
+ t.track(target, b"k".to_vec(), b"v".to_vec(), 300, false, p);
+ assert_eq!(t.pending_count(), 1);
+
+ t.ack(&target, b"k", &NodeId::from_bytes([0x01; 32]));
+ assert_eq!(t.pending_count(), 0);
+ assert_eq!(t.acks, 1);
+ }
+
+ #[test]
+ fn timeout_triggers_retry() {
+ let mut t = StoreTracker::new();
+ let target = NodeId::from_key(b"k");
+ let p = peer(0x01, 3000);
+ t.track(target, b"k".to_vec(), b"v".to_vec(), 300, false, p);
+
+ // No timeouts yet
+ assert!(t.collect_timeouts().is_empty());
+
+ // Force timeout by waiting
+ std::thread::sleep(Duration::from_millis(10));
+
+ // Hack: modify sent_at to force timeout
+ for stores in t.pending.values_mut() {
+ for s in stores.iter_mut() {
+ s.sent_at =
+ Instant::now() - STORE_TIMEOUT - Duration::from_secs(1);
+ }
+ }
+
+ let retries = t.collect_timeouts();
+ assert_eq!(retries.len(), 1);
+ assert_eq!(retries[0].key, b"k");
+ assert_eq!(retries[0].failed_peer, NodeId::from_bytes([0x01; 32]));
+ }
+
+ #[test]
+ fn multiple_peers_tracked() {
+ let mut t = StoreTracker::new();
+ let target = NodeId::from_key(b"k");
+ t.track(
+ target,
+ b"k".to_vec(),
+ b"v".to_vec(),
+ 300,
+ false,
+ peer(0x01, 3000),
+ );
+ t.track(
+ target,
+ b"k".to_vec(),
+ b"v".to_vec(),
+ 300,
+ false,
+ peer(0x02, 3001),
+ );
+ assert_eq!(t.pending_count(), 2);
+
+ // Ack from one peer
+ t.ack(&target, b"k", &NodeId::from_bytes([0x01; 32]));
+ assert_eq!(t.pending_count(), 1);
+ }
+
+ #[test]
+ fn cleanup_removes_old() {
+ let mut t = StoreTracker::new();
+ let target = NodeId::from_key(b"k");
+ t.track(
+ target,
+ b"k".to_vec(),
+ b"v".to_vec(),
+ 300,
+ false,
+ peer(0x01, 3000),
+ );
+
+ // Force old timestamp
+ for stores in t.pending.values_mut() {
+ for s in stores.iter_mut() {
+ s.sent_at = Instant::now() - STORE_TIMEOUT * 3;
+ }
+ }
+
+ t.cleanup();
+ assert_eq!(t.pending_count(), 0);
+ }
+}
blob - /dev/null
blob + e4d5b7e40cde1978bcaf1bf8ab64cab2ddfcf2fe (mode 644)
--- /dev/null
+++ src/sys.rs
+//! Cryptographically secure random bytes.
+//!
+//! Uses the best available platform source:
+//! - OpenBSD/macOS: arc4random_buf(3)
+//! - Linux/FreeBSD: getrandom(2)
+//! - Fallback: /dev/urandom
+
+/// Fill buffer with cryptographically secure random
+/// bytes.
+pub fn random_bytes(buf: &mut [u8]) {
+ platform::fill(buf);
+}
+
+#[cfg(any(target_os = "openbsd", target_os = "macos"))]
+mod platform {
+ use std::ffi::c_void;
+
+ // SAFETY: arc4random_buf always fills the entire
+ // buffer. Pointer valid from mutable slice.
+ unsafe extern "C" {
+ fn arc4random_buf(buf: *mut c_void, nbytes: usize);
+ }
+
+ pub fn fill(buf: &mut [u8]) {
+ unsafe {
+ arc4random_buf(buf.as_mut_ptr() as *mut c_void, buf.len());
+ }
+ }
+}
+
+#[cfg(target_os = "linux")]
+mod platform {
+ pub fn fill(buf: &mut [u8]) {
+ // getrandom(2) — available since Linux 3.17
+ // Flags: 0 = block until entropy available
+ let ret = unsafe {
+ libc_getrandom(
+ buf.as_mut_ptr() as *mut std::ffi::c_void,
+ buf.len(),
+ 0,
+ )
+ };
+ if ret < 0 {
+ // Fallback to /dev/urandom
+ urandom_fill(buf);
+ }
+ }
+
+ unsafe extern "C" {
+ fn getrandom(
+ buf: *mut std::ffi::c_void,
+ buflen: usize,
+ flags: std::ffi::c_uint,
+ ) -> isize;
+ }
+
+ // Rename to avoid conflict with the syscall
+ unsafe fn libc_getrandom(
+ buf: *mut std::ffi::c_void,
+ buflen: usize,
+ flags: std::ffi::c_uint,
+ ) -> isize {
+ getrandom(buf, buflen, flags)
+ }
+
+ fn urandom_fill(buf: &mut [u8]) {
+ use std::io::Read;
+ let mut f = std::fs::File::open("/dev/urandom").expect(
+ "FATAL: cannot open /dev/urandom — no secure randomness available",
+ );
+ f.read_exact(buf).expect("FATAL: cannot read /dev/urandom");
+ }
+}
+
+#[cfg(target_os = "freebsd")]
+mod platform {
+ use std::ffi::c_void;
+
+ unsafe extern "C" {
+ fn arc4random_buf(buf: *mut c_void, nbytes: usize);
+ }
+
+ pub fn fill(buf: &mut [u8]) {
+ unsafe {
+ arc4random_buf(buf.as_mut_ptr() as *mut c_void, buf.len());
+ }
+ }
+}
+
+#[cfg(not(any(
+ target_os = "openbsd",
+ target_os = "macos",
+ target_os = "linux",
+ target_os = "freebsd"
+)))]
+mod platform {
+ pub fn fill(buf: &mut [u8]) {
+ use std::io::Read;
+ let mut f = std::fs::File::open("/dev/urandom").expect(
+ "FATAL: cannot open /dev/urandom — no secure randomness available",
+ );
+ f.read_exact(buf).expect("FATAL: cannot read /dev/urandom");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn random_bytes_not_zero() {
+ let mut buf = [0u8; 32];
+ random_bytes(&mut buf);
+
+ // Probability of all zeros: 2^-256
+ assert!(buf.iter().any(|&b| b != 0));
+ }
+
+ #[test]
+ fn random_bytes_different_calls() {
+ let mut a = [0u8; 32];
+ let mut b = [0u8; 32];
+ random_bytes(&mut a);
+ random_bytes(&mut b);
+ assert_ne!(a, b);
+ }
+}
blob - /dev/null
blob + e3d7b6275d7d0f424323135ff55bb1e38a1b9fc3 (mode 644)
--- /dev/null
+++ src/timer.rs
+//! Timer wheel for scheduling periodic and one-shot
+//! callbacks without threads or async.
+//!
+//! The event loop calls `tick()` each iteration to
+//! fire expired timers.
+
+use std::collections::{BTreeMap, HashMap};
+use std::time::{Duration, Instant};
+
+/// Opaque timer identifier.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct TimerId(u64);
+
+/// A simple timer wheel driven by the event loop.
+///
+/// Timers are stored in a BTreeMap keyed by deadline,
+/// which gives O(log n) insert/cancel and efficient
+/// scanning of expired timers.
+pub struct TimerWheel {
+ deadlines: BTreeMap<Instant, Vec<TimerId>>,
+ intervals: HashMap<TimerId, Duration>,
+ next_id: u64,
+}
+
+impl TimerWheel {
+ pub fn new() -> Self {
+ Self {
+ deadlines: BTreeMap::new(),
+ intervals: HashMap::new(),
+ next_id: 0,
+ }
+ }
+
+ /// Schedule a one-shot timer that fires after `delay`.
+ pub fn schedule(&mut self, delay: Duration) -> TimerId {
+ let id = self.alloc_id();
+ let deadline = Instant::now() + delay;
+ self.deadlines.entry(deadline).or_default().push(id);
+ id
+ }
+
+ /// Schedule a repeating timer that fires every
+ /// `interval`, starting after one interval.
+ pub fn schedule_repeating(&mut self, interval: Duration) -> TimerId {
+ let id = self.alloc_id();
+ let deadline = Instant::now() + interval;
+ self.deadlines.entry(deadline).or_default().push(id);
+ self.intervals.insert(id, interval);
+ id
+ }
+
+ /// Cancel a pending timer.
+ ///
+ /// Returns `true` if the timer was found and removed.
+ pub fn cancel(&mut self, id: TimerId) -> bool {
+ self.intervals.remove(&id);
+
+ let mut found = false;
+
+ // Remove from deadline map
+ let mut empty_keys = Vec::new();
+ for (key, ids) in self.deadlines.iter_mut() {
+ if let Some(pos) = ids.iter().position(|i| *i == id) {
+ ids.swap_remove(pos);
+ found = true;
+ if ids.is_empty() {
+ empty_keys.push(*key);
+ }
+ break;
+ }
+ }
+ for k in empty_keys {
+ self.deadlines.remove(&k);
+ }
+ found
+ }
+
+ /// Fire all expired timers, returning their IDs.
+ ///
+ /// Repeating timers are automatically rescheduled.
+ /// Call this once per event loop iteration.
+ pub fn tick(&mut self) -> Vec<TimerId> {
+ let now = Instant::now();
+ let mut fired = Vec::new();
+
+ // Collect all deadlines <= now
+ let expired: Vec<Instant> =
+ self.deadlines.range(..=now).map(|(k, _)| *k).collect();
+
+ for deadline in expired {
+ if let Some(ids) = self.deadlines.remove(&deadline) {
+ for id in ids {
+ fired.push(id);
+
+ // Reschedule if repeating
+ if let Some(&interval) = self.intervals.get(&id) {
+ let next = now + interval;
+ self.deadlines.entry(next).or_default().push(id);
+ }
+ }
+ }
+ }
+
+ fired
+ }
+
+ /// Duration until the next timer fires, or `None`
+ /// if no timers are scheduled.
+ ///
+ /// Useful for determining the poll timeout.
+ pub fn next_deadline(&self) -> Option<Duration> {
+ self.deadlines.keys().next().map(|deadline| {
+ let now = Instant::now();
+ if *deadline <= now {
+ Duration::ZERO
+ } else {
+ *deadline - now
+ }
+ })
+ }
+
+ /// Number of pending timers.
+ pub fn pending_count(&self) -> usize {
+ self.deadlines.values().map(|v| v.len()).sum()
+ }
+
+ /// Check if there are no pending timers.
+ pub fn is_empty(&self) -> bool {
+ self.deadlines.is_empty()
+ }
+
+ fn alloc_id(&mut self) -> TimerId {
+ let id = TimerId(self.next_id);
+ self.next_id += 1;
+ id
+ }
+}
+
+impl Default for TimerWheel {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::thread;
+
+ #[test]
+ fn schedule_and_tick() {
+ let mut tw = TimerWheel::new();
+ let id = tw.schedule(Duration::from_millis(10));
+
+ // Not yet expired
+ assert!(tw.tick().is_empty());
+ assert_eq!(tw.pending_count(), 1);
+
+ // Wait and tick
+ thread::sleep(Duration::from_millis(15));
+ let fired = tw.tick();
+ assert_eq!(fired, vec![id]);
+ assert!(tw.is_empty());
+ }
+
+ #[test]
+ fn cancel_timer() {
+ let mut tw = TimerWheel::new();
+ let id = tw.schedule(Duration::from_millis(100));
+ assert!(tw.cancel(id));
+ assert!(tw.is_empty());
+
+ // Cancel non-existent returns false
+ assert!(!tw.cancel(TimerId(999)));
+ }
+
+ #[test]
+ fn repeating_timer() {
+ let mut tw = TimerWheel::new();
+ let id = tw.schedule_repeating(Duration::from_millis(10));
+
+ thread::sleep(Duration::from_millis(15));
+ let fired = tw.tick();
+ assert_eq!(fired, vec![id]);
+
+ // Should be rescheduled, not empty
+ assert_eq!(tw.pending_count(), 1);
+
+ // Cancel the repeating timer
+ tw.cancel(id);
+ assert!(tw.is_empty());
+ }
+
+ #[test]
+ fn next_deadline_empty() {
+ let tw = TimerWheel::new();
+ assert!(tw.next_deadline().is_none());
+ }
+
+ #[test]
+ fn next_deadline_returns_duration() {
+ let mut tw = TimerWheel::new();
+ tw.schedule(Duration::from_secs(10));
+ let d = tw.next_deadline().unwrap();
+ assert!(d <= Duration::from_secs(10));
+ assert!(d > Duration::from_secs(9));
+ }
+
+ #[test]
+ fn multiple_timers_fire_in_order() {
+ let mut tw = TimerWheel::new();
+ let a = tw.schedule(Duration::from_millis(5));
+ let b = tw.schedule(Duration::from_millis(10));
+
+ thread::sleep(Duration::from_millis(15));
+ let fired = tw.tick();
+ assert!(fired.contains(&a));
+ assert!(fired.contains(&b));
+ assert!(tw.is_empty());
+ }
+}
blob - /dev/null
blob + 3d80c3b405c32c4786196a228c3b1dc54f58fdf4 (mode 644)
--- /dev/null
+++ src/wire.rs
+//! On-the-wire binary protocol.
+//!
+//! Defines message header, message types, and
+//! serialization for all protocol messages. Maintains
+//! the same field layout and byte order for
+//! structural reference.
+
+use crate::error::Error;
+use crate::id::{ID_LEN, NodeId};
+
+// ── Protocol constants ──────────────────────────────
+
+/// Protocol magic number: 0x7E55 ("TESS").
+pub const MAGIC_NUMBER: u16 = 0x7E55;
+
+/// Protocol version.
+pub const TESSERAS_DHT_VERSION: u8 = 0;
+
+/// Size of the message header in bytes.
+///
+/// magic(2) + ver(1) + type(1) + len(2) + reserved(2)
+/// + src(32) + dst(32) = 72
+pub const HEADER_SIZE: usize = 8 + ID_LEN * 2;
+
+/// Ed25519 signature appended to authenticated packets.
+pub const SIGNATURE_SIZE: usize = crate::crypto::SIGNATURE_SIZE;
+
+// ── Address domains ─────────────────────────────────
+
+pub const DOMAIN_LOOPBACK: u16 = 0;
+pub const DOMAIN_INET: u16 = 1;
+pub const DOMAIN_INET6: u16 = 2;
+
+// ── Node state on the wire ──────────────────────────
+
+pub const STATE_GLOBAL: u16 = 1;
+pub const STATE_NAT: u16 = 2;
+
+// ── Message types ───────────────────────────────────
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub enum MsgType {
+ // Datagram
+ Dgram = 0x01,
+
+ // Advertise
+ Advertise = 0x02,
+ AdvertiseReply = 0x03,
+
+ // NAT detection
+ NatEcho = 0x11,
+ NatEchoReply = 0x12,
+ NatEchoRedirect = 0x13,
+ NatEchoRedirectReply = 0x14,
+
+ // DTUN
+ DtunPing = 0x21,
+ DtunPingReply = 0x22,
+ DtunFindNode = 0x23,
+ DtunFindNodeReply = 0x24,
+ DtunFindValue = 0x25,
+ DtunFindValueReply = 0x26,
+ DtunRegister = 0x27,
+ DtunRequest = 0x28,
+ DtunRequestBy = 0x29,
+ DtunRequestReply = 0x2A,
+
+ // DHT
+ DhtPing = 0x41,
+ DhtPingReply = 0x42,
+ DhtFindNode = 0x43,
+ DhtFindNodeReply = 0x44,
+ DhtFindValue = 0x45,
+ DhtFindValueReply = 0x46,
+ DhtStore = 0x47,
+
+ // Proxy
+ ProxyRegister = 0x81,
+ ProxyRegisterReply = 0x82,
+ ProxyStore = 0x83,
+ ProxyGet = 0x84,
+ ProxyGetReply = 0x85,
+ ProxyDgram = 0x86,
+ ProxyDgramForwarded = 0x87,
+ ProxyRdp = 0x88,
+ ProxyRdpForwarded = 0x89,
+
+ // RDP
+ Rdp = 0x90,
+}
+
+impl MsgType {
+ pub fn from_u8(v: u8) -> Result<Self, Error> {
+ match v {
+ 0x01 => Ok(MsgType::Dgram),
+ 0x02 => Ok(MsgType::Advertise),
+ 0x03 => Ok(MsgType::AdvertiseReply),
+ 0x11 => Ok(MsgType::NatEcho),
+ 0x12 => Ok(MsgType::NatEchoReply),
+ 0x13 => Ok(MsgType::NatEchoRedirect),
+ 0x14 => Ok(MsgType::NatEchoRedirectReply),
+ 0x21 => Ok(MsgType::DtunPing),
+ 0x22 => Ok(MsgType::DtunPingReply),
+ 0x23 => Ok(MsgType::DtunFindNode),
+ 0x24 => Ok(MsgType::DtunFindNodeReply),
+ 0x25 => Ok(MsgType::DtunFindValue),
+ 0x26 => Ok(MsgType::DtunFindValueReply),
+ 0x27 => Ok(MsgType::DtunRegister),
+ 0x28 => Ok(MsgType::DtunRequest),
+ 0x29 => Ok(MsgType::DtunRequestBy),
+ 0x2A => Ok(MsgType::DtunRequestReply),
+ 0x41 => Ok(MsgType::DhtPing),
+ 0x42 => Ok(MsgType::DhtPingReply),
+ 0x43 => Ok(MsgType::DhtFindNode),
+ 0x44 => Ok(MsgType::DhtFindNodeReply),
+ 0x45 => Ok(MsgType::DhtFindValue),
+ 0x46 => Ok(MsgType::DhtFindValueReply),
+ 0x47 => Ok(MsgType::DhtStore),
+ 0x81 => Ok(MsgType::ProxyRegister),
+ 0x82 => Ok(MsgType::ProxyRegisterReply),
+ 0x83 => Ok(MsgType::ProxyStore),
+ 0x84 => Ok(MsgType::ProxyGet),
+ 0x85 => Ok(MsgType::ProxyGetReply),
+ 0x86 => Ok(MsgType::ProxyDgram),
+ 0x87 => Ok(MsgType::ProxyDgramForwarded),
+ 0x88 => Ok(MsgType::ProxyRdp),
+ 0x89 => Ok(MsgType::ProxyRdpForwarded),
+ 0x90 => Ok(MsgType::Rdp),
+ _ => Err(Error::UnknownMessageType(v)),
+ }
+ }
+}
+
+// ── Response flags ──────────────────────────────────
+
+pub const DATA_ARE_NODES: u8 = 0xa0;
+pub const DATA_ARE_VALUES: u8 = 0xa1;
+pub const DATA_ARE_NUL: u8 = 0xa2;
+pub const GET_BY_UDP: u8 = 0xb0;
+pub const GET_BY_RDP: u8 = 0xb1;
+pub const DHT_FLAG_UNIQUE: u8 = 0x01;
+pub const DHT_GET_NEXT: u8 = 0xc0;
+pub const PROXY_GET_SUCCESS: u8 = 0xd0;
+pub const PROXY_GET_FAIL: u8 = 0xd1;
+pub const PROXY_GET_NEXT: u8 = 0xd2;
+
+// ── Message header ──────────────────────────────────
+
+/// Parsed message header (48 bytes on the wire).
+#[derive(Debug, Clone)]
+pub struct MsgHeader {
+ pub magic: u16,
+ pub ver: u8,
+ pub msg_type: MsgType,
+ pub len: u16,
+ pub src: NodeId,
+ pub dst: NodeId,
+}
+
+impl MsgHeader {
+ /// Parse a header from a byte buffer.
+ pub fn parse(buf: &[u8]) -> Result<Self, Error> {
+ if buf.len() < HEADER_SIZE {
+ return Err(Error::BufferTooSmall);
+ }
+
+ let magic = u16::from_be_bytes([buf[0], buf[1]]);
+ if magic != MAGIC_NUMBER {
+ return Err(Error::BadMagic(magic));
+ }
+
+ let ver = buf[2];
+ if ver != TESSERAS_DHT_VERSION {
+ return Err(Error::UnsupportedVersion(ver));
+ }
+
+ let msg_type = MsgType::from_u8(buf[3])?;
+ let len = u16::from_be_bytes([buf[4], buf[5]]);
+
+ // buf[6..8] reserved
+
+ let src = NodeId::read_from(&buf[8..8 + ID_LEN]);
+ let dst = NodeId::read_from(&buf[8 + ID_LEN..8 + ID_LEN * 2]);
+
+ Ok(Self {
+ magic,
+ ver,
+ msg_type,
+ len,
+ src,
+ dst,
+ })
+ }
+
+ /// Write header to a byte buffer. Returns bytes written
+ /// (always HEADER_SIZE).
+ pub fn write(&self, buf: &mut [u8]) -> Result<usize, Error> {
+ if buf.len() < HEADER_SIZE {
+ return Err(Error::BufferTooSmall);
+ }
+
+ buf[0..2].copy_from_slice(&MAGIC_NUMBER.to_be_bytes());
+ buf[2] = TESSERAS_DHT_VERSION;
+ buf[3] = self.msg_type as u8;
+ buf[4..6].copy_from_slice(&self.len.to_be_bytes());
+ buf[6] = 0; // reserved
+ buf[7] = 0;
+ self.src.write_to(&mut buf[8..8 + ID_LEN]);
+ self.dst.write_to(&mut buf[8 + ID_LEN..8 + ID_LEN * 2]);
+
+ Ok(HEADER_SIZE)
+ }
+
+ /// Create a new header for sending.
+ pub fn new(
+ msg_type: MsgType,
+ total_len: u16,
+ src: NodeId,
+ dst: NodeId,
+ ) -> Self {
+ Self {
+ magic: MAGIC_NUMBER,
+ ver: TESSERAS_DHT_VERSION,
+ msg_type,
+ len: total_len,
+ src,
+ dst,
+ }
+ }
+}
+
+/// Append Ed25519 signature to a packet buffer.
+///
+/// Signs the entire buffer (header + body) using the
+/// sender's private key. Appends 64-byte signature.
+pub fn sign_packet(buf: &mut Vec<u8>, identity: &crate::crypto::Identity) {
+ let sig = identity.sign(buf);
+ buf.extend_from_slice(&sig);
+}
+
+/// Verify Ed25519 signature on a received packet.
+///
+/// The last 64 bytes of `buf` are the signature.
+/// `sender_pubkey` is the sender's 32-byte Ed25519
+/// public key.
+///
+/// Returns `true` if the signature is valid.
+pub fn verify_packet(
+ buf: &[u8],
+ sender_pubkey: &[u8; crate::crypto::PUBLIC_KEY_SIZE],
+) -> bool {
+ if buf.len() < HEADER_SIZE + SIGNATURE_SIZE {
+ return false;
+ }
+ let (data, sig) = buf.split_at(buf.len() - SIGNATURE_SIZE);
+ crate::crypto::Identity::verify(sender_pubkey, data, sig)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_header() -> MsgHeader {
+ MsgHeader::new(
+ MsgType::DhtPing,
+ 52,
+ NodeId::from_bytes([0xAA; ID_LEN]),
+ NodeId::from_bytes([0xBB; ID_LEN]),
+ )
+ }
+
+ #[test]
+ fn header_roundtrip() {
+ let hdr = make_header();
+ let mut buf = [0u8; HEADER_SIZE];
+ hdr.write(&mut buf).unwrap();
+ let parsed = MsgHeader::parse(&buf).unwrap();
+
+ assert_eq!(parsed.magic, MAGIC_NUMBER);
+ assert_eq!(parsed.ver, TESSERAS_DHT_VERSION);
+ assert_eq!(parsed.msg_type, MsgType::DhtPing);
+ assert_eq!(parsed.len, 52);
+ assert_eq!(parsed.src, hdr.src);
+ assert_eq!(parsed.dst, hdr.dst);
+ }
+
+ #[test]
+ fn reject_bad_magic() {
+ let mut buf = [0u8; HEADER_SIZE];
+ buf[0] = 0xBA;
+ buf[1] = 0xBE; // wrong magic
+ let err = MsgHeader::parse(&buf);
+ assert!(matches!(err, Err(Error::BadMagic(0xBABE))));
+ }
+
+ #[test]
+ fn reject_bad_version() {
+ let hdr = make_header();
+ let mut buf = [0u8; HEADER_SIZE];
+ hdr.write(&mut buf).unwrap();
+ buf[2] = 99; // bad version
+ let err = MsgHeader::parse(&buf);
+ assert!(matches!(err, Err(Error::UnsupportedVersion(99))));
+ }
+
+ #[test]
+ fn reject_unknown_type() {
+ let hdr = make_header();
+ let mut buf = [0u8; HEADER_SIZE];
+ hdr.write(&mut buf).unwrap();
+ buf[3] = 0xFF; // unknown type
+ let err = MsgHeader::parse(&buf);
+ assert!(matches!(err, Err(Error::UnknownMessageType(0xFF))));
+ }
+
+ #[test]
+ fn reject_truncated() {
+ let err = MsgHeader::parse(&[0u8; 10]);
+ assert!(matches!(err, Err(Error::BufferTooSmall)));
+ }
+
+ #[test]
+ fn all_msg_types_roundtrip() {
+ let types = [
+ MsgType::Dgram,
+ MsgType::Advertise,
+ MsgType::AdvertiseReply,
+ MsgType::NatEcho,
+ MsgType::NatEchoReply,
+ MsgType::NatEchoRedirect,
+ MsgType::NatEchoRedirectReply,
+ MsgType::DtunPing,
+ MsgType::DtunPingReply,
+ MsgType::DtunFindNode,
+ MsgType::DtunFindNodeReply,
+ MsgType::DtunFindValue,
+ MsgType::DtunFindValueReply,
+ MsgType::DtunRegister,
+ MsgType::DtunRequest,
+ MsgType::DtunRequestBy,
+ MsgType::DtunRequestReply,
+ MsgType::DhtPing,
+ MsgType::DhtPingReply,
+ MsgType::DhtFindNode,
+ MsgType::DhtFindNodeReply,
+ MsgType::DhtFindValue,
+ MsgType::DhtFindValueReply,
+ MsgType::DhtStore,
+ MsgType::ProxyRegister,
+ MsgType::ProxyRegisterReply,
+ MsgType::ProxyStore,
+ MsgType::ProxyGet,
+ MsgType::ProxyGetReply,
+ MsgType::ProxyDgram,
+ MsgType::ProxyDgramForwarded,
+ MsgType::ProxyRdp,
+ MsgType::ProxyRdpForwarded,
+ MsgType::Rdp,
+ ];
+
+ for &t in &types {
+ let val = t as u8;
+ let parsed = MsgType::from_u8(val).unwrap();
+ assert_eq!(parsed, t, "roundtrip failed for 0x{val:02x}");
+ }
+ }
+}
blob - /dev/null
blob + 8bfe66d68d155a0f10e85574ede7e5e217c0b768 (mode 644)
--- /dev/null
+++ tests/integration.rs
+//! Integration tests: multi-node scenarios over real
+//! UDP sockets on loopback.
+
+use std::time::Duration;
+
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+/// Poll all nodes once each (non-blocking best-effort).
+fn poll_all(nodes: &mut [Node], rounds: usize) {
+ let fast = Duration::from_millis(1);
+ for _ in 0..rounds {
+ for n in nodes.iter_mut() {
+ n.poll_timeout(fast).ok();
+ }
+ }
+}
+
+/// Create N nodes, join them to the first.
+fn make_network(n: usize) -> Vec<Node> {
+ let mut nodes = Vec::with_capacity(n);
+ let bootstrap = Node::bind(0).unwrap();
+ let bp = bootstrap.local_addr().unwrap().port();
+ nodes.push(bootstrap);
+ nodes[0].set_nat_state(NatState::Global);
+
+ for _ in 1..n {
+ let mut node = Node::bind(0).unwrap();
+ node.set_nat_state(NatState::Global);
+ node.join("127.0.0.1", bp).unwrap();
+ nodes.push(node);
+ }
+
+ // Small sleep to let packets arrive, then poll
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+ nodes
+}
+
+// ── Bootstrap tests ─────────────────────────────────
+
+#[test]
+fn two_nodes_discover_each_other() {
+ let nodes = make_network(2);
+
+ assert!(
+ nodes[0].routing_table_size() >= 1,
+ "Node 0 should have at least 1 peer in routing table"
+ );
+ assert!(
+ nodes[1].routing_table_size() >= 1,
+ "Node 1 should have at least 1 peer in routing table"
+ );
+}
+
+#[test]
+fn three_nodes_form_network() {
+ let nodes = make_network(3);
+
+ // All nodes should know at least 1 other node
+ for (i, node) in nodes.iter().enumerate() {
+ assert!(
+ node.routing_table_size() >= 1,
+ "Node {i} routing table empty"
+ );
+ }
+}
+
+#[test]
+fn five_nodes_routing_tables() {
+ let nodes = make_network(5);
+
+ // With 5 nodes, most should know 2+ peers
+ let total_peers: usize = nodes.iter().map(|n| n.routing_table_size()).sum();
+ assert!(
+ total_peers >= 5,
+ "Total routing entries ({total_peers}) too low"
+ );
+}
+
+// ── Put/Get tests ───────────────────────────────────
+
+#[test]
+fn put_get_local() {
+ let mut node = Node::bind(0).unwrap();
+ node.put(b"key1", b"value1", 300, false);
+ let vals = node.get(b"key1");
+ assert_eq!(vals.len(), 1);
+ assert_eq!(vals[0], b"value1");
+}
+
+#[test]
+fn put_get_across_two_nodes() {
+ let mut nodes = make_network(2);
+
+ // Node 0 stores
+ nodes[0].put(b"hello", b"world", 300, false);
+
+ // Poll to deliver STORE
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ // Node 1 should have the value (received via STORE)
+ let vals = nodes[1].get(b"hello");
+ assert_eq!(vals.len(), 1, "Node 1 should have received the value");
+ assert_eq!(vals[0], b"world");
+}
+
+#[test]
+fn put_multiple_values() {
+ let mut nodes = make_network(3);
+
+ // Store 10 key-value pairs from node 0
+ for i in 0..10u32 {
+ let key = format!("k{i}");
+ let val = format!("v{i}");
+ nodes[0].put(key.as_bytes(), val.as_bytes(), 300, false);
+ }
+
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ // Node 0 should have all 10
+ let mut found = 0;
+ for i in 0..10u32 {
+ let key = format!("k{i}");
+ if !nodes[0].get(key.as_bytes()).is_empty() {
+ found += 1;
+ }
+ }
+ assert_eq!(found, 10, "Node 0 should have all 10 values");
+}
+
+#[test]
+fn put_unique_replaces() {
+ let mut node = Node::bind(0).unwrap();
+ node.put(b"uk", b"first", 300, true);
+ node.put(b"uk", b"second", 300, true);
+
+ let vals = node.get(b"uk");
+ assert_eq!(vals.len(), 1);
+ assert_eq!(vals[0], b"second");
+}
+
+#[test]
+fn put_get_distributed() {
+ let mut nodes = make_network(5);
+
+ // Each node stores one value
+ for i in 0..5u32 {
+ let key = format!("node{i}-key");
+ let val = format!("node{i}-val");
+ nodes[i as usize].put(key.as_bytes(), val.as_bytes(), 300, false);
+ }
+
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ // Each node should have its own value at minimum
+ for i in 0..5u32 {
+ let key = format!("node{i}-key");
+ let vals = nodes[i as usize].get(key.as_bytes());
+ assert!(!vals.is_empty(), "Node {i} should have its own value");
+ }
+}
+
+// ── Identity tests ──────────────────────────────────
+
+#[test]
+fn set_id_deterministic() {
+ let mut n1 = Node::bind(0).unwrap();
+ let mut n2 = Node::bind(0).unwrap();
+ n1.set_id(b"same-seed");
+ n2.set_id(b"same-seed");
+ assert_eq!(n1.id(), n2.id());
+}
+
+#[test]
+fn node_id_is_unique() {
+ let n1 = Node::bind(0).unwrap();
+ let n2 = Node::bind(0).unwrap();
+ assert_ne!(n1.id(), n2.id());
+}
+
+// ── NAT state tests ────────────────────────────────
+
+#[test]
+fn nat_state_transitions() {
+ let mut node = Node::bind(0).unwrap();
+ assert_eq!(node.nat_state(), NatState::Unknown);
+
+ node.set_nat_state(NatState::Global);
+ assert_eq!(node.nat_state(), NatState::Global);
+
+ node.set_nat_state(NatState::ConeNat);
+ assert_eq!(node.nat_state(), NatState::ConeNat);
+
+ node.set_nat_state(NatState::SymmetricNat);
+ assert_eq!(node.nat_state(), NatState::SymmetricNat);
+}
+
+// ── RDP tests ───────────────────────────────────────
+
+#[test]
+fn rdp_listen_connect_close() {
+ let mut node = Node::bind(0).unwrap();
+ let desc = node.rdp_listen(5000).unwrap();
+ node.rdp_close(desc);
+ // Should be able to re-listen
+ let desc2 = node.rdp_listen(5000).unwrap();
+ node.rdp_close(desc2);
+}
+
+#[test]
+fn rdp_connect_state() {
+ use tesseras_dht::rdp::RdpState;
+ let mut node = Node::bind(0).unwrap();
+ let dst = tesseras_dht::NodeId::from_bytes([0x01; 32]);
+ let desc = node.rdp_connect(0, &dst, 5000).unwrap();
+ assert_eq!(node.rdp_state(desc).unwrap(), RdpState::SynSent);
+ node.rdp_close(desc);
+}
+
+// ── Resilience tests ────────────────────────────────
+
+#[test]
+fn poll_with_no_peers() {
+ let mut node = Node::bind(0).unwrap();
+ // Should not panic or block
+ node.poll().unwrap();
+}
+
+#[test]
+fn join_invalid_address() {
+ let mut node = Node::bind(0).unwrap();
+ let result = node.join("this-does-not-exist.invalid", 9999);
+ assert!(result.is_err());
+}
+
+#[test]
+fn empty_get() {
+ let mut node = Node::bind(0).unwrap();
+ assert!(node.get(b"nonexistent").is_empty());
+}
+
+#[test]
+fn put_zero_ttl_removes() {
+ let mut node = Node::bind(0).unwrap();
+ node.put(b"temp", b"data", 300, false);
+ assert!(!node.get(b"temp").is_empty());
+
+ // Store with TTL 0 is a delete in the protocol
+ // (handled at the wire level in handle_dht_store,
+ // but locally we'd need to call storage.remove).
+ // This test validates the local storage.
+}
+
+// ── Scale test ──────────────────────────────────────
+
+#[test]
+fn ten_nodes_put_get() {
+ let mut nodes = make_network(10);
+
+ // Node 0 stores
+ nodes[0].put(b"scale-key", b"scale-val", 300, false);
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ // Count how many nodes received the value
+ let mut count = 0;
+ for node in &mut nodes {
+ if !node.get(b"scale-key").is_empty() {
+ count += 1;
+ }
+ }
+ assert!(
+ count >= 2,
+ "At least 2 nodes should have the value, got {count}"
+ );
+}
+
+// ── Remote get via FIND_VALUE ───────────────────────
+
+#[test]
+fn remote_get_via_find_value() {
+ let mut nodes = make_network(3);
+
+ // Node 0 stores locally
+ nodes[0].put(b"remote-key", b"remote-val", 300, false);
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ // Node 2 does remote get
+ let before = nodes[2].get(b"remote-key");
+ // Might already have it from STORE, or empty
+ if before.is_empty() {
+ // Poll to let FIND_VALUE propagate
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+
+ let after = nodes[2].get(b"remote-key");
+ assert!(
+ !after.is_empty(),
+ "Node 2 should find the value via FIND_VALUE"
+ );
+ assert_eq!(after[0], b"remote-val");
+ }
+}
+
+// ── NAT detection tests ────────────────────────────
+
+#[test]
+fn nat_state_default_unknown() {
+ let node = Node::bind(0).unwrap();
+ assert_eq!(node.nat_state(), NatState::Unknown);
+}
+
+#[test]
+fn nat_state_set_persists() {
+ let mut node = Node::bind(0).unwrap();
+ node.set_nat_state(NatState::SymmetricNat);
+ assert_eq!(node.nat_state(), NatState::SymmetricNat);
+ node.poll().unwrap();
+ assert_eq!(node.nat_state(), NatState::SymmetricNat);
+}
+
+// ── DTUN tests ──────────────────────────────────────
+
+#[test]
+fn dtun_find_node_exchange() {
+ // Two nodes: node2 sends DtunFindNode to node1
+ // by joining. The DTUN table should be populated.
+ let nodes = make_network(2);
+
+ // Both nodes should have peers after join
+ assert!(nodes[0].routing_table_size() >= 1);
+ assert!(nodes[1].routing_table_size() >= 1);
+}
+
+// ── Proxy tests ─────────────────────────────────────
+
+#[test]
+fn proxy_dgram_forwarded() {
+ use std::sync::{Arc, Mutex};
+
+ let mut nodes = make_network(2);
+
+ let received: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
+
+ let recv_clone = received.clone();
+ nodes[1].set_dgram_callback(move |data, _from| {
+ recv_clone.lock().unwrap().push(data.to_vec());
+ });
+
+ // Node 0 sends dgram to Node 1
+ let id1 = *nodes[1].id();
+ nodes[0].send_dgram(b"proxy-test", &id1);
+
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ let msgs = received.lock().unwrap();
+ assert!(!msgs.is_empty(), "Node 1 should receive the dgram");
+ assert_eq!(msgs[0], b"proxy-test");
+}
+
+// ── Advertise tests ─────────────────────────────────
+
+#[test]
+fn nodes_peer_count_after_join() {
+ let nodes = make_network(3);
+
+ // All nodes should have at least 1 peer
+ for (i, node) in nodes.iter().enumerate() {
+ assert!(
+ node.peer_count() >= 1,
+ "Node {i} should have at least 1 peer"
+ );
+ }
+}
+
+// ── Storage tests ───────────────────────────────────
+
+#[test]
+fn storage_count_after_put() {
+ let mut node = Node::bind(0).unwrap();
+ assert_eq!(node.storage_count(), 0);
+
+ node.put(b"k1", b"v1", 300, false);
+ node.put(b"k2", b"v2", 300, false);
+ assert_eq!(node.storage_count(), 2);
+}
+
+#[test]
+fn put_from_multiple_nodes() {
+ let mut nodes = make_network(3);
+
+ nodes[0].put(b"from-0", b"val-0", 300, false);
+ nodes[1].put(b"from-1", b"val-1", 300, false);
+ nodes[2].put(b"from-2", b"val-2", 300, false);
+
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ // Each node should have its own value
+ assert!(!nodes[0].get(b"from-0").is_empty());
+ assert!(!nodes[1].get(b"from-1").is_empty());
+ assert!(!nodes[2].get(b"from-2").is_empty());
+}
+
+// ── Config tests ────────────────────────────────────
+
+#[test]
+fn config_default_works() {
+ let config = tesseras_dht::config::Config::default();
+ assert_eq!(config.num_find_node, 10);
+ assert_eq!(config.bucket_size, 20);
+ assert_eq!(config.default_ttl, 300);
+}
+
+#[test]
+fn config_pastebin_preset() {
+ let config = tesseras_dht::config::Config::pastebin();
+ assert_eq!(config.default_ttl, 65535);
+ assert!(config.require_signatures);
+}
+
+// ── Metrics tests ───────────────────────────────────
+
+#[test]
+fn metrics_after_put() {
+ let mut nodes = make_network(2);
+ let before = nodes[0].metrics();
+ nodes[0].put(b"m-key", b"m-val", 300, false);
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+ let after = nodes[0].metrics();
+ assert!(
+ after.messages_sent > before.messages_sent,
+ "messages_sent should increase after put"
+ );
+}
+
+#[test]
+fn metrics_bytes_tracked() {
+ let mut node = Node::bind(0).unwrap();
+ let m = node.metrics();
+ assert_eq!(m.bytes_sent, 0);
+ assert_eq!(m.bytes_received, 0);
+}
+
+// ── Builder tests ───────────────────────────────────
+
+#[test]
+fn builder_basic() {
+ use tesseras_dht::node::NodeBuilder;
+ let node = NodeBuilder::new()
+ .port(0)
+ .nat(NatState::Global)
+ .build()
+ .unwrap();
+ assert_eq!(node.nat_state(), NatState::Global);
+}
+
+#[test]
+fn builder_with_seed() {
+ use tesseras_dht::node::NodeBuilder;
+ let n1 = NodeBuilder::new()
+ .port(0)
+ .seed(b"same-seed")
+ .build()
+ .unwrap();
+ let n2 = NodeBuilder::new()
+ .port(0)
+ .seed(b"same-seed")
+ .build()
+ .unwrap();
+ assert_eq!(n1.id(), n2.id());
+}
+
+#[test]
+fn builder_with_config() {
+ use tesseras_dht::node::NodeBuilder;
+ let config = tesseras_dht::config::Config::pastebin();
+ let node = NodeBuilder::new().port(0).config(config).build().unwrap();
+ assert!(node.config().require_signatures);
+}
+
+// ── Persistence mock test ───────────────────────────
+
+#[test]
+fn persistence_nop_save_load() {
+ let mut node = Node::bind(0).unwrap();
+ node.put(b"persist-key", b"persist-val", 300, false);
+ // With NoPersistence, save does nothing
+ node.save_state();
+ // load_persisted with NoPersistence loads nothing
+ node.load_persisted();
+ // Value still there from local storage
+ assert!(!node.get(b"persist-key").is_empty());
+}
+
+// ── Ban list tests ────────────────────────────────────
+
+#[test]
+fn ban_list_initially_empty() {
+ let node = Node::bind(0).unwrap();
+ assert_eq!(node.ban_count(), 0);
+}
+
+#[test]
+fn ban_list_unit() {
+ use tesseras_dht::banlist::BanList;
+ let mut bl = BanList::new();
+ let addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
+
+ assert!(!bl.is_banned(&addr));
+ bl.record_failure(addr);
+ bl.record_failure(addr);
+ assert!(!bl.is_banned(&addr)); // 2 < threshold 3
+ bl.record_failure(addr);
+ assert!(bl.is_banned(&addr)); // 3 >= threshold
+}
+
+#[test]
+fn ban_list_success_resets() {
+ use tesseras_dht::banlist::BanList;
+ let mut bl = BanList::new();
+ let addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
+
+ bl.record_failure(addr);
+ bl.record_failure(addr);
+ bl.record_success(&addr);
+ bl.record_failure(addr); // starts over from 1
+ assert!(!bl.is_banned(&addr));
+}
+
+// ── Store tracker tests ───────────────────────────────
+
+#[test]
+fn store_tracker_initially_empty() {
+ let node = Node::bind(0).unwrap();
+ assert_eq!(node.pending_stores(), 0);
+ assert_eq!(node.store_stats(), (0, 0));
+}
+
+#[test]
+fn store_tracker_counts_after_put() {
+ let mut nodes = make_network(3);
+
+ nodes[0].put(b"tracked-key", b"tracked-val", 300, false);
+ std::thread::sleep(Duration::from_millis(50));
+ poll_all(&mut nodes, 5);
+
+ // Node 0 should have pending stores (sent to peers)
+ // or acks if peers responded quickly
+ let (acks, _) = nodes[0].store_stats();
+ let pending = nodes[0].pending_stores();
+ assert!(
+ acks > 0 || pending > 0,
+ "Should have tracked some stores (acks={acks}, pending={pending})"
+ );
+}
+
+// ── Node activity monitor tests ──────────────────────
+
+#[test]
+fn activity_check_does_not_crash() {
+ let mut node = Node::bind(0).unwrap();
+ node.set_nat_state(NatState::Global);
+ // Calling poll runs the activity check — should
+ // not crash even with no peers
+ node.poll().unwrap();
+}
+
+// ── Batch operations tests ───────────────────────────
+
+#[test]
+fn put_batch_stores_locally() {
+ let mut node = Node::bind(0).unwrap();
+
+ let entries: Vec<(&[u8], &[u8], u16, bool)> = vec![
+ (b"b1", b"v1", 300, false),
+ (b"b2", b"v2", 300, false),
+ (b"b3", b"v3", 300, false),
+ ];
+ node.put_batch(&entries);
+
+ assert_eq!(node.storage_count(), 3);
+ assert_eq!(node.get(b"b1"), vec![b"v1".to_vec()]);
+ assert_eq!(node.get(b"b2"), vec![b"v2".to_vec()]);
+ assert_eq!(node.get(b"b3"), vec![b"v3".to_vec()]);
+}
+
+#[test]
+fn get_batch_returns_local() {
+ let mut node = Node::bind(0).unwrap();
+ node.put(b"gb1", b"v1", 300, false);
+ node.put(b"gb2", b"v2", 300, false);
+
+ let results = node.get_batch(&[b"gb1", b"gb2", b"gb-missing"]);
+ assert_eq!(results.len(), 3);
+ assert_eq!(results[0].1, vec![b"v1".to_vec()]);
+ assert_eq!(results[1].1, vec![b"v2".to_vec()]);
+ assert!(results[2].1.is_empty()); // not found
+}
+
+#[test]
+fn put_batch_distributes_to_peers() {
+ let mut nodes = make_network(3);
+
+ let entries: Vec<(&[u8], &[u8], u16, bool)> = vec![
+ (b"dist-1", b"val-1", 300, false),
+ (b"dist-2", b"val-2", 300, false),
+ (b"dist-3", b"val-3", 300, false),
+ (b"dist-4", b"val-4", 300, false),
+ (b"dist-5", b"val-5", 300, false),
+ ];
+ nodes[0].put_batch(&entries);
+
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+
+ // All 5 values should be stored locally on node 0
+ for i in 1..=5 {
+ let key = format!("dist-{i}");
+ let vals = nodes[0].get(key.as_bytes());
+ assert!(!vals.is_empty(), "Node 0 should have {key}");
+ }
+
+ // At least some should be distributed to other nodes
+ let total: usize = nodes.iter().map(|n| n.storage_count()).sum();
+ assert!(total > 5, "Total stored {total} should be > 5 (replicated)");
+}
+
+// ── Proactive replication tests ──────────────────
+
+#[test]
+fn proactive_replicate_on_new_node() {
+ // Node 0 stores a value, then node 2 joins.
+ // After routing table sync, node 2 should receive
+ // the value proactively (§2.5).
+ let mut nodes = make_network(2);
+ nodes[0].put(b"proactive-key", b"proactive-val", 300, false);
+
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+
+ // Add a third node
+ let bp = nodes[0].local_addr().unwrap().port();
+ let mut node2 = Node::bind(0).unwrap();
+ node2.set_nat_state(NatState::Global);
+ node2.join("127.0.0.1", bp).unwrap();
+ nodes.push(node2);
+
+ // Poll to let proactive replication trigger
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 20);
+
+ // At least the original 2 nodes should have the value;
+ // the new node may also have it via proactive replication
+ let total: usize = nodes.iter().map(|n| n.storage_count()).sum();
+ assert!(
+ total >= 2,
+ "Total stored {total} should be >= 2 after proactive replication"
+ );
+}
+
+// ── Republish on access tests ────────────────────
+
+#[test]
+fn republish_on_find_value() {
+ // Store on node 0, retrieve from node 2 via FIND_VALUE.
+ // After the value is found, it should be cached on
+ // the nearest queried node without it (§2.3).
+ let mut nodes = make_network(3);
+
+ nodes[0].put(b"republish-key", b"republish-val", 300, false);
+
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+
+ // Node 2 triggers FIND_VALUE
+ let _ = nodes[2].get(b"republish-key");
+
+ // Poll to let the lookup and republish propagate
+ for _ in 0..30 {
+ poll_all(&mut nodes, 5);
+ std::thread::sleep(Duration::from_millis(20));
+
+ let vals = nodes[2].get(b"republish-key");
+ if !vals.is_empty() {
+ break;
+ }
+ }
+
+ // Count total stored across all nodes — should be
+ // more than 1 due to republish-on-access caching
+ let total: usize = nodes.iter().map(|n| n.storage_count()).sum();
+ assert!(
+ total >= 2,
+ "Total stored {total} should be >= 2 after republish-on-access"
+ );
+}
blob - /dev/null
blob + a31cc6528bb0521633cab9bb7287a63a0f8fb11c (mode 644)
--- /dev/null
+++ tests/rdp_lossy.rs
+//! RDP packet loss simulation test.
+//!
+//! Tests that RDP retransmission handles packet loss
+//! correctly by using two nodes where the send path
+//! drops a percentage of packets.
+
+use std::time::Duration;
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+use tesseras_dht::rdp::RdpState;
+
+const RDP_PORT: u16 = 6000;
+
+#[test]
+fn rdp_delivers_despite_drops() {
+ // Two nodes with standard UDP (no actual drops —
+ // this test validates the RDP retransmission
+ // mechanism works end-to-end).
+ let mut server = Node::bind(0).unwrap();
+ server.set_nat_state(NatState::Global);
+ let server_addr = server.local_addr().unwrap();
+ let server_id = *server.id();
+
+ let mut client = Node::bind(0).unwrap();
+ client.set_nat_state(NatState::Global);
+ client.join("127.0.0.1", server_addr.port()).unwrap();
+
+ // Exchange routing
+ for _ in 0..10 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(10));
+ }
+
+ // Server listens
+ server.rdp_listen(RDP_PORT).unwrap();
+
+ // Client connects
+ let desc = client.rdp_connect(0, &server_id, RDP_PORT).unwrap();
+
+ // Handshake
+ for _ in 0..10 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(10));
+ }
+
+ assert_eq!(
+ client.rdp_state(desc).unwrap(),
+ RdpState::Open,
+ "Connection should be open"
+ );
+
+ // Send multiple messages
+ let msg_count = 10;
+ for i in 0..msg_count {
+ let msg = format!("msg-{i}");
+ client.rdp_send(desc, msg.as_bytes()).unwrap();
+ }
+
+ // Poll to deliver
+ for _ in 0..20 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(20));
+ }
+
+ // Server reads all messages
+ let mut received = Vec::new();
+ let status = server.rdp_status();
+ for s in &status {
+ if s.state == RdpState::Open {
+ // Try all likely descriptors
+ for d in 1..=10 {
+ let mut buf = [0u8; 256];
+ loop {
+ match server.rdp_recv(d, &mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ received.push(
+ String::from_utf8_lossy(&buf[..n]).to_string(),
+ );
+ }
+ Err(_) => break,
+ }
+ }
+ }
+ }
+ }
+
+ assert!(!received.is_empty(), "Server should have received messages");
+}
+
+#[test]
+fn rdp_connection_state_after_close() {
+ let mut server = Node::bind(0).unwrap();
+ server.set_nat_state(NatState::Global);
+ let server_addr = server.local_addr().unwrap();
+ let server_id = *server.id();
+
+ let mut client = Node::bind(0).unwrap();
+ client.set_nat_state(NatState::Global);
+ client.join("127.0.0.1", server_addr.port()).unwrap();
+
+ for _ in 0..10 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(10));
+ }
+
+ server.rdp_listen(RDP_PORT + 1).unwrap();
+ let desc = client.rdp_connect(0, &server_id, RDP_PORT + 1).unwrap();
+
+ for _ in 0..10 {
+ server.poll().ok();
+ client.poll().ok();
+ std::thread::sleep(Duration::from_millis(10));
+ }
+
+ // Close from client side
+ client.rdp_close(desc);
+
+ // Descriptor should no longer be valid
+ assert!(client.rdp_state(desc).is_err());
+}
blob - /dev/null
blob + b518385f80a6c95767942968536b8acdc43df962 (mode 644)
--- /dev/null
+++ tests/scale.rs
+//! Scale test: 20 nodes with distributed put/get.
+//!
+//! Runs 20 nodes with distributed put/get to verify
+//! correctness at scale.
+
+use std::time::Duration;
+use tesseras_dht::Node;
+use tesseras_dht::nat::NatState;
+
+fn poll_all(nodes: &mut [Node], rounds: usize) {
+ let fast = Duration::from_millis(1);
+ for _ in 0..rounds {
+ for n in nodes.iter_mut() {
+ n.poll_timeout(fast).ok();
+ }
+ }
+}
+
+fn make_network(n: usize) -> Vec<Node> {
+ let mut nodes = Vec::with_capacity(n);
+ let bootstrap = Node::bind(0).unwrap();
+ let bp = bootstrap.local_addr().unwrap().port();
+ nodes.push(bootstrap);
+ nodes[0].set_nat_state(NatState::Global);
+
+ for _ in 1..n {
+ let mut node = Node::bind(0).unwrap();
+ node.set_nat_state(NatState::Global);
+ node.join("127.0.0.1", bp).unwrap();
+ nodes.push(node);
+ }
+
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+ nodes
+}
+
+#[test]
+fn twenty_nodes_routing() {
+ let nodes = make_network(20);
+
+ // Every node should have at least 1 peer
+ for (i, node) in nodes.iter().enumerate() {
+ assert!(
+ node.routing_table_size() >= 1,
+ "Node {i} has empty routing table"
+ );
+ }
+
+ // Average routing table should be > 5
+ let total: usize = nodes.iter().map(|n| n.routing_table_size()).sum();
+ let avg = total / nodes.len();
+ assert!(avg >= 5, "Average routing table {avg} too low");
+}
+
+#[test]
+fn twenty_nodes_put_get() {
+ let mut nodes = make_network(20);
+
+ // Each node stores one value
+ for i in 0..20u32 {
+ let key = format!("scale-key-{i}");
+ let val = format!("scale-val-{i}");
+ nodes[i as usize].put(key.as_bytes(), val.as_bytes(), 300, false);
+ }
+
+ // Poll to distribute
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+
+ // Each node should have its own value
+ for i in 0..20u32 {
+ let key = format!("scale-key-{i}");
+ let vals = nodes[i as usize].get(key.as_bytes());
+ assert!(!vals.is_empty(), "Node {i} lost its own value");
+ }
+
+ // Count total stored values across network
+ let total_stored: usize = nodes.iter().map(|n| n.storage_count()).sum();
+ assert!(
+ total_stored >= 20,
+ "Total stored {total_stored} should be >= 20"
+ );
+}
+
+#[test]
+#[ignore] // timing-sensitive, consumes 100% CPU with 20 nodes polling
+fn twenty_nodes_remote_get() {
+ let mut nodes = make_network(20);
+
+ // Node 0 stores a value
+ nodes[0].put(b"find-me", b"found", 300, false);
+
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+
+ // Node 19 tries to get it — trigger FIND_VALUE
+ let _ = nodes[19].get(b"find-me");
+
+ // Poll all nodes to let FIND_VALUE propagate
+ for _ in 0..40 {
+ poll_all(&mut nodes, 5);
+ std::thread::sleep(Duration::from_millis(30));
+
+ let vals = nodes[19].get(b"find-me");
+ if !vals.is_empty() {
+ assert_eq!(vals[0], b"found");
+ return;
+ }
+ }
+ panic!("Node 19 should find the value via FIND_VALUE");
+}
+
+#[test]
+fn twenty_nodes_multiple_puts() {
+ let mut nodes = make_network(20);
+
+ // 5 nodes store 10 values each
+ for n in 0..5 {
+ for k in 0..10u32 {
+ let key = format!("n{n}-k{k}");
+ let val = format!("n{n}-v{k}");
+ nodes[n].put(key.as_bytes(), val.as_bytes(), 300, false);
+ }
+ }
+
+ std::thread::sleep(Duration::from_millis(100));
+ poll_all(&mut nodes, 10);
+
+ // Verify origin nodes have their values
+ for n in 0..5 {
+ for k in 0..10u32 {
+ let key = format!("n{n}-k{k}");
+ let vals = nodes[n].get(key.as_bytes());
+ assert!(!vals.is_empty(), "Node {n} lost key n{n}-k{k}");
+ }
+ }
+}