@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.91"
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo check --workspace --all-targets --all-features
|
||||
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
- run: cargo test --workspace
|
||||
@@ -0,0 +1,104 @@
|
||||
# AGENTS.md
|
||||
|
||||
These instructions apply to the entire Frid workspace.
|
||||
|
||||
## Project role
|
||||
|
||||
Frid is shared networking infrastructure. `federation-net` is the generic
|
||||
transport; `music-dht` is the distributed music-directory overlay used by
|
||||
Furumi applications. Treat public APIs, persisted state, hash derivations, and
|
||||
wire formats as compatibility-sensitive.
|
||||
|
||||
Read `ARCHITECTURE.md` before changing protocols, routing, persistence,
|
||||
rendezvous, tickets, identity, or record derivation.
|
||||
|
||||
## Required checks
|
||||
|
||||
The workspace uses Rust edition 2024 and Rust 1.91 or newer.
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --all-targets --all-features
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Integration tests open real sockets and establish iroh connections. If a
|
||||
restricted environment prevents socket binding or discovery, rerun them with
|
||||
the required permission and report the limitation; do not weaken or delete the
|
||||
tests.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Do not change these without an explicit migration/versioning decision:
|
||||
|
||||
- ALPN byte strings;
|
||||
- transport, DHT, device-sync, catalog, or ticket versions;
|
||||
- `NetworkId`, `SchemaId`, `NodeId`, `ItemId`, or `DhtKey` derivation domains;
|
||||
- postcard/serde wire enum layouts;
|
||||
- content-ID normalization;
|
||||
- SQLite schema or identity-key encoding;
|
||||
- public re-exports from either crate.
|
||||
|
||||
Postcard is not self-describing. Adding a field to a serialized type can be a
|
||||
breaking wire change even when serde would accept the Rust source change.
|
||||
|
||||
## Layer boundaries
|
||||
|
||||
- `federation-net` owns identity, iroh endpoints, handshake, tickets,
|
||||
rendezvous, typed framing, event backpressure, and generic byte streams.
|
||||
- `federation-net` must not acquire music, catalog, playlist, or application
|
||||
authorization logic.
|
||||
- `music-dht` owns Kademlia routing, requests, records, replication, search,
|
||||
content lookup, and its persistence abstraction.
|
||||
- Rich catalogs, audio, and device sync use dedicated ALPN stream protocols;
|
||||
do not push bulk data through the typed DHT event channel.
|
||||
- Applications own policy: knowing a network ID or discovering a peer is not
|
||||
proof of authorization.
|
||||
|
||||
## Async and concurrency
|
||||
|
||||
- Never hold a mutex guard across `.await`.
|
||||
- Keep SQLite and blocking filesystem work off Tokio reactor threads.
|
||||
- Bound channels, request maps, batches, frames, and peer-provided collections.
|
||||
- Make shutdown idempotent and ensure background tasks cannot retain event
|
||||
senders indefinitely.
|
||||
- Preserve request peer-matching so a response from the wrong peer cannot
|
||||
resolve another peer's request.
|
||||
- Use dial backoff and bounded lookup work for dead contacts.
|
||||
|
||||
## DHT behavior
|
||||
|
||||
- Routing uses stable node IDs and XOR distance; do not replace derivation or
|
||||
ordering as a cosmetic refactor.
|
||||
- Validate that a record belongs under the key on which it was received.
|
||||
- Owners advance revisions; replicas do not rewrite ownership.
|
||||
- Tombstones must beat active records at the same or older revision.
|
||||
- TTL refresh, expiry, and republish behavior must remain deterministic.
|
||||
- `sync_library` is declarative and idempotent: unchanged items stay stable,
|
||||
changed items advance, and removed items are tombstoned.
|
||||
- Search may return partial results when peers fail, but lookup must terminate.
|
||||
|
||||
## Tests
|
||||
|
||||
Add unit tests beside deterministic routing, framing, validation, storage, and
|
||||
merge logic. Use integration tests for behavior that depends on authenticated
|
||||
connections, handshakes, streams, disconnects, or multi-node lookup.
|
||||
|
||||
Tests must use temporary directories and isolated network/schema IDs. Never
|
||||
use a developer identity, repository `tmp/` state, fixed public network, or
|
||||
real application database as a fixture.
|
||||
|
||||
For protocol changes, test both rejection of incompatible peers and the
|
||||
intended compatible path. For input-bound changes, test the limit and the first
|
||||
value beyond it.
|
||||
|
||||
## Documentation and packaging
|
||||
|
||||
- Keep crate-level rustdoc and README examples compiling.
|
||||
- Root documentation describes the workspace; crate READMEs describe their
|
||||
public API and operational model.
|
||||
- Do not describe Mainline-DHT rendezvous as private or as authorization.
|
||||
- Keep demo crates `publish = false`.
|
||||
- Published packages must include the WTFPL version 2 license text.
|
||||
- Update `ARCHITECTURE.md` when changing a protocol boundary or invariant.
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# Frid architecture
|
||||
|
||||
Frid separates decentralized applications into a transport layer and a
|
||||
domain-specific overlay. The transport answers **how peers find and talk to
|
||||
each other**; the overlay answers **what data is routed, replicated, and
|
||||
queried**.
|
||||
|
||||
```text
|
||||
application
|
||||
│
|
||||
├── domain API, persistence, policy
|
||||
│
|
||||
music-dht
|
||||
├── Kademlia routing and iterative lookup
|
||||
├── replicated records, revisions, TTLs, tombstones
|
||||
├── catalog/content discovery
|
||||
│
|
||||
federation-net
|
||||
├── authenticated iroh connections
|
||||
├── typed bounded messages
|
||||
├── application ALPN byte streams
|
||||
└── optional Mainline-DHT rendezvous
|
||||
```
|
||||
|
||||
Neither layer assigns a permanent server role to a peer.
|
||||
|
||||
## Transport: federation-net
|
||||
|
||||
### Identity and authentication
|
||||
|
||||
Every engine owns a persistent iroh secret key. Its public endpoint ID is the
|
||||
peer identity used by connections, tickets, events, and higher-level routing.
|
||||
Authentication comes from the iroh connection; address or ticket payloads are
|
||||
never treated as proof of peer identity.
|
||||
|
||||
Keeping transport identity stable across restarts lets overlays derive stable
|
||||
node IDs and retain routing knowledge without a separate account system.
|
||||
|
||||
### Network and schema isolation
|
||||
|
||||
The transport ALPN identifies the federation-net protocol. An application
|
||||
handshake then verifies:
|
||||
|
||||
- protocol version;
|
||||
- `NetworkId`, which selects an independent deployment;
|
||||
- `SchemaId`, which identifies the typed message format.
|
||||
|
||||
This is intentional double isolation: the ALPN selects the transport protocol,
|
||||
while network and schema IDs prevent unrelated deployments or incompatible
|
||||
applications from exchanging domain payloads.
|
||||
|
||||
Changing serialized messages incompatibly requires a new schema ID. Changing
|
||||
the transport handshake incompatibly requires a protocol-version/ALPN plan.
|
||||
|
||||
### Discovery is not authorization
|
||||
|
||||
Automatic discovery publishes short-lived endpoint records into a
|
||||
network-specific BEP44 mutable record in the public BitTorrent Mainline DHT.
|
||||
The signing key is derived from the network ID, allowing equal peers to update
|
||||
the shared rendezvous set without a dedicated bootstrap service.
|
||||
|
||||
Knowing a network ID allows discovery. It does not establish application
|
||||
authorization. Private membership, ACLs, or trusted-device pairing belong in a
|
||||
higher-level protocol.
|
||||
|
||||
Tickets provide explicit discovery and carry enough connection information to
|
||||
dial a peer directly. They remain useful when public rendezvous is disabled or
|
||||
unavailable.
|
||||
|
||||
### Two data paths
|
||||
|
||||
Typed messages and raw byte streams serve different workloads:
|
||||
|
||||
- the message channel carries bounded, serde/postcard application messages and
|
||||
emits them through a bounded event queue;
|
||||
- registered ALPN streams carry protocol-specific request/response or bulk
|
||||
data directly between authenticated peers.
|
||||
|
||||
This prevents catalogs, media, and synchronization payloads from inflating the
|
||||
control/event channel. Higher layers can evolve their stream protocols without
|
||||
adding domain logic to federation-net.
|
||||
|
||||
## Overlay: music-dht
|
||||
|
||||
### Routing
|
||||
|
||||
Each peer derives a stable 256-bit `NodeId` from its endpoint identity. Routing
|
||||
uses XOR distance and Kademlia-style buckets. Iterative lookups query a bounded
|
||||
number of the closest known peers in parallel and learn additional contacts
|
||||
from responses.
|
||||
|
||||
The overlay is not broadcast-based. Query cost follows the routing graph, and
|
||||
contacts are dialed on demand using stored peer tickets.
|
||||
|
||||
### Records and indexing
|
||||
|
||||
`LibraryItem` is the published domain record. An item is indexed under several
|
||||
BLAKE3-derived DHT keys:
|
||||
|
||||
- the complete normalized name;
|
||||
- tokens from names, artists, featured artists, and release titles;
|
||||
- an exact content key when a content ID is available.
|
||||
|
||||
Multiple index keys point to the same stable item identity. This makes search
|
||||
and exact-content resolution different queries over one record model rather
|
||||
than separate databases.
|
||||
|
||||
Publishers declare their complete desired library through `sync_library`.
|
||||
The service diffs that declaration against owned state, republishes changes,
|
||||
and tombstones removed items. Callers do not manually maintain every DHT index
|
||||
entry.
|
||||
|
||||
### Replication and convergence
|
||||
|
||||
Records are stored on the `K` known nodes closest to each key. Owners advance
|
||||
revisions and periodically republish active state. Replicas expire after a TTL
|
||||
if their owner disappears.
|
||||
|
||||
Deletion uses revisioned tombstones. A tombstone wins over an active record at
|
||||
the same or an older revision, preventing delayed replication from immediately
|
||||
resurrecting deleted content. Tombstones have a longer lifetime than ordinary
|
||||
records so the removal has time to propagate.
|
||||
|
||||
This model provides eventual availability and convergence without consensus.
|
||||
The DHT is a distributed directory, not a transactional global database.
|
||||
|
||||
### Persistence abstraction
|
||||
|
||||
`MusicDhtStorage` separates protocol behavior from storage ownership. The
|
||||
default backend stores local items, replicas, routing contacts, and publication
|
||||
state in SQLite. Applications may supply another durable implementation while
|
||||
preserving the same service semantics.
|
||||
|
||||
Database work is executed outside async reactor threads. Corrupt or expired
|
||||
replica payloads are isolated from healthy records rather than crashing the
|
||||
node.
|
||||
|
||||
### Extension protocols
|
||||
|
||||
Music discovery identifies an owner and content; it does not force all domain
|
||||
traffic through DHT messages. `music-dht` exposes the underlying authenticated
|
||||
stream capability and defines shared wire models for Furumi catalog and device
|
||||
sync protocols. Audio transfer, rich catalog exchange, and synchronization can
|
||||
therefore use dedicated ALPNs while sharing identity and connectivity.
|
||||
|
||||
## Failure model
|
||||
|
||||
Frid assumes normal distributed-system failures:
|
||||
|
||||
- discovered peers may be offline before they are dialed;
|
||||
- connections may change between direct and relay paths;
|
||||
- rendezvous records may be stale, malformed, or concurrently updated;
|
||||
- messages may time out after a peer disconnects;
|
||||
- routing tables may contain dead contacts;
|
||||
- replicas may expire before an owner returns;
|
||||
- event consumers may be slower than producers.
|
||||
|
||||
The response is bounded degradation, not global failure. Dial backoff limits
|
||||
repeated failures, lookup budgets guarantee termination, stale records expire,
|
||||
per-connection errors become events, and bounded channels apply backpressure.
|
||||
|
||||
## Resource boundaries
|
||||
|
||||
Untrusted network input is bounded before allocation or fan-out:
|
||||
|
||||
- maximum typed-message frame size;
|
||||
- ticket and rendezvous record sizes;
|
||||
- event channel capacity;
|
||||
- concurrent streams per peer;
|
||||
- pending request count and lookup budget;
|
||||
- peer-exchange contacts;
|
||||
- records per response and store batch;
|
||||
- item names, artist lists, tokens, and content IDs.
|
||||
|
||||
New protocols must define equivalent limits. A valid peer identity does not
|
||||
make its payloads trusted.
|
||||
|
||||
## Compatibility rules
|
||||
|
||||
Frid is consumed by multiple applications, so compatibility is an
|
||||
architectural constraint:
|
||||
|
||||
1. Do not change existing ALPN bytes, ticket formats, protocol versions,
|
||||
derivation domains, or serialized wire layouts accidentally.
|
||||
2. Additive serialized fields require an explicit backward-compatibility
|
||||
strategy; postcard formats do not become extensible automatically.
|
||||
3. Changes to normalization or key derivation alter where records live and
|
||||
require a migration/protocol plan.
|
||||
4. SQLite schema changes must preserve existing identities, owned records, and
|
||||
routing state.
|
||||
5. Public re-exports are part of the library API even when their definitions
|
||||
live in another crate.
|
||||
6. A refactor is not behavior-preserving until unit, integration, and doctests
|
||||
pass with real peer connections.
|
||||
|
||||
## Architectural invariants
|
||||
|
||||
- No Frid-operated service is required for peer communication.
|
||||
- All peers can initiate, route, store, and query according to the same rules.
|
||||
- Discovery mechanisms do not silently become authorization mechanisms.
|
||||
- Domain policy stays above federation-net.
|
||||
- DHT operations remain bounded and terminate in the presence of dead peers.
|
||||
- Local durable state survives ordinary peer and network failure.
|
||||
- Additional stream protocols reuse authenticated connectivity without
|
||||
coupling bulk data to the typed control channel.
|
||||
Generated
-123
@@ -150,41 +150,6 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "artist-dht"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
"federation-net",
|
||||
"futures",
|
||||
"iroh",
|
||||
"postcard",
|
||||
"rand 0.9.5",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artist-dht-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artist-dht",
|
||||
"clap",
|
||||
"federation-net",
|
||||
"rustyline",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
@@ -426,15 +391,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
@@ -894,12 +850,6 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "endian-type"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2"
|
||||
|
||||
[[package]]
|
||||
name = "enum-assoc"
|
||||
version = "1.3.0"
|
||||
@@ -927,12 +877,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -1382,15 +1326,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
@@ -2338,27 +2273,6 @@ dependencies = [
|
||||
"wmi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nibble_vec"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noq"
|
||||
version = "1.0.1"
|
||||
@@ -2877,16 +2791,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "radix_trie"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a"
|
||||
dependencies = [
|
||||
"endian-type",
|
||||
"nibble_vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
@@ -3148,27 +3052,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rustyline"
|
||||
version = "18.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"clipboard-win",
|
||||
"home",
|
||||
"libc",
|
||||
"log",
|
||||
"memchr",
|
||||
"nix",
|
||||
"radix_trie",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"utf8parse",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -3944,12 +3827,6 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
||||
+3
-6
@@ -2,19 +2,18 @@
|
||||
resolver = "3"
|
||||
members = [
|
||||
"crates/federation-net",
|
||||
"crates/artist-dht",
|
||||
"crates/music-dht",
|
||||
"apps/federation-net-demo",
|
||||
"apps/artist-dht-cli",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = "WTFPL"
|
||||
rust-version = "1.91"
|
||||
repository = "https://gt.hexor.cy/ab/frid"
|
||||
|
||||
[workspace.dependencies]
|
||||
federation-net = { path = "crates/federation-net" }
|
||||
federation-net = { path = "crates/federation-net", version = "0.1.0" }
|
||||
iroh = "1"
|
||||
iroh-base = "1"
|
||||
iroh-tickets = "1"
|
||||
@@ -25,7 +24,6 @@ serde_json = "1"
|
||||
postcard = { version = "1", features = ["alloc"] }
|
||||
blake3 = "1"
|
||||
futures = "0.3"
|
||||
uuid = { version = "1", features = ["v7"] }
|
||||
unicode-normalization = "0.1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
async-trait = "0.1"
|
||||
@@ -34,7 +32,6 @@ tracing = "0.1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
anyhow = "1"
|
||||
rustyline = "18"
|
||||
data-encoding = "2"
|
||||
rand = "0.9"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
@@ -1,202 +1,81 @@
|
||||
# federation-net
|
||||
# frid
|
||||
|
||||
A reusable Rust library for direct peer-to-peer networking between equal
|
||||
peers, built on [Iroh](https://iroh.computer). It establishes QUIC
|
||||
connections that work through NATs (with hole punching and relay assistance),
|
||||
and exchanges typed, application-defined messages over them.
|
||||
Frid is the decentralized networking core behind Furumi. It provides reusable
|
||||
Rust libraries for building equal-peer applications with no application
|
||||
server, central peer registry, or privileged node.
|
||||
|
||||
The library contains **no domain logic** — no music, video, file libraries or
|
||||
databases. The application defines its own message type; the engine treats it
|
||||
as an opaque serde-serializable payload.
|
||||
The workspace has two layers:
|
||||
|
||||
## What the first version does
|
||||
- [`federation-net`](crates/federation-net) establishes authenticated,
|
||||
NAT-traversing P2P connections over iroh, isolates independent networks and
|
||||
message schemas, and optionally discovers peers through the BitTorrent
|
||||
Mainline DHT.
|
||||
- [`music-dht`](crates/music-dht) builds a Kademlia-style distributed music
|
||||
directory on top of that transport, including catalog discovery, content-id
|
||||
lookup, direct byte streams, and shared Furumi wire types.
|
||||
|
||||
* Persistent peer identity (`<data_dir>/identity.key`, created on first start).
|
||||
* Connection establishment via a shareable string ticket (`fnet...`).
|
||||
* Optional automatic peer discovery (**rendezvous**): peers of a network find
|
||||
each other through the BitTorrent Mainline DHT knowing nothing but the
|
||||
network id — no tickets and no bootstrap servers.
|
||||
* An application-level handshake that isolates networks and schemas.
|
||||
* Typed message exchange in both directions over one QUIC connection.
|
||||
* Network events (connect, disconnect, message, protocol error).
|
||||
* Graceful shutdown.
|
||||
Each participant is a client, router, and storage peer. Automatic rendezvous
|
||||
has no Frid-operated bootstrap service, while self-contained peer tickets
|
||||
remain available for explicit or isolated connections.
|
||||
|
||||
## What it deliberately does not do (yet)
|
||||
## Design principles
|
||||
|
||||
No gossip, broadcast overlay, content search, file/chunk/streaming transfer,
|
||||
database sync, CRDTs, authorization, ACLs, HTTP APIs or metrics. The
|
||||
architecture allows adding these later as separate modules or ALPN protocols.
|
||||
- **Equal peers.** No node receives a permanent coordinator or server role.
|
||||
- **Application-owned protocols.** The transport moves typed messages and
|
||||
byte streams without owning domain state.
|
||||
- **Bounded inputs.** Frames, tickets, routing tables, requests, and record
|
||||
batches have explicit limits.
|
||||
- **Offline-tolerant discovery.** Records are replicated, refreshed by their
|
||||
owners, expired by TTL, and removed through revisioned tombstones.
|
||||
- **Protocol isolation.** Network IDs, schema IDs, versions, and ALPNs prevent
|
||||
unrelated or incompatible applications from being merged accidentally.
|
||||
- **Local persistence.** Identity, routing knowledge, owned records, and
|
||||
replicas survive restarts in application-controlled storage.
|
||||
|
||||
## Usage
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for the protocol layers, trust model,
|
||||
failure handling, and compatibility rules.
|
||||
|
||||
Define your own message type — any `serde`-serializable type works, no extra
|
||||
traits to implement:
|
||||
|
||||
```rust
|
||||
use federation_net::{NetworkConfig, NetworkEngine, NetworkEvent, NetworkId, SchemaId};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
enum DemoMessage {
|
||||
Text { sender: String, body: String },
|
||||
Ping { nonce: u64 },
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> federation_net::Result<()> {
|
||||
let config = NetworkConfig::builder()
|
||||
.data_dir("./peer-a")
|
||||
.network_id(NetworkId::from_name("example-network"))
|
||||
.schema_id(SchemaId::from_name("demo-message-v1"))
|
||||
.build()?;
|
||||
|
||||
let (engine, mut events) = NetworkEngine::<DemoMessage>::start(config).await?;
|
||||
|
||||
// Share this string with another peer out of band.
|
||||
println!("Ticket: {}", engine.ticket().await?);
|
||||
|
||||
while let Some(event) = events.recv().await {
|
||||
match event {
|
||||
NetworkEvent::PeerConnected { peer_id, .. } => {
|
||||
engine
|
||||
.send(peer_id, &DemoMessage::Ping { nonce: 1 })
|
||||
.await?;
|
||||
}
|
||||
NetworkEvent::MessageReceived { peer_id, message } => {
|
||||
println!("{peer_id}: {message:?}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
engine.shutdown().await
|
||||
}
|
||||
```
|
||||
|
||||
## Running the demo
|
||||
|
||||
Start the first peer:
|
||||
|
||||
```bash
|
||||
cargo run -p federation-net-demo -- \
|
||||
--data-dir ./tmp/peer-a \
|
||||
--network-id demo-network \
|
||||
--name alice
|
||||
```
|
||||
|
||||
It prints its endpoint id and a ticket:
|
||||
## Workspace
|
||||
|
||||
```text
|
||||
Endpoint ID: ...
|
||||
Network ID: ...
|
||||
Schema ID: ...
|
||||
Ticket: fnet...
|
||||
Waiting for peers...
|
||||
crates/
|
||||
federation-net/ generic iroh transport and rendezvous
|
||||
music-dht/ distributed music catalog and content discovery
|
||||
apps/
|
||||
federation-net-demo/ small interactive transport example
|
||||
```
|
||||
|
||||
Start the second peer with that ticket:
|
||||
## Using the libraries
|
||||
|
||||
Until crates are published to a registry, depend on the repository directly:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
federation-net = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
```
|
||||
|
||||
Start with the crate documentation:
|
||||
|
||||
- [`federation-net` guide](crates/federation-net/README.md)
|
||||
- [`music-dht` guide](crates/music-dht/README.md)
|
||||
|
||||
## Development
|
||||
|
||||
Frid uses Rust edition 2024 and requires Rust 1.91 or newer.
|
||||
|
||||
```bash
|
||||
cargo run -p federation-net-demo -- \
|
||||
--data-dir ./tmp/peer-b \
|
||||
--network-id demo-network \
|
||||
--name bob \
|
||||
--connect 'fnet...'
|
||||
```
|
||||
|
||||
Type a line and press Enter to send it to all connected peers. Commands:
|
||||
`/peers` lists connections, `/ticket` prints your ticket, `/ping` sends a
|
||||
ping, `/quit` (or Ctrl+C) shuts down gracefully.
|
||||
|
||||
If the second peer uses a different `--network-id`, the connection is refused
|
||||
with `Connection rejected: network id mismatch`; an incompatible schema is
|
||||
refused with `Connection rejected: schema id mismatch`.
|
||||
|
||||
## NetworkId
|
||||
|
||||
A `NetworkId` identifies one distinct P2P network. It is 32 bytes, derived
|
||||
deterministically from a name: `BLAKE3("federation-net:network:" + name)`.
|
||||
The same name always yields the same id. Peers whose network ids differ
|
||||
refuse to establish an application-level session, even though they share the
|
||||
same transport protocol — this isolates independent deployments from each
|
||||
other.
|
||||
|
||||
## SchemaId
|
||||
|
||||
A `SchemaId` identifies the wire format of the domain message type, derived
|
||||
as `BLAKE3("federation-net:schema:" + name)` (e.g. `music-domain-v1`,
|
||||
`demo-chat-v1`). Peers on the same network but with different schema ids
|
||||
reject each other, because they could not decode each other's messages. Any
|
||||
backwards-incompatible change to your message type requires a new schema
|
||||
name.
|
||||
|
||||
## Rendezvous (peer discovery by network id)
|
||||
|
||||
Passing a `RendezvousConfig` to the config builder enables automatic peer
|
||||
discovery: every peer periodically publishes its own Iroh address into a
|
||||
shared BEP44 mutable record in the public BitTorrent Mainline DHT and dials
|
||||
the addresses other peers published there. The record's signing key is
|
||||
derived deterministically from the `NetworkId`, so knowing the network id is
|
||||
enough to find and join the network; the first peer of a new network simply
|
||||
publishes itself and waits.
|
||||
|
||||
```rust
|
||||
let config = NetworkConfig::builder()
|
||||
.data_dir("./peer-a")
|
||||
.network_id(NetworkId::from_name("example-network"))
|
||||
.schema_id(SchemaId::from_name("demo-message-v1"))
|
||||
.rendezvous(federation_net::RendezvousConfig::default())
|
||||
.build()?;
|
||||
```
|
||||
|
||||
Because the rendezvous record is world-readable, the network id acts as a
|
||||
public rendezvous token: anyone who knows it can discover and join the
|
||||
network. Treat the id of a private network like a shared secret (e.g.
|
||||
`myorg-prod-7f3a`); the handshake still rejects peers whose network id does
|
||||
not match exactly. Rendezvous is off by default — without it, peers are
|
||||
connected via tickets only.
|
||||
|
||||
## Tickets
|
||||
|
||||
A `PeerTicket` is a self-contained invitation string with the `fnet` prefix
|
||||
(base32-encoded postcard, versioned). It carries the peer's Iroh address
|
||||
(endpoint id, relay URL and direct addresses) plus the network id, schema id
|
||||
and protocol version, so incompatibility is detected before any message is
|
||||
exchanged. Tickets implement `Display`/`FromStr` and round-trip through their
|
||||
string form. The remote peer's identity is always taken from the
|
||||
authenticated Iroh connection, never trusted from the ticket payload.
|
||||
|
||||
## Events
|
||||
|
||||
The engine reports what happens on the network through a single bounded
|
||||
event channel (`NetworkEventReceiver`):
|
||||
|
||||
* `PeerConnected { peer_id, direction }` — a handshake completed
|
||||
(`Incoming` or `Outgoing`).
|
||||
* `PeerDisconnected { peer_id, reason }` — a connection closed.
|
||||
* `MessageReceived { peer_id, message }` — a domain message arrived and was
|
||||
decoded into your type.
|
||||
* `ProtocolError { peer_id, error }` — a per-connection error; the engine
|
||||
itself keeps running.
|
||||
|
||||
Consume events promptly: the channel is bounded and the engine applies
|
||||
back-pressure instead of buffering without limit.
|
||||
|
||||
## Example: distributed search (`artist-dht`)
|
||||
|
||||
The workspace also contains a bigger example built entirely on this library:
|
||||
[`crates/artist-dht`](crates/artist-dht/README.md) — a Kademlia-style DHT
|
||||
where every peer stores, routes and searches artist records without any
|
||||
dedicated servers, plus its interactive CLI
|
||||
[`apps/artist-dht-cli`](apps/artist-dht-cli). See its README for the
|
||||
three-peer demo scenario.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --all-targets --all-features
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
The integration tests establish real connections between two engines in one
|
||||
process (they may use Iroh's public relay/discovery infrastructure), so they
|
||||
need network access and take a few seconds.
|
||||
The integration tests open real local sockets and establish iroh connections.
|
||||
Some rendezvous tests also use public discovery infrastructure, so a restricted
|
||||
sandbox may need explicit network permission.
|
||||
|
||||
## License
|
||||
|
||||
Frid is released under the
|
||||
[Do What The Fuck You Want To Public License, Version 2](LICENSE).
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "artist-dht-cli"
|
||||
version = "0.1.0"
|
||||
description = "Interactive CLI for the artist-dht PoC"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
artist-dht = { path = "../../crates/artist-dht" }
|
||||
federation-net = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
rustyline = { workspace = true }
|
||||
@@ -1,438 +0,0 @@
|
||||
//! Interactive CLI for the artist-dht PoC.
|
||||
//!
|
||||
//! Starts a DHT node, optionally connects to other peers by ticket and lets
|
||||
//! the user manage and search artists with slash commands.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
|
||||
use anyhow::Context;
|
||||
use artist_dht::{
|
||||
Artist, ArtistDhtConfig, ArtistDhtEvent, ArtistDhtService, NetworkId, PeerTicket,
|
||||
RendezvousConfig, SearchOutcome,
|
||||
};
|
||||
use clap::Parser;
|
||||
use rustyline::ExternalPrinter;
|
||||
use rustyline::error::ReadlineError;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "artist-dht-cli", about = "Distributed artist directory PoC")]
|
||||
struct Args {
|
||||
/// Directory for the peer identity and database.
|
||||
#[arg(long)]
|
||||
data_dir: PathBuf,
|
||||
|
||||
/// Name of the network to join (all peers must use the same name).
|
||||
#[arg(long)]
|
||||
network_id: String,
|
||||
|
||||
/// Display name of this peer (used only for the prompt).
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
|
||||
/// Ticket(s) of peers to connect to on startup; may be repeated.
|
||||
/// Optional: peers of the same network normally find each other
|
||||
/// automatically through the mainline DHT.
|
||||
#[arg(long)]
|
||||
connect: Vec<String>,
|
||||
|
||||
/// Disable automatic peer discovery through the mainline DHT; only the
|
||||
/// tickets given via --connect are used.
|
||||
#[arg(long)]
|
||||
no_bootstrap: bool,
|
||||
|
||||
/// Enable verbose logging.
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
/// A line of user input, or the request to stop.
|
||||
enum Input {
|
||||
Line(String),
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let filter = if args.verbose {
|
||||
"artist_dht=debug,federation_net=debug,info"
|
||||
} else {
|
||||
"warn"
|
||||
};
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let mut config_builder = ArtistDhtConfig::builder()
|
||||
.data_dir(&args.data_dir)
|
||||
.network_id(NetworkId::from_name(&args.network_id));
|
||||
if !args.no_bootstrap {
|
||||
config_builder = config_builder.rendezvous(RendezvousConfig::default());
|
||||
}
|
||||
let config = config_builder.build().context("invalid configuration")?;
|
||||
|
||||
let (service, mut events) = ArtistDhtService::start(config)
|
||||
.await
|
||||
.context("failed to start the DHT node")?;
|
||||
|
||||
println!("Endpoint ID: {}", service.endpoint_id());
|
||||
println!("Node ID: {}", service.node_id());
|
||||
match service.ticket().await {
|
||||
Ok(ticket) => println!("Ticket: {ticket}"),
|
||||
Err(err) => eprintln!("Could not create a ticket yet: {err}"),
|
||||
}
|
||||
|
||||
for ticket in &args.connect {
|
||||
let ticket: PeerTicket = match ticket.parse() {
|
||||
Ok(ticket) => ticket,
|
||||
Err(err) => {
|
||||
println!("Invalid ticket (make sure you copied the whole string): {err}");
|
||||
let _ = service.shutdown().await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
match service.connect(ticket).await {
|
||||
Ok(peer) => println!("Connected to: {peer}"),
|
||||
Err(err) => {
|
||||
println!("Connection rejected: {err}");
|
||||
let _ = service.shutdown().await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !args.no_bootstrap {
|
||||
println!(
|
||||
"Discovering '{}' peers via the mainline DHT... (may take up to a minute)",
|
||||
args.network_id
|
||||
);
|
||||
} else if args.connect.is_empty() {
|
||||
println!("Waiting for peers... (share the ticket above)");
|
||||
}
|
||||
println!("Type /help for commands.");
|
||||
|
||||
// Rustyline is blocking, so it runs on its own thread and forwards lines
|
||||
// through a channel; the external printer keeps async output readable.
|
||||
let mut editor = rustyline::DefaultEditor::new().context("failed to init the line editor")?;
|
||||
// The external printer needs a TTY; when stdout is piped (e.g. through
|
||||
// `tee`), fall back to plain println.
|
||||
let mut printer = editor.create_external_printer().ok();
|
||||
let prompt = format!("{}> ", args.name);
|
||||
let (line_tx, line_rx) = std_mpsc::sync_channel::<Input>(16);
|
||||
// The reader waits for this ack after every line, so the next prompt is
|
||||
// drawn only after the command's output has been printed — otherwise the
|
||||
// line editor's redraws can visually swallow the output.
|
||||
let (ack_tx, ack_rx) = std_mpsc::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
match editor.readline(&prompt) {
|
||||
Ok(line) => {
|
||||
let _ = editor.add_history_entry(&line);
|
||||
if line_tx.send(Input::Line(line)).is_err() || ack_rx.recv().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
|
||||
let _ = line_tx.send(Input::Quit);
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("input error: {err}");
|
||||
let _ = line_tx.send(Input::Quit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (async_line_tx, mut async_line_rx) = tokio::sync::mpsc::channel::<Input>(16);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(input) = line_rx.recv() {
|
||||
if async_line_tx.blocking_send(input).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => break,
|
||||
input = async_line_rx.recv() => {
|
||||
match input {
|
||||
Some(Input::Line(line)) => {
|
||||
let quit = handle_line(&service, line.trim()).await;
|
||||
// Let the reader draw the next prompt.
|
||||
let _ = ack_tx.send(());
|
||||
if quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Input::Quit) | None => break,
|
||||
}
|
||||
}
|
||||
event = events.recv() => {
|
||||
match event {
|
||||
Some(event) => {
|
||||
let message = format_event(event);
|
||||
match printer.as_mut() {
|
||||
Some(printer) => {
|
||||
let _ = printer.print(message);
|
||||
}
|
||||
None => println!("{message}"),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("Node stopped.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.shutdown().await.context("shutdown failed")?;
|
||||
println!("Bye.");
|
||||
// The rustyline thread keeps stdin busy; exit explicitly after a clean
|
||||
// shutdown.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// Executes one command line. Returns `true` when the user asked to quit.
|
||||
async fn handle_line(service: &ArtistDhtService, line: &str) -> bool {
|
||||
let (command, rest) = match line.split_once(' ') {
|
||||
Some((command, rest)) => (command, rest.trim()),
|
||||
None => (line, ""),
|
||||
};
|
||||
match command {
|
||||
"" => {}
|
||||
"/help" => print_help(),
|
||||
"/quit" => return true,
|
||||
"/id" => {
|
||||
println!("Endpoint ID: {}", service.endpoint_id());
|
||||
println!("Node ID: {}", service.node_id());
|
||||
}
|
||||
"/ticket" => match service.ticket().await {
|
||||
Ok(ticket) => println!("Ticket: {ticket}"),
|
||||
Err(err) => println!("Could not create a ticket: {err}"),
|
||||
},
|
||||
"/peers" => {
|
||||
let peers = service.connected_peers();
|
||||
if peers.is_empty() {
|
||||
println!("No connected peers.");
|
||||
} else {
|
||||
println!("Connected peers: {}", peers.len());
|
||||
for peer in peers {
|
||||
println!(" {peer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
"/routing" => print_routing(service),
|
||||
"/list" => match service.list_local_artists().await {
|
||||
Ok(artists) if artists.is_empty() => println!("No local artists."),
|
||||
Ok(artists) => {
|
||||
println!("Local artists: {}", artists.len());
|
||||
for artist in artists {
|
||||
println!(" {} {}", short_id(&artist), artist.name);
|
||||
}
|
||||
}
|
||||
Err(err) => println!("Error: {err}"),
|
||||
},
|
||||
"/add" => {
|
||||
if rest.is_empty() {
|
||||
println!("Usage: /add <ARTIST_NAME>");
|
||||
} else {
|
||||
match service.add_artist(rest.to_string()).await {
|
||||
Ok((artist, stats)) => {
|
||||
println!("Added artist:");
|
||||
println!(" ID: {}", artist.id);
|
||||
println!(" Name: {}", artist.name);
|
||||
println!(" Normalized: {}", artist.normalized_name);
|
||||
println!(
|
||||
"Published {} keys to {} DHT nodes{}.",
|
||||
stats.keys,
|
||||
stats.remote_nodes,
|
||||
if stats.local_replica {
|
||||
" (+ local replica)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
Err(err) => println!("Error: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"/delete" => {
|
||||
if rest.is_empty() {
|
||||
println!("Usage: /delete <ARTIST_ID>");
|
||||
} else {
|
||||
match service.resolve_local_artist_id(rest).await {
|
||||
Ok(artist_id) => match service.delete_artist(artist_id).await {
|
||||
Ok(stats) => {
|
||||
println!("Deleted artist {artist_id}");
|
||||
println!(
|
||||
"Published tombstone to {} DHT nodes{}.",
|
||||
stats.remote_nodes,
|
||||
if stats.local_replica {
|
||||
" (+ local replica)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
Err(err) => println!("Error: {err}"),
|
||||
},
|
||||
Err(_) => println!("No unique local artist matches '{rest}'."),
|
||||
}
|
||||
}
|
||||
}
|
||||
"/search-local" => {
|
||||
if rest.is_empty() {
|
||||
println!("Usage: /search-local <QUERY>");
|
||||
} else {
|
||||
match service.search_local(rest).await {
|
||||
Ok(artists) if artists.is_empty() => println!("No matches."),
|
||||
Ok(artists) => {
|
||||
for artist in artists {
|
||||
println!(" {} {}", short_id(&artist), artist.name);
|
||||
}
|
||||
}
|
||||
Err(err) => println!("Error: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"/search" => {
|
||||
if rest.is_empty() {
|
||||
println!("Usage: /search <QUERY>");
|
||||
} else {
|
||||
match service.search_network(rest).await {
|
||||
Ok(outcome) => print_search(rest, &outcome),
|
||||
Err(err) => println!("Error: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"/republish" => match service.republish().await {
|
||||
Ok(stats) => println!(
|
||||
"Republished {} records ({} keys) to up to {} nodes.",
|
||||
stats.records, stats.keys, stats.remote_nodes
|
||||
),
|
||||
Err(err) => println!("Error: {err}"),
|
||||
},
|
||||
other => println!("Unknown command: {other}. Type /help."),
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"Commands:\n\
|
||||
\x20 /help show this help\n\
|
||||
\x20 /id show endpoint and node ids\n\
|
||||
\x20 /ticket print the connection ticket\n\
|
||||
\x20 /peers list open connections\n\
|
||||
\x20 /routing list known DHT contacts\n\
|
||||
\x20 /list list local artists\n\
|
||||
\x20 /add <ARTIST_NAME> add and publish an artist\n\
|
||||
\x20 /delete <ARTIST_ID> delete a local artist (id or hex prefix)\n\
|
||||
\x20 /search-local <QUERY> search the local database only\n\
|
||||
\x20 /search <QUERY> search locally and across the DHT\n\
|
||||
\x20 /republish republish local records now\n\
|
||||
\x20 /quit shut down"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_routing(service: &ArtistDhtService) {
|
||||
let mut contacts = service.known_peers();
|
||||
if contacts.is_empty() {
|
||||
println!("No known DHT peers.");
|
||||
return;
|
||||
}
|
||||
contacts.sort_by_key(|contact| std::cmp::Reverse(contact.last_seen_ms));
|
||||
println!("Known DHT peers: {}", contacts.len());
|
||||
println!(
|
||||
"{:<15} {:<15} {:<10} Last seen",
|
||||
"Peer ID", "Node ID", "Connected"
|
||||
);
|
||||
let now = now_ms();
|
||||
for contact in contacts {
|
||||
let connected = if service.is_connected(contact.peer_id) {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
};
|
||||
let ago_s = now.saturating_sub(contact.last_seen_ms) / 1000;
|
||||
println!(
|
||||
"{:<15} {:<15} {:<10} {}s ago",
|
||||
shorten(&contact.peer_id.to_string()),
|
||||
shorten(&contact.node_id.to_string()),
|
||||
connected,
|
||||
ago_s
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_search(query: &str, outcome: &SearchOutcome) {
|
||||
println!("Search: {}", artist_dht::normalize_artist_name(query));
|
||||
println!();
|
||||
println!("Local results:");
|
||||
if outcome.local_results.is_empty() {
|
||||
println!(" No matches.");
|
||||
} else {
|
||||
for artist in &outcome.local_results {
|
||||
println!(" {} {}", short_id(artist), artist.name);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("DHT results:");
|
||||
if outcome.network_results.is_empty() {
|
||||
println!(" No matches.");
|
||||
} else {
|
||||
for (i, artist) in outcome.network_results.iter().enumerate() {
|
||||
println!(" {}. {}", i + 1, artist.name);
|
||||
println!(" Artist ID: {}", artist.id);
|
||||
println!(" Owner: {}", artist.owner);
|
||||
println!(" Revision: {}", artist.revision);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("Lookup:");
|
||||
println!(" Queried nodes: {}", outcome.queried_nodes);
|
||||
println!(" Discovered nodes: {}", outcome.discovered_nodes);
|
||||
println!(" Duration: {} ms", outcome.duration.as_millis());
|
||||
}
|
||||
|
||||
fn format_event(event: ArtistDhtEvent) -> String {
|
||||
match event {
|
||||
ArtistDhtEvent::PeerConnected { peer_id } => format!("Peer connected: {peer_id}"),
|
||||
ArtistDhtEvent::PeerDisconnected { peer_id } => {
|
||||
format!("Peer disconnected: {peer_id}")
|
||||
}
|
||||
ArtistDhtEvent::ContactDiscovered { contact } => {
|
||||
format!("Discovered DHT contact: {}", contact.peer_id)
|
||||
}
|
||||
ArtistDhtEvent::Error { message } => format!("Error: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn short_id(artist: &Artist) -> String {
|
||||
shorten(&artist.id.to_string())
|
||||
}
|
||||
|
||||
fn shorten(hex: &str) -> String {
|
||||
if hex.len() > 12 {
|
||||
format!("{}...", &hex[..12])
|
||||
} else {
|
||||
hex.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "federation-net-demo"
|
||||
version = "0.1.0"
|
||||
description = "CLI demo for the federation-net P2P engine"
|
||||
publish = false
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
[package]
|
||||
name = "artist-dht"
|
||||
version = "0.1.0"
|
||||
description = "Distributed artist search PoC: a Kademlia-style DHT on top of federation-net"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
federation-net = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
unicode-normalization = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
data-encoding = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
iroh = { workspace = true }
|
||||
@@ -1,173 +0,0 @@
|
||||
# artist-dht
|
||||
|
||||
A proof-of-concept **distributed artist directory** built on top of
|
||||
[`federation-net`](../federation-net). Every running process is a full DHT
|
||||
participant — client, router and storage node at once. There are no
|
||||
bootstrap servers, index nodes or search servers: the "server" is the set of
|
||||
running peers itself.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
```text
|
||||
/add on Peer B → name normalization → publish index records into the DHT
|
||||
→ replication to regular peers
|
||||
/search on A/C → iterative Kademlia-style lookup (no broadcast)
|
||||
→ record of Peer B is found, with its owner id
|
||||
```
|
||||
|
||||
* Records are published under BLAKE3 keys: one **exact key** for the whole
|
||||
normalized name and one **token key** per word, so `/search massive` finds
|
||||
*Massive Attack*.
|
||||
* Each peer has a stable 256-bit `NodeId` derived from its persistent
|
||||
`federation-net` endpoint id; records live on the `K = 8` XOR-closest
|
||||
nodes.
|
||||
* A peer joins the network knowing **only the network id**: on startup it
|
||||
finds other peers through a rendezvous record in the public BitTorrent
|
||||
Mainline DHT (see `federation-net`'s rendezvous). Within the network peers
|
||||
then discover each other through automatic Hello/PeerExchange gossip;
|
||||
connections to newly learned peers are opened **on demand** from stored
|
||||
tickets.
|
||||
* Deletions propagate as **tombstones** (revision-based, they beat active
|
||||
records of the same or lower revision). Replicas expire by TTL (30 min
|
||||
active, 2 h tombstones); owners republish every 10 minutes.
|
||||
* Local state (identity, own artists, replicas, known peers) lives in
|
||||
`<data_dir>/state.sqlite3` and `<data_dir>/identity.key`.
|
||||
|
||||
Out of scope (by design): fuzzy search, content transfer, CRDTs, consensus,
|
||||
signatures on DHT records, Sybil protection, accounts, GUI.
|
||||
|
||||
## Bootstrapping
|
||||
|
||||
There is deliberately **no bootstrap server**. A peer joins a network knowing
|
||||
only its `--network-id`: peers of the same network find each other through a
|
||||
shared rendezvous record in the public BitTorrent Mainline DHT (the record's
|
||||
signing key is derived from the network id). The first peer of a new network
|
||||
simply publishes itself and waits; every later peer with the same id finds it
|
||||
within a rendezvous round or two (typically well under a minute). No peer has
|
||||
a special role.
|
||||
|
||||
Because the rendezvous record is world-readable, the network id acts as a
|
||||
public rendezvous token: anyone who knows it can discover and join the
|
||||
network. Pick a unique, hard-to-guess name for a private network (e.g.
|
||||
`myband-artists-prod-7f3a`).
|
||||
|
||||
Discovery can be turned off with `--no-bootstrap`; then a peer can only join
|
||||
through the ticket of *any* already running peer passed via `--connect`
|
||||
(tickets also work in addition to discovery, e.g. on isolated networks
|
||||
without internet access).
|
||||
|
||||
## Running the demo (three peers)
|
||||
|
||||
Start the peers in any order — they only share the network id:
|
||||
|
||||
```bash
|
||||
cargo run -p artist-dht-cli -- \
|
||||
--data-dir ./peer-a \
|
||||
--network-id demo-artists \
|
||||
--name alice
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo run -p artist-dht-cli -- \
|
||||
--data-dir ./peer-b \
|
||||
--network-id demo-artists \
|
||||
--name bob
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo run -p artist-dht-cli -- \
|
||||
--data-dir ./peer-c \
|
||||
--network-id demo-artists \
|
||||
--name charlie
|
||||
```
|
||||
|
||||
Within a minute `Peer connected: ...` lines appear on all three. All
|
||||
processes must use the same `--network-id`; a peer from another network is
|
||||
rejected during the transport handshake. (With `--no-bootstrap`, pass the
|
||||
ticket printed by a running peer via `--connect 'fnet...'` instead;
|
||||
`--connect` may be repeated.)
|
||||
|
||||
### Demo scenario
|
||||
|
||||
1. On **bob**: `/add Massive Attack` and `/add Portishead` — each prints the
|
||||
artist id and how many DHT nodes stored the replicas.
|
||||
2. On **alice**: `/search massive attack` — the DHT results list *Massive
|
||||
Attack* with bob's endpoint id as the owner.
|
||||
3. On **charlie**: `/search portishead` (and `/search massive`) — same
|
||||
records found through an iterative lookup, not a broadcast.
|
||||
4. Stop **bob** (Ctrl+C). Both alice and charlie still find the records from
|
||||
replicas until the TTL expires.
|
||||
5. Restart **bob** with the same `--data-dir`: the endpoint id is unchanged,
|
||||
the local database is intact and records are republished automatically.
|
||||
6. On **bob**: `/delete <ARTIST_ID>` (a unique hex prefix is enough) — a
|
||||
tombstone propagates and the other peers stop returning the record.
|
||||
|
||||
### Commands
|
||||
|
||||
```text
|
||||
/help show help
|
||||
/id show endpoint and node ids
|
||||
/ticket print the connection ticket
|
||||
/peers list open connections
|
||||
/routing list known DHT contacts (connected / last seen)
|
||||
/list list local artists
|
||||
/add <ARTIST_NAME> add and publish an artist
|
||||
/delete <ARTIST_ID> delete a local artist (id or unique hex prefix)
|
||||
/search-local <QUERY> search the local database only
|
||||
/search <QUERY> search locally and across the DHT
|
||||
/republish republish local records now
|
||||
/quit shut down gracefully
|
||||
```
|
||||
|
||||
## Protocol limits
|
||||
|
||||
```text
|
||||
K = 8, ALPHA = 3, max lookup requests = 32
|
||||
max contacts per PeerExchange = 32
|
||||
max records per FindValue response = 100
|
||||
max artist name = 512 bytes, max tokens = 32
|
||||
max pending requests = 1024
|
||||
request timeout = 5 s, lookup timeout = 15 s
|
||||
on-demand dial timeout = 5 s
|
||||
dial backoff = 30 s doubling up to 10 min, eviction after 5 failures
|
||||
```
|
||||
|
||||
Value lookups return as soon as the first records arrive; in-flight requests
|
||||
to slower or dead contacts are cancelled. Contacts that failed to dial are
|
||||
skipped for an exponentially growing backoff window and evicted after five
|
||||
consecutive failures (peer exchange re-adds them with a clean slate if they
|
||||
come back).
|
||||
|
||||
## Library usage
|
||||
|
||||
The CLI is a thin wrapper around the `artist-dht` library:
|
||||
|
||||
```rust
|
||||
use artist_dht::{ArtistDhtConfig, ArtistDhtService};
|
||||
use federation_net::NetworkId;
|
||||
|
||||
let config = ArtistDhtConfig::builder()
|
||||
.data_dir("./peer-a")
|
||||
.network_id(NetworkId::from_name("demo-artists"))
|
||||
// Optional: discover peers of this network via the mainline DHT.
|
||||
.rendezvous(artist_dht::RendezvousConfig::default())
|
||||
.build()?;
|
||||
let (service, mut events) = ArtistDhtService::start(config).await?;
|
||||
|
||||
let (artist, stats) = service.add_artist("Massive Attack".into()).await?;
|
||||
let outcome = service.search_network("massive").await?;
|
||||
service.shutdown().await?;
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
The integration tests start three real DHT nodes in one process and walk the
|
||||
whole demo scenario (publish, distributed search, replica survival after the
|
||||
owner leaves, restart, tombstones), so they need network access and take a
|
||||
couple of minutes.
|
||||
@@ -1,210 +0,0 @@
|
||||
//! Service configuration.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use federation_net::{NetworkId, RendezvousConfig};
|
||||
|
||||
use crate::error::{ArtistDhtError, Result};
|
||||
|
||||
/// Default interval between republish rounds.
|
||||
pub const DEFAULT_REPUBLISH_INTERVAL: Duration = Duration::from_secs(10 * 60);
|
||||
/// Default interval between expired-record sweeps.
|
||||
pub const DEFAULT_EXPIRE_INTERVAL: Duration = Duration::from_secs(60);
|
||||
/// Default timeout of a single DHT request.
|
||||
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Default timeout of a whole iterative lookup.
|
||||
pub const DEFAULT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Default timeout for transport operations (dialing, handshakes, sends).
|
||||
pub const DEFAULT_TRANSPORT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Default timeout for on-demand dials during DHT operations.
|
||||
pub const DEFAULT_DIAL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Configuration for an [`crate::ArtistDhtService`].
|
||||
///
|
||||
/// Use [`ArtistDhtConfig::builder`] to construct a validated instance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArtistDhtConfig {
|
||||
/// Directory for the peer identity and the SQLite database.
|
||||
pub data_dir: PathBuf,
|
||||
/// Network this peer participates in.
|
||||
pub network_id: NetworkId,
|
||||
/// Interval between automatic republish rounds.
|
||||
pub republish_interval: Duration,
|
||||
/// Interval between sweeps of expired DHT records.
|
||||
pub expire_interval: Duration,
|
||||
/// Timeout of a single DHT request.
|
||||
pub request_timeout: Duration,
|
||||
/// Timeout of a whole iterative lookup.
|
||||
pub lookup_timeout: Duration,
|
||||
/// Timeout for transport operations: dialing a peer, handshakes and
|
||||
/// message delivery. Kept separate from `request_timeout` because
|
||||
/// establishing a connection through relays can take much longer than a
|
||||
/// request over an existing one.
|
||||
pub transport_timeout: Duration,
|
||||
/// Timeout for dialing a contact **on demand during DHT operations**
|
||||
/// (lookups, publishes). Deliberately shorter than `transport_timeout`:
|
||||
/// a dead contact must not stall a whole lookup, and a peer that needs
|
||||
/// longer than this to dial will still be reached by the periodic
|
||||
/// rendezvous/republish machinery.
|
||||
pub dial_timeout: Duration,
|
||||
/// Automatic peer discovery over the mainline DHT: peers of the same
|
||||
/// network find each other knowing nothing but the network id. `None`
|
||||
/// disables it; peers are then connected via tickets only.
|
||||
pub rendezvous: Option<RendezvousConfig>,
|
||||
}
|
||||
|
||||
impl ArtistDhtConfig {
|
||||
/// Returns a new [`ArtistDhtConfigBuilder`].
|
||||
pub fn builder() -> ArtistDhtConfigBuilder {
|
||||
ArtistDhtConfigBuilder::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`ArtistDhtConfig`].
|
||||
///
|
||||
/// `data_dir` and `network_id` are required; the timers default to the
|
||||
/// production values and are configurable mainly for tests.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ArtistDhtConfigBuilder {
|
||||
data_dir: Option<PathBuf>,
|
||||
network_id: Option<NetworkId>,
|
||||
republish_interval: Option<Duration>,
|
||||
expire_interval: Option<Duration>,
|
||||
request_timeout: Option<Duration>,
|
||||
lookup_timeout: Option<Duration>,
|
||||
transport_timeout: Option<Duration>,
|
||||
dial_timeout: Option<Duration>,
|
||||
rendezvous: Option<RendezvousConfig>,
|
||||
}
|
||||
|
||||
impl ArtistDhtConfigBuilder {
|
||||
/// Sets the data directory.
|
||||
pub fn data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||
self.data_dir = Some(dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the network identifier.
|
||||
pub fn network_id(mut self, network_id: NetworkId) -> Self {
|
||||
self.network_id = Some(network_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the republish interval.
|
||||
pub fn republish_interval(mut self, interval: Duration) -> Self {
|
||||
self.republish_interval = Some(interval);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the expired-record sweep interval.
|
||||
pub fn expire_interval(mut self, interval: Duration) -> Self {
|
||||
self.expire_interval = Some(interval);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the timeout of a single DHT request.
|
||||
pub fn request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.request_timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the timeout of a whole iterative lookup.
|
||||
pub fn lookup_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.lookup_timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the timeout for transport operations (dialing, handshakes).
|
||||
pub fn transport_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.transport_timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the timeout for on-demand dials during DHT operations.
|
||||
pub fn dial_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.dial_timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables automatic peer discovery over the mainline DHT.
|
||||
pub fn rendezvous(mut self, rendezvous: RendezvousConfig) -> Self {
|
||||
self.rendezvous = Some(rendezvous);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validates and builds the configuration.
|
||||
pub fn build(self) -> Result<ArtistDhtConfig> {
|
||||
let data_dir = self
|
||||
.data_dir
|
||||
.ok_or_else(|| ArtistDhtError::Database("data_dir is required".into()))?;
|
||||
if data_dir.as_os_str().is_empty() {
|
||||
return Err(ArtistDhtError::Database(
|
||||
"data_dir must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let network_id = self
|
||||
.network_id
|
||||
.ok_or_else(|| ArtistDhtError::Network("network_id is required".into()))?;
|
||||
|
||||
let config = ArtistDhtConfig {
|
||||
data_dir,
|
||||
network_id,
|
||||
republish_interval: self
|
||||
.republish_interval
|
||||
.unwrap_or(DEFAULT_REPUBLISH_INTERVAL),
|
||||
expire_interval: self.expire_interval.unwrap_or(DEFAULT_EXPIRE_INTERVAL),
|
||||
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
|
||||
lookup_timeout: self.lookup_timeout.unwrap_or(DEFAULT_LOOKUP_TIMEOUT),
|
||||
transport_timeout: self.transport_timeout.unwrap_or(DEFAULT_TRANSPORT_TIMEOUT),
|
||||
dial_timeout: self.dial_timeout.unwrap_or(DEFAULT_DIAL_TIMEOUT),
|
||||
rendezvous: self.rendezvous,
|
||||
};
|
||||
for (name, value) in [
|
||||
("republish_interval", config.republish_interval),
|
||||
("expire_interval", config.expire_interval),
|
||||
("request_timeout", config.request_timeout),
|
||||
("lookup_timeout", config.lookup_timeout),
|
||||
("transport_timeout", config.transport_timeout),
|
||||
("dial_timeout", config.dial_timeout),
|
||||
] {
|
||||
if value.is_zero() {
|
||||
return Err(ArtistDhtError::Database(format!(
|
||||
"{name} must be greater than zero"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builder_applies_defaults() {
|
||||
let config = ArtistDhtConfig::builder()
|
||||
.data_dir("./dir")
|
||||
.network_id(NetworkId::from_name("test"))
|
||||
.build()
|
||||
.expect("valid");
|
||||
assert_eq!(config.republish_interval, DEFAULT_REPUBLISH_INTERVAL);
|
||||
assert_eq!(config.expire_interval, DEFAULT_EXPIRE_INTERVAL);
|
||||
assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
|
||||
assert_eq!(config.lookup_timeout, DEFAULT_LOOKUP_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_missing_or_invalid() {
|
||||
assert!(ArtistDhtConfig::builder().build().is_err());
|
||||
assert!(
|
||||
ArtistDhtConfig::builder()
|
||||
.data_dir("./dir")
|
||||
.network_id(NetworkId::from_name("test"))
|
||||
.request_timeout(Duration::ZERO)
|
||||
.build()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,492 +0,0 @@
|
||||
//! Local SQLite persistence.
|
||||
//!
|
||||
//! `rusqlite` is synchronous, so every database call runs on the blocking
|
||||
//! thread pool via `tokio::task::spawn_blocking`; the async runtime is never
|
||||
//! blocked on file I/O.
|
||||
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use federation_net::EndpointId;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::dht::{StoreDecision, decide_store};
|
||||
use crate::error::{ArtistDhtError, Result};
|
||||
use crate::message::MAX_RECORDS_PER_RESPONSE;
|
||||
use crate::normalization::tokenize;
|
||||
use crate::record::{Artist, ArtistId, DhtKey, StoredArtistRecord, TOMBSTONE_TTL};
|
||||
use crate::routing::{NodeContact, NodeId};
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS local_artists (
|
||||
id BLOB PRIMARY KEY,
|
||||
owner_peer_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_local_artists_normalized_name
|
||||
ON local_artists(normalized_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dht_records (
|
||||
dht_key BLOB NOT NULL,
|
||||
artist_id BLOB NOT NULL,
|
||||
owner_peer_id TEXT NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
deleted INTEGER NOT NULL,
|
||||
expires_at_ms INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (dht_key, artist_id, owner_peer_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dht_records_expires_at
|
||||
ON dht_records(expires_at_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS known_peers (
|
||||
peer_id TEXT PRIMARY KEY,
|
||||
node_id BLOB NOT NULL,
|
||||
ticket TEXT NOT NULL,
|
||||
last_seen_ms INTEGER NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// Handle to the local SQLite database.
|
||||
///
|
||||
/// Cheap to clone; all clones share one connection guarded by a mutex that is
|
||||
/// only ever locked from blocking-pool threads.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Database {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Opens (creating if needed) the database at `path` and applies the
|
||||
/// schema.
|
||||
pub async fn open(path: &Path) -> Result<Self> {
|
||||
let path = path.to_path_buf();
|
||||
let conn = tokio::task::spawn_blocking(move || -> Result<Connection> {
|
||||
let conn = Connection::open(&path).map_err(|err| {
|
||||
ArtistDhtError::Database(format!("failed to open {}: {err}", path.display()))
|
||||
})?;
|
||||
conn.execute_batch(SCHEMA).map_err(|err| {
|
||||
ArtistDhtError::Database(format!("failed to apply schema: {err}"))
|
||||
})?;
|
||||
Ok(conn)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| ArtistDhtError::Database(format!("database task panicked: {err}")))??;
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a closure against the connection on the blocking pool.
|
||||
async fn call<F, R>(&self, f: F) -> Result<R>
|
||||
where
|
||||
F: FnOnce(&Connection) -> rusqlite::Result<R> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(&guard).map_err(|err| ArtistDhtError::Database(err.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| ArtistDhtError::Database(format!("database task panicked: {err}")))?
|
||||
}
|
||||
|
||||
/// Inserts or replaces a locally owned artist record.
|
||||
pub async fn upsert_local_artist(&self, artist: &Artist) -> Result<()> {
|
||||
let artist = artist.clone();
|
||||
self.call(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO local_artists
|
||||
(id, owner_peer_id, name, normalized_name, revision, deleted, updated_at_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
artist.id.as_bytes().as_slice(),
|
||||
artist.owner.to_string(),
|
||||
artist.name,
|
||||
artist.normalized_name,
|
||||
artist.revision as i64,
|
||||
artist.deleted as i64,
|
||||
artist.updated_at_ms as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetches one locally owned artist by id.
|
||||
pub async fn get_local_artist(&self, id: ArtistId) -> Result<Option<Artist>> {
|
||||
self.call(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT id, owner_peer_id, name, normalized_name, revision, deleted, updated_at_ms
|
||||
FROM local_artists WHERE id = ?1",
|
||||
params![id.as_bytes().as_slice()],
|
||||
artist_from_row,
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Finds locally owned artists whose id starts with the given hex prefix.
|
||||
pub async fn find_local_by_id_prefix(&self, prefix: String) -> Result<Vec<Artist>> {
|
||||
let prefix = prefix.to_lowercase();
|
||||
self.call(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, owner_peer_id, name, normalized_name, revision, deleted, updated_at_ms
|
||||
FROM local_artists WHERE deleted = 0",
|
||||
)?;
|
||||
let rows = stmt.query_map([], artist_from_row)?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
let artist = row?;
|
||||
if artist.id.to_hex().starts_with(&prefix) {
|
||||
result.push(artist);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Lists locally owned artists. Tombstones are excluded unless
|
||||
/// `include_deleted` is set.
|
||||
pub async fn list_local_artists(&self, include_deleted: bool) -> Result<Vec<Artist>> {
|
||||
self.call(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, owner_peer_id, name, normalized_name, revision, deleted, updated_at_ms
|
||||
FROM local_artists ORDER BY normalized_name",
|
||||
)?;
|
||||
let rows = stmt.query_map([], artist_from_row)?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
let artist = row?;
|
||||
if include_deleted || !artist.deleted {
|
||||
result.push(artist);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns everything that must be republished: active records plus
|
||||
/// tombstones that have not outlived [`TOMBSTONE_TTL`] yet.
|
||||
pub async fn local_artists_for_republish(&self, now_ms: u64) -> Result<Vec<Artist>> {
|
||||
let all = self.list_local_artists(true).await?;
|
||||
let tombstone_ttl = TOMBSTONE_TTL.as_millis() as u64;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|artist| {
|
||||
!artist.deleted || artist.updated_at_ms.saturating_add(tombstone_ttl) > now_ms
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Searches locally owned active artists: exact normalized match, or all
|
||||
/// query tokens present in the artist's token set.
|
||||
pub async fn search_local(&self, normalized_query: String) -> Result<Vec<Artist>> {
|
||||
let all = self.list_local_artists(false).await?;
|
||||
let query_tokens = tokenize(&normalized_query);
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|artist| {
|
||||
if artist.normalized_name == normalized_query {
|
||||
return true;
|
||||
}
|
||||
if query_tokens.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let artist_tokens = tokenize(&artist.normalized_name);
|
||||
query_tokens
|
||||
.iter()
|
||||
.all(|token| artist_tokens.iter().any(|t| t == token))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Applies a validated incoming record to the replica store, following
|
||||
/// the revision/tombstone rules. Returns `true` if the record was written
|
||||
/// or refreshed.
|
||||
pub async fn store_dht_record(&self, key: DhtKey, record: StoredArtistRecord) -> Result<bool> {
|
||||
self.call(move |conn| {
|
||||
let existing: Option<(i64, i64, i64)> = conn
|
||||
.query_row(
|
||||
"SELECT revision, deleted, expires_at_ms FROM dht_records
|
||||
WHERE dht_key = ?1 AND artist_id = ?2 AND owner_peer_id = ?3",
|
||||
params![
|
||||
key.as_bytes().as_slice(),
|
||||
record.artist.id.as_bytes().as_slice(),
|
||||
record.artist.owner.to_string(),
|
||||
],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let existing =
|
||||
existing.map(|(rev, del, exp)| (rev as u64, del != 0, exp as u64));
|
||||
match decide_store(existing, &record) {
|
||||
StoreDecision::Ignore => Ok(false),
|
||||
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {
|
||||
let payload = postcard::to_stdvec(&record).map_err(|err| {
|
||||
rusqlite::Error::ToSqlConversionFailure(Box::new(err))
|
||||
})?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO dht_records
|
||||
(dht_key, artist_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
key.as_bytes().as_slice(),
|
||||
record.artist.id.as_bytes().as_slice(),
|
||||
record.artist.owner.to_string(),
|
||||
payload,
|
||||
record.artist.revision as i64,
|
||||
record.artist.deleted as i64,
|
||||
record.expires_at_ms as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns non-expired replicas stored under `key`, including tombstones
|
||||
/// (they inform other peers about deletions). Capped at
|
||||
/// [`MAX_RECORDS_PER_RESPONSE`].
|
||||
pub async fn dht_records_by_key(
|
||||
&self,
|
||||
key: DhtKey,
|
||||
now_ms: u64,
|
||||
) -> Result<Vec<StoredArtistRecord>> {
|
||||
self.call(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT payload FROM dht_records
|
||||
WHERE dht_key = ?1 AND expires_at_ms > ?2
|
||||
LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![
|
||||
key.as_bytes().as_slice(),
|
||||
now_ms as i64,
|
||||
MAX_RECORDS_PER_RESPONSE as i64
|
||||
],
|
||||
|row| row.get::<_, Vec<u8>>(0),
|
||||
)?;
|
||||
let mut records = Vec::new();
|
||||
for row in rows {
|
||||
let payload = row?;
|
||||
// A payload we cannot decode is skipped, not fatal.
|
||||
if let Ok(record) = postcard::from_bytes::<StoredArtistRecord>(&payload) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes expired replicas. Returns the number of removed rows.
|
||||
pub async fn delete_expired_records(&self, now_ms: u64) -> Result<usize> {
|
||||
self.call(move |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM dht_records WHERE expires_at_ms <= ?1",
|
||||
params![now_ms as i64],
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Inserts or refreshes a known peer contact.
|
||||
pub async fn upsert_known_peer(&self, contact: &NodeContact) -> Result<()> {
|
||||
let contact = contact.clone();
|
||||
self.call(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO known_peers (peer_id, node_id, ticket, last_seen_ms)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
contact.peer_id.to_string(),
|
||||
contact.node_id.as_bytes().as_slice(),
|
||||
contact.ticket,
|
||||
contact.last_seen_ms as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes a persisted peer contact (e.g. after repeated failed dials).
|
||||
pub async fn delete_known_peer(&self, peer_id: EndpointId) -> Result<()> {
|
||||
self.call(move |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM known_peers WHERE peer_id = ?1",
|
||||
params![peer_id.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Loads all persisted peer contacts.
|
||||
pub async fn load_known_peers(&self) -> Result<Vec<NodeContact>> {
|
||||
self.call(|conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT peer_id, node_id, ticket, last_seen_ms FROM known_peers")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Vec<u8>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
})?;
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
let (peer_id, node_id, ticket, last_seen_ms) = row?;
|
||||
let Ok(peer_id) = EndpointId::from_str(&peer_id) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(node_id) = <[u8; 32]>::try_from(node_id.as_slice()) else {
|
||||
continue;
|
||||
};
|
||||
contacts.push(NodeContact {
|
||||
node_id: NodeId::from_bytes(node_id),
|
||||
peer_id,
|
||||
ticket,
|
||||
last_seen_ms: last_seen_ms as u64,
|
||||
});
|
||||
}
|
||||
Ok(contacts)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn artist_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Artist> {
|
||||
let id: Vec<u8> = row.get(0)?;
|
||||
let owner: String = row.get(1)?;
|
||||
let id = <[u8; 32]>::try_from(id.as_slice()).map_err(|_| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Blob,
|
||||
"artist id must be 32 bytes".into(),
|
||||
)
|
||||
})?;
|
||||
let owner = EndpointId::from_str(&owner).map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
1,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("invalid owner peer id: {err}").into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Artist {
|
||||
id: ArtistId::from_bytes(id),
|
||||
owner,
|
||||
name: row.get(2)?,
|
||||
normalized_name: row.get(3)?,
|
||||
revision: row.get::<_, i64>(4)? as u64,
|
||||
deleted: row.get::<_, i64>(5)? != 0,
|
||||
updated_at_ms: row.get::<_, i64>(6)? as u64,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::record::now_ms;
|
||||
|
||||
fn test_peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn artist(owner: EndpointId, name: &str, revision: u64, deleted: bool) -> Artist {
|
||||
Artist {
|
||||
id: ArtistId::derive(&owner, &uuid::Uuid::from_u128(1)),
|
||||
owner,
|
||||
name: name.to_string(),
|
||||
normalized_name: crate::normalization::normalize_artist_name(name),
|
||||
revision,
|
||||
deleted,
|
||||
updated_at_ms: now_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_temp() -> (tempfile::TempDir, Database) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = Database::open(&dir.path().join("state.sqlite3"))
|
||||
.await
|
||||
.expect("open db");
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_artist_round_trip() {
|
||||
let (_dir, db) = open_temp().await;
|
||||
let owner = test_peer(1);
|
||||
let artist = artist(owner, "Massive Attack", 1, false);
|
||||
db.upsert_local_artist(&artist).await.expect("upsert");
|
||||
let loaded = db.get_local_artist(artist.id).await.expect("get");
|
||||
assert_eq!(loaded, Some(artist.clone()));
|
||||
let found = db
|
||||
.search_local("massive attack".into())
|
||||
.await
|
||||
.expect("search");
|
||||
assert_eq!(found.len(), 1);
|
||||
let by_token = db.search_local("attack".into()).await.expect("search");
|
||||
assert_eq!(by_token.len(), 1);
|
||||
let none = db.search_local("portishead".into()).await.expect("search");
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_dht_record_is_not_returned() {
|
||||
let (_dir, db) = open_temp().await;
|
||||
let owner = test_peer(1);
|
||||
let artist = artist(owner, "Massive Attack", 1, false);
|
||||
let key = DhtKey::exact(&federation_net::NetworkId::from_name("t"), "massive attack");
|
||||
let now = now_ms();
|
||||
let record = StoredArtistRecord {
|
||||
artist,
|
||||
publisher: owner,
|
||||
expires_at_ms: now + 50,
|
||||
};
|
||||
assert!(db.store_dht_record(key, record).await.expect("store"));
|
||||
assert_eq!(db.dht_records_by_key(key, now).await.expect("get").len(), 1);
|
||||
// After expiry the record is filtered out and then swept.
|
||||
let later = now + 100;
|
||||
assert!(
|
||||
db.dht_records_by_key(key, later)
|
||||
.await
|
||||
.expect("get")
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(db.delete_expired_records(later).await.expect("sweep"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_peers_round_trip() {
|
||||
let (_dir, db) = open_temp().await;
|
||||
let peer = test_peer(2);
|
||||
let contact = NodeContact {
|
||||
node_id: NodeId::from_endpoint(&peer),
|
||||
peer_id: peer,
|
||||
ticket: "fnet-test".into(),
|
||||
last_seen_ms: 42,
|
||||
};
|
||||
db.upsert_known_peer(&contact).await.expect("upsert");
|
||||
let loaded = db.load_known_peers().await.expect("load");
|
||||
assert_eq!(loaded, vec![contact]);
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
//! DHT record validation and replacement rules.
|
||||
|
||||
use federation_net::NetworkId;
|
||||
|
||||
use crate::message::StoreRecordRequest;
|
||||
use crate::normalization::{normalize_artist_name, tokenize};
|
||||
use crate::record::{
|
||||
ACTIVE_RECORD_TTL, DhtKey, MAX_ARTIST_NAME_BYTES, MAX_TOKENS_PER_ARTIST, StoredArtistRecord,
|
||||
TOMBSTONE_TTL,
|
||||
};
|
||||
|
||||
/// Outcome of comparing an incoming record with the stored one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StoreDecision {
|
||||
/// No record stored yet, or the incoming one supersedes it: write it.
|
||||
Write,
|
||||
/// Same logical record: keep the stored row but extend its expiry.
|
||||
RefreshExpiry(u64),
|
||||
/// The incoming record is older or otherwise loses: ignore it.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
/// Decides what to do with an incoming record given the stored state
|
||||
/// `(revision, deleted, expires_at_ms)` for the same
|
||||
/// `(key, artist_id, owner)`.
|
||||
///
|
||||
/// Rules:
|
||||
/// * a higher revision always wins;
|
||||
/// * on equal revisions a tombstone beats an active record;
|
||||
/// * on equal revisions and equal deletion state the record is the same —
|
||||
/// only the expiry is refreshed (this is how republish extends TTL);
|
||||
/// * an older revision never replaces a newer one, in particular an old
|
||||
/// active record never resurrects a tombstone.
|
||||
pub(crate) fn decide_store(
|
||||
existing: Option<(u64, bool, u64)>,
|
||||
incoming: &StoredArtistRecord,
|
||||
) -> StoreDecision {
|
||||
let Some((revision, deleted, expires_at_ms)) = existing else {
|
||||
return StoreDecision::Write;
|
||||
};
|
||||
let artist = &incoming.artist;
|
||||
if artist.revision > revision {
|
||||
return StoreDecision::Write;
|
||||
}
|
||||
if artist.revision < revision {
|
||||
return StoreDecision::Ignore;
|
||||
}
|
||||
// Equal revisions.
|
||||
match (deleted, artist.deleted) {
|
||||
(false, true) => StoreDecision::Write,
|
||||
(true, false) => StoreDecision::Ignore,
|
||||
_ => {
|
||||
if incoming.expires_at_ms > expires_at_ms {
|
||||
StoreDecision::RefreshExpiry(incoming.expires_at_ms)
|
||||
} else {
|
||||
StoreDecision::Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates an incoming `StoreRecord` request.
|
||||
///
|
||||
/// Checks the size limits, that the record is internally consistent, that the
|
||||
/// key actually corresponds to the record's name or one of its tokens, and
|
||||
/// clamps the expiry to the maximum TTL allowed for the record type. Returns
|
||||
/// the record with a possibly clamped `expires_at_ms`.
|
||||
pub(crate) fn validate_store(
|
||||
request: StoreRecordRequest,
|
||||
network_id: &NetworkId,
|
||||
now_ms: u64,
|
||||
) -> Result<StoredArtistRecord, String> {
|
||||
let mut record = request.record;
|
||||
let artist = &record.artist;
|
||||
|
||||
if artist.name.len() > MAX_ARTIST_NAME_BYTES {
|
||||
return Err("artist name too long".into());
|
||||
}
|
||||
if artist.normalized_name != normalize_artist_name(&artist.name) {
|
||||
return Err("normalized name does not match the artist name".into());
|
||||
}
|
||||
if artist.normalized_name.is_empty() {
|
||||
return Err("artist name normalizes to nothing".into());
|
||||
}
|
||||
let tokens = tokenize(&artist.normalized_name);
|
||||
if tokens.len() > MAX_TOKENS_PER_ARTIST {
|
||||
return Err("too many tokens".into());
|
||||
}
|
||||
|
||||
let key_matches = request.key == DhtKey::exact(network_id, &artist.normalized_name)
|
||||
|| tokens
|
||||
.iter()
|
||||
.any(|token| request.key == DhtKey::token(network_id, token));
|
||||
if !key_matches {
|
||||
return Err("key does not correspond to the record".into());
|
||||
}
|
||||
|
||||
if record.expires_at_ms <= now_ms {
|
||||
return Err("record is already expired".into());
|
||||
}
|
||||
let max_ttl_ms = if artist.deleted {
|
||||
TOMBSTONE_TTL.as_millis() as u64
|
||||
} else {
|
||||
ACTIVE_RECORD_TTL.as_millis() as u64
|
||||
};
|
||||
record.expires_at_ms = record.expires_at_ms.min(now_ms + max_ttl_ms);
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use federation_net::EndpointId;
|
||||
|
||||
use super::*;
|
||||
use crate::record::{Artist, ArtistId};
|
||||
|
||||
fn test_peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn record(revision: u64, deleted: bool, expires_at_ms: u64) -> StoredArtistRecord {
|
||||
let owner = test_peer(1);
|
||||
StoredArtistRecord {
|
||||
artist: Artist {
|
||||
id: ArtistId::from_bytes([9u8; 32]),
|
||||
owner,
|
||||
name: "Massive Attack".into(),
|
||||
normalized_name: "massive attack".into(),
|
||||
revision,
|
||||
deleted,
|
||||
updated_at_ms: 0,
|
||||
},
|
||||
publisher: owner,
|
||||
expires_at_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_revision_replaces_older() {
|
||||
let incoming = record(2, false, 1000);
|
||||
assert_eq!(
|
||||
decide_store(Some((1, false, 500)), &incoming),
|
||||
StoreDecision::Write
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_revision_is_ignored() {
|
||||
let incoming = record(1, false, 1000);
|
||||
assert_eq!(
|
||||
decide_store(Some((2, false, 500)), &incoming),
|
||||
StoreDecision::Ignore
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_beats_active_record_of_same_revision() {
|
||||
let incoming = record(1, true, 1000);
|
||||
assert_eq!(
|
||||
decide_store(Some((1, false, 500)), &incoming),
|
||||
StoreDecision::Write
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_record_does_not_resurrect_tombstone() {
|
||||
let incoming = record(1, false, 1000);
|
||||
assert_eq!(
|
||||
decide_store(Some((1, true, 500)), &incoming),
|
||||
StoreDecision::Ignore
|
||||
);
|
||||
// Even an older active record loses to a newer tombstone.
|
||||
let incoming = record(1, false, 1000);
|
||||
assert_eq!(
|
||||
decide_store(Some((2, true, 500)), &incoming),
|
||||
StoreDecision::Ignore
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn republish_refreshes_expiry() {
|
||||
let incoming = record(1, false, 2000);
|
||||
assert_eq!(
|
||||
decide_store(Some((1, false, 500)), &incoming),
|
||||
StoreDecision::RefreshExpiry(2000)
|
||||
);
|
||||
let stale = record(1, false, 100);
|
||||
assert_eq!(
|
||||
decide_store(Some((1, false, 500)), &stale),
|
||||
StoreDecision::Ignore
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_record_is_written() {
|
||||
let incoming = record(1, false, 1000);
|
||||
assert_eq!(decide_store(None, &incoming), StoreDecision::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_checks_key_and_clamps_ttl() {
|
||||
let net = NetworkId::from_name("test");
|
||||
let now = 1_000_000;
|
||||
let rec = record(1, false, now + ACTIVE_RECORD_TTL.as_millis() as u64 * 10);
|
||||
|
||||
// Correct exact key: accepted, expiry clamped to the maximum TTL.
|
||||
let ok = validate_store(
|
||||
StoreRecordRequest {
|
||||
key: DhtKey::exact(&net, "massive attack"),
|
||||
record: rec.clone(),
|
||||
},
|
||||
&net,
|
||||
now,
|
||||
)
|
||||
.expect("valid");
|
||||
assert_eq!(ok.expires_at_ms, now + ACTIVE_RECORD_TTL.as_millis() as u64);
|
||||
|
||||
// Correct token key: accepted.
|
||||
assert!(
|
||||
validate_store(
|
||||
StoreRecordRequest {
|
||||
key: DhtKey::token(&net, "massive"),
|
||||
record: rec.clone(),
|
||||
},
|
||||
&net,
|
||||
now,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// Unrelated key: rejected.
|
||||
assert!(
|
||||
validate_store(
|
||||
StoreRecordRequest {
|
||||
key: DhtKey::exact(&net, "portishead"),
|
||||
record: rec.clone(),
|
||||
},
|
||||
&net,
|
||||
now,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Expired record: rejected.
|
||||
let expired = record(1, false, now - 1);
|
||||
assert!(
|
||||
validate_store(
|
||||
StoreRecordRequest {
|
||||
key: DhtKey::exact(&net, "massive attack"),
|
||||
record: expired,
|
||||
},
|
||||
&net,
|
||||
now,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Inconsistent normalization: rejected.
|
||||
let mut bad = rec;
|
||||
bad.artist.normalized_name = "something else".into();
|
||||
assert!(
|
||||
validate_store(
|
||||
StoreRecordRequest {
|
||||
key: DhtKey::exact(&net, "something else"),
|
||||
record: bad,
|
||||
},
|
||||
&net,
|
||||
now,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//! Error types for the artist-dht library.
|
||||
|
||||
/// Convenient result alias used across the library.
|
||||
pub type Result<T, E = ArtistDhtError> = std::result::Result<T, E>;
|
||||
|
||||
/// All errors that can be returned by the public API of this library.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ArtistDhtError {
|
||||
/// A local database operation failed.
|
||||
#[error("database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
/// The underlying network layer reported an error.
|
||||
#[error("network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
/// The artist name is empty or normalizes to nothing.
|
||||
#[error("invalid artist name")]
|
||||
InvalidArtistName,
|
||||
|
||||
/// The artist name exceeds the maximum allowed length.
|
||||
#[error("artist name is too long")]
|
||||
ArtistNameTooLong,
|
||||
|
||||
/// No artist with the given id exists locally.
|
||||
#[error("artist not found")]
|
||||
ArtistNotFound,
|
||||
|
||||
/// Only locally created artists can be deleted.
|
||||
#[error("cannot delete a remote artist")]
|
||||
CannotDeleteRemoteArtist,
|
||||
|
||||
/// A DHT request did not receive a response in time.
|
||||
#[error("request timed out")]
|
||||
Timeout,
|
||||
|
||||
/// The lookup exhausted its request budget without finishing.
|
||||
#[error("lookup budget exhausted")]
|
||||
LookupBudgetExhausted,
|
||||
|
||||
/// A stored peer ticket could not be parsed.
|
||||
#[error("invalid peer ticket: {0}")]
|
||||
InvalidTicket(String),
|
||||
|
||||
/// A peer violated the DHT protocol.
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(String),
|
||||
|
||||
/// The service is shutting down and no longer accepts operations.
|
||||
#[error("service is shutting down")]
|
||||
ShuttingDown,
|
||||
}
|
||||
|
||||
impl From<federation_net::NetworkError> for ArtistDhtError {
|
||||
fn from(err: federation_net::NetworkError) -> Self {
|
||||
use federation_net::NetworkError;
|
||||
match err {
|
||||
NetworkError::Timeout => Self::Timeout,
|
||||
NetworkError::ShuttingDown => Self::ShuttingDown,
|
||||
NetworkError::InvalidTicket(msg) => Self::InvalidTicket(msg),
|
||||
other => Self::Network(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//! # artist-dht
|
||||
//!
|
||||
//! A proof-of-concept distributed artist directory on top of
|
||||
//! [`federation_net`]. Every running node is simultaneously a client, a DHT
|
||||
//! router and a storage node — there are no dedicated bootstrap, index or
|
||||
//! search servers.
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
//! * Every peer derives a stable 256-bit [`NodeId`] from its persistent
|
||||
//! `federation-net` endpoint id.
|
||||
//! * Artist records are published under BLAKE3-derived [`DhtKey`]s: one exact
|
||||
//! key for the whole normalized name plus one key per name token.
|
||||
//! * Records are replicated to the `K` nodes whose ids are XOR-closest to
|
||||
//! each key, discovered with an iterative Kademlia-style lookup (never a
|
||||
//! broadcast).
|
||||
//! * Peers learn about each other through a Hello/PeerExchange gossip that
|
||||
//! runs automatically on every new connection; connections to further
|
||||
//! nodes are opened on demand from stored tickets.
|
||||
//! * Deletions propagate as tombstones that win over active records of the
|
||||
//! same or lower revision; replicas expire by TTL and owners republish
|
||||
//! periodically.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use artist_dht::{ArtistDhtConfig, ArtistDhtService};
|
||||
//! use federation_net::NetworkId;
|
||||
//!
|
||||
//! # async fn run() -> artist_dht::Result<()> {
|
||||
//! let config = ArtistDhtConfig::builder()
|
||||
//! .data_dir("./peer-a")
|
||||
//! .network_id(NetworkId::from_name("demo-artists"))
|
||||
//! .build()?;
|
||||
//! let (service, mut events) = ArtistDhtService::start(config).await?;
|
||||
//! println!("share this ticket: {}", service.ticket().await?);
|
||||
//!
|
||||
//! let (artist, stats) = service.add_artist("Massive Attack".into()).await?;
|
||||
//! println!("published {} under {} keys", artist.name, stats.keys);
|
||||
//!
|
||||
//! let outcome = service.search_network("massive").await?;
|
||||
//! for artist in &outcome.network_results {
|
||||
//! println!("found {} owned by {}", artist.name, artist.owner);
|
||||
//! }
|
||||
//! # while let Some(event) = events.recv().await { drop(event); }
|
||||
//! # service.shutdown().await
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod config;
|
||||
mod database;
|
||||
mod dht;
|
||||
mod error;
|
||||
mod message;
|
||||
mod node;
|
||||
mod normalization;
|
||||
mod record;
|
||||
mod request;
|
||||
mod routing;
|
||||
mod service;
|
||||
|
||||
pub use config::{
|
||||
ArtistDhtConfig, ArtistDhtConfigBuilder, DEFAULT_EXPIRE_INTERVAL, DEFAULT_LOOKUP_TIMEOUT,
|
||||
DEFAULT_REPUBLISH_INTERVAL, DEFAULT_REQUEST_TIMEOUT, DEFAULT_TRANSPORT_TIMEOUT,
|
||||
};
|
||||
pub use error::{ArtistDhtError, Result};
|
||||
pub use message::{
|
||||
ArtistDhtMessage, DHT_PROTOCOL_VERSION, FindNodeRequest, FindNodeResponse, FindValueRequest,
|
||||
FindValueResponse, Hello, MAX_PEER_EXCHANGE_CONTACTS, MAX_RECORDS_PER_RESPONSE, PeerExchange,
|
||||
PingRequest, PongResponse, RequestEnvelope, RequestId, ResponseEnvelope, StoreRecordRequest,
|
||||
StoreRecordResponse,
|
||||
};
|
||||
pub use normalization::{normalize_artist_name, tokenize};
|
||||
pub use record::{
|
||||
ACTIVE_RECORD_TTL, Artist, ArtistId, DhtKey, MAX_ARTIST_NAME_BYTES, MAX_TOKENS_PER_ARTIST,
|
||||
PeerId, StoredArtistRecord, TOMBSTONE_TTL,
|
||||
};
|
||||
pub use request::MAX_PENDING_REQUESTS;
|
||||
pub use routing::{
|
||||
ALPHA, Distance, K, MAX_LOOKUP_REQUESTS, NodeContact, NodeId, RoutingTable, distance,
|
||||
key_distance,
|
||||
};
|
||||
pub use service::{
|
||||
ArtistDhtEvent, ArtistDhtEventReceiver, ArtistDhtService, PublishStats, SCHEMA_NAME,
|
||||
SearchOutcome,
|
||||
};
|
||||
|
||||
// Re-exported types from the transport layer that appear in this API.
|
||||
pub use federation_net::{EndpointId, NetworkId, PeerTicket, RendezvousConfig};
|
||||
@@ -1,174 +0,0 @@
|
||||
//! The domain protocol carried over `federation-net`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use federation_net::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::record::{DhtKey, StoredArtistRecord};
|
||||
use crate::routing::{NodeContact, NodeId};
|
||||
|
||||
/// Version of the artist-dht protocol.
|
||||
pub const DHT_PROTOCOL_VERSION: u16 = 1;
|
||||
/// Maximum number of contacts in a single [`PeerExchange`].
|
||||
pub const MAX_PEER_EXCHANGE_CONTACTS: usize = 32;
|
||||
/// Maximum number of records in a single [`FindValueResponse`].
|
||||
pub const MAX_RECORDS_PER_RESPONSE: usize = 100;
|
||||
|
||||
/// Correlates a response with its request.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct RequestId([u8; 16]);
|
||||
|
||||
impl RequestId {
|
||||
/// Generates a random request id.
|
||||
pub fn random() -> Self {
|
||||
Self(rand::random())
|
||||
}
|
||||
|
||||
/// Returns the raw bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; 16] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RequestId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "RequestId(")?;
|
||||
for byte in &self.0 {
|
||||
write!(f, "{byte:02x}")?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a request payload with its correlation id.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RequestEnvelope<T> {
|
||||
/// Correlation id; echoed back in the response.
|
||||
pub request_id: RequestId,
|
||||
/// The request itself.
|
||||
pub payload: T,
|
||||
}
|
||||
|
||||
/// Wraps a response payload with the correlation id of its request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResponseEnvelope<T> {
|
||||
/// Correlation id of the request being answered.
|
||||
pub request_id: RequestId,
|
||||
/// The response itself.
|
||||
pub payload: T,
|
||||
}
|
||||
|
||||
/// Introduction sent right after a connection is established.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// DHT identifier of the sender.
|
||||
pub node_id: NodeId,
|
||||
/// Transport identifier of the sender (informational; the authenticated
|
||||
/// id always comes from the connection itself).
|
||||
pub peer_id: EndpointId,
|
||||
/// Ticket other peers can use to reach the sender.
|
||||
pub ticket: String,
|
||||
/// Protocol version of the sender.
|
||||
pub protocol_version: u16,
|
||||
}
|
||||
|
||||
/// A batch of known contacts, shared after [`Hello`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerExchange {
|
||||
/// Up to [`MAX_PEER_EXCHANGE_CONTACTS`] contacts.
|
||||
pub peers: Vec<NodeContact>,
|
||||
}
|
||||
|
||||
/// Liveness probe.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PingRequest {}
|
||||
|
||||
/// Reply to [`PingRequest`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PongResponse {
|
||||
/// DHT identifier of the responder.
|
||||
pub node_id: NodeId,
|
||||
}
|
||||
|
||||
/// Asks for the closest known nodes to `target`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FindNodeRequest {
|
||||
/// Point of the key space to search around.
|
||||
pub target: NodeId,
|
||||
}
|
||||
|
||||
/// Reply to [`FindNodeRequest`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FindNodeResponse {
|
||||
/// Up to `K` known nodes closest to the target.
|
||||
pub nodes: Vec<NodeContact>,
|
||||
}
|
||||
|
||||
/// Asks for records stored under `key`, or the closest nodes to it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FindValueRequest {
|
||||
/// The DHT key to look up.
|
||||
pub key: DhtKey,
|
||||
}
|
||||
|
||||
/// Reply to [`FindValueRequest`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FindValueResponse {
|
||||
/// The responder stores records under the key.
|
||||
Records {
|
||||
/// Up to [`MAX_RECORDS_PER_RESPONSE`] non-expired records.
|
||||
records: Vec<StoredArtistRecord>,
|
||||
},
|
||||
/// The responder has nothing stored; here are closer nodes instead.
|
||||
CloserNodes {
|
||||
/// Up to `K` known nodes closest to the key.
|
||||
nodes: Vec<NodeContact>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Asks the receiver to store a replica of a record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoreRecordRequest {
|
||||
/// The key the record is published under.
|
||||
pub key: DhtKey,
|
||||
/// The record to store.
|
||||
pub record: StoredArtistRecord,
|
||||
}
|
||||
|
||||
/// Reply to [`StoreRecordRequest`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoreRecordResponse {
|
||||
/// `true` if the record was accepted and stored (or refreshed).
|
||||
pub stored: bool,
|
||||
}
|
||||
|
||||
/// Every message exchanged between artist-dht peers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ArtistDhtMessage {
|
||||
/// Introduction after connect.
|
||||
Hello(Hello),
|
||||
/// Contact gossip after `Hello`.
|
||||
PeerExchange(PeerExchange),
|
||||
|
||||
/// Liveness probe.
|
||||
Ping(RequestEnvelope<PingRequest>),
|
||||
/// Reply to `Ping`.
|
||||
Pong(ResponseEnvelope<PongResponse>),
|
||||
|
||||
/// Node lookup request.
|
||||
FindNode(RequestEnvelope<FindNodeRequest>),
|
||||
/// Reply to `FindNode`.
|
||||
FindNodeResult(ResponseEnvelope<FindNodeResponse>),
|
||||
|
||||
/// Value lookup request.
|
||||
FindValue(RequestEnvelope<FindValueRequest>),
|
||||
/// Reply to `FindValue`.
|
||||
FindValueResult(ResponseEnvelope<FindValueResponse>),
|
||||
|
||||
/// Replication request.
|
||||
StoreRecord(RequestEnvelope<StoreRecordRequest>),
|
||||
/// Reply to `StoreRecord`.
|
||||
StoreRecordResult(ResponseEnvelope<StoreRecordResponse>),
|
||||
}
|
||||
@@ -1,928 +0,0 @@
|
||||
//! The DHT node: event handling, peer exchange, iterative lookups and
|
||||
//! publication.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use federation_net::{EndpointId, NetworkEngine, NetworkEvent, NetworkEventReceiver, PeerTicket};
|
||||
use futures::future::join_all;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::ArtistDhtConfig;
|
||||
use crate::database::Database;
|
||||
use crate::dht::validate_store;
|
||||
use crate::error::{ArtistDhtError, Result};
|
||||
use crate::message::{
|
||||
ArtistDhtMessage, DHT_PROTOCOL_VERSION, FindNodeRequest, FindNodeResponse, FindValueRequest,
|
||||
FindValueResponse, Hello, MAX_PEER_EXCHANGE_CONTACTS, PeerExchange, PingRequest, PongResponse,
|
||||
RequestEnvelope, RequestId, ResponseEnvelope, StoreRecordRequest, StoreRecordResponse,
|
||||
};
|
||||
use crate::record::{ACTIVE_RECORD_TTL, Artist, DhtKey, StoredArtistRecord, TOMBSTONE_TTL, now_ms};
|
||||
use crate::request::{DhtResponse, PendingRequests};
|
||||
use crate::routing::{ALPHA, K, MAX_LOOKUP_REQUESTS, NodeContact, NodeId, RoutingTable, distance};
|
||||
use crate::service::{ArtistDhtEvent, PublishStats};
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
mutex.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Base of the exponential backoff applied to a contact after a failed dial.
|
||||
const DIAL_BACKOFF_BASE: Duration = Duration::from_secs(30);
|
||||
/// Upper bound of the dial backoff.
|
||||
const DIAL_BACKOFF_MAX: Duration = Duration::from_secs(10 * 60);
|
||||
/// Consecutive failed dials after which a contact is evicted entirely.
|
||||
const DIAL_FAILURES_BEFORE_EVICT: u32 = 5;
|
||||
|
||||
/// Dial-failure state of one currently unreachable contact.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct DialFailure {
|
||||
consecutive: u32,
|
||||
last_attempt_ms: u64,
|
||||
}
|
||||
|
||||
/// How long a contact is skipped after `consecutive` failed dials:
|
||||
/// 30s, 1m, 2m, 4m, 8m, then capped at 10 minutes.
|
||||
fn dial_backoff(consecutive: u32) -> Duration {
|
||||
let exponent = consecutive.saturating_sub(1).min(8);
|
||||
DIAL_BACKOFF_BASE
|
||||
.saturating_mul(1u32 << exponent)
|
||||
.min(DIAL_BACKOFF_MAX)
|
||||
}
|
||||
|
||||
/// An outbound DHT request, before it is wrapped in an envelope.
|
||||
enum OutboundRequest {
|
||||
Ping,
|
||||
FindNode(FindNodeRequest),
|
||||
FindValue(FindValueRequest),
|
||||
Store(StoreRecordRequest),
|
||||
}
|
||||
|
||||
/// Result of one iterative lookup.
|
||||
pub(crate) struct LookupOutcome {
|
||||
/// Records found (value lookups only).
|
||||
pub records: Vec<StoredArtistRecord>,
|
||||
/// Closest known contacts to the target, best first, at most `K`.
|
||||
pub closest: Vec<NodeContact>,
|
||||
/// Number of distinct peers actually queried.
|
||||
pub queried: usize,
|
||||
/// Number of distinct nodes known to the lookup (seeds + discovered).
|
||||
pub discovered: usize,
|
||||
}
|
||||
|
||||
/// Shared state of one DHT node.
|
||||
pub(crate) struct Node {
|
||||
pub engine: NetworkEngine<ArtistDhtMessage>,
|
||||
pub db: Database,
|
||||
pub config: ArtistDhtConfig,
|
||||
pub node_id: NodeId,
|
||||
pub endpoint_id: EndpointId,
|
||||
routing: Mutex<RoutingTable>,
|
||||
pending: PendingRequests,
|
||||
/// Peers we already introduced ourselves to (per connection).
|
||||
hello_sent: Mutex<HashSet<EndpointId>>,
|
||||
/// Peers we already gossiped contacts to (per connection).
|
||||
exchange_sent: Mutex<HashSet<EndpointId>>,
|
||||
/// Contacts that recently failed to dial, with their backoff state.
|
||||
dial_failures: Mutex<HashMap<EndpointId, DialFailure>>,
|
||||
events: Mutex<Option<mpsc::Sender<ArtistDhtEvent>>>,
|
||||
/// Set once the post-startup republish has been triggered.
|
||||
initial_republish_done: AtomicBool,
|
||||
shutting_down: AtomicBool,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn new(
|
||||
engine: NetworkEngine<ArtistDhtMessage>,
|
||||
db: Database,
|
||||
config: ArtistDhtConfig,
|
||||
events: mpsc::Sender<ArtistDhtEvent>,
|
||||
) -> Self {
|
||||
let endpoint_id = engine.endpoint_id();
|
||||
let node_id = NodeId::from_endpoint(&endpoint_id);
|
||||
Self {
|
||||
engine,
|
||||
db,
|
||||
config,
|
||||
node_id,
|
||||
endpoint_id,
|
||||
routing: Mutex::new(RoutingTable::new(node_id)),
|
||||
pending: PendingRequests::default(),
|
||||
hello_sent: Mutex::new(HashSet::new()),
|
||||
exchange_sent: Mutex::new(HashSet::new()),
|
||||
dial_failures: Mutex::new(HashMap::new()),
|
||||
events: Mutex::new(Some(events)),
|
||||
initial_republish_done: AtomicBool::new(false),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
self.shutting_down.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn begin_shutdown(&self) {
|
||||
self.shutting_down.store(true, Ordering::SeqCst);
|
||||
*lock(&self.events) = None;
|
||||
}
|
||||
|
||||
pub fn ensure_running(&self) -> Result<()> {
|
||||
if self.is_shutting_down() {
|
||||
Err(ArtistDhtError::ShuttingDown)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit(&self, event: ArtistDhtEvent) {
|
||||
let sender = lock(&self.events).clone();
|
||||
if let Some(sender) = sender {
|
||||
let _ = sender.send(event).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// All known DHT contacts.
|
||||
pub fn known_contacts(&self) -> Vec<NodeContact> {
|
||||
lock(&self.routing).contacts()
|
||||
}
|
||||
|
||||
/// Seeds the routing table (used at startup with persisted contacts).
|
||||
pub fn seed_contacts(&self, contacts: Vec<NodeContact>) {
|
||||
let mut routing = lock(&self.routing);
|
||||
for contact in contacts {
|
||||
if contact.peer_id != self.endpoint_id {
|
||||
routing.upsert(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or refreshes a contact learned from the network.
|
||||
///
|
||||
/// The node id is always re-derived from the endpoint id instead of
|
||||
/// trusting the gossiped value. The first contact ever learned triggers
|
||||
/// the post-startup republish.
|
||||
async fn upsert_contact(self: &Arc<Self>, peer_id: EndpointId, ticket: String) {
|
||||
if peer_id == self.endpoint_id {
|
||||
return;
|
||||
}
|
||||
let contact = NodeContact {
|
||||
node_id: NodeId::from_endpoint(&peer_id),
|
||||
peer_id,
|
||||
ticket,
|
||||
last_seen_ms: now_ms(),
|
||||
};
|
||||
let is_new = lock(&self.routing).upsert(contact.clone());
|
||||
if let Err(err) = self.db.upsert_known_peer(&contact).await {
|
||||
warn!(error = %err, "failed to persist known peer");
|
||||
}
|
||||
if is_new {
|
||||
info!(peer = %contact.peer_id, node = %contact.node_id, "learned new DHT contact");
|
||||
self.emit(ArtistDhtEvent::ContactDiscovered {
|
||||
contact: contact.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
self.maybe_trigger_initial_republish();
|
||||
}
|
||||
|
||||
/// Spawns the post-startup republish once at least one contact is known.
|
||||
pub fn maybe_trigger_initial_republish(self: &Arc<Self>) {
|
||||
if lock(&self.routing).is_empty() || self.is_shutting_down() {
|
||||
return;
|
||||
}
|
||||
if self.initial_republish_done.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let node = self.clone();
|
||||
tokio::spawn(async move {
|
||||
match node.republish_all().await {
|
||||
Ok(stats) => info!(
|
||||
records = stats.records,
|
||||
keys = stats.keys,
|
||||
nodes = stats.remote_nodes,
|
||||
"post-startup republish finished"
|
||||
),
|
||||
Err(err) => warn!(error = %err, "post-startup republish failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Consumes `federation-net` events until the engine shuts down.
|
||||
pub async fn run_event_loop(
|
||||
self: Arc<Self>,
|
||||
mut receiver: NetworkEventReceiver<ArtistDhtMessage>,
|
||||
) {
|
||||
while let Some(event) = receiver.recv().await {
|
||||
match event {
|
||||
NetworkEvent::PeerConnected { peer_id, .. } => {
|
||||
debug!(peer = %peer_id, "peer connected");
|
||||
self.clear_dial_failures(&peer_id);
|
||||
self.emit(ArtistDhtEvent::PeerConnected { peer_id }).await;
|
||||
self.send_hello(peer_id).await;
|
||||
}
|
||||
NetworkEvent::PeerDisconnected { peer_id, .. } => {
|
||||
debug!(peer = %peer_id, "peer disconnected");
|
||||
lock(&self.hello_sent).remove(&peer_id);
|
||||
lock(&self.exchange_sent).remove(&peer_id);
|
||||
self.emit(ArtistDhtEvent::PeerDisconnected { peer_id })
|
||||
.await;
|
||||
}
|
||||
NetworkEvent::MessageReceived { peer_id, message } => {
|
||||
self.on_message(peer_id, message).await;
|
||||
}
|
||||
NetworkEvent::ProtocolError { peer_id, error } => {
|
||||
self.emit(ArtistDhtEvent::Error {
|
||||
message: match peer_id {
|
||||
Some(peer) => format!("transport error with {peer}: {error}"),
|
||||
None => format!("transport error: {error}"),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!("network event loop finished");
|
||||
}
|
||||
|
||||
async fn send_message(&self, peer: EndpointId, message: &ArtistDhtMessage) -> Result<()> {
|
||||
self.engine.send(peer, message).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn send_hello(self: &Arc<Self>, peer: EndpointId) {
|
||||
// Mark before sending so a crossing Hello does not trigger an echo.
|
||||
if !lock(&self.hello_sent).insert(peer) {
|
||||
return;
|
||||
}
|
||||
let ticket = match self.engine.ticket().await {
|
||||
Ok(ticket) => ticket.to_string(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "cannot create own ticket for hello");
|
||||
lock(&self.hello_sent).remove(&peer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let hello = ArtistDhtMessage::Hello(Hello {
|
||||
node_id: self.node_id,
|
||||
peer_id: self.endpoint_id,
|
||||
ticket,
|
||||
protocol_version: DHT_PROTOCOL_VERSION,
|
||||
});
|
||||
if let Err(err) = self.send_message(peer, &hello).await {
|
||||
debug!(peer = %peer, error = %err, "failed to send hello");
|
||||
lock(&self.hello_sent).remove(&peer);
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_peer_exchange(self: &Arc<Self>, peer: EndpointId) {
|
||||
if !lock(&self.exchange_sent).insert(peer) {
|
||||
return;
|
||||
}
|
||||
let mut peers: Vec<NodeContact> = self
|
||||
.known_contacts()
|
||||
.into_iter()
|
||||
.filter(|contact| contact.peer_id != peer && contact.peer_id != self.endpoint_id)
|
||||
.collect();
|
||||
// Prefer the most recently seen contacts.
|
||||
peers.sort_by_key(|contact| std::cmp::Reverse(contact.last_seen_ms));
|
||||
peers.truncate(MAX_PEER_EXCHANGE_CONTACTS);
|
||||
if peers.is_empty() {
|
||||
return;
|
||||
}
|
||||
debug!(peer = %peer, count = peers.len(), "sending peer exchange");
|
||||
let message = ArtistDhtMessage::PeerExchange(PeerExchange { peers });
|
||||
if let Err(err) = self.send_message(peer, &message).await {
|
||||
debug!(peer = %peer, error = %err, "failed to send peer exchange");
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_message(self: &Arc<Self>, peer: EndpointId, message: ArtistDhtMessage) {
|
||||
lock(&self.routing).touch(&peer, now_ms());
|
||||
match message {
|
||||
ArtistDhtMessage::Hello(hello) => self.on_hello(peer, hello).await,
|
||||
ArtistDhtMessage::PeerExchange(exchange) => {
|
||||
self.on_peer_exchange(peer, exchange).await;
|
||||
}
|
||||
ArtistDhtMessage::Ping(env) => {
|
||||
let response = ArtistDhtMessage::Pong(ResponseEnvelope {
|
||||
request_id: env.request_id,
|
||||
payload: PongResponse {
|
||||
node_id: self.node_id,
|
||||
},
|
||||
});
|
||||
let _ = self.send_message(peer, &response).await;
|
||||
}
|
||||
ArtistDhtMessage::FindNode(env) => {
|
||||
let nodes = self.closest_for_response(env.payload.target.as_bytes(), &peer);
|
||||
let response = ArtistDhtMessage::FindNodeResult(ResponseEnvelope {
|
||||
request_id: env.request_id,
|
||||
payload: FindNodeResponse { nodes },
|
||||
});
|
||||
let _ = self.send_message(peer, &response).await;
|
||||
}
|
||||
ArtistDhtMessage::FindValue(env) => {
|
||||
let payload = self.answer_find_value(&env.payload, &peer).await;
|
||||
let response = ArtistDhtMessage::FindValueResult(ResponseEnvelope {
|
||||
request_id: env.request_id,
|
||||
payload,
|
||||
});
|
||||
let _ = self.send_message(peer, &response).await;
|
||||
}
|
||||
ArtistDhtMessage::StoreRecord(env) => {
|
||||
let stored = self.answer_store(env.payload, &peer).await;
|
||||
let response = ArtistDhtMessage::StoreRecordResult(ResponseEnvelope {
|
||||
request_id: env.request_id,
|
||||
payload: StoreRecordResponse { stored },
|
||||
});
|
||||
let _ = self.send_message(peer, &response).await;
|
||||
}
|
||||
ArtistDhtMessage::Pong(env) => {
|
||||
self.pending
|
||||
.complete(&env.request_id, &peer, DhtResponse::Pong(env.payload));
|
||||
}
|
||||
ArtistDhtMessage::FindNodeResult(env) => {
|
||||
self.pending
|
||||
.complete(&env.request_id, &peer, DhtResponse::FindNode(env.payload));
|
||||
}
|
||||
ArtistDhtMessage::FindValueResult(env) => {
|
||||
self.pending
|
||||
.complete(&env.request_id, &peer, DhtResponse::FindValue(env.payload));
|
||||
}
|
||||
ArtistDhtMessage::StoreRecordResult(env) => {
|
||||
self.pending
|
||||
.complete(&env.request_id, &peer, DhtResponse::Store(env.payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_hello(self: &Arc<Self>, peer: EndpointId, hello: Hello) {
|
||||
if hello.protocol_version != DHT_PROTOCOL_VERSION {
|
||||
warn!(peer = %peer, version = hello.protocol_version, "unsupported DHT protocol version");
|
||||
return;
|
||||
}
|
||||
// The authenticated identity comes from the connection; the id fields
|
||||
// inside the payload must be consistent with it.
|
||||
if hello.peer_id != peer || hello.node_id != NodeId::from_endpoint(&peer) {
|
||||
warn!(peer = %peer, "hello with inconsistent identity; ignoring");
|
||||
self.emit(ArtistDhtEvent::Error {
|
||||
message: format!("peer {peer} sent a hello with a mismatched identity"),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
debug!(peer = %peer, "received hello");
|
||||
self.upsert_contact(peer, hello.ticket).await;
|
||||
// Introduce ourselves if the remote connected first, then gossip.
|
||||
self.send_hello(peer).await;
|
||||
self.send_peer_exchange(peer).await;
|
||||
}
|
||||
|
||||
async fn on_peer_exchange(self: &Arc<Self>, peer: EndpointId, exchange: PeerExchange) {
|
||||
let contacts = sanitize_peer_exchange(self.endpoint_id, peer, exchange.peers);
|
||||
let accepted = contacts.len();
|
||||
for contact in contacts {
|
||||
self.upsert_contact(contact.peer_id, contact.ticket).await;
|
||||
}
|
||||
debug!(peer = %peer, accepted, "processed peer exchange");
|
||||
}
|
||||
|
||||
/// Contacts for a FindNode/FindValue response: closest to the target,
|
||||
/// excluding the requester itself.
|
||||
fn closest_for_response(&self, target: &[u8; 32], requester: &EndpointId) -> Vec<NodeContact> {
|
||||
lock(&self.routing)
|
||||
.closest(target, K + 1)
|
||||
.into_iter()
|
||||
.filter(|contact| &contact.peer_id != requester)
|
||||
.take(K)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn answer_find_value(
|
||||
&self,
|
||||
request: &FindValueRequest,
|
||||
requester: &EndpointId,
|
||||
) -> FindValueResponse {
|
||||
match self.db.dht_records_by_key(request.key, now_ms()).await {
|
||||
Ok(records) if !records.is_empty() => FindValueResponse::Records { records },
|
||||
Ok(_) => FindValueResponse::CloserNodes {
|
||||
nodes: self.closest_for_response(request.key.as_bytes(), requester),
|
||||
},
|
||||
Err(err) => {
|
||||
warn!(error = %err, "find-value lookup in the local store failed");
|
||||
FindValueResponse::CloserNodes {
|
||||
nodes: self.closest_for_response(request.key.as_bytes(), requester),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn answer_store(&self, request: StoreRecordRequest, sender: &EndpointId) -> bool {
|
||||
if self.is_shutting_down() {
|
||||
return false;
|
||||
}
|
||||
let key = request.key;
|
||||
match validate_store(request, &self.config.network_id, now_ms()) {
|
||||
Ok(record) => {
|
||||
let artist_id = record.artist.id;
|
||||
let deleted = record.artist.deleted;
|
||||
match self.db.store_dht_record(key, record).await {
|
||||
Ok(stored) => {
|
||||
if stored {
|
||||
info!(
|
||||
artist = %artist_id,
|
||||
tombstone = deleted,
|
||||
from = %sender,
|
||||
"stored DHT record"
|
||||
);
|
||||
}
|
||||
stored
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to store DHT record");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(reason) => {
|
||||
warn!(from = %sender, reason = %reason, "rejected DHT store request");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes sure a connection to the contact exists, dialing its ticket if
|
||||
/// necessary. The Hello exchange runs asynchronously via the event loop.
|
||||
///
|
||||
/// On-demand dials are bounded by the (short) `dial_timeout` and feed the
|
||||
/// dial-failure backoff, so unreachable contacts cannot stall lookups.
|
||||
async fn ensure_connected(&self, contact: &NodeContact) -> Result<EndpointId> {
|
||||
self.ensure_running()?;
|
||||
if self.engine.is_connected(contact.peer_id) {
|
||||
return Ok(contact.peer_id);
|
||||
}
|
||||
let ticket: PeerTicket = contact
|
||||
.ticket
|
||||
.parse()
|
||||
.map_err(|err| ArtistDhtError::InvalidTicket(format!("{err}")))?;
|
||||
debug!(peer = %contact.peer_id, "connecting on demand");
|
||||
let result = timeout(self.config.dial_timeout, self.engine.connect(ticket))
|
||||
.await
|
||||
.map_err(|_| ArtistDhtError::Timeout)
|
||||
.and_then(|res| res.map_err(Into::into));
|
||||
match result {
|
||||
Ok(peer) => {
|
||||
self.clear_dial_failures(&peer);
|
||||
Ok(peer)
|
||||
}
|
||||
Err(err) => {
|
||||
self.note_dial_failure(contact).await;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the dial-failure history of a peer (it proved reachable).
|
||||
fn clear_dial_failures(&self, peer: &EndpointId) {
|
||||
lock(&self.dial_failures).remove(peer);
|
||||
}
|
||||
|
||||
/// `true` if the contact recently failed to dial and its backoff window
|
||||
/// has not elapsed yet. Connected peers are never considered backed off.
|
||||
fn dial_backoff_active(&self, peer: &EndpointId, now_ms: u64) -> bool {
|
||||
if self.engine.is_connected(*peer) {
|
||||
return false;
|
||||
}
|
||||
match lock(&self.dial_failures).get(peer) {
|
||||
Some(failure) => {
|
||||
let backoff = dial_backoff(failure.consecutive).as_millis() as u64;
|
||||
now_ms < failure.last_attempt_ms.saturating_add(backoff)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a failed dial; after [`DIAL_FAILURES_BEFORE_EVICT`] failures
|
||||
/// in a row the contact is dropped from the routing table and the
|
||||
/// database (gossip re-adds it with a clean slate if it comes back).
|
||||
async fn note_dial_failure(&self, contact: &NodeContact) {
|
||||
let consecutive = {
|
||||
let mut failures = lock(&self.dial_failures);
|
||||
let failure = failures.entry(contact.peer_id).or_insert(DialFailure {
|
||||
consecutive: 0,
|
||||
last_attempt_ms: 0,
|
||||
});
|
||||
failure.consecutive += 1;
|
||||
failure.last_attempt_ms = now_ms();
|
||||
failure.consecutive
|
||||
};
|
||||
if consecutive < DIAL_FAILURES_BEFORE_EVICT {
|
||||
debug!(
|
||||
peer = %contact.peer_id,
|
||||
consecutive,
|
||||
backoff_s = dial_backoff(consecutive).as_secs(),
|
||||
"dial failed; backing off"
|
||||
);
|
||||
return;
|
||||
}
|
||||
lock(&self.dial_failures).remove(&contact.peer_id);
|
||||
lock(&self.routing).remove(&contact.peer_id);
|
||||
if let Err(err) = self.db.delete_known_peer(contact.peer_id).await {
|
||||
warn!(error = %err, "failed to delete evicted peer from the database");
|
||||
}
|
||||
info!(peer = %contact.peer_id, "evicted unreachable DHT contact");
|
||||
}
|
||||
|
||||
/// Sends one request and awaits its response, cleaning up the pending
|
||||
/// entry on timeout.
|
||||
async fn request(
|
||||
&self,
|
||||
contact: &NodeContact,
|
||||
request: OutboundRequest,
|
||||
) -> Result<DhtResponse> {
|
||||
let peer = self.ensure_connected(contact).await?;
|
||||
let request_id = RequestId::random();
|
||||
let receiver = self.pending.register(request_id, peer)?;
|
||||
// Lookups may cancel this future (early exit); the guard makes sure
|
||||
// the pending entry never outlives it.
|
||||
let _cleanup = self.pending.remove_on_drop(request_id);
|
||||
tracing::trace!(pending = self.pending.len(), peer = %peer, "sending DHT request");
|
||||
let message = match request {
|
||||
OutboundRequest::Ping => ArtistDhtMessage::Ping(RequestEnvelope {
|
||||
request_id,
|
||||
payload: PingRequest {},
|
||||
}),
|
||||
OutboundRequest::FindNode(payload) => ArtistDhtMessage::FindNode(RequestEnvelope {
|
||||
request_id,
|
||||
payload,
|
||||
}),
|
||||
OutboundRequest::FindValue(payload) => ArtistDhtMessage::FindValue(RequestEnvelope {
|
||||
request_id,
|
||||
payload,
|
||||
}),
|
||||
OutboundRequest::Store(payload) => ArtistDhtMessage::StoreRecord(RequestEnvelope {
|
||||
request_id,
|
||||
payload,
|
||||
}),
|
||||
};
|
||||
self.send_message(peer, &message).await?;
|
||||
match timeout(self.config.request_timeout, receiver).await {
|
||||
Ok(Ok(response)) => {
|
||||
lock(&self.routing).touch(&peer, now_ms());
|
||||
Ok(response)
|
||||
}
|
||||
Ok(Err(_)) => Err(ArtistDhtError::Protocol("response channel closed".into())),
|
||||
Err(_) => Err(ArtistDhtError::Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures the round-trip time to a known contact and verifies its
|
||||
/// DHT identity.
|
||||
pub async fn ping(&self, contact: &NodeContact) -> Result<std::time::Duration> {
|
||||
let started = Instant::now();
|
||||
match self.request(contact, OutboundRequest::Ping).await? {
|
||||
DhtResponse::Pong(pong) => {
|
||||
if pong.node_id != NodeId::from_endpoint(&contact.peer_id) {
|
||||
return Err(ArtistDhtError::Protocol(
|
||||
"pong with a mismatched node id".into(),
|
||||
));
|
||||
}
|
||||
lock(&self.routing).touch(&contact.peer_id, now_ms());
|
||||
Ok(started.elapsed())
|
||||
}
|
||||
_ => Err(ArtistDhtError::Protocol(
|
||||
"unexpected response to ping".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterative Kademlia-style lookup.
|
||||
///
|
||||
/// With `find_value: None` this is a node lookup converging on the
|
||||
/// closest known nodes to `target`; with `Some(key)` it sends `FindValue`
|
||||
/// and returns as soon as the **first** records arrive — in-flight
|
||||
/// requests to slower or unreachable peers are cancelled instead of
|
||||
/// awaited. Contacts in dial backoff are skipped. Never broadcasts: at
|
||||
/// most [`ALPHA`] requests run concurrently and at most
|
||||
/// [`MAX_LOOKUP_REQUESTS`] are sent in total, all hard-bounded by the
|
||||
/// lookup timeout.
|
||||
pub async fn lookup(&self, target: [u8; 32], find_value: Option<DhtKey>) -> LookupOutcome {
|
||||
let started = Instant::now();
|
||||
let deadline = tokio::time::Instant::now() + self.config.lookup_timeout;
|
||||
let mut candidates: Vec<NodeContact> = lock(&self.routing).closest(&target, K);
|
||||
let mut known: HashSet<EndpointId> =
|
||||
candidates.iter().map(|contact| contact.peer_id).collect();
|
||||
let mut queried: HashSet<EndpointId> = HashSet::new();
|
||||
let mut records: HashMap<(crate::record::ArtistId, EndpointId), StoredArtistRecord> =
|
||||
HashMap::new();
|
||||
let mut sent = 0usize;
|
||||
|
||||
debug!(target = %NodeId::from_bytes(target), seeds = candidates.len(), "lookup started");
|
||||
|
||||
'rounds: loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
debug!("lookup deadline reached");
|
||||
break;
|
||||
}
|
||||
candidates.sort_by_key(|contact| distance(contact.node_id.as_bytes(), &target));
|
||||
let budget = MAX_LOOKUP_REQUESTS.saturating_sub(sent);
|
||||
let round_now = now_ms();
|
||||
let batch: Vec<NodeContact> = candidates
|
||||
.iter()
|
||||
.filter(|contact| {
|
||||
!queried.contains(&contact.peer_id)
|
||||
&& !self.dial_backoff_active(&contact.peer_id, round_now)
|
||||
})
|
||||
.take(ALPHA.min(budget))
|
||||
.cloned()
|
||||
.collect();
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
sent += batch.len();
|
||||
for contact in &batch {
|
||||
queried.insert(contact.peer_id);
|
||||
}
|
||||
|
||||
// Process responses as they complete: one dead contact must not
|
||||
// hold back the answers of the live ones.
|
||||
let mut in_flight: FuturesUnordered<_> = batch
|
||||
.iter()
|
||||
.map(|contact| async move {
|
||||
let request = match find_value {
|
||||
Some(key) => OutboundRequest::FindValue(FindValueRequest { key }),
|
||||
None => OutboundRequest::FindNode(FindNodeRequest {
|
||||
target: NodeId::from_bytes(target),
|
||||
}),
|
||||
};
|
||||
(contact, self.request(contact, request).await)
|
||||
})
|
||||
.collect();
|
||||
while let Ok(next) = tokio::time::timeout_at(deadline, in_flight.next()).await {
|
||||
let Some((contact, result)) = next else {
|
||||
break; // The round is complete.
|
||||
};
|
||||
let mut found_records = false;
|
||||
let nodes = match result {
|
||||
Ok(DhtResponse::FindNode(response)) => response.nodes,
|
||||
Ok(DhtResponse::FindValue(FindValueResponse::Records { records: found })) => {
|
||||
for record in found {
|
||||
let key = (record.artist.id, record.artist.owner);
|
||||
match records.get(&key) {
|
||||
Some(existing) if !record_supersedes(&record, existing) => {}
|
||||
_ => {
|
||||
records.insert(key, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
found_records = true;
|
||||
Vec::new()
|
||||
}
|
||||
Ok(DhtResponse::FindValue(FindValueResponse::CloserNodes { nodes })) => nodes,
|
||||
Ok(_) => Vec::new(),
|
||||
Err(err) => {
|
||||
debug!(peer = %contact.peer_id, error = %err, "lookup request failed");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
for node in nodes.into_iter().take(K) {
|
||||
if node.peer_id == self.endpoint_id || !known.insert(node.peer_id) {
|
||||
continue;
|
||||
}
|
||||
// Re-derive the node id instead of trusting gossip.
|
||||
candidates.push(NodeContact {
|
||||
node_id: NodeId::from_endpoint(&node.peer_id),
|
||||
peer_id: node.peer_id,
|
||||
ticket: node.ticket,
|
||||
last_seen_ms: now_ms(),
|
||||
});
|
||||
}
|
||||
if find_value.is_some() && found_records {
|
||||
// Dropping `in_flight` cancels the outstanding requests.
|
||||
break 'rounds;
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
debug!("lookup deadline reached");
|
||||
break;
|
||||
}
|
||||
if sent >= MAX_LOOKUP_REQUESTS {
|
||||
debug!("lookup request budget exhausted");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The closest set feeds publishes (store targets); contacts that are
|
||||
// currently backed off would only burn a dial timeout each.
|
||||
let closest_now = now_ms();
|
||||
candidates.retain(|contact| !self.dial_backoff_active(&contact.peer_id, closest_now));
|
||||
candidates.sort_by_key(|contact| distance(contact.node_id.as_bytes(), &target));
|
||||
candidates.truncate(K);
|
||||
info!(
|
||||
queried = queried.len(),
|
||||
discovered = known.len(),
|
||||
records = records.len(),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"lookup finished"
|
||||
);
|
||||
LookupOutcome {
|
||||
records: records.into_values().collect(),
|
||||
closest: candidates,
|
||||
queried: queried.len(),
|
||||
discovered: known.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes one artist record (active or tombstone) under all its DHT
|
||||
/// keys to the closest known nodes. Returns
|
||||
/// `(keys, remote nodes stored, local replica stored)`.
|
||||
pub async fn publish_artist(&self, artist: &Artist) -> Result<PublishStats> {
|
||||
self.ensure_running()?;
|
||||
let ttl = if artist.deleted {
|
||||
TOMBSTONE_TTL
|
||||
} else {
|
||||
ACTIVE_RECORD_TTL
|
||||
};
|
||||
let record = StoredArtistRecord {
|
||||
artist: artist.clone(),
|
||||
publisher: self.endpoint_id,
|
||||
expires_at_ms: now_ms() + ttl.as_millis() as u64,
|
||||
};
|
||||
let keys = artist.dht_keys(&self.config.network_id);
|
||||
let mut remote_nodes: HashSet<EndpointId> = HashSet::new();
|
||||
let mut local_replica = false;
|
||||
|
||||
for key in &keys {
|
||||
let outcome = self.lookup(*key.as_bytes(), None).await;
|
||||
let targets = outcome.closest;
|
||||
|
||||
// The record belongs on this node too if it is among the K
|
||||
// closest (always true while the network is smaller than K).
|
||||
let own_distance = distance(self.node_id.as_bytes(), key.as_bytes());
|
||||
let self_is_close = targets.len() < K
|
||||
|| targets.last().is_none_or(|farthest| {
|
||||
own_distance <= distance(farthest.node_id.as_bytes(), key.as_bytes())
|
||||
});
|
||||
if self_is_close {
|
||||
match self.db.store_dht_record(*key, record.clone()).await {
|
||||
Ok(_) => local_replica = true,
|
||||
Err(err) => warn!(error = %err, "failed to store own replica"),
|
||||
}
|
||||
}
|
||||
|
||||
let stores = targets.iter().map(|contact| async {
|
||||
let result = self
|
||||
.request(
|
||||
contact,
|
||||
OutboundRequest::Store(StoreRecordRequest {
|
||||
key: *key,
|
||||
record: record.clone(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
(contact.peer_id, result)
|
||||
});
|
||||
for (peer, result) in join_all(stores).await {
|
||||
match result {
|
||||
Ok(DhtResponse::Store(StoreRecordResponse { stored: true })) => {
|
||||
remote_nodes.insert(peer);
|
||||
}
|
||||
Ok(DhtResponse::Store(StoreRecordResponse { stored: false })) => {
|
||||
debug!(peer = %peer, "peer declined to store the record");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => debug!(peer = %peer, error = %err, "store request failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
artist = %artist.id,
|
||||
tombstone = artist.deleted,
|
||||
keys = keys.len(),
|
||||
nodes = remote_nodes.len(),
|
||||
"published artist record"
|
||||
);
|
||||
Ok(PublishStats {
|
||||
records: 1,
|
||||
keys: keys.len(),
|
||||
remote_nodes: remote_nodes.len(),
|
||||
local_replica,
|
||||
})
|
||||
}
|
||||
|
||||
/// Republishes every local record that is still alive.
|
||||
pub async fn republish_all(&self) -> Result<PublishStats> {
|
||||
self.ensure_running()?;
|
||||
let artists = self.db.local_artists_for_republish(now_ms()).await?;
|
||||
let mut total = PublishStats::default();
|
||||
for artist in &artists {
|
||||
let stats = self.publish_artist(artist).await?;
|
||||
total.records += 1;
|
||||
total.keys += stats.keys;
|
||||
// remote_nodes counts unique nodes per record; report the widest
|
||||
// replication seen across records.
|
||||
total.remote_nodes = total.remote_nodes.max(stats.remote_nodes);
|
||||
total.local_replica |= stats.local_replica;
|
||||
}
|
||||
info!(
|
||||
records = total.records,
|
||||
keys = total.keys,
|
||||
"republish finished"
|
||||
);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Drops expired replicas from the local store.
|
||||
pub async fn sweep_expired(&self) {
|
||||
match self.db.delete_expired_records(now_ms()).await {
|
||||
Ok(0) => {}
|
||||
Ok(count) => info!(count, "removed expired DHT records"),
|
||||
Err(err) => warn!(error = %err, "failed to sweep expired records"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if `candidate` should replace `existing` in a search result set.
|
||||
pub(crate) fn record_supersedes(
|
||||
candidate: &StoredArtistRecord,
|
||||
existing: &StoredArtistRecord,
|
||||
) -> bool {
|
||||
let (c, e) = (&candidate.artist, &existing.artist);
|
||||
c.revision > e.revision || (c.revision == e.revision && c.deleted && !e.deleted)
|
||||
}
|
||||
|
||||
/// Filters an incoming peer-exchange batch: drops our own contact, the
|
||||
/// sender's contact and duplicate endpoint ids, and enforces the batch cap.
|
||||
pub(crate) fn sanitize_peer_exchange(
|
||||
own: EndpointId,
|
||||
sender: EndpointId,
|
||||
peers: Vec<NodeContact>,
|
||||
) -> Vec<NodeContact> {
|
||||
let mut seen: HashSet<EndpointId> = HashSet::new();
|
||||
peers
|
||||
.into_iter()
|
||||
.take(MAX_PEER_EXCHANGE_CONTACTS)
|
||||
.filter(|contact| {
|
||||
contact.peer_id != own && contact.peer_id != sender && seen.insert(contact.peer_id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn contact(seed: u8) -> NodeContact {
|
||||
let peer = test_peer(seed);
|
||||
NodeContact {
|
||||
node_id: NodeId::from_endpoint(&peer),
|
||||
peer_id: peer,
|
||||
ticket: format!("fnet-test-{seed}"),
|
||||
last_seen_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dial_backoff_doubles_and_is_capped() {
|
||||
assert_eq!(dial_backoff(1), Duration::from_secs(30));
|
||||
assert_eq!(dial_backoff(2), Duration::from_secs(60));
|
||||
assert_eq!(dial_backoff(3), Duration::from_secs(120));
|
||||
assert_eq!(dial_backoff(5), Duration::from_secs(480));
|
||||
// Capped at the maximum from the 6th failure on, even for huge counts.
|
||||
assert_eq!(dial_backoff(6), DIAL_BACKOFF_MAX);
|
||||
assert_eq!(dial_backoff(u32::MAX), DIAL_BACKOFF_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_exchange_drops_duplicates_self_and_sender() {
|
||||
let own = test_peer(1);
|
||||
let sender = test_peer(2);
|
||||
let peers = vec![
|
||||
contact(3),
|
||||
contact(3), // duplicate
|
||||
contact(1), // ourselves
|
||||
contact(2), // the sender
|
||||
contact(4),
|
||||
];
|
||||
let sanitized = sanitize_peer_exchange(own, sender, peers);
|
||||
let ids: Vec<EndpointId> = sanitized.iter().map(|c| c.peer_id).collect();
|
||||
assert_eq!(ids, vec![test_peer(3), test_peer(4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_exchange_is_capped() {
|
||||
let own = test_peer(1);
|
||||
let sender = test_peer(2);
|
||||
let peers: Vec<NodeContact> = (10..10 + MAX_PEER_EXCHANGE_CONTACTS as u8 + 8)
|
||||
.map(contact)
|
||||
.collect();
|
||||
let sanitized = sanitize_peer_exchange(own, sender, peers);
|
||||
assert_eq!(sanitized.len(), MAX_PEER_EXCHANGE_CONTACTS);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//! Artist name normalization and tokenization.
|
||||
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
/// Normalizes an artist name for indexing and comparison.
|
||||
///
|
||||
/// The algorithm is: Unicode NFKC normalization, lowercasing, replacing every
|
||||
/// non-alphanumeric character with a space, collapsing repeated spaces and
|
||||
/// trimming. The result is deterministic for a given input.
|
||||
///
|
||||
/// ```
|
||||
/// use artist_dht::normalize_artist_name;
|
||||
/// assert_eq!(normalize_artist_name("Massive Attack"), "massive attack");
|
||||
/// assert_eq!(normalize_artist_name(" MASSIVE ATTACK "), "massive attack");
|
||||
/// assert_eq!(normalize_artist_name("Massive-Attack"), "massive attack");
|
||||
/// assert_eq!(normalize_artist_name("Björk"), "björk");
|
||||
/// ```
|
||||
pub fn normalize_artist_name(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut pending_space = false;
|
||||
for ch in input.nfkc() {
|
||||
if ch.is_alphanumeric() {
|
||||
if pending_space && !result.is_empty() {
|
||||
result.push(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
for lower in ch.to_lowercase() {
|
||||
result.push(lower);
|
||||
}
|
||||
} else {
|
||||
pending_space = true;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Splits a normalized name into search tokens.
|
||||
///
|
||||
/// Empty tokens are ignored.
|
||||
///
|
||||
/// ```
|
||||
/// use artist_dht::tokenize;
|
||||
/// assert_eq!(tokenize("massive attack"), vec!["massive", "attack"]);
|
||||
/// ```
|
||||
pub fn tokenize(normalized: &str) -> Vec<String> {
|
||||
normalized
|
||||
.split(' ')
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalization_is_deterministic() {
|
||||
let a = normalize_artist_name("Massive Attack");
|
||||
let b = normalize_artist_name("Massive Attack");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a, "massive attack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_handles_case_and_whitespace() {
|
||||
assert_eq!(
|
||||
normalize_artist_name(" MASSIVE ATTACK "),
|
||||
"massive attack"
|
||||
);
|
||||
assert_eq!(normalize_artist_name("Massive-Attack"), "massive attack");
|
||||
assert_eq!(
|
||||
normalize_artist_name("Massive___Attack!!!"),
|
||||
"massive attack"
|
||||
);
|
||||
assert_eq!(normalize_artist_name("Björk"), "björk");
|
||||
assert_eq!(normalize_artist_name(" "), "");
|
||||
assert_eq!(normalize_artist_name("!!!"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_applies_nfkc() {
|
||||
// U+FF21 FULLWIDTH LATIN CAPITAL LETTER A normalizes to 'A' → 'a'.
|
||||
assert_eq!(normalize_artist_name("\u{FF21}BBA"), "abba");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenize_splits_and_skips_empty() {
|
||||
assert_eq!(tokenize("massive attack"), vec!["massive", "attack"]);
|
||||
assert_eq!(tokenize(""), Vec::<String>::new());
|
||||
assert_eq!(tokenize("solo"), vec!["solo"]);
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
//! Artist records, DHT keys and record lifetime rules.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use federation_net::{EndpointId, NetworkId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ArtistDhtError;
|
||||
use crate::normalization::{normalize_artist_name, tokenize};
|
||||
|
||||
/// Identifier of the peer that owns a record (its `federation-net` endpoint).
|
||||
pub type PeerId = EndpointId;
|
||||
|
||||
/// Maximum artist name length in UTF-8 bytes.
|
||||
pub const MAX_ARTIST_NAME_BYTES: usize = 512;
|
||||
/// Maximum number of tokens a single artist name may produce.
|
||||
pub const MAX_TOKENS_PER_ARTIST: usize = 32;
|
||||
/// Maximum lifetime of an active DHT record.
|
||||
pub const ACTIVE_RECORD_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
/// Maximum lifetime of a tombstone.
|
||||
pub const TOMBSTONE_TTL: Duration = Duration::from_secs(2 * 60 * 60);
|
||||
|
||||
fn fmt_hex(bytes: &[u8; 32], f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for byte in bytes {
|
||||
write!(f, "{byte:02x}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stable identifier of one artist record.
|
||||
///
|
||||
/// Derived deterministically from the owning peer and a fresh UUID, so two
|
||||
/// peers adding the same name produce two distinct records.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct ArtistId([u8; 32]);
|
||||
|
||||
impl ArtistId {
|
||||
/// Derives an artist id: `BLAKE3("artist-dht:artist:" || owner || uuid)`.
|
||||
pub fn derive(owner: &EndpointId, uuid: &Uuid) -> Self {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"artist-dht:artist:");
|
||||
hasher.update(owner.as_bytes());
|
||||
hasher.update(uuid.as_bytes());
|
||||
Self(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
/// Creates an id from raw bytes.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Returns the raw bytes of this id.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Renders the id as lowercase hex.
|
||||
pub fn to_hex(&self) -> String {
|
||||
data_encoding::HEXLOWER.encode(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ArtistId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt_hex(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ArtistId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ArtistId(")?;
|
||||
fmt_hex(&self.0, f)?;
|
||||
write!(f, ")")
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ArtistId {
|
||||
type Err = ArtistDhtError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let bytes = data_encoding::HEXLOWER_PERMISSIVE
|
||||
.decode(s.trim().as_bytes())
|
||||
.map_err(|_| ArtistDhtError::ArtistNotFound)?;
|
||||
let bytes: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| ArtistDhtError::ArtistNotFound)?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// One artist record as stored by its owner.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Artist {
|
||||
/// Stable identifier of this record.
|
||||
pub id: ArtistId,
|
||||
/// Peer that created (and owns) the record.
|
||||
pub owner: PeerId,
|
||||
/// Human-readable artist name as entered by the user.
|
||||
pub name: String,
|
||||
/// Normalized form of the name, used for indexing.
|
||||
pub normalized_name: String,
|
||||
/// Monotonically increasing revision; bumped on every change.
|
||||
pub revision: u64,
|
||||
/// `true` if this record is a deletion tombstone.
|
||||
pub deleted: bool,
|
||||
/// Unix timestamp (milliseconds) of the last modification.
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
impl Artist {
|
||||
/// Returns the DHT keys this artist is published under: the exact key
|
||||
/// plus one key per unique token.
|
||||
pub fn dht_keys(&self, network_id: &NetworkId) -> Vec<DhtKey> {
|
||||
let mut keys = vec![DhtKey::exact(network_id, &self.normalized_name)];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for token in tokenize(&self.normalized_name) {
|
||||
if seen.insert(token.clone()) {
|
||||
keys.push(DhtKey::token(network_id, &token));
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
/// A replicated DHT entry: an artist plus replication metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StoredArtistRecord {
|
||||
/// The artist record itself.
|
||||
pub artist: Artist,
|
||||
/// Peer that pushed this replica. Not cryptographically verified in the
|
||||
/// PoC: the connection authenticates the direct sender, not the origin
|
||||
/// of a replicated record.
|
||||
pub publisher: PeerId,
|
||||
/// Unix timestamp (milliseconds) after which the replica must be dropped.
|
||||
pub expires_at_ms: u64,
|
||||
}
|
||||
|
||||
/// A 256-bit DHT key. Lives in the same key space as [`crate::NodeId`].
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct DhtKey([u8; 32]);
|
||||
|
||||
impl DhtKey {
|
||||
/// Key for exact-name lookups:
|
||||
/// `BLAKE3(NetworkId || "artist:exact:" || normalized_name)`.
|
||||
pub fn exact(network_id: &NetworkId, normalized_name: &str) -> Self {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(network_id.as_bytes());
|
||||
hasher.update(b"artist:exact:");
|
||||
hasher.update(normalized_name.as_bytes());
|
||||
Self(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
/// Key for single-token lookups:
|
||||
/// `BLAKE3(NetworkId || "artist:token:" || token)`.
|
||||
pub fn token(network_id: &NetworkId, token: &str) -> Self {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(network_id.as_bytes());
|
||||
hasher.update(b"artist:token:");
|
||||
hasher.update(token.as_bytes());
|
||||
Self(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
/// Creates a key from raw bytes.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Returns the raw bytes of this key.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DhtKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt_hex(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DhtKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "DhtKey(")?;
|
||||
fmt_hex(&self.0, f)?;
|
||||
write!(f, ")")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current Unix time in milliseconds.
|
||||
pub(crate) fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Validates a user-supplied artist name and returns its normalized form.
|
||||
pub(crate) fn validate_name(name: &str) -> Result<String, ArtistDhtError> {
|
||||
if name.len() > MAX_ARTIST_NAME_BYTES {
|
||||
return Err(ArtistDhtError::ArtistNameTooLong);
|
||||
}
|
||||
let normalized = normalize_artist_name(name);
|
||||
if normalized.is_empty() {
|
||||
return Err(ArtistDhtError::InvalidArtistName);
|
||||
}
|
||||
if tokenize(&normalized).len() > MAX_TOKENS_PER_ARTIST {
|
||||
return Err(ArtistDhtError::ArtistNameTooLong);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_id_is_deterministic() {
|
||||
let key = test_peer(7);
|
||||
let uuid = Uuid::from_u128(42);
|
||||
let a = ArtistId::derive(&key, &uuid);
|
||||
let b = ArtistId::derive(&key, &uuid);
|
||||
assert_eq!(a, b);
|
||||
let other = ArtistId::derive(&key, &Uuid::from_u128(43));
|
||||
assert_ne!(a, other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_id_hex_round_trip() {
|
||||
let id = ArtistId::from_bytes([0xabu8; 32]);
|
||||
let parsed: ArtistId = id.to_hex().parse().expect("parse");
|
||||
assert_eq!(parsed, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_key_is_deterministic_and_distinct() {
|
||||
let net = NetworkId::from_name("test");
|
||||
let a = DhtKey::exact(&net, "massive attack");
|
||||
let b = DhtKey::exact(&net, "massive attack");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, DhtKey::exact(&net, "portishead"));
|
||||
// A different network yields different keys for the same name.
|
||||
let other_net = NetworkId::from_name("other");
|
||||
assert_ne!(a, DhtKey::exact(&other_net, "massive attack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_key_is_deterministic_and_distinct_from_exact() {
|
||||
let net = NetworkId::from_name("test");
|
||||
let token = DhtKey::token(&net, "massive");
|
||||
assert_eq!(token, DhtKey::token(&net, "massive"));
|
||||
assert_ne!(token, DhtKey::exact(&net, "massive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dht_keys_cover_exact_and_unique_tokens() {
|
||||
let key = test_peer(7);
|
||||
let artist = Artist {
|
||||
id: ArtistId::from_bytes([1u8; 32]),
|
||||
owner: key,
|
||||
name: "Attack Attack".into(),
|
||||
normalized_name: "attack attack".into(),
|
||||
revision: 1,
|
||||
deleted: false,
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
let net = NetworkId::from_name("test");
|
||||
let keys = artist.dht_keys(&net);
|
||||
// One exact key + one deduplicated token key.
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(keys[0], DhtKey::exact(&net, "attack attack"));
|
||||
assert_eq!(keys[1], DhtKey::token(&net, "attack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation() {
|
||||
assert!(validate_name("Massive Attack").is_ok());
|
||||
assert!(matches!(
|
||||
validate_name("!!!"),
|
||||
Err(ArtistDhtError::InvalidArtistName)
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_name(&"x".repeat(MAX_ARTIST_NAME_BYTES + 1)),
|
||||
Err(ArtistDhtError::ArtistNameTooLong)
|
||||
));
|
||||
let many_tokens = (0..MAX_TOKENS_PER_ARTIST + 1)
|
||||
.map(|i| format!("t{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert!(matches!(
|
||||
validate_name(&many_tokens),
|
||||
Err(ArtistDhtError::ArtistNameTooLong)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Tracking of in-flight DHT requests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use federation_net::EndpointId;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::error::{ArtistDhtError, Result};
|
||||
use crate::message::{
|
||||
FindNodeResponse, FindValueResponse, PongResponse, RequestId, StoreRecordResponse,
|
||||
};
|
||||
|
||||
/// Maximum number of simultaneously pending requests.
|
||||
pub const MAX_PENDING_REQUESTS: usize = 1024;
|
||||
|
||||
/// A response payload of any DHT request type.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DhtResponse {
|
||||
Pong(PongResponse),
|
||||
FindNode(FindNodeResponse),
|
||||
FindValue(FindValueResponse),
|
||||
Store(StoreRecordResponse),
|
||||
}
|
||||
|
||||
struct PendingEntry {
|
||||
/// The peer the response is expected from.
|
||||
peer: EndpointId,
|
||||
sender: oneshot::Sender<DhtResponse>,
|
||||
}
|
||||
|
||||
/// Correlates responses with awaiting requesters.
|
||||
///
|
||||
/// Entries are removed when the response arrives, and the requester removes
|
||||
/// its own entry on timeout, so the map cannot grow without bound; a hard cap
|
||||
/// of [`MAX_PENDING_REQUESTS`] guards against bugs.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PendingRequests {
|
||||
map: Mutex<HashMap<RequestId, PendingEntry>>,
|
||||
}
|
||||
|
||||
impl PendingRequests {
|
||||
/// Registers a new pending request and returns the receiver for its
|
||||
/// response.
|
||||
pub fn register(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
peer: EndpointId,
|
||||
) -> Result<oneshot::Receiver<DhtResponse>> {
|
||||
let mut map = lock(&self.map);
|
||||
if map.len() >= MAX_PENDING_REQUESTS {
|
||||
return Err(ArtistDhtError::Protocol(
|
||||
"too many pending requests".to_string(),
|
||||
));
|
||||
}
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
map.insert(request_id, PendingEntry { peer, sender });
|
||||
Ok(receiver)
|
||||
}
|
||||
|
||||
/// Completes a pending request with a response from `from_peer`.
|
||||
///
|
||||
/// The response is delivered only if it comes from the peer the request
|
||||
/// was sent to; otherwise the entry stays and the stray response is
|
||||
/// dropped. Returns `true` if a waiting requester was resolved.
|
||||
pub fn complete(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
from_peer: &EndpointId,
|
||||
response: DhtResponse,
|
||||
) -> bool {
|
||||
let mut map = lock(&self.map);
|
||||
match map.get(request_id) {
|
||||
Some(entry) if &entry.peer == from_peer => {
|
||||
if let Some(entry) = map.remove(request_id) {
|
||||
// The requester may have timed out already; that is fine.
|
||||
let _ = entry.sender.send(response);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a pending request, e.g. after a timeout.
|
||||
pub fn remove(&self, request_id: &RequestId) {
|
||||
lock(&self.map).remove(request_id);
|
||||
}
|
||||
|
||||
/// Returns a guard that removes the entry when dropped, making a request
|
||||
/// future safe to cancel (e.g. when a lookup exits early). Removing an
|
||||
/// already-completed entry is a no-op.
|
||||
pub fn remove_on_drop(&self, request_id: RequestId) -> PendingCleanup<'_> {
|
||||
PendingCleanup {
|
||||
pending: self,
|
||||
request_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of currently pending requests.
|
||||
pub fn len(&self) -> usize {
|
||||
lock(&self.map).len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a pending entry on drop; see [`PendingRequests::remove_on_drop`].
|
||||
pub(crate) struct PendingCleanup<'a> {
|
||||
pending: &'a PendingRequests,
|
||||
request_id: RequestId,
|
||||
}
|
||||
|
||||
impl Drop for PendingCleanup<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.pending.remove(&self.request_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routing::NodeId;
|
||||
|
||||
fn test_peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn pong() -> DhtResponse {
|
||||
DhtResponse::Pong(PongResponse {
|
||||
node_id: NodeId::from_bytes([0u8; 32]),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_resolves_pending_request() {
|
||||
let pending = PendingRequests::default();
|
||||
let peer = test_peer(1);
|
||||
let id = RequestId::random();
|
||||
let receiver = pending.register(id, peer).expect("register");
|
||||
assert!(pending.complete(&id, &peer, pong()));
|
||||
assert!(receiver.await.is_ok());
|
||||
assert_eq!(pending.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_from_wrong_peer_is_ignored() {
|
||||
let pending = PendingRequests::default();
|
||||
let peer = test_peer(1);
|
||||
let wrong = test_peer(2);
|
||||
let id = RequestId::random();
|
||||
let _receiver = pending.register(id, peer).expect("register");
|
||||
assert!(!pending.complete(&id, &wrong, pong()));
|
||||
// The entry is still pending for the right peer.
|
||||
assert_eq!(pending.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entry_is_removed_after_timeout() {
|
||||
let pending = PendingRequests::default();
|
||||
let peer = test_peer(1);
|
||||
let id = RequestId::random();
|
||||
let receiver = pending.register(id, peer).expect("register");
|
||||
// Simulate the requester timing out: it removes its own entry.
|
||||
let result = tokio::time::timeout(std::time::Duration::from_millis(20), receiver).await;
|
||||
assert!(result.is_err());
|
||||
pending.remove(&id);
|
||||
assert_eq!(pending.len(), 0);
|
||||
// A late response finds nothing to complete.
|
||||
assert!(!pending.complete(&id, &peer, pong()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_is_capped() {
|
||||
let pending = PendingRequests::default();
|
||||
let peer = test_peer(1);
|
||||
let mut receivers = Vec::new();
|
||||
for _ in 0..MAX_PENDING_REQUESTS {
|
||||
receivers.push(
|
||||
pending
|
||||
.register(RequestId::random(), peer)
|
||||
.expect("register"),
|
||||
);
|
||||
}
|
||||
assert!(pending.register(RequestId::random(), peer).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
//! Kademlia-style node identifiers, XOR distance and routing table.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use federation_net::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::record::DhtKey;
|
||||
|
||||
/// Replication factor: how many closest nodes store a record and how many
|
||||
/// contacts a single response may carry.
|
||||
pub const K: usize = 8;
|
||||
/// Lookup parallelism: how many candidates are queried concurrently.
|
||||
pub const ALPHA: usize = 3;
|
||||
/// Hard budget of requests a single iterative lookup may send.
|
||||
pub const MAX_LOOKUP_REQUESTS: usize = 32;
|
||||
|
||||
/// A 256-bit DHT node identifier, derived from the peer's endpoint id.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct NodeId([u8; 32]);
|
||||
|
||||
impl NodeId {
|
||||
/// Derives the node id: `BLAKE3("artist-dht:node:" || endpoint id)`.
|
||||
///
|
||||
/// The endpoint id is persistent, so the node id is stable across
|
||||
/// restarts.
|
||||
pub fn from_endpoint(endpoint_id: &EndpointId) -> Self {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"artist-dht:node:");
|
||||
hasher.update(endpoint_id.as_bytes());
|
||||
Self(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
/// Creates a node id from raw bytes.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Returns the raw bytes of this id.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NodeId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for byte in &self.0 {
|
||||
write!(f, "{byte:02x}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for NodeId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "NodeId({self})")
|
||||
}
|
||||
}
|
||||
|
||||
/// XOR distance between two points of the 256-bit key space, compared as
|
||||
/// unsigned big-endian integers.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
pub struct Distance([u8; 32]);
|
||||
|
||||
/// Computes the XOR distance between two 256-bit values.
|
||||
pub fn distance(a: &[u8; 32], b: &[u8; 32]) -> Distance {
|
||||
let mut out = [0u8; 32];
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
*byte = a[i] ^ b[i];
|
||||
}
|
||||
Distance(out)
|
||||
}
|
||||
|
||||
impl Distance {
|
||||
/// Index of the k-bucket this distance falls into: the position of the
|
||||
/// highest set bit (0..=255). Returns `None` for a zero distance (self).
|
||||
pub fn bucket_index(&self) -> Option<usize> {
|
||||
for (i, byte) in self.0.iter().enumerate() {
|
||||
if *byte != 0 {
|
||||
return Some(255 - (i * 8 + byte.leading_zeros() as usize));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to reach another DHT node.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeContact {
|
||||
/// DHT identifier of the node.
|
||||
pub node_id: NodeId,
|
||||
/// Transport identifier of the node.
|
||||
pub peer_id: EndpointId,
|
||||
/// `federation-net` ticket used to connect on demand.
|
||||
pub ticket: String,
|
||||
/// Unix timestamp (milliseconds) of the last observed activity.
|
||||
pub last_seen_ms: u64,
|
||||
}
|
||||
|
||||
/// A simplified Kademlia routing table: 256 k-buckets of up to [`K`] contacts.
|
||||
pub struct RoutingTable {
|
||||
own_id: NodeId,
|
||||
buckets: Vec<Vec<NodeContact>>,
|
||||
}
|
||||
|
||||
impl RoutingTable {
|
||||
/// Creates an empty routing table for the given local node id.
|
||||
pub fn new(own_id: NodeId) -> Self {
|
||||
Self {
|
||||
own_id,
|
||||
buckets: vec![Vec::new(); 256],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the local node id.
|
||||
pub fn own_id(&self) -> NodeId {
|
||||
self.own_id
|
||||
}
|
||||
|
||||
/// Inserts or refreshes a contact. Returns `true` if the contact was not
|
||||
/// known before.
|
||||
///
|
||||
/// A full bucket evicts its least recently seen contact; the PoC skips
|
||||
/// the classic ping-before-evict procedure.
|
||||
pub fn upsert(&mut self, contact: NodeContact) -> bool {
|
||||
let Some(index) =
|
||||
distance(self.own_id.as_bytes(), contact.node_id.as_bytes()).bucket_index()
|
||||
else {
|
||||
// Zero distance: never store ourselves.
|
||||
return false;
|
||||
};
|
||||
let bucket = &mut self.buckets[index];
|
||||
if let Some(existing) = bucket
|
||||
.iter_mut()
|
||||
.find(|entry| entry.peer_id == contact.peer_id)
|
||||
{
|
||||
existing.node_id = contact.node_id;
|
||||
existing.ticket = contact.ticket;
|
||||
existing.last_seen_ms = existing.last_seen_ms.max(contact.last_seen_ms);
|
||||
return false;
|
||||
}
|
||||
if bucket.len() >= K {
|
||||
// Evict the least recently seen contact.
|
||||
if let Some((oldest, _)) = bucket
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, entry)| entry.last_seen_ms)
|
||||
{
|
||||
bucket.remove(oldest);
|
||||
}
|
||||
}
|
||||
bucket.push(contact);
|
||||
true
|
||||
}
|
||||
|
||||
/// Refreshes the `last_seen_ms` of a known peer. Returns `true` if the
|
||||
/// peer was found.
|
||||
pub fn touch(&mut self, peer_id: &EndpointId, now_ms: u64) -> bool {
|
||||
for bucket in &mut self.buckets {
|
||||
if let Some(entry) = bucket.iter_mut().find(|entry| &entry.peer_id == peer_id) {
|
||||
entry.last_seen_ms = entry.last_seen_ms.max(now_ms);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Removes a contact by its transport id. Returns `true` if it was known.
|
||||
pub fn remove(&mut self, peer_id: &EndpointId) -> bool {
|
||||
for bucket in &mut self.buckets {
|
||||
if let Some(index) = bucket.iter().position(|entry| &entry.peer_id == peer_id) {
|
||||
bucket.remove(index);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Looks up a contact by its transport id.
|
||||
pub fn get(&self, peer_id: &EndpointId) -> Option<NodeContact> {
|
||||
self.buckets
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|entry| &entry.peer_id == peer_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Returns up to `count` known contacts closest to `target` by XOR
|
||||
/// distance.
|
||||
pub fn closest(&self, target: &[u8; 32], count: usize) -> Vec<NodeContact> {
|
||||
let mut contacts: Vec<NodeContact> = self.buckets.iter().flatten().cloned().collect();
|
||||
contacts.sort_by_key(|contact| distance(contact.node_id.as_bytes(), target));
|
||||
contacts.truncate(count);
|
||||
contacts
|
||||
}
|
||||
|
||||
/// Returns all known contacts.
|
||||
pub fn contacts(&self) -> Vec<NodeContact> {
|
||||
self.buckets.iter().flatten().cloned().collect()
|
||||
}
|
||||
|
||||
/// Returns the number of known contacts.
|
||||
pub fn len(&self) -> usize {
|
||||
self.buckets.iter().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
/// Returns `true` if no contacts are known.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: distance from a node to a DHT key.
|
||||
pub fn key_distance(node: &NodeId, key: &DhtKey) -> Distance {
|
||||
distance(node.as_bytes(), key.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_peer(seed: u8) -> EndpointId {
|
||||
iroh::SecretKey::from_bytes(&[seed; 32]).public()
|
||||
}
|
||||
|
||||
fn contact(seed: u8, last_seen_ms: u64) -> NodeContact {
|
||||
let peer = test_peer(seed);
|
||||
NodeContact {
|
||||
node_id: NodeId::from_endpoint(&peer),
|
||||
peer_id: peer,
|
||||
ticket: format!("fnet-test-{seed}"),
|
||||
last_seen_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_id_is_deterministic() {
|
||||
let peer = test_peer(1);
|
||||
assert_eq!(NodeId::from_endpoint(&peer), NodeId::from_endpoint(&peer));
|
||||
assert_ne!(
|
||||
NodeId::from_endpoint(&peer),
|
||||
NodeId::from_endpoint(&test_peer(2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_distance_properties() {
|
||||
let a = [0b1010_0000u8; 32];
|
||||
let b = [0b0000_0000u8; 32];
|
||||
assert_eq!(distance(&a, &a), distance(&b, &b));
|
||||
assert_eq!(distance(&a, &b), distance(&b, &a));
|
||||
// d(a, a) == 0 and is the smallest possible distance.
|
||||
assert!(distance(&a, &a) < distance(&a, &b));
|
||||
|
||||
// Big-endian comparison: a difference in the first byte outweighs
|
||||
// any difference in later bytes.
|
||||
let mut c = [0u8; 32];
|
||||
c[0] = 1;
|
||||
let mut d = [0u8; 32];
|
||||
d[31] = 0xff;
|
||||
assert!(distance(&c, &b) > distance(&d, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_index_matches_highest_bit() {
|
||||
let zero = [0u8; 32];
|
||||
let mut one = [0u8; 32];
|
||||
one[31] = 1;
|
||||
assert_eq!(distance(&zero, &one).bucket_index(), Some(0));
|
||||
let mut top = [0u8; 32];
|
||||
top[0] = 0x80;
|
||||
assert_eq!(distance(&zero, &top).bucket_index(), Some(255));
|
||||
assert_eq!(distance(&zero, &zero).bucket_index(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_sorts_by_distance() {
|
||||
let own = NodeId::from_bytes([0u8; 32]);
|
||||
let mut table = RoutingTable::new(own);
|
||||
for seed in 1..=20u8 {
|
||||
table.upsert(contact(seed, seed as u64));
|
||||
}
|
||||
let target = [0x42u8; 32];
|
||||
let closest = table.closest(&target, K);
|
||||
assert!(closest.len() <= K);
|
||||
for pair in closest.windows(2) {
|
||||
assert!(
|
||||
distance(pair[0].node_id.as_bytes(), &target)
|
||||
<= distance(pair[1].node_id.as_bytes(), &target)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_is_capped_at_k() {
|
||||
// All contacts whose distance to `own` shares the same highest bit
|
||||
// land in one bucket; force that by controlling the node ids.
|
||||
let own = NodeId::from_bytes([0u8; 32]);
|
||||
let mut table = RoutingTable::new(own);
|
||||
for i in 0..(K as u8 + 4) {
|
||||
let peer = test_peer(i + 1);
|
||||
let mut id = [0x80u8; 32];
|
||||
id[31] = i;
|
||||
table.upsert(NodeContact {
|
||||
node_id: NodeId::from_bytes(id),
|
||||
peer_id: peer,
|
||||
ticket: String::new(),
|
||||
last_seen_ms: u64::from(i),
|
||||
});
|
||||
}
|
||||
assert_eq!(table.len(), K);
|
||||
// The oldest contacts (smallest last_seen_ms) were evicted.
|
||||
let contacts = table.contacts();
|
||||
assert!(contacts.iter().all(|c| c.last_seen_ms >= 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_contact() {
|
||||
let own = NodeId::from_bytes([0u8; 32]);
|
||||
let mut table = RoutingTable::new(own);
|
||||
table.upsert(contact(1, 10));
|
||||
table.upsert(contact(2, 10));
|
||||
assert!(table.remove(&test_peer(1)));
|
||||
assert!(!table.remove(&test_peer(1)));
|
||||
assert_eq!(table.len(), 1);
|
||||
assert!(table.get(&test_peer(1)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_refreshes_existing_contact() {
|
||||
let own = NodeId::from_bytes([0u8; 32]);
|
||||
let mut table = RoutingTable::new(own);
|
||||
assert!(table.upsert(contact(1, 10)));
|
||||
assert!(!table.upsert(contact(1, 20)));
|
||||
assert_eq!(table.len(), 1);
|
||||
assert_eq!(table.contacts()[0].last_seen_ms, 20);
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
//! The public service facade: lifecycle, artist operations and search.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use federation_net::{EndpointId, NetworkConfig, NetworkEngine, PeerTicket, SchemaId};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::ArtistDhtConfig;
|
||||
use crate::database::Database;
|
||||
use crate::error::{ArtistDhtError, Result};
|
||||
use crate::node::{Node, record_supersedes};
|
||||
use crate::normalization::{normalize_artist_name, tokenize};
|
||||
use crate::record::{Artist, ArtistId, DhtKey, PeerId, StoredArtistRecord, now_ms, validate_name};
|
||||
use crate::routing::{NodeContact, NodeId};
|
||||
|
||||
/// Fixed schema of the artist-dht protocol; peers with a different schema are
|
||||
/// rejected by `federation-net` during the handshake.
|
||||
pub const SCHEMA_NAME: &str = "artist-dht-poc-v1";
|
||||
|
||||
/// Capacity of the application event channel.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// Events delivered to the application.
|
||||
#[derive(Debug)]
|
||||
pub enum ArtistDhtEvent {
|
||||
/// A transport connection to a peer was established.
|
||||
PeerConnected {
|
||||
/// The connected peer.
|
||||
peer_id: EndpointId,
|
||||
},
|
||||
/// A transport connection to a peer closed.
|
||||
PeerDisconnected {
|
||||
/// The disconnected peer.
|
||||
peer_id: EndpointId,
|
||||
},
|
||||
/// A previously unknown DHT contact was learned.
|
||||
ContactDiscovered {
|
||||
/// The new contact.
|
||||
contact: NodeContact,
|
||||
},
|
||||
/// A non-fatal error occurred.
|
||||
Error {
|
||||
/// Human-readable description.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Receiving side of the service event channel.
|
||||
#[derive(Debug)]
|
||||
pub struct ArtistDhtEventReceiver {
|
||||
rx: mpsc::Receiver<ArtistDhtEvent>,
|
||||
}
|
||||
|
||||
impl ArtistDhtEventReceiver {
|
||||
/// Receives the next event; `None` after shutdown.
|
||||
pub async fn recv(&mut self) -> Option<ArtistDhtEvent> {
|
||||
self.rx.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics of one publish or republish operation.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PublishStats {
|
||||
/// Number of artist records published.
|
||||
pub records: usize,
|
||||
/// Total number of DHT keys published.
|
||||
pub keys: usize,
|
||||
/// Number of distinct remote nodes that accepted at least one replica.
|
||||
pub remote_nodes: usize,
|
||||
/// Whether a replica was also stored locally.
|
||||
pub local_replica: bool,
|
||||
}
|
||||
|
||||
/// Result of a combined local + network search.
|
||||
#[derive(Debug)]
|
||||
pub struct SearchOutcome {
|
||||
/// Matches from the local `local_artists` table.
|
||||
pub local_results: Vec<Artist>,
|
||||
/// Matches found in the DHT (including local replicas), tombstones and
|
||||
/// duplicates already filtered out. Artists matching every query token
|
||||
/// come first.
|
||||
pub network_results: Vec<Artist>,
|
||||
/// Number of distinct peers queried during the lookups.
|
||||
pub queried_nodes: usize,
|
||||
/// Number of distinct nodes discovered during the lookups.
|
||||
pub discovered_nodes: usize,
|
||||
/// Total wall-clock duration of the search.
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
/// A distributed artist directory node.
|
||||
///
|
||||
/// Every instance is simultaneously a client, a DHT router and a storage
|
||||
/// node; there are no special server roles. See the crate documentation for
|
||||
/// the protocol description.
|
||||
pub struct ArtistDhtService {
|
||||
node: Arc<Node>,
|
||||
tasks: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ArtistDhtService {
|
||||
/// Starts the service: opens the database, starts the network engine,
|
||||
/// loads persisted contacts and spawns the maintenance tasks.
|
||||
pub async fn start(config: ArtistDhtConfig) -> Result<(Self, ArtistDhtEventReceiver)> {
|
||||
let mut engine_builder = NetworkConfig::builder()
|
||||
.data_dir(&config.data_dir)
|
||||
.network_id(config.network_id)
|
||||
.schema_id(SchemaId::from_name(SCHEMA_NAME))
|
||||
.request_timeout(config.transport_timeout);
|
||||
if let Some(rendezvous) = config.rendezvous.clone() {
|
||||
engine_builder = engine_builder.rendezvous(rendezvous);
|
||||
}
|
||||
let engine_config = engine_builder
|
||||
.build()
|
||||
.map_err(|err| ArtistDhtError::Network(err.to_string()))?;
|
||||
let (engine, net_events) = NetworkEngine::start(engine_config).await?;
|
||||
let db = Database::open(&config.data_dir.join("state.sqlite3")).await?;
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY);
|
||||
let node = Arc::new(Node::new(engine, db, config.clone(), event_tx));
|
||||
info!(
|
||||
endpoint_id = %node.endpoint_id,
|
||||
node_id = %node.node_id,
|
||||
"artist-dht node starting"
|
||||
);
|
||||
|
||||
// Contacts persisted by earlier runs seed the routing table; if any
|
||||
// exist, local records are republished right away.
|
||||
let persisted = node.db.load_known_peers().await?;
|
||||
if !persisted.is_empty() {
|
||||
info!(count = persisted.len(), "loaded persisted DHT contacts");
|
||||
node.seed_contacts(persisted);
|
||||
}
|
||||
|
||||
let tasks = vec![
|
||||
tokio::spawn(node.clone().run_event_loop(net_events)),
|
||||
tokio::spawn(republish_timer(node.clone())),
|
||||
tokio::spawn(expire_timer(node.clone())),
|
||||
];
|
||||
node.maybe_trigger_initial_republish();
|
||||
|
||||
Ok((
|
||||
Self { node, tasks },
|
||||
ArtistDhtEventReceiver { rx: event_rx },
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns the transport identifier of this peer.
|
||||
pub fn endpoint_id(&self) -> EndpointId {
|
||||
self.node.endpoint_id
|
||||
}
|
||||
|
||||
/// Returns the DHT identifier of this peer.
|
||||
pub fn node_id(&self) -> NodeId {
|
||||
self.node.node_id
|
||||
}
|
||||
|
||||
/// Creates a shareable ticket for this peer.
|
||||
pub async fn ticket(&self) -> Result<PeerTicket> {
|
||||
self.node.ensure_running()?;
|
||||
self.node.engine.ticket().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Connects to another peer by ticket. Contacts are exchanged
|
||||
/// automatically once the connection is up.
|
||||
pub async fn connect(&self, ticket: PeerTicket) -> Result<EndpointId> {
|
||||
self.node.ensure_running()?;
|
||||
let peer = self.node.engine.connect(ticket).await?;
|
||||
Ok(peer)
|
||||
}
|
||||
|
||||
/// All DHT contacts currently known to this node.
|
||||
pub fn known_peers(&self) -> Vec<NodeContact> {
|
||||
self.node.known_contacts()
|
||||
}
|
||||
|
||||
/// Transport connections that are currently open.
|
||||
pub fn connected_peers(&self) -> Vec<EndpointId> {
|
||||
self.node.engine.connected_peers()
|
||||
}
|
||||
|
||||
/// Returns `true` if a transport connection to `peer` is open.
|
||||
pub fn is_connected(&self, peer: EndpointId) -> bool {
|
||||
self.node.engine.is_connected(peer)
|
||||
}
|
||||
|
||||
/// Adds a new artist to the local database and publishes it to the DHT.
|
||||
pub async fn add_artist(&self, name: String) -> Result<(Artist, PublishStats)> {
|
||||
self.node.ensure_running()?;
|
||||
let name = name.trim().to_string();
|
||||
let normalized = validate_name(&name)?;
|
||||
let uuid = uuid::Uuid::now_v7();
|
||||
let artist = Artist {
|
||||
id: ArtistId::derive(&self.node.endpoint_id, &uuid),
|
||||
owner: self.node.endpoint_id,
|
||||
name,
|
||||
normalized_name: normalized,
|
||||
revision: 1,
|
||||
deleted: false,
|
||||
updated_at_ms: now_ms(),
|
||||
};
|
||||
self.node.db.upsert_local_artist(&artist).await?;
|
||||
info!(artist = %artist.id, name = %artist.name, "added local artist");
|
||||
let stats = self.node.publish_artist(&artist).await?;
|
||||
Ok((artist, stats))
|
||||
}
|
||||
|
||||
/// Deletes a locally owned artist: stores a tombstone and publishes it.
|
||||
pub async fn delete_artist(&self, artist_id: ArtistId) -> Result<PublishStats> {
|
||||
self.node.ensure_running()?;
|
||||
let Some(mut artist) = self.node.db.get_local_artist(artist_id).await? else {
|
||||
return Err(ArtistDhtError::ArtistNotFound);
|
||||
};
|
||||
if artist.owner != self.node.endpoint_id {
|
||||
return Err(ArtistDhtError::CannotDeleteRemoteArtist);
|
||||
}
|
||||
if artist.deleted {
|
||||
return Err(ArtistDhtError::ArtistNotFound);
|
||||
}
|
||||
artist.revision += 1;
|
||||
artist.deleted = true;
|
||||
artist.updated_at_ms = now_ms();
|
||||
self.node.db.upsert_local_artist(&artist).await?;
|
||||
info!(artist = %artist.id, "deleted local artist; publishing tombstone");
|
||||
self.node.publish_artist(&artist).await
|
||||
}
|
||||
|
||||
/// Resolves a (possibly shortened) hex artist id against local records.
|
||||
///
|
||||
/// Returns [`ArtistDhtError::ArtistNotFound`] unless exactly one active
|
||||
/// local artist matches the prefix.
|
||||
pub async fn resolve_local_artist_id(&self, prefix: &str) -> Result<ArtistId> {
|
||||
let prefix = prefix.trim().to_lowercase();
|
||||
if prefix.len() < 4 || !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(ArtistDhtError::ArtistNotFound);
|
||||
}
|
||||
let matches = self.node.db.find_local_by_id_prefix(prefix).await?;
|
||||
match matches.as_slice() {
|
||||
[artist] => Ok(artist.id),
|
||||
_ => Err(ArtistDhtError::ArtistNotFound),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists all locally owned active artists.
|
||||
pub async fn list_local_artists(&self) -> Result<Vec<Artist>> {
|
||||
self.node.db.list_local_artists(false).await
|
||||
}
|
||||
|
||||
/// Searches only the local database.
|
||||
pub async fn search_local(&self, query: &str) -> Result<Vec<Artist>> {
|
||||
let normalized = normalize_artist_name(query);
|
||||
if normalized.is_empty() {
|
||||
return Err(ArtistDhtError::InvalidArtistName);
|
||||
}
|
||||
self.node.db.search_local(normalized).await
|
||||
}
|
||||
|
||||
/// Searches locally and across the DHT.
|
||||
///
|
||||
/// The exact key is looked up first; only if it yields nothing the token
|
||||
/// keys are tried. No broadcast is involved: every step is an iterative
|
||||
/// Kademlia-style lookup.
|
||||
pub async fn search_network(&self, query: &str) -> Result<SearchOutcome> {
|
||||
self.node.ensure_running()?;
|
||||
let started = Instant::now();
|
||||
let normalized = normalize_artist_name(query);
|
||||
if normalized.is_empty() {
|
||||
return Err(ArtistDhtError::InvalidArtistName);
|
||||
}
|
||||
let tokens = tokenize(&normalized);
|
||||
let network_id = self.node.config.network_id;
|
||||
|
||||
let local_results = self.node.db.search_local(normalized.clone()).await?;
|
||||
|
||||
let mut queried_nodes = 0usize;
|
||||
let mut discovered_nodes = 0usize;
|
||||
// (artist id, owner) -> best record seen so far.
|
||||
let mut merged: HashMap<(ArtistId, PeerId), StoredArtistRecord> = HashMap::new();
|
||||
fn merge(
|
||||
merged: &mut HashMap<(ArtistId, PeerId), StoredArtistRecord>,
|
||||
records: Vec<StoredArtistRecord>,
|
||||
) {
|
||||
for record in records {
|
||||
let key = (record.artist.id, record.artist.owner);
|
||||
match merged.get(&key) {
|
||||
Some(existing) if !record_supersedes(&record, existing) => {}
|
||||
_ => {
|
||||
merged.insert(key, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: the exact key — local replicas, then the network.
|
||||
let exact_key = DhtKey::exact(&network_id, &normalized);
|
||||
merge(
|
||||
&mut merged,
|
||||
self.node.db.dht_records_by_key(exact_key, now_ms()).await?,
|
||||
);
|
||||
let outcome = self
|
||||
.node
|
||||
.lookup(*exact_key.as_bytes(), Some(exact_key))
|
||||
.await;
|
||||
queried_nodes += outcome.queried;
|
||||
discovered_nodes = discovered_nodes.max(outcome.discovered);
|
||||
merge(&mut merged, outcome.records);
|
||||
|
||||
// Step 2: token keys, only when the exact key produced no live match.
|
||||
let has_live_match = merged.values().any(|record| !record.artist.deleted);
|
||||
if !has_live_match {
|
||||
for token in &tokens {
|
||||
let key = DhtKey::token(&network_id, token);
|
||||
merge(
|
||||
&mut merged,
|
||||
self.node.db.dht_records_by_key(key, now_ms()).await?,
|
||||
);
|
||||
let outcome = self.node.lookup(*key.as_bytes(), Some(key)).await;
|
||||
queried_nodes += outcome.queried;
|
||||
discovered_nodes = discovered_nodes.max(outcome.discovered);
|
||||
merge(&mut merged, outcome.records);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop tombstones and expired records, then rank: full-token matches
|
||||
// first, then alphabetically.
|
||||
let now = now_ms();
|
||||
let mut network_results: Vec<Artist> = merged
|
||||
.into_values()
|
||||
.filter(|record| !record.artist.deleted && record.expires_at_ms > now)
|
||||
.map(|record| record.artist)
|
||||
.collect();
|
||||
let matches_all_tokens = |artist: &Artist| {
|
||||
let artist_tokens = tokenize(&artist.normalized_name);
|
||||
tokens
|
||||
.iter()
|
||||
.all(|token| artist_tokens.iter().any(|t| t == token))
|
||||
};
|
||||
network_results.sort_by(|a, b| {
|
||||
matches_all_tokens(b)
|
||||
.cmp(&matches_all_tokens(a))
|
||||
.then_with(|| a.normalized_name.cmp(&b.normalized_name))
|
||||
});
|
||||
|
||||
Ok(SearchOutcome {
|
||||
local_results,
|
||||
network_results,
|
||||
queried_nodes,
|
||||
discovered_nodes,
|
||||
duration: started.elapsed(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pings a known peer: verifies liveness and DHT identity, returns the
|
||||
/// round-trip time and refreshes the contact.
|
||||
pub async fn ping(&self, peer: EndpointId) -> Result<Duration> {
|
||||
self.node.ensure_running()?;
|
||||
let contact = self
|
||||
.node
|
||||
.known_contacts()
|
||||
.into_iter()
|
||||
.find(|contact| contact.peer_id == peer)
|
||||
.ok_or_else(|| ArtistDhtError::Network(format!("unknown peer {peer}")))?;
|
||||
self.node.ping(&contact).await
|
||||
}
|
||||
|
||||
/// Republishes all live local records immediately.
|
||||
pub async fn republish(&self) -> Result<PublishStats> {
|
||||
self.node.republish_all().await
|
||||
}
|
||||
|
||||
/// Shuts the service down gracefully: stops the maintenance tasks, shuts
|
||||
/// the network engine down and closes the event channel.
|
||||
pub async fn shutdown(self) -> Result<()> {
|
||||
info!("artist-dht node shutting down");
|
||||
self.node.begin_shutdown();
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
self.node.engine.clone().shutdown().await?;
|
||||
for task in self.tasks {
|
||||
let _ = task.await;
|
||||
}
|
||||
info!("artist-dht node shut down");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn republish_timer(node: Arc<Node>) {
|
||||
let mut interval = tokio::time::interval(node.config.republish_interval);
|
||||
// The first tick fires immediately; skip it, the initial republish is
|
||||
// triggered by contact discovery instead.
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if node.is_shutting_down() {
|
||||
break;
|
||||
}
|
||||
if node.known_contacts().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = node.republish_all().await {
|
||||
tracing::warn!(error = %err, "periodic republish failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn expire_timer(node: Arc<Node>) {
|
||||
let mut interval = tokio::time::interval(node.config.expire_interval);
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if node.is_shutting_down() {
|
||||
break;
|
||||
}
|
||||
node.sweep_expired().await;
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
//! Integration tests: three DHT nodes in one Tokio runtime.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use artist_dht::{
|
||||
ArtistDhtConfig, ArtistDhtError, ArtistDhtEventReceiver, ArtistDhtService, EndpointId,
|
||||
NetworkId,
|
||||
};
|
||||
|
||||
/// Hard cap on every test so a regression can never hang CI.
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
|
||||
/// Serializes the network-facing tests: many concurrent endpoints contend on
|
||||
/// relay discovery and produce spurious timeouts.
|
||||
static NET_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
type Started = (ArtistDhtService, ArtistDhtEventReceiver);
|
||||
|
||||
async fn start(dir: &Path, network: &str) -> Started {
|
||||
let config = ArtistDhtConfig::builder()
|
||||
.data_dir(dir)
|
||||
.network_id(NetworkId::from_name(network))
|
||||
// Fast timers so republish/expiry behavior is observable in tests.
|
||||
.republish_interval(Duration::from_secs(5))
|
||||
.expire_interval(Duration::from_secs(2))
|
||||
.request_timeout(Duration::from_secs(5))
|
||||
.lookup_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("valid config");
|
||||
ArtistDhtService::start(config)
|
||||
.await
|
||||
.expect("service starts")
|
||||
}
|
||||
|
||||
/// Polls `check` until it returns `Some` or the deadline passes.
|
||||
async fn wait_for<T>(
|
||||
what: &str,
|
||||
deadline: Duration,
|
||||
mut check: impl AsyncFnMut() -> Option<T>,
|
||||
) -> T {
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(value) = check().await {
|
||||
return value;
|
||||
}
|
||||
assert!(started.elapsed() < deadline, "timed out waiting for {what}");
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn knows_peer(service: &ArtistDhtService, peer: EndpointId) -> bool {
|
||||
service
|
||||
.known_peers()
|
||||
.iter()
|
||||
.any(|contact| contact.peer_id == peer)
|
||||
}
|
||||
|
||||
/// The full demo scenario: bootstrap through one peer, peer exchange,
|
||||
/// publish, distributed search, replica survival, restart and tombstones.
|
||||
#[tokio::test]
|
||||
async fn full_distributed_flow() {
|
||||
let _net = NET_LOCK.lock().await;
|
||||
tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
let dir_a = tempfile::tempdir().expect("tempdir");
|
||||
let dir_b = tempfile::tempdir().expect("tempdir");
|
||||
let dir_c = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
// Step 1: peer A starts alone and issues a ticket.
|
||||
let (a, _events_a) = start(dir_a.path(), "test-artists").await;
|
||||
let ticket_a = a.ticket().await.expect("ticket");
|
||||
|
||||
// Step 2: peer B joins through A.
|
||||
let (b, _events_b) = start(dir_b.path(), "test-artists").await;
|
||||
let peer_a = b.connect(ticket_a.clone()).await.expect("b connects to a");
|
||||
assert_eq!(peer_a, a.endpoint_id());
|
||||
|
||||
// Step 3: peer C joins through A only...
|
||||
let (c, _events_c) = start(dir_c.path(), "test-artists").await;
|
||||
c.connect(ticket_a.clone()).await.expect("c connects to a");
|
||||
|
||||
// ...and learns about B via peer exchange, without connecting to it.
|
||||
wait_for(
|
||||
"C to learn about B via peer exchange",
|
||||
Duration::from_secs(30),
|
||||
async || knows_peer(&c, b.endpoint_id()).then_some(()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Step 4: B adds artists; they are published into the DHT.
|
||||
let (massive, stats) = b
|
||||
.add_artist("Massive Attack".into())
|
||||
.await
|
||||
.expect("add artist");
|
||||
assert_eq!(massive.normalized_name, "massive attack");
|
||||
// 1 exact key + 2 token keys.
|
||||
assert_eq!(stats.keys, 3);
|
||||
assert!(
|
||||
stats.remote_nodes >= 1,
|
||||
"the record must be replicated to at least one other node"
|
||||
);
|
||||
b.add_artist("Portishead".into()).await.expect("add artist");
|
||||
|
||||
// Step 5: A finds the record through the DHT (exact search).
|
||||
let outcome = a.search_network("massive attack").await.expect("search");
|
||||
assert!(outcome.local_results.is_empty(), "A has no local artists");
|
||||
assert_eq!(outcome.network_results.len(), 1);
|
||||
assert_eq!(outcome.network_results[0].name, "Massive Attack");
|
||||
// The result names the owner, peer B.
|
||||
assert_eq!(outcome.network_results[0].owner, b.endpoint_id());
|
||||
|
||||
// Step 6: C finds Portishead via a single-token search.
|
||||
let outcome = c.search_network("portishead").await.expect("search");
|
||||
assert_eq!(outcome.network_results.len(), 1);
|
||||
assert_eq!(outcome.network_results[0].owner, b.endpoint_id());
|
||||
|
||||
// Token search also finds multi-token names.
|
||||
let outcome = c.search_network("massive").await.expect("search");
|
||||
assert!(
|
||||
outcome
|
||||
.network_results
|
||||
.iter()
|
||||
.any(|artist| artist.name == "Massive Attack"),
|
||||
"token search must find 'Massive Attack'"
|
||||
);
|
||||
|
||||
// Step 7: stop B; replicas must keep the record findable.
|
||||
let b_id = b.endpoint_id();
|
||||
b.shutdown().await.expect("shutdown b");
|
||||
let outcome = a.search_network("massive attack").await.expect("search");
|
||||
assert_eq!(
|
||||
outcome.network_results.len(),
|
||||
1,
|
||||
"the record must survive on replicas after the owner left"
|
||||
);
|
||||
let outcome = c.search_network("massive attack").await.expect("search");
|
||||
assert_eq!(outcome.network_results.len(), 1);
|
||||
|
||||
// Step 8: B restarts with the same data dir: same identity, and its
|
||||
// local database is intact.
|
||||
let (b, _events_b) = start(dir_b.path(), "test-artists").await;
|
||||
assert_eq!(b.endpoint_id(), b_id, "endpoint id must persist");
|
||||
let local = b.list_local_artists().await.expect("list");
|
||||
assert_eq!(local.len(), 2, "local database must persist");
|
||||
b.connect(ticket_a).await.expect("b reconnects to a");
|
||||
|
||||
// Step 9: B deletes Massive Attack; the tombstone propagates and the
|
||||
// other peers stop returning the record.
|
||||
let stats = b.delete_artist(massive.id).await.expect("delete");
|
||||
assert!(stats.remote_nodes >= 1, "tombstone must reach replicas");
|
||||
let outcome = a.search_network("massive attack").await.expect("search");
|
||||
assert!(
|
||||
outcome.network_results.is_empty(),
|
||||
"A must not return a tombstoned record, got {:?}",
|
||||
outcome.network_results
|
||||
);
|
||||
let outcome = c.search_network("massive").await.expect("search");
|
||||
assert!(
|
||||
outcome
|
||||
.network_results
|
||||
.iter()
|
||||
.all(|artist| artist.id != massive.id),
|
||||
"C must not return the tombstoned record"
|
||||
);
|
||||
// Portishead is still there.
|
||||
let outcome = a.search_network("portishead").await.expect("search");
|
||||
assert_eq!(outcome.network_results.len(), 1);
|
||||
|
||||
a.shutdown().await.expect("shutdown a");
|
||||
b.shutdown().await.expect("shutdown b");
|
||||
c.shutdown().await.expect("shutdown c");
|
||||
})
|
||||
.await
|
||||
.expect("test timed out");
|
||||
}
|
||||
|
||||
/// A peer from a different network is rejected by the transport handshake.
|
||||
#[tokio::test]
|
||||
async fn different_network_is_rejected() {
|
||||
let _net = NET_LOCK.lock().await;
|
||||
tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
let dir_a = tempfile::tempdir().expect("tempdir");
|
||||
let dir_b = tempfile::tempdir().expect("tempdir");
|
||||
let (a, _events_a) = start(dir_a.path(), "network-one").await;
|
||||
let (b, _events_b) = start(dir_b.path(), "network-two").await;
|
||||
|
||||
let ticket = a.ticket().await.expect("ticket");
|
||||
let err = b.connect(ticket).await.expect_err("must be rejected");
|
||||
assert!(
|
||||
matches!(err, ArtistDhtError::Network(_)),
|
||||
"expected a network error, got {err:?}"
|
||||
);
|
||||
assert!(b.connected_peers().is_empty());
|
||||
|
||||
a.shutdown().await.expect("shutdown a");
|
||||
b.shutdown().await.expect("shutdown b");
|
||||
})
|
||||
.await
|
||||
.expect("test timed out");
|
||||
}
|
||||
|
||||
/// A lookup over dead contacts finishes within its budget and timeout
|
||||
/// instead of hanging.
|
||||
#[tokio::test]
|
||||
async fn lookup_terminates_with_dead_contacts() {
|
||||
let _net = NET_LOCK.lock().await;
|
||||
tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
let dir_a = tempfile::tempdir().expect("tempdir");
|
||||
let dir_b = tempfile::tempdir().expect("tempdir");
|
||||
let (a, _events_a) = start(dir_a.path(), "test-artists").await;
|
||||
let (b, _events_b) = start(dir_b.path(), "test-artists").await;
|
||||
|
||||
let ticket_a = a.ticket().await.expect("ticket");
|
||||
b.connect(ticket_a).await.expect("connect");
|
||||
wait_for("A to learn about B", Duration::from_secs(30), async || {
|
||||
knows_peer(&a, b.endpoint_id()).then_some(())
|
||||
})
|
||||
.await;
|
||||
|
||||
// Kill B: A still remembers it in the routing table.
|
||||
b.shutdown().await.expect("shutdown b");
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let outcome = a.search_network("anything").await.expect("search finishes");
|
||||
assert!(outcome.network_results.is_empty());
|
||||
// Bounded by the lookup timeout per key (exact + 1 token) with slack
|
||||
// for connection attempts; the essential property is that it returns.
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(60),
|
||||
"lookup took too long: {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
|
||||
a.shutdown().await.expect("shutdown a");
|
||||
})
|
||||
.await
|
||||
.expect("test timed out");
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
name = "federation-net"
|
||||
version = "0.1.0"
|
||||
description = "Generic peer-to-peer networking engine built on Iroh"
|
||||
readme = "README.md"
|
||||
documentation = "https://docs.rs/federation-net"
|
||||
repository.workspace = true
|
||||
keywords = ["p2p", "iroh", "quic", "networking", "dht"]
|
||||
categories = ["network-programming", "asynchronous"]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
@@ -0,0 +1,114 @@
|
||||
# federation-net
|
||||
|
||||
`federation-net` is a generic Rust networking engine for equal peers, built on
|
||||
[iroh](https://iroh.computer). It establishes authenticated QUIC connections
|
||||
through NATs, exchanges typed application messages, and exposes direct byte
|
||||
streams for additional protocols.
|
||||
|
||||
The crate contains no music or application database logic. Applications own
|
||||
their message schema and persistent domain state.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Persistent Ed25519/iroh identity.
|
||||
- Self-contained, versioned peer tickets.
|
||||
- Optional peer rendezvous through BEP44 records in the BitTorrent Mainline
|
||||
DHT.
|
||||
- Network and message-schema isolation during handshake.
|
||||
- Typed bidirectional messages with bounded framing and backpressure.
|
||||
- Application-defined ALPN byte streams.
|
||||
- Connection path and traffic statistics.
|
||||
- Graceful disconnect and shutdown events.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```rust,no_run
|
||||
use federation_net::{NetworkConfig, NetworkEngine, NetworkEvent, NetworkId, SchemaId};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
enum Message {
|
||||
Ping { nonce: u64 },
|
||||
}
|
||||
|
||||
# async fn run() -> federation_net::Result<()> {
|
||||
let config = NetworkConfig::builder()
|
||||
.data_dir("./peer-a")
|
||||
.network_id(NetworkId::from_name("example-network"))
|
||||
.schema_id(SchemaId::from_name("example-message-v1"))
|
||||
.build()?;
|
||||
|
||||
let (engine, mut events) = NetworkEngine::<Message>::start(config).await?;
|
||||
println!("share this ticket: {}", engine.ticket().await?);
|
||||
|
||||
while let Some(event) = events.recv().await {
|
||||
if let NetworkEvent::PeerConnected { peer_id, .. } = event {
|
||||
engine.send(peer_id, &Message::Ping { nonce: 1 }).await?;
|
||||
}
|
||||
}
|
||||
|
||||
engine.shutdown().await
|
||||
# }
|
||||
```
|
||||
|
||||
## Network and schema IDs
|
||||
|
||||
`NetworkId` selects an independent peer network. `SchemaId` selects the wire
|
||||
format of the application's typed messages. Both are checked during the
|
||||
application handshake; peers with mismatched values are rejected before
|
||||
domain messages are decoded.
|
||||
|
||||
Treat a backwards-incompatible message change as a schema change. Derive a new
|
||||
schema ID instead of attempting to decode incompatible payloads under the old
|
||||
identifier.
|
||||
|
||||
## Discovery
|
||||
|
||||
Rendezvous is optional. When enabled, peers publish signed, expiring endpoint
|
||||
records into a network-specific BEP44 record in the public Mainline DHT. A
|
||||
shared network ID is enough to discover current participants; no
|
||||
Frid-operated registry is involved.
|
||||
|
||||
```rust,no_run
|
||||
# use federation_net::{NetworkConfig, NetworkId, RendezvousConfig, SchemaId};
|
||||
# fn config() -> federation_net::Result<()> {
|
||||
let config = NetworkConfig::builder()
|
||||
.data_dir("./peer-a")
|
||||
.network_id(NetworkId::from_name("example-network"))
|
||||
.schema_id(SchemaId::from_name("example-message-v1"))
|
||||
.rendezvous(RendezvousConfig::default())
|
||||
.build()?;
|
||||
# drop(config);
|
||||
# Ok(())
|
||||
# }
|
||||
```
|
||||
|
||||
The rendezvous record is discoverable by anyone who knows the network ID. It
|
||||
provides discovery, not authorization. Applications that need restricted
|
||||
membership must implement and enforce that policy in their own protocol.
|
||||
|
||||
Without rendezvous, peers can connect through tickets and operate on isolated
|
||||
networks.
|
||||
|
||||
## Byte streams
|
||||
|
||||
Applications may register additional ALPNs in `NetworkConfig` and accept or
|
||||
open authenticated streams through the same endpoint. This is how higher
|
||||
layers add catalog, audio, or synchronization protocols without putting large
|
||||
payloads into the typed message channel.
|
||||
|
||||
## Events and backpressure
|
||||
|
||||
The bounded event channel reports connection, disconnection, decoded message,
|
||||
and protocol-error events. Consumers must drain it continuously. The engine
|
||||
applies backpressure instead of allowing unbounded memory growth.
|
||||
|
||||
Per-connection protocol failures are reported without stopping the engine.
|
||||
|
||||
## Verification
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```bash
|
||||
cargo test -p federation-net
|
||||
cargo clippy -p federation-net --all-targets -- -D warnings
|
||||
```
|
||||
@@ -2,6 +2,11 @@
|
||||
name = "music-dht"
|
||||
version = "0.1.0"
|
||||
description = "Distributed music library search: a Kademlia-style DHT on top of federation-net"
|
||||
readme = "README.md"
|
||||
documentation = "https://docs.rs/music-dht"
|
||||
repository.workspace = true
|
||||
keywords = ["p2p", "dht", "music", "kademlia", "iroh"]
|
||||
categories = ["network-programming", "multimedia::audio"]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
@@ -7,9 +7,8 @@ files**) — into a Kademlia-style DHT and search each other's libraries.
|
||||
Every running node is simultaneously a client, a DHT router and a storage
|
||||
node; there are no dedicated servers of any kind.
|
||||
|
||||
This crate is the grown-up sibling of the minimal
|
||||
[`artist-dht`](../artist-dht) example: same DHT machinery, richer records and
|
||||
an application-oriented API.
|
||||
It combines the original DHT proof of concept with the richer record model and
|
||||
application-oriented API used by Furumi.
|
||||
|
||||
## Records
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
//! small metadata, never files) into a Kademlia-style DHT and search each
|
||||
//! other's libraries. Every running node is simultaneously a client, a DHT
|
||||
//! router and a storage node — there are no dedicated bootstrap, index or
|
||||
//! search servers. This crate is the grown-up sibling of the minimal
|
||||
//! `artist-dht` example.
|
||||
//! search servers.
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
|
||||
@@ -835,10 +835,13 @@ impl Node {
|
||||
}
|
||||
|
||||
for index in targets.iter() {
|
||||
per_peer.entry(*index).or_default().push(StoreRecordRequest {
|
||||
key,
|
||||
record: record.clone(),
|
||||
});
|
||||
per_peer
|
||||
.entry(*index)
|
||||
.or_default()
|
||||
.push(StoreRecordRequest {
|
||||
key,
|
||||
record: record.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1043,7 +1046,9 @@ mod tests {
|
||||
|
||||
// Byte limit: huge entries split well before the count limit.
|
||||
let huge = "x".repeat(crate::record::MAX_ITEM_NAME_BYTES);
|
||||
let entries: Vec<_> = (0..MAX_RECORDS_PER_BATCH).map(|_| store_entry(&huge)).collect();
|
||||
let entries: Vec<_> = (0..MAX_RECORDS_PER_BATCH)
|
||||
.map(|_| store_entry(&huge))
|
||||
.collect();
|
||||
for batch in chunk_store_batches(entries) {
|
||||
assert!(!batch.is_empty());
|
||||
let bytes: usize = batch
|
||||
|
||||
@@ -236,9 +236,9 @@ impl LibraryItem {
|
||||
pub struct StoredRecord {
|
||||
/// The item record itself.
|
||||
pub item: LibraryItem,
|
||||
/// Peer that pushed this replica. Not cryptographically verified in the
|
||||
/// PoC: the connection authenticates the direct sender, not the origin
|
||||
/// of a replicated record.
|
||||
/// Peer that pushed this replica. The connection authenticates the direct
|
||||
/// sender, not the origin embedded in a forwarded record. Revision
|
||||
/// ordering provides convergence, not end-to-end origin authentication.
|
||||
pub publisher: PeerId,
|
||||
/// Unix timestamp (milliseconds) after which the replica must be dropped.
|
||||
pub expires_at_ms: u64,
|
||||
|
||||
@@ -120,8 +120,9 @@ impl RoutingTable {
|
||||
/// Inserts or refreshes a contact. Returns `true` if the contact was not
|
||||
/// known before.
|
||||
///
|
||||
/// A full bucket evicts its least recently seen contact; the PoC skips
|
||||
/// the classic ping-before-evict procedure.
|
||||
/// A full bucket evicts its least recently seen contact. Active liveness
|
||||
/// handling stays in the node layer so routing-table updates remain
|
||||
/// deterministic.
|
||||
pub fn upsert(&mut self, contact: NodeContact) -> bool {
|
||||
let Some(index) =
|
||||
distance(self.own_id.as_bytes(), contact.node_id.as_bytes()).bucket_index()
|
||||
|
||||
@@ -23,8 +23,11 @@ use crate::record::{
|
||||
};
|
||||
use crate::routing::{NodeContact, NodeId};
|
||||
|
||||
/// Fixed schema of the music-dht protocol; peers with a different schema are
|
||||
/// rejected by `federation-net` during the handshake.
|
||||
/// Fixed compatibility identifier of the music-dht protocol; peers with a
|
||||
/// different schema are rejected by `federation-net` during the handshake.
|
||||
///
|
||||
/// The historical value is retained because changing it would partition
|
||||
/// existing deployments.
|
||||
pub const SCHEMA_NAME: &str = "music-dht-poc-v3";
|
||||
|
||||
/// Capacity of the application event channel.
|
||||
|
||||
Reference in New Issue
Block a user