prepared release
CI / check (push) Successful in 5m17s

This commit is contained in:
Ultradesu
2026-07-26 03:48:30 +03:00
parent a9012351dc
commit c6b2a066b4
36 changed files with 590 additions and 4801 deletions
+26
View File
@@ -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
+104
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+13
View File
@@ -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.
+63 -184
View File
@@ -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).
-16
View File
@@ -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 }
-438
View File
@@ -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
View File
@@ -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
-27
View File
@@ -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 }
-173
View File
@@ -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.
-210
View File
@@ -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()
);
}
}
-492
View File
@@ -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]);
}
}
-274
View File
@@ -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()
);
}
}
-65
View File
@@ -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()),
}
}
}
-92
View File
@@ -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};
-174
View File
@@ -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>),
}
-928
View File
@@ -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);
}
}
-93
View File
@@ -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"]);
}
}
-300
View File
@@ -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)
));
}
}
-192
View File
@@ -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());
}
}
-338
View File
@@ -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);
}
}
-421
View File
@@ -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;
}
}
-238
View File
@@ -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");
}
+5
View File
@@ -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
+13
View File
@@ -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.
+114
View File
@@ -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
```
+5
View File
@@ -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
+13
View File
@@ -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.
+2 -3
View File
@@ -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
+1 -2
View File
@@ -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
//!
+10 -5
View File
@@ -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
+3 -3
View File
@@ -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,
+3 -2
View File
@@ -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()
+5 -2
View File
@@ -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.