Compare commits

..
11 Commits
Author SHA1 Message Date
Ben_Kosytorz 246b7969d6 Document daemon and packaging; refresh status across docs
Root README and documentation README: daemon + Docker/Nix status.
Roadmap: daemon checked off, real overlay peering stays open.
pilot.md: docker/nix and sn-node daemon mentioned as the comfortable
path beside the manual steps. AGENTS.md server status line updated.
2026-09-24 20:13:02 +02:00
Ben_Kosytorz 57b9b44d8a Add Docker and Nix packaging for the node
server/docker/: multi-stage image (rust:1-slim build, trixie runtime)
with all four binaries plus the pinned yggdrasil 0.5.14 .deb
(sha256-verified, fetched from the official release), a compose
example per household (host network, NET_ADMIN, /dev/net/tun,
init: true), a German-commented node.toml.example (safe default:
leech mode) and a README covering privileges, two-household wiring,
leech mode and the kill switch. Image build and smoke tests verified
locally with docker 29.8.1.

flake.nix: builds the workspace as one spidernet package via
rustPlatform.buildRustPackage (cargoLock against the committed
Cargo.lock) plus an experimental NixOS module; written without a
local Nix and marked accordingly.
2026-09-24 20:12:51 +02:00
Ben_Kosytorz f2f3609b87 Add sn-node daemon: supervise yggdrasil sidecar + in-process exit
sn-node daemon [--config node.toml] [--check] runs one household node
from a validated TOML config: generates the yggdrasil config with a
persistent node key in the state dir (PrivateKeyPath takes precedence
per the yggdrasil config reference, so the identity survives restarts),
waits for the overlay address via the admin socket, then serves the
sn-exit proxy in-process (or runs overlay-only in leech mode).
SIGINT/SIGTERM stop the accept loop, SIGTERM the sidecar (5s grace,
then SIGKILL); unexpected sidecar death shuts the daemon down with an
error. sn-exit gains an additive serve_until() with a shutdown future;
serve() keeps its behavior. Workspace: tokio gains process+signal,
serde/toml added. 73 tests green, clippy clean.
2026-09-24 20:12:44 +02:00
Ben_Kosytorz d8bd14ab1b Add pilot handbook and document sn-fair v0
pilot.md: step-by-step for connecting two households (link-agnostic),
measurement protocol M0-M4 (link baseline, uplink baseline, pooled,
concurrent downloads, sn-fair Soll/Ist comparison), safety/legal
checklist, and a results section to be filled after the pilot.
Cross-linked from Home, documentation README, roadmap, architecture
and server README; roadmap marks sn-fair v0 done.
2026-09-24 18:46:47 +02:00
Ben_Kosytorz ecf8848ca4 Add sn-fair v0: weighted max-min fairness scheduler with leech policy
Two-class allocation engine: contributors (configurable weight) get
weighted max-min shares of the pooled capacity, leeches (receive-only,
explicitly allowed) share the leftover capacity. Deterministic u128
arithmetic, validated newtypes, demo CLI in Mbit/s for the pilot
Soll/Ist comparison. 19 tests, workspace clippy clean.
2026-09-24 18:46:38 +02:00
Ben_Kosytorz afaeb76f6f sn-exit v0: quota-controlled uplink proxy (byte relay, CONNECT, SSRF guard) 2026-09-24 16:59:08 +02:00
Ben_Kosytorz c8ea5fc58c Add top-level README with repo map; refresh docs status 2026-09-24 16:12:24 +02:00
Ben_Kosytorz 50e459ed79 Docs: Yggdrasil overlay note (rationale, deployment, admin API) + link updates 2026-09-24 16:00:43 +02:00
Ben_Kosytorz c422deee7c sn-node: Yggdrasil config generation and admin socket client (genconf, status) 2026-09-24 15:58:12 +02:00
Ben_Kosytorz 23dc906da0 sn-fetch: per-exit egress (Direct vs Proxy) for neighbor exits over the mesh 2026-09-24 15:58:12 +02:00
Ben_Kosytorz 810529d502 sn-fetch: per-segment retry with exit reassignment and progress sinks 2026-09-24 15:58:11 +02:00
54 changed files with 7232 additions and 82 deletions
+3 -1
View File
@@ -42,7 +42,9 @@ Das Projekt besteht aus zwei Hälften:
- Kernfeatures: Multi-Exit-Bandbreiten-Pooling, segmentierte Downloads,
Fairness-Scheduler, Nachbarschafts-Cache, **Leech-Modus** (Empfangen ohne
Exit-Bereitstellung ist ausdrücklich erlaubt — Netz unter Nachbarn)
- Status: Konzeptphase, Details in `server/README.md`
- Status: Code existiert und ist getestet (`sn-fetch`, `sn-node` inkl.
Daemon, `sn-exit`, `sn-fair`; Deployment via Docker/Nix) — Details in
`server/README.md`
## Git / Gitea
+92
View File
@@ -0,0 +1,92 @@
# SpiderNet
**Teile die Internetbandbreite deiner Nachbarschaft — ohne Mehrkosten.**
SpiderNet ist **Software** für ein Nachbarschaftsnetz: Häuser werden
direkt verbunden (Glasfaser, LAN, Funk — was da ist), und jeder Haushalt
profitiert von der **Summe aller Internetanschlüsse** im Viertel. Große
Downloads werden segmentiert und über alle Anschlüsse parallel geladen
— schwache Anschlüsse werden stärker, niemand zahlt mehr. Als
Nebenprodukt verteilt sich ausgehender Traffic auf viele Haushalte,
was Rückverfolgbarkeit für Außenstehende erschwert.
> **KEINE GARANTIE.** Diese Software wird "as is" ohne jegliche Garantie
> bereitgestellt (AGPL §§ 15/16). Was Nutzer:innen damit übertragen,
> liegt in ihrer Verantwortung — wir übernehmen keine Haftung.
Wir providen **nur die Werkzeuge**: Jede Nachbarschaft richtet ihre
eigene Instanz ein und entscheidet selbst über Topologie, Link-Typen,
Geschwindigkeiten und Hausregeln. Es kann beliebig viele unabhängige
SpiderNet-Instanzen geben — kein Zentralserver, keine zentrale
Datensammlung, keine Bezahlung.
## Struktur des Repos
| Ordner/Datei | Inhalt | Einstieg |
|---|---|---|
| `documentation/` | Idee, Ethos, Architektur, Rechtslage, Roadmap (Deutsch, Obsidian-freundlich mit `[[Wikilinks]]`; `documentation/Home.md` ist die Map of Content) | [README](documentation/README.md) |
| `server/` | Die Software (Rust): `sn-fetch`, `sn-node`, `sn-exit`, `sn-fair`; geplant `sn-cache`, `sn-freenet` | [README](server/README.md) |
| `scripts/` | Repo-Setup (`bootstrap-gitea.sh`) | — |
| `AGENTS.md` | Arbeitsanweisungen für Coding-Agents und Contributor:innen | [AGENTS.md](AGENTS.md) |
| `LICENSE` | Code-Lizenz: AGPL-3.0-only | [LICENSE](LICENSE) |
| `documentation/LICENSE` | Doku-Lizenz: CC-BY-SA-4.0 | [LICENSE](documentation/LICENSE) |
## Die Idee in einem Satz
Häuser werden direkt per Glasfaser verbunden; jede Anfrage ins Internet
kann über **alle** Anschlüsse des Viertels gleichzeitig laufen
(segmentiert und fair gemesht), sodass die effektive Bandbreite ~ der
Summe aller Anschlüsse ist — ohne Mehrkosten und mit besserer
Versorgung schwacher Haushalte.
## Status
Prototyp (Konzeptphase + erster Code):
- **`sn-fetch` PoC** funktioniert: segmentierter Multi-Exit-Download
(Range-Probe, gewichteter Planner, parallele Segmente, Retry mit
Exit-Reassignment, Reassembly + SHA-256) — verifiziert gegen einen
echten Server (Debian-Mirror, 756 MB, Hash-Check) und gegen einen
lokalen Proxy für Nachbar-Exits. Details: [server/README.md](server/README.md)
- **`sn-node` v0** funktioniert: Yggdrasil-Config-Generierung (`genconf`)
+ Overlay-Status (`status` via Admin-Socket)
- **`sn-exit` v0** funktioniert: kontingentierter Uplink-Proxy (Byte-Relay,
CONNECT-Tunnel für HTTPS, SSRF-Schutz, Source-Allowlist) — End-to-End
mit `sn-fetch` gegen einen echten Server verifiziert
- **`sn-fair` v0** funktioniert: Fairness-Engine (gewichtetes Max-Min,
Leech-Politik) + Demo-CLI — die Soll-Aufteilung für den Piloten
- **`sn-node` Daemon** funktioniert: ein Befehl orchestriert Yggdrasil-
Sidecar + Exit-Dienst (`sn-node daemon --config node.toml`, TOML-Konfig,
Kill-Switch-Semantik)
- **Deployment:** Docker-Image (gebaut + getestet) und Nix-Flake
(unverifiziert) — Details in [server/README.md](server/README.md)
- **Nächste Schritte:** Pilot mit 2–3 Haushalten (Yggdrasil-Overlay
verkabeln, Messwerte — Handbuch:
[pilot.md](documentation/ideen/pilot.md)) — siehe
[Roadmap](documentation/ideen/roadmap.md)
## Doku lesen (Obsidian)
Ordner `documentation/` (oder das ganze Repo) als Obsidian-Vault öffnen
und mit `documentation/Home.md` starten. Alternativ genügen die
Markdown-Dateien in `documentation/` auch so.
## Nicht-Ziele
- Keine Bezahl-/Krypto-Infrastruktur (Kontrast: Althea)
- Keine Anonymitätsversprechen (Yggdrasil und Freenet bieten keine)
- Keine inhaltliche Kontrolle oder Zensur durch die Software selbst
- Kein zentraler Exit, kein zentraler Server
## Verwandte Projekte (Prior Art)
[Freifunk](https://freifunk.net/) · [NYC Mesh](https://www.nycmesh.net/) ·
[guifi.net](https://www.guifi.net/) ·
[Althea](https://www.althea.net/) (Kontrast: Bezahl-Modell) ·
[Yggdrasil](https://yggdrasilnetwork.github.io/) (Overlay) ·
[lancache](https://lancache.net/) (Cache-Prinzip)
## Lizenz
- Code: [AGPL-3.0-only](LICENSE)
- Dokumentation: [CC-BY-SA-4.0](documentation/LICENSE)
+6
View File
@@ -21,10 +21,12 @@ Nachbarschaft betreibt ihre eigene Instanz nach eigenen Regeln.
| [[Ethos]] | Werte: Gemeingut, Fairness, Leechen erlaubt, legale Basis |
| [[Idee]] | Kernidee + ehrlicher Realitätscheck |
| [[Architektur]] | Schichten: Physik → Routing → Pooling → Freenet-Inhalte |
| [[Overlay]] | Yggdrasil als Overlay: Auswahl, Deployment, Grenzen |
| [[Recht]] | Deutsche Rechtslage + Pflichtregeln für die Software |
| [[Splitting]] | Was sich splitten lässt: Ranges, Flow-Verteilung, Realtime |
| [[Lizenz]] | Lizenz-Optionen (Code & Doku) |
| [[Roadmap]] | Phasen: Pilot → MVP → Rollout → Freenet → Backbone |
| [[Pilot]] | Pilot-Handbuch: 2 Haushalte verkabeln, messen, dokumentieren |
| [[Server-README|Server-Konzept]] | Software-Komponenten, Code-Standards |
## Lesereihenfolge zum Einstieg
@@ -41,6 +43,10 @@ Nachbarschaft betreibt ihre eigene Instanz nach eigenen Regeln.
- [[Recht]] §5 (Pflichtregeln) ↔ [[Server-README]] (Haftungsausschluss)
- [[Idee]] (Realitätscheck) ↔ [[Architektur]] (link-agnostische Physik, `sn-fetch`)
- [[Splitting]] ↔ [[Architektur]] (Schicht 2) — was der Exit-Pool tatsächlich nutzen kann
- [[Overlay]] ↔ [[Architektur]] (Schicht 1) — Routing-Entscheidung und deren Grenzen
- [[Overlay]] ↔ [[Server-README]] — `sn-node` generiert die Yggdrasil-Config, `sn-fetch` nutzt `Egress::Proxy`
- [[Pilot]] ↔ [[Roadmap]] (Phase 1) und [[Overlay]] §Deployment — der Pilot ist der erste Real-Test
- [[Pilot]] §M4 ↔ [[Architektur]] §Fairness-Scheduler — `sn-fair` berechnet die Soll-Aufteilung
- [[Lizenz]] ↔ [[Ethos]] §6 (Transparenz) und `freenet-core` (AGPL)
## Außerhalb der Doku
+9 -3
View File
@@ -17,6 +17,7 @@ unabhängige SpiderNet-Instanzen geben; wir providen nur die Werkzeuge.
- [[Splitting]] — Was sich über mehrere Exits splitten/verteilen lässt (und was nicht)
- [[Lizenz]] — Lizenz-Optionen und Empfehlung
- [[Roadmap]] — Phasenplan vom Pilot bis zum Nachbarschafts-Backbone
- [[Pilot]] — Pilot-Handbuch: Schritt-für-Schritt + Messprotokoll (Phase 1)
- [[Server-README|Server-Konzept]] — geplante Software (`sn-node`, `sn-fetch`, …)
## Die Idee in einem Satz
@@ -28,9 +29,14 @@ ist — ohne Mehrkosten und mit besserer Versorgung schwacher Haushalte.
## Status
Konzeptphase. Die Software in `server/` ist noch nicht implementiert; der
erste Meilenstein ist ein PoC "segmentierter Download über zwei Haushalte"
(siehe [[Roadmap]]).
Prototypphase (siehe [[Roadmap]]): `sn-fetch` (segmentierter Multi-Exit-
Download mit Retry + Egress), `sn-node` v0 + Daemon (Yggdrasil-Config,
Status, Sidecar-/Exit-Orchestrierung), `sn-exit` v0 (kontingentierter
Uplink-Proxy mit SSRF-Schutz) und `sn-fair` v0 (Fairness-Engine +
Demo-CLI) sind implementiert und getestet — Details in
[`server/README.md`](../server/README.md), Deployment via Docker/Nix
(`server/docker/`, `flake.nix`). Nächster Meilenstein: Pilot mit
2–3 Haushalten (Handbuch: [[Pilot]]).
## Verwandte Projekte (Prior Art)
+7 -1
View File
@@ -53,7 +53,8 @@ mehr Pooling-Gewinn.
## Schicht 1 — Routing (Overlay über alle Haushalte)
Kandidaten (Auswahl offen, Kriterium: simpel, wartbar, verschlüsselt):
Auswahl, Deployment und Grenzen stehen in [[Overlay]] — kurz: **Yggdrasil
ist gewählt** (erste Wahl, bewusst austauschbar gehalten):
| Kandidat | Pro | Con |
|---|---|---|
@@ -132,6 +133,11 @@ siehe [[Splitting]].
- Exit-Kontingente (Volumenlimit, Wartungszeit) werden respektiert
- Leech-Nodes: niedrigste Priorität bei Knappheit, nie ausgeschlossen
Status: **v0 implementiert** — gewichtetes Max-Min in zwei Klassen:
Contributors teilen die volle Kapazität nach Max-Min, Leeches teilen
die Restkapazität (bei Sättigung zuerst gedrosselt, nie ausgeschlossen).
Details in `server/README.md`; Soll-Ist-Vergleich im Piloten: [[Pilot]] §M4.
### Nachbarschafts-Cache (`sn-cache`)
- Transparenter Shared-Cache (lancache-Prinzip): große, populäre Objekte
+134
View File
@@ -0,0 +1,134 @@
# Overlay: Yggdrasil
Wie die Häuser untereinander erreichbar werden, bevor irgendwas gepoolt
wird. Stand: konzeptionell entschieden (erste Wahl), `sn-node`-Anbindung
begonnen (siehe [[Server-README]]).
## Warum Yggdrasil?
[Yggdrasil](https://yggdrasilnetwork.github.io/) ist ein
**self-contained Userspace-Router**: Jeder Node bekommt eine stabilen
IPv6-Adresse im `200::/7`-Raum, Nodes verbinden sich zu einem
end-to-end-verschlüsselten Overlay (Routing über einen
Spanning-Tree), egal ob Glasfaser, LAN, WLAN oder Internet dazwischen
liegt.
Pro:
- **Out-of-the-box verschlüsselt** — Peering-Verbindungen sind
kryptographisch gesichert, ohne extra WireGuard-Stapel
- **Null Konfiguration fürs Peering**: auf demselben LAN (also über die
Nachbarschafts-Faser oder den Keller-Switch) finden sich Nodes per
Multicast automatisch
- **Link-agnostisch** wie SpiderNet selbst: tcp://, tls://, quic://
Listener und Peer-URIs, Funk/LAN/Faser egal
- **Feste Adresse pro Keypair** — die Node-Adresse bleibt, solange der
Schlüssel bleibt (wichtig für Exit-Zuteilung und Nachbarschafts-ACL)
- **`AllowedPublicKeys`**: nur gelistete Nachbarn dürfen peeren —
das Nachbarschafts-Vertrauen wird direkt vom Overlay erzwungen
- Single Rust/CPU-Binary, läuft auf Mini-PCs und Einplatinen
Contra (ehrlich benannt):
- Experimenteller als Babel; Performance-Obergrenze liegt deutlich
unter der, die dichte Glasfaser theoretisch bieten könnte — für
Phase 1 (2–3 Haushalte) völlig ausreichend, Messwerte dokumentieren
- Wenn das Overlay zum Flaschenhals wird: Babel + WireGuard als
Ausweich-Kandidat (siehe [[Architektur]], Schicht 1)
## Rollen im SpiderNet-Node
```mermaid
flowchart LR
subgraph Haushalt
DEVICES[Geraete] -->|own uplink| ROUTER[Router/ISP]
NODE[sn-node] --- YG[yggdrasil daemon]
YG <-->|mesh links| NACHBAR[yggdrasil der Nachbarn]
NODE -->|getSelf / getPeers| YG
NODE -->|sn-exit: HTTP proxy| YG
end
NACHBAR -->| Exit requests| ORIGIN((Origin))
```
1. **yggdrasil-Daemon** (Prozess/Sidecar, nicht von uns implementiert):
bildet das Overlay, jede Faser wird zur Overlay-Verbindung.
2. **sn-node** (unser Daemon): generiert die yggdrasil.conf aus
validierten Einstellungen, fragt den Admin-Socket ab (Node-Adresse,
Peers), orchestriert Exit und Fetch.
3. **sn-exit** (geplant): HTTP-Proxy, der auf der Yggdrasil-Adresse des
Haushalts lauscht — Nachbarn senden ihre Segment-Requests dorthin,
der Haushalt holt sie über seinen Uplink.
4. **sn-fetch**: kennt pro Exit einen `Egress` (`Direct` = eigener
Uplink, `Proxy` = HTTP-Proxy eines Nachbarn) und plant Segmente
darauf.
## Admin-Socket (technische Fakten)
Quelle: [Admin-API-Doku](https://yggdrasil-network.github.io/admin.html)
- Default `localhost:9001` (TCP), alternativ Unix-Socket
(`AdminListen` in der Konfig)
- Protokoll: JSON-Stanza + `\n`; Anfrage `{"request": "getself"}`;
Antwort enthält immer `"status"` (`"success"`/`"error"`), optional
`"response"` und `"error"`
- `getSelf` → eigene Adresse, routed `/64`-Subnetz (`300:…::/64`),
Public Key, Build-Version
- `getPeers` → aktive Peer-Sessions inkl. Peering-URI
- `sn-node status` kapselt genau das (siehe unten)
## Deployment: zwei Häuser verkabeln
Schritt-für-Schritt für Phase 1 (Prototyp-Scope, **keine Garantie**,
siehe [[Recht]]):
1. **Hardware:** je Haus ein Mini-PC mit zwei NICs (eine zum
Heim-Router, eine zur Nachbarschafts-Faser bzw. zum Switch im
Keller) — oder übergangsweise eine NIC am Mesh-Switch.
2. **Yggdrasil installieren** (Debian-Paket oder Binary).
3. **Config generieren:**
`sn-node genconf --peer "tcp://[<Nachbar-Adresse>]:1337" --listen "tcp://[::]:1337" --allow-key <Nachbar-Public-Key> --out /etc/yggdrasil.conf`
- im selben LAN reicht Multicast-Auto-Peering ohne `--peer`
- `--no-multicast`, wenn Peering nur über die explizite Liste laufen
soll (z. B. gemischte Nachbarschaft)
- die `allowed_public_keys`-ACL: nur die gelisteten Nachbarn können
peeren — für ein Viertel die persönliche Freischaltung
4. **Start:** `yggdrasil -useconffile /etc/yggdrasil.conf`
5. **Status:** `sn-node status` → eigene Overlay-Adresse, Subnetz,
Version, verbundene Nachbarn
6. **Test:** von Haus A `ping <Adresse von Haus B>` (IPv6 im Overlay).
7. **Exit anbieten** (geplanter `sn-exit`): HTTP-Proxy auf der
Overlay-Adresse binden; beim Nachbarn dann
`sn-fetch <url> -e "1=1,2=1@http://<Nachbar-Adresse>:8080"`.
Die Config-Datei enthält den **privaten** Schlüssel des Nodes:
`chmod 600` (macht `sn-node genconf` selbst), niemals committen, nie
teilen. Private Adressen realer Nachbarn gehören nicht ins Repo
(Regeln in `AGENTS.md`).
## Grenzen & Sicherheit
- **Yggdrasil ist keine Anonymisierung.** Die Overlay-Adresse ist ein
stabiler Pseudonym; wer Peering-Verbindungen beobachtet, sieht, wer
mit wem verbunden ist. SpiderNet verspricht deshalb nirgends
Anonymität (konsistent mit Freenet-Position in [[Ethos]]).
- **Exit = Vertrauensstellung:** Der Exit-Haushalt sieht die URLs der
Segmente, die er für Nachbarn holt. Standard-Kontingente, jederzeit
widerruflich (Kill-Switch, siehe [[Architektur]]).
- **Port-Forwarding:** Eingehende Peerings über das Internet (z. B.
wenn zwei Häuser nicht direkt verbunden sind und den Weg übers
normale Internet nehmen) brauchen ein Forward des `Listen`-Ports;
innerhalb der Faser/Multicast nicht nötig.
- **Wenn die Faser später schneller als Yggdrasil wird:** Messwerte
sammeln; ggf. pro Link WireGuard-Punkt-zu-Punkt direkt nutzen und
Yggdrasil nur als Control-Plane — Architektur-Schicht 1 ist bewusst
austauschbar gehalten.
## Quellen
- Yggdrasil-Website & Docs: <https://yggdrasilnetwork.github.io/>
- Admin API: <https://yggdrasil-network.github.io/admin.html>
- Configuration: <https://yggdrasil-network.github.io/configuration.html>
(genconf/useconf, HJSON/JSON, `Peers`, `Listen`, `MulticastInterfaces`,
`AllowedPublicKeys`, `IfName: none` für router-only)
- Quellcode (Go, AGPL-3.0 — wir rufen nur das Binary auf, keine
Derivate): <https://github.com/yggdrasil-network/yggdrasil-go>
+214
View File
@@ -0,0 +1,214 @@
# Pilot: zwei Haushalte verbinden und messen
> Prototyp-Betrieb, **keine Garantie** (AGPL §§ 15/16) — Nutzung auf eigene
> Verantwortung, siehe [[Recht]] und [[Server-README]]. Persönliche Daten
> (Namen, Adressen, Keys realer Nachbarn) bleiben **außerhalb des Repos**.
Ziel der Phase 1 (siehe [[Roadmap]]) ist der Beweis, dass Bandbreiten-
Pooling über zwei echte Anschlüsse real funktioniert — nicht auf dem
Papier. Der Pilot liefert:
1. **Messwerte:** 1-Exit-Baseline vs. 2-Exit-gepoolt (gleiche Datei)
2. **Fairness-Daten:** zwei gleichzeitige Downloads teilen sich den Pool
3. **Stolpersteine:** was die Doku/Software dadurch lernen muss
Der Pilot ist **link-agnostisch**: Ob die Häuser per Glasfaser,
SFP+-Direktkabel, Switch im Keller, 60-GHz-Richtfunk oder normalem
Ethernet verbunden sind, entscheidet die Nachbarschaft — die Software
misst, was da ist (siehe [[Architektur]], Schicht 0). Alle
Angaben zu Link-Typen in dieser Datei sind Beispiele.
## Voraussetzungen
### Teilnehmer
- 2 Haushalte (ideal 3, der dritte als **Leech** — Empfang ohne
Exit-Angebot, um genau diesen Modus mitzutesten)
- Ein persönlicher Termin; technisches Onboarding gesicht zu gesicht
(Nachbarn sind der Trust-Anchor, siehe [[Architektur]])
### Hardware (je Haushalt, Beispiele)
- Ein Mini-PC oder Einplatinenrechner (≥ 2 GB RAM, Debian o. ä.) als
Node-Host — je eine NIC Richtung Heim-Router und Richtung
Nachbarschafts-Link (übergangsweise reicht auch eine NIC am
Mesh-Switch)
- Verbindungsweg — **eins davon genügt:**
- Glasfaser/DAC-Direktkabel zwischen den SFP/SFP+-Ports
- gemeinsamer Switch im Keller/Treppenhaus
- 60-GHz-Richtfunk-Pair (Sichtlinie nötig, siehe [[Recht]] §3)
- vorhandenes Ethernet/WLAN (langsamer, aber null Aufwand)
### Software
- [yggdrasil](https://yggdrasil-network.github.io/) (Binary oder
Debian-Paket)
- SpiderNet-Binaries bauen und rüberkopieren:
```sh
cd server
cargo build --release --workspace
# → target/release/sn-node, sn-exit, sn-fetch, sn-fair
```
Alternativ (bequemer ab dem zweiten Haushalt): **Docker** —
`server/docker/README.md` (Image mit allen Binaries + Yggdrasil,
Compose-Beispiel) — oder **Nix** via `flake.nix`. Der Node-Daemon
(`sn-node daemon --config node.toml`, siehe `server/README.md`)
orchestriert Overlay + Exit in einem Befehl; die manuellen Schritte
unten zeigen trotzdem erst den Geräteweg, damit man sieht, was
gepoolt wird.
### Vor dem Termin (Checkliste)
- [ ] ISP-AGB der Teilnehmenden geprüft (Weitergabe an Dritte,
siehe [[Recht]] §2)
- [ ] Nachbarschafts-Abkommen geklärt: wer spendiert wie viel
(Quota), Leechen ausdrücklich erlaubt, alles jederzeit widerruflich
- [ ] Hardware vorhanden und bootfähig
- [ ] Binaries gebaut und auf beide Nodes kopiert
- [ ] Testdatei gewählt: Range-fähiger Server, ~750 MB, bekannte
SHA-256 (im PoC hat sich eine Debian-Netinst-ISO bewährt; die
offizielle `SHA256SUMS` der jeweiligen Version nutzen)
## Termintag: Schritt-für-Schritt
Die technischen Details (Config, Schlüssel, Admin-Socket) stehen in
[[Overlay]] §Deployment — hier die Reihenfolge mit Checkpunkten.
1. **Physik verbinden.** Link herstellen, L2-Test: `ping` über die
lokale Verbindung. Optional `iperf3` über den Link → das ist
**Messpunkt M0** (Kapazität des Nachbarschafts-Links).
2. **Yggdrasil einrichten (beide Häuser).**
```sh
sn-node genconf --listen "tcp://[::]:1337" \
--peer "tcp://[<Mesh-IP des Nachbarn>]:1337" \
--allow-key <Public-Key des Nachbarn> \
--private-key-path /etc/spidernet/node.key \
--out /etc/yggdrasil.conf
yggdrasil -useconffile /etc/yggdrasil.conf
sn-node status # eigene Overlay-Adresse + Peering-Status
```
- Im selben LAN reicht Multicast-Auto-Peering (ohne `--peer`).
- Config enthält den **privaten** Key (`chmod 600` macht
`genconf` selbst) — niemals teilen oder committen.
- Die eigenen Adressen/Keys werden **persönlich** ausgetauscht,
nicht per Chat/Repo.
3. **Overlay-Test.** `sn-node status` zeigt auf *beiden* Seiten den
Nachbarn als Peer; `ping <Overlay-Adresse des Nachbarn>` geht.
4. **Exit starten (Haus B spendiert):**
```sh
sn-exit --bind "[<Overlay-Adresse B>]:8080" \
--allow <Overlay-Adresse A> \
--quota-mib 1024
```
- `--allow` nie offen lassen (ohne Liste ist nur Loopback
erlaubt, das ist der Dev-Mode).
- Test von Haus A: ein HTTP-Request gegen einen Origin über
`http://[<Overlay-Adresse B>]:8080` als Proxy (z. B. mit
`sn-fetch --probe-only` oder `curl -x`).
5. **Erster gepoolter Download (Haus A):**
```sh
sn-fetch <url> -o testdatei.iso \
-e "1=1,2=1@http://[<Overlay-Adresse B>]:8080" \
--sha256 <erwarteter-hash>
```
6. **Kill-Switch-Test.** `sn-exit` auf B stoppen → laufender und
nächster Download fällt auf den eigenen Uplink zurück (Retry mit
Exit-Reassignment). Danach B wieder starten. Beides dokumentieren:
Dauer der Störung, verlorene Segmente.
Wichtig zur Beruhigung: SpiderNet v0 installiert **keine
Routing-Änderung** im Heimnetz. Geräte nutzen wie gewohnt den eigenen
Router; nur `sn-fetch`-Downloads gehen (explizit) in den Pool. Alles
stoppen (`sn-exit`, `yggdrasil`) = das Haus ist wie vorher.
## Messprotokoll
Regeln für belastbare Werte:
- Gleiche Testdatei, gleiche Uhrzeit ± 1 h (ISP-Congestion schwankt
tageszeitabhängig)
- Pro Szenario **3 Läufe**, Median notieren; zwischen Läufen Pause
- Nichts anderes auf den Nodes laufen lassen; Messwerte immer mit
Datum/Uhrzeit und den beidseitigen Uplink-Tarifen notieren
### Szenarien
| # | Szenario | Wo | Was gemessen wird |
|---|---|---|---|
| M0 | Nachbarschafts-Link-Kapazität | A↔B | `iperf3` über den lokalen Link |
| M1 | Uplink-Baseline | je Haus | nur eigener Uplink (`-e "1=1"`), ISP-Kapazität |
| M2 | Gepoolt, 1 Download | A | 2 Exits (`-e "1=1,2=1@http://[B]:8080"`), Gesamt-Durchsatz vs. M1 |
| M3 | Gepoolt, Konkurrenz | A + B | zwei `sn-fetch`-Läufe gleichzeitig, beide über beide Exits — wie teilen sich die Aufstellung |
| M4 | Fairness-Soll | (Simulation) | `sn-fair` mit den M1/M2-Werten → Soll-Aufteilung zum Vergleich mit M3 |
### Tabelle M2/M3 (Vorlage, pro Lauf eine Zeile)
| Lauf | Szenario | Datum/Zeit | Durchsatz Mbit/s | Dauer | Segmente | Retries | Bemerkung |
|---|---|---|---|---|---|---|---|
| 1 | M2 | | | | | | |
| 2 | M2 | | | | | | |
| 3 | M2 | | | | | | |
Zusätzlich notieren: Quota-Rest auf B, CPU/RAM der Node-Hosts,
(evtl.) Firmware/Treiber des Link-Wegs.
### Fairness-Soll mit `sn-fair` (M4)
Die gemessenen Uplink-Kapazitäten (M1) als Pool-Kapazität und die
gleichzeitigen Nachfragen (M3) einsetzen — das CLI arbeitet in
Mbit/s:
```sh
sn-fair --capacity <M1-A + M1-B, Mbit/s> \
--participant 1=contributing \
--participant 2=contributing \
--participant 3=leeching \
--demand 1=<Nachfrage A> \
--demand 2=<Nachfrage B> \
--demand 3=<Nachfrage Leech>
```
Erwartung (Regelwerk in [[Architektur]] §Fairness-Scheduler):
Contributors bekommen die Kapazität nach max-min Fairness (gewichtet),
Leeches profitieren von Restkapazität — bei Sättigung zuerst gedrosselt,
nie ausgeschlossen. Weicht M3 (Ist) deutlich von M4 (Soll) ab, landet
das als Erkenntnis in der Ergebnisse-Sektion unten (die echte
Runtime-Integration in `sn-fetch` ist Phase 2, siehe [[Roadmap]]).
## Sicherheit & Recht (Checkliste während des Piloten)
- [ ] `sn-exit` nur mit `--allow` (Allowlist) und Quota laufen lassen
- [ ] SSRF-Schutz nie deaktivieren (verhindert Nachbarn-Zugriff auf
Heim-LAN/Admin-Sockets des Exit-Haushalts)
- [ ] Keine Port-Forwards ins Internet, außer ein Peering braucht es
zwingend (siehe [[Overlay]] §Grenzen)
- [ ] Logs minimal und lokal (keine Inhaltslogs, siehe [[Recht]] §4)
- [ ] Haftungsausschluss ist den Teilnehmenden bekannt gezeigt
- [ ] Sperrbarkeit praktisch geübt: Exit stoppen = Kill-Switch,
Teilnehmersperre = `--allow`-Liste ändern
## Ergebnisse
*(wird nach dem Piloten gefüllt — Messwerte, Erkenntnisse und
Stolpersteine gehören hier ins Repo, nicht in den Chat)*
### Messwerte
*(Tabelle ergänzen — M0 bis M4, Mediane der 3 Läufe)*
### Erkenntnisse
*(kurz, ehrlich, mit Quellen/Logs wo sinnvoll)*
### Stolpersteine & Anpassungen
*(was in [[Architektur]], [[Overlay]], [[Roadmap]] oder der Software
geändert werden muss, damit der nächste Pilot weniger stolpert)*
+20 -7
View File
@@ -14,23 +14,36 @@ endet mit etwas, das man benutzen kann.
## Phase 1 — Pilot (2–3 Haushalte)
Ziel: Beweis, dass Pooling über zwei Anschlüsse real funktioniert.
Handbuch mit Messprotokoll: [[Pilot]].
- [x] `sn-fetch` PoC (lokal): Range-Probe, gewichteter Planner, parallele
Segment-Downloads, Reassembly + SHA-256 — verifiziert gegen einen
echten Server (Debian-Mirror); Multi-Exit-Routing folgt mit dem Mesh
- [ ] SpiderNet-Node auf Mini-PC (Raspberry Pi / N100-Box), Yggdrasil-Overlay
zwischen 2 Haushalten (Kabel oder Kurzstrecke WLAN/60-GHz)
- [x] `sn-fetch`: Segment-Retry mit Exit-Reassignment, Progress-Sinks,
per-Exit-Egress (`Direct`/`Proxy`) — Nachbar-Exit über HTTP-Proxy
end-to-end getestet
- [x] `sn-node` v0: Yggdrasil-Config-Generierung (`genconf`) + Admin-Socket-
Client (`status`) — die Overlay-Anbindung beginnt
- [x] `sn-node` Daemon v1: ein Befehl orchestriert Yggdrasil-Sidecar +
Exit-Dienst (TOML-Konfig, Node-Key-Persistenz, Kill-Switch-,
Supervisor-Semantik) — Deployment via Docker/Nix vorhanden
- [ ] Yggdrasil-Overlay zwischen 2 Haushalten (Kabel oder Kurzstrecke
WLAN/60-GHz) aufsetzen
- [x] `sn-exit` v0: HTTP-Proxy auf der Yggdrasil-Adresse (Kontingente,
SSRF-Schutz, Kill-Switch = Prozess stoppen)
- [x] `sn-fair` v0: Fairness-Engine (gewichtetes Max-Min über zwei
Klassen, Leech-Politik) + Demo-CLI; Runtime-Integration in
`sn-fetch` ist Phase 2
- [ ] `sn-fetch` PoC über 2 echte Exits, plus Baseline-Messung
(1-Exit vs. 2-Exit, gleiche Datei)
- [ ] Fair-Share-Scheduler v0: zwei gleichzeitige Downloads, faire Aufteilung
- [ ] Doku: Messwerte, Erkenntnisse, Stolpersteine → hier in das Repo
(1-Exit vs. 2-Exit, gleiche Datei — Protokoll in [[Pilot]])
- [ ] Doku: Messwerte, Erkenntnisse, Stolpersteine → in [[Pilot]] §Ergebnisse
## Phase 2 — MVP-Software
Ziel: ein Paket, das 3–10 Haushalte installieren können.
- [ ] `sn-node` Daemon: Discovery, Peering, Metriken (per-Exit Bandbreite/
Latenz), Onboarding-Flow (QR/Knopfdruck)
- [ ] `sn-node` Daemon v1: Metriken aus Yggdrasil-Peering (per-Exit
Bandbreite/Latenz), Onboarding-Flow (QR/Knopfdruck)
- [ ] Exit-Pool: Kontingente, opt-in/opt-out, Leech-Modus, Kill-Switch
- [ ] `sn-fetch`: API + CLI + einfache Browser-Integration
- [ ] `sn-cache`: lancache-artiger transparenter Cache (DNS-basiert)
+193
View File
@@ -0,0 +1,193 @@
# SpiderNet Nix flake — builds the Rust workspace in server/ and exposes
# one `spidernet` package containing all four binaries (sn-node, sn-exit,
# sn-fetch, sn-fair) in $out/bin, plus an experimental NixOS module.
#
# NO WARRANTY — SpiderNet is provided "as is" without warranty of any kind
# (AGPL-3.0, sections 15/16). Use is entirely at the operator's own
# responsibility.
#
# !!! NOT VERIFIED ON THIS MACHINE !!!
# Nix is not installed in the environment where this flake was written.
# `nix flake check` and `nix build` have NOT been run — treat everything
# below as best-effort and report build failures as issues.
#
# Usage (once Nix >= 2.19 is installed):
# nix build . # -> result/bin/{sn-node,sn-exit,...}
# nix shell .# -c sn-fair --capacity 100 --participant 1=contributing
#
# The nixpkgs input is pinned to the current stable NixOS release branch
# (nixos-26.05, released 2026-05-30; nixos-25.11 is already deprecated).
{
description =
"SpiderNet: pooled-bandwidth neighborhood network node software";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
};
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
# Only the files cargo needs; keeps target/, docs and any other
# clutter out of the Nix store hash.
src = lib.cleanSourceWith {
src = lib.cleanSource ./server;
filter =
path: type:
# Prune cargo build dirs entirely.
!(type == "directory" && lib.hasSuffix "/target" path)
&& (
type == "directory"
|| lib.hasSuffix ".rs" path
|| lib.hasSuffix ".toml" path
|| lib.hasSuffix ".lock" path
);
};
spidernet = pkgs.rustPlatform.buildRustPackage {
pname = "spidernet";
version = "0.1.0";
inherit src;
cargoLock.lockFile = ./server/Cargo.lock;
# Integration tests talk to real servers; skip them when
# packaging. Run `cargo test` inside server/ for development.
doCheck = false;
meta = {
description =
"SpiderNet node software (sn-node, sn-exit, sn-fetch, sn-fair)";
longDescription = ''
SpiderNet pools the bandwidth of household internet
connections over a neighborhood mesh (Yggdrasil overlay).
NO WARRANTY — AGPL-3.0, sections 15/16.
'';
license = lib.licenses.agpl3Only;
mainProgram = "sn-node";
platforms = lib.platforms.unix;
};
};
in
{
inherit spidernet;
default = spidernet;
}
);
# ------------------------------------------------------------------
# EXPERIMENTAL NixOS module — never built or activated anywhere.
# Runs `sn-node daemon` (which manages the yggdrasil sidecar) as a
# hardened systemd service.
#
# Usage example (in a NixOS configuration that imports this flake):
#
# imports = [ spidernet.nixosModules.default ];
#
# services.spidernet = {
# enable = true;
# configFile = "/etc/spidernet/node.toml";
# # stateDir defaults to "spidernet" -> /var/lib/spidernet,
# # which the example config uses as state_dir.
# };
#
# The config file must set state_dir = "/var/lib/spidernet" (or
# leave the default and rely on WorkingDirectory below).
# ------------------------------------------------------------------
nixosModules.default =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.spidernet;
in
{
options.services.spidernet = {
enable = lib.mkEnableOption "SpiderNet node daemon (sn-node)";
package = lib.mkOption {
type = lib.types.package;
# The flake package of the evaluating system; can be
# overridden with any build of spidernet.
default = self.packages.${pkgs.system}.default;
defaultText = lib.literalExpression "spidernet.packages.\${pkgs.system}.default";
description = "SpiderNet package to run (contains sn-node etc.).";
};
configFile = lib.mkOption {
type = lib.types.path;
description = ''
Path to the node.toml config file read by
`sn-node daemon --config`.
'';
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "spidernet";
description = ''
State directory (systemd StateDirectory), holding node.key
and the generated yggdrasil.conf. Created as
/var/lib/<stateDir>.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.spidernet = {
description = "SpiderNet node (Yggdrasil overlay, optional exit)";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
# The daemon spawns yggdrasil (manage = true by default).
path = [ pkgs.yggdrasil ];
serviceConfig = {
ExecStart =
"${lib.getExe cfg.package} daemon --config ${cfg.configFile}";
StateDirectory = cfg.stateDir;
WorkingDirectory = "/var/lib/${cfg.stateDir}";
# TUN setup requires CAP_NET_ADMIN; nothing more.
AmbientCapabilities = [ "CAP_NET_ADMIN" ];
CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
DeviceAllow = [
"/dev/net/tun rw"
# Yggdrasil TUN devices are created at runtime.
"char-tun rwm"
];
Restart = "on-failure";
RestartSec = 5;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
# State dir must stay writable.
ReadWritePaths = [ "/var/lib/${cfg.stateDir}" ];
};
};
};
};
};
}
+9
View File
@@ -0,0 +1,9 @@
# Keep the build context small; everything needed is Cargo.toml,
# Cargo.lock and crates/ (see server/docker/Dockerfile).
target/
.git/
docker/node.toml.example
docker/node.toml
docker/README.md
docker/docker-compose.yml
spidernet-state/
+128
View File
@@ -228,6 +228,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@@ -329,6 +335,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
@@ -543,6 +555,16 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -924,6 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@@ -959,6 +982,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -997,6 +1029,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -1009,6 +1051,25 @@ version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "sn-exit"
version = "0.1.0"
dependencies = [
"clap",
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "sn-fair"
version = "0.1.0"
dependencies = [
"clap",
"thiserror",
]
[[package]]
name = "sn-fetch"
version = "0.1.0"
@@ -1024,6 +1085,22 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "sn-node"
version = "0.1.0"
dependencies = [
"clap",
"serde",
"serde_json",
"sn-exit",
"tempfile",
"thiserror",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -1162,6 +1239,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -1188,6 +1266,47 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
@@ -1541,6 +1660,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "writeable"
version = "0.6.4"
+5 -2
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/sn-fetch"]
members = ["crates/sn-fetch", "crates/sn-node", "crates/sn-exit", "crates/sn-fair"]
[workspace.package]
version = "0.1.0"
@@ -18,7 +18,10 @@ panic = "deny"
unreachable = "deny"
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "time", "process", "signal"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
hex = "0.4"
+98 -9
View File
@@ -12,17 +12,106 @@ macht. Sprache: **Rust**.
Exit, Mindest-Segmentgröße mit Merge-Fix, validated: lückenlose,
vollständige Abdeckung)
- Parallele Segment-Downloads (Streaming, eigener reqwest-Client pro Exit)
- **Retry mit Exit-Reassignment:** fehlgeschlagene Segmente werden (wieder-
holbar, Standard 3 Versuche) bevorzugt auf einem anderen Exit wiederholt;
nicht-retrybare Fehler (4xx, korrupte Parts) brechen sofort ab — ein
halbfertiges File wird nie als Erfolg ausgegeben
- **Progress:** `ProgressSink`-Events (Segment started/finished/failed,
`BytesTracker`) + `tracing`-Logging
- **Per-Exit-Egress:** `Direct` (eigener Uplink) oder `Proxy` (HTTP-Proxy
eines Nachbarn — der Weg für `sn-exit` über das Overlay); end-to-end
getestet gegen einen echten Forward-Proxy
- Reassembly in Index-Reihenfolge + Streaming-SHA-256 + Längen-Check
- Fallback: range-hostile Server werden als single-stream geladen
- CLI: `sn-fetch <url> [-o out] [-e "1=2,2=1"] [-s n] [--sha256 hex] --probe-only`
- CLI: `sn-fetch <url> [-o out] [-e "1=2,2=1@http://[ygg]:8080"] [-s n]
[--max-attempts n] [--sha256 hex] --probe-only`
Verifiziert gegen einen echten Server (Debian-CD-Mirror, 756 MB, 8 Segmente,
SHA-256 entspricht der offiziellen `SHA256SUMS`).
**Noch nicht drin:** Multi-Exit-Routing über das Mesh (aktuell laufen alle
Exits als lokale Verbindungen — die Struktur [`HttpExit`]-Trait,
Exit-Registry] ist dafür schon bereit), Retry bei Segment-Fehlern,
Progress-Anzeige während des Laufs.
**`sn-node` v0 ist implementiert** (siehe `crates/sn-node/`):
- `sn-node genconf`: erzeugt eine Yggdrasil-Config aus validierten
Einstellungen — Basis ist immer der echte `yggdrasil -genconf -json`
Output (keine hartcodierten Defaults), gepatcht mit `Peers`, `Listen`,
`MulticastInterfaces`, `AdminListen`, `AllowedPublicKeys` (Nachbarschafts-
ACL), `IfName`, `PrivateKeyPath`; Output-Datei wird auf 600 gesetzt
- `sn-node status`: fragt den Admin-Socket ab (`getSelf`/`getPeers` über
TCP `localhost:9001` oder Unix-Socket) — Overlay-Adresse, Subnetz,
Version, aktive Peer-Verbindungen
**`sn-exit` v0 ist implementiert** (siehe `crates/sn-exit/`):
- HTTP-Proxy-Dienst, der auf der Yggdrasil-Adresse des Haushalts lauscht
und Nachbar-Requests über den eigenen Uplink holt — Sharing ist opt-in
durch Starten des Dienstes, Stoppen ist der Kill-Switch
- **Transparentes Byte-Relay:** absolute-URI-GETs (HTTP) und CONNECT-
Tunnel (HTTPS) werden unverändert weitergeleitet; TLS bleibt end-to-end
zwischen Neighbor-Client und Origin, Range-Header und Retries
funktionieren (Segmentierung bleibt Sache von `sn-fetch`)
- **Quota pro Neighbor:** Bytes pro Zeitfenster (fixed window),
erschöpft → HTTP 503 mit `Retry-After`; Accounting auch bei abgebrochenen
Verbindungen (Counter-Wrapper, nicht Copy-Ergebnis)
- **SSRF-Schutz:** Origins, die zu Loopback/Private/Link-Local/ULA/
Yggdrasil-Ranges auflösen, werden abgewiesen — Nachbarn können nicht in
Heim-LAN oder Admin-Sockets des Exit-Haushalts fetchen
- Source-Allowlist: nur gelistete Neighbor-Adressen; ohne Liste nur
Loopback (Dev-Mode)
- Verifiziert end-to-end: `sn-fetch` über einen laufenden `sn-exit`
lädt die Debian-ISO (756 MB, 4 Segmente, SHA-256 korrekt)
**`sn-fair` v0 ist implementiert** (siehe `crates/sn-fair/`):
- Gewichtetes Max-Min-Fairness-Engine (zwei Klassen): **Contributors**
(Haushalte mit Exit-Angebot, konfigurierbares Gewicht) teilen sich
die Gesamtkapazität nach Max-Min-Fairness; **Leeches** (Empfang ohne
Beitrag, ausdrücklich legitim) bekommen die Restkapazität — bei
Sättigung zuerst gedrosselt, nie ausgeschlossen
- Deterministische, overflow-sichere Berechnung (u128-Arithmetik,
Restverteilung in ID-Ordnung), validierte Newtypes
(`ParticipantId`, `Weight`, `Rate`, `Capacity`)
- Demo-CLI in Mbit/s für den Piloten: Soll-Aufteilung simulieren und
gegen die gemessene Ist-Aufteilung halten (siehe [[Pilot]], M4)
- Runtime-Integration in `sn-fetch` (echte gleichzeitige Downloads
drosseln) ist Phase 2, siehe [[Roadmap]]
**`sn-node` Daemon v1 ist implementiert** (`sn-node daemon`, siehe
`crates/sn-node/src/daemon.rs`):
- **Ein Befehl pro Haushalt:** `sn-node daemon --config node.toml`
orchestriert den Node — Yggdrasil-Sidecar (generierte Config +
persistenter Node-Key im `state_dir`, Identität bleibt über Neustarts
stabil: `PrivateKeyPath` hat Vorrang vor dem eingebetteten Key, siehe
[Config-Referenz](https://yggdrasil-network.github.io/configurationref.html))
+ optional den in-process `sn-exit`-Dienst
- **TOML-Konfiguration, validiert beim Laden** (`deny_unknown_fields`,
wiederverwendete validierte Typen; `sn-node daemon --check` prüft
ohne Start — für Packaging/Ops)
- **Leech-Modus per Konfig** (`[exit] enabled = false`): Overlay läuft,
kein Exit-Angebot; enabled + leere Allowlist = Start verweigert
(Ausnahme: Loopback-Dev-Bind)
- **Kill-Switch-Semantik:** SIGINT/SIGTERM → Accept-Loop stoppt,
Sidecar bekommt SIGTERM (5 s Gnade, dann SIGKILL); stirbt Yggdrasil
unerwartet, fährt der Daemon mit Fehler herunter (Supervisor-Pflicht)
- Getestet mit Fake-Yggdrasil (Script-Binary) und Fake-Admin-Socket:
Key-Persistenz, Exit-Bind, Shutdown, Sidecar-Tod
**Deployment: Docker & Nix** (siehe `server/docker/README.md` und
`flake.nix`):
- `server/docker/`: Multi-Stage-Image (alle 4 Binaries + Yggdrasil
v0.5.14, SHA-256-verifiziert), Compose-Beispiel pro Haushalt
(`network_mode: host`, `NET_ADMIN`, `/dev/net/tun`, `init: true`) —
Image gebaut und geraucht-getestet
- `flake.nix`: `nix build` baut das Workspace als ein Paket
(+ experimentelles NixOS-Modul) — **nicht lokal verifiziert**
(Nix nicht installiert), Feedback willkommen
**Noch nicht drin:** Freenet-Integration, Cache, Metriken/Onboarding
(Phase 2).
Details zum Overlay: [[Overlay]]. Pilot-Handbuch mit Messprotokoll:
[[Pilot]].
## Haftungsausschluss (muss im Produkt sichtbar sein)
@@ -52,14 +141,14 @@ Einplatinenrechner), der:
| Crate/Binary | Zweck |
|---|---|
| `sn-node` | Daemon: Discovery, Peering, Metriken, Onboarding |
| `sn-exit` | Exit-Dienst: kontingentierter Uplink-Proxy für Nachbarn (verschlüsselt) |
| `sn-node` | Node-Glue: Yggdrasil-Config (`genconf`), Overlay-Status (`status`), Node-Daemon (`daemon`: Sidecar + Exit-Orchestrierung) |
| `sn-exit` | Exit-Dienst: kontingentierter Uplink-Proxy auf der Yggdrasil-Adresse (v0: Byte-Relay + Quota + SSRF-Schutz) |
| `sn-fetch` | Segmentierter Multi-Exit-Downloader (HTTP-API + CLI) |
| `sn-fair` | Fairness-Scheduler (max-min, Exit-Kontingente, Karma, Leech-Modus) |
| `sn-fair` | Fairness-Scheduler (max-min, Exit-Kontingente, Karma, Leech-Modus) — v0: zwei-Klassen-Max-Min-Engine + Demo-CLI |
| `sn-cache` | Transparenter Nachbarschafts-Cache (DNS-basiert) |
| `sn-freenet` | Integration von `freenet-core` (Contracts für Nachbarschafts-Inhalte) |
## MVP (Phase 2 der [[Roadmap]])
## MVP (Phase 1→2, siehe [[Roadmap]])
Kleinster brauchbarer Schnitt:
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "sn-exit"
description = "SpiderNet exit service: quota-controlled uplink proxy for neighbors over the overlay"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
clap = { workspace = true }
tracing-subscriber = { workspace = true }
[lints]
workspace = true
+285
View File
@@ -0,0 +1,285 @@
//! Typed, validated configuration for the exit service.
use std::collections::HashSet;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use crate::error::ExitError;
/// Quota: how many bytes a neighbor may push through the exit per window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuotaPolicy {
max_bytes: u64,
window: Duration,
}
impl QuotaPolicy {
pub fn try_new(max_bytes: u64, window: Duration) -> Result<Self, ExitError> {
if max_bytes == 0 {
return Err(ExitError::InvalidQuota("max_bytes must be non-zero"));
}
if window.is_zero() {
return Err(ExitError::InvalidQuota("window must be non-zero"));
}
Ok(Self { max_bytes, window })
}
pub const fn max_bytes(self) -> u64 {
self.max_bytes
}
pub const fn window(self) -> Duration {
self.window
}
}
/// Which source addresses (neighbors) may use this exit. Empty list =
/// loopback only (development mode); real deployments always list the
/// neighbors' overlay addresses explicitly.
#[derive(Debug, Clone)]
pub struct SourceAllowlist {
allowed: HashSet<IpAddr>,
allow_loopback: bool,
}
impl SourceAllowlist {
/// Loopback-only allowlist (development mode).
pub fn loopback_only() -> Self {
Self {
allowed: HashSet::new(),
allow_loopback: true,
}
}
/// Explicit allowlist; loopback is allowed additionally in dev mode
/// (when no explicit entries exist).
pub fn new(allowed: Vec<IpAddr>) -> Self {
Self {
allowed: allowed.into_iter().collect(),
allow_loopback: false,
}
}
pub fn is_allowed(&self, source: IpAddr) -> bool {
source.is_loopback() && self.allow_loopback || self.allowed.contains(&source)
}
pub fn len(&self) -> usize {
self.allowed.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.allowed.is_empty()
}
pub fn is_loopback_only(&self) -> bool {
self.allow_loopback
}
}
/// Which origins a neighbor may reach through this exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginPolicy {
/// Production: only public internet addresses (see
/// [`is_public_origin`]). Protects the exit household's LAN, its
/// admin sockets and overlay addresses from neighbor requests.
PublicOnly,
/// Test-only: any origin, including loopback. Never use in
/// production — it removes the SSRF protection entirely.
AnyForTesting,
}
/// Everything the exit needs to run.
#[derive(Debug, Clone)]
pub struct ExitConfig {
bind: SocketAddr,
allowlist: SourceAllowlist,
quota: Option<QuotaPolicy>,
origin_policy: OriginPolicy,
}
impl ExitConfig {
pub fn new(bind: SocketAddr, allowlist: SourceAllowlist, quota: Option<QuotaPolicy>) -> Self {
Self {
bind,
allowlist,
quota,
origin_policy: OriginPolicy::PublicOnly,
}
}
/// Override the origin policy (`AnyForTesting` only in tests).
#[must_use]
pub const fn with_origin_policy(mut self, policy: OriginPolicy) -> Self {
self.origin_policy = policy;
self
}
pub const fn bind(&self) -> SocketAddr {
self.bind
}
pub fn allowlist(&self) -> &SourceAllowlist {
&self.allowlist
}
pub const fn quota(&self) -> Option<QuotaPolicy> {
self.quota
}
pub const fn origin_policy(&self) -> OriginPolicy {
self.origin_policy
}
}
/// SSRF protection: an origin is only fetchable if it is *public*
/// internet. Loopback, private/LAN, link-local, CGNAT, ULA, multicast,
/// unspecified and Yggdrasil overlay ranges are refused — neighbors must
/// not be able to reach the exit household's own LAN or admin sockets,
/// and the exit is for internet egress, not overlay-internal traffic.
pub fn is_public_origin(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
!(v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_multicast()
|| v4.is_documentation()
// 100.64/10 CGNAT (`is_shared()` is still unstable)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
|| v4.octets()[0] == 0) // 0.0.0.0/8 "this network"
}
IpAddr::V6(v6) => {
let segments = v6.segments();
!(v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
// fe80::/10 link-local
|| (segments[0] & 0xffc0) == 0xfe80
// fc00::/7 unique-local (also would cover Yggdrasil's
// 200::/7? No: 200::/7 is 0x0200-0x03ff, fc00::/7 is
// 0xfc00-0xfdff — distinct. Checked separately below.)
|| (segments[0] & 0xfe00) == 0xfc00
// 200::/7 Yggdrasil overlay (0x0200-0x03ff)
|| (segments[0] & 0xfe00) == 0x0200
// ::ffff:x.y.z.w IPv4-mapped — judge by the v4 part
|| (segments[0] == 0 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0 && segments[4] == 0 && segments[5] == 0xffff && {
let mapped = IpAddr::V4(std::net::Ipv4Addr::new(
(segments[6] >> 8) as u8,
(segments[6] & 0xff) as u8,
(segments[7] >> 8) as u8,
(segments[7] & 0xff) as u8,
));
!is_public_origin(mapped)
}))
}
}
}
/// Parse an address from CLI (`--bind`, `--allow`). Returns a typed error
/// instead of panicking on junk input.
pub fn parse_bind(input: &str) -> Result<SocketAddr, ExitError> {
input
.parse()
.map_err(|_| ExitError::InvalidBind(input.to_string()))
}
pub fn parse_source(input: &str) -> Result<IpAddr, ExitError> {
input
.parse()
.map_err(|_| ExitError::InvalidSource(input.to_string()))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn quota_rejects_zero() {
assert!(QuotaPolicy::try_new(0, Duration::from_secs(60)).is_err());
assert!(QuotaPolicy::try_new(100, Duration::ZERO).is_err());
assert!(QuotaPolicy::try_new(100, Duration::from_secs(60)).is_ok());
}
#[test]
fn allowlist_loopback_default_and_explicit_entries() {
let dev = SourceAllowlist::loopback_only();
assert!(dev.is_allowed(std::net::IpAddr::from([127, 0, 0, 1])));
assert!(!dev.is_allowed(std::net::IpAddr::from([192, 168, 1, 2])));
let explicit = SourceAllowlist::new(vec![std::net::IpAddr::from([
0x200u16, 1, 0, 0, 0, 0, 0, 1,
])]);
assert!(!explicit.is_allowed(std::net::IpAddr::from([127, 0, 0, 1])));
assert!(explicit.is_allowed(std::net::IpAddr::from([0x200, 1, 0, 0, 0, 0, 0, 1])));
}
#[test]
fn public_origin_rejects_private_and_local_targets() {
// Public internet: allowed.
assert!(is_public_origin(std::net::IpAddr::from([93, 184, 216, 34])));
assert!(is_public_origin(
"2606:2800:220:1:248:1893:25c8:1946"
.parse::<std::net::Ipv6Addr>()
.unwrap()
.into()
));
// Refused: loopback, RFC1918, link-local, CGNAT, docs, multicast.
for bad_v4 in [
[127, 0, 0, 1],
[10, 0, 0, 5],
[172, 16, 3, 4],
[192, 168, 1, 1],
[169, 254, 9, 9],
[100, 64, 0, 1],
[192, 0, 2, 55],
[224, 0, 0, 1],
[0, 0, 0, 0],
] {
assert!(
!is_public_origin(std::net::IpAddr::from(bad_v4)),
"v4 {bad_v4:?} must be refused"
);
}
// Refused: v6 loopback, link-local, ULA, yggdrasil 200::/7,
// IPv4-mapped loopback.
for bad_v6 in [
"::1",
"fe80::1",
"fc00::1",
"200:1234::1",
"::ffff:127.0.0.1",
] {
let v6 = bad_v6.parse::<std::net::Ipv6Addr>().unwrap();
assert!(
!is_public_origin(std::net::IpAddr::V6(v6)),
"v6 {bad_v6} must be refused"
);
}
}
#[test]
fn parse_bind_and_source() {
assert_eq!(
parse_bind("[::1]:8080").unwrap(),
SocketAddr::new(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), 8080)
);
assert_eq!(
parse_bind("127.0.0.1:8080").unwrap(),
SocketAddr::from(([127, 0, 0, 1], 8080))
);
assert!(parse_bind("nope").is_err());
assert_eq!(
parse_source("200:1:2:3:4:5:6:7").unwrap(),
std::net::IpAddr::V6(std::net::Ipv6Addr::new(0x200, 1, 2, 3, 4, 5, 6, 7))
);
assert!(parse_source("banana").is_err());
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Error type for all sn-exit operations.
#[derive(Debug, thiserror::Error)]
pub enum ExitError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid bind address `{0}`")]
InvalidBind(String),
#[error("invalid allowed source address `{0}`")]
InvalidSource(String),
#[error("invalid quota: {0}")]
InvalidQuota(&'static str),
#[error("protocol violation: {0}")]
Protocol(&'static str),
}
+40
View File
@@ -0,0 +1,40 @@
//! sn-exit — the `SpiderNet` exit service.
//!
//! An exit is one household's opt-in offer to its neighbors: this binary
//! listens on the household's Yggdrasil overlay address and forwards
//! neighbor requests through the household's own internet uplink, under
//! a per-neighbor quota.
//!
//! Design (v0):
//!
//! - **Transparent byte relay.** CONNECT tunnels are relayed byte for
//! byte (TLS stays end-to-end between neighbor and origin — the exit
//! never sees plaintext HTTPS), plain proxy requests are relayed the
//! same way. Range headers and retries keep working because headers
//! are never rewritten; segmentation stays a client-side concern
//! (`sn-fetch`).
//! - **SSRF protection.** Origins that resolve to loopback, private,
//! link-local, ULA or Yggdrasil ranges are refused — a neighbor must
//! not be able to reach the exit household's LAN, its admin sockets
//! or other overlay nodes through this service.
//! - **Quota.** Per source address, per fixed window. Enforcement
//! happens when a connection starts; the traffic a connection actually
//! carries is accounted when it ends. A single very large connection
//! can overshoot the quota — documented v0 limitation.
//!
//! Sharing is opt-in by running this binary; stopping it is the
//! kill-switch. Receiving without offering an exit is the explicitly
//! allowed leech mode — no punishment, see `[[Ethos]]` in the docs.
//!
//! `SpiderNet` software carries no warranty and accepts no responsibility
//! for its usage or for transported content (AGPL-3.0, sections 15/16).
#![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
// PoC scope: doc-annotation noise, not correctness. Revisit at release.
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
pub mod config;
pub mod error;
pub mod parse;
pub mod proxy;
+107
View File
@@ -0,0 +1,107 @@
//! CLI entry point for sn-exit.
//!
//! KEINE GARANTIE — this software is provided "as is" without warranty of
//! any kind (AGPL-3.0, sections 15/16). Users are solely responsible for
//! what they transmit or receive with it.
use std::time::Duration;
use clap::Parser;
use sn_exit::config::{self, ExitConfig, QuotaPolicy, SourceAllowlist};
/// Per-neighbor quota is disabled when 0 is passed (no limit).
const QUOTA_DISABLED: u64 = 0;
#[derive(Debug, Parser)]
#[command(
name = "sn-exit",
about = "SpiderNet exit service: quota-controlled uplink proxy for neighbors"
)]
struct Args {
/// Address to listen on — ideally the household's Yggdrasil address,
/// e.g. `[200:1234::1]:8080`.
#[arg(long, default_value = "127.0.0.1:8080")]
bind: String,
/// Neighbor addresses allowed to use this exit (repeatable, e.g.
/// `200:5678::2`). Without any `--allow`, only loopback clients are
/// accepted (development mode).
#[arg(long = "allow", value_delimiter = ',')]
allow: Vec<String>,
/// Quota per neighbor per window, in MiB. `0` disables the quota.
#[arg(long, default_value_t = 1024)]
quota_mib: u64,
/// Quota window in hours (fixed window per neighbor).
#[arg(long, default_value_t = 24)]
window_hours: u64,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
if let Err(report) = run().await {
eprintln!("NO WARRANTY — SpiderNet sn-exit (AGPL-3.0, no liability)");
eprintln!("error: {report}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let args = Args::parse();
let bind = config::parse_bind(&args.bind).map_err(|err| err.to_string())?;
let allowlist = if args.allow.is_empty() {
SourceAllowlist::loopback_only()
} else {
let sources = args
.allow
.iter()
.map(|addr| config::parse_source(addr).map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, String>>()?;
SourceAllowlist::new(sources)
};
let quota = if args.quota_mib == QUOTA_DISABLED {
None
} else {
let max_bytes = args.quota_mib.saturating_mul(1024 * 1024);
Some(
QuotaPolicy::try_new(max_bytes, Duration::from_secs(args.window_hours * 3600))
.map_err(|err| err.to_string())?,
)
};
let config = ExitConfig::new(bind, allowlist, quota);
let listener = tokio::net::TcpListener::bind(config.bind())
.await
.map_err(|err| format!("cannot bind {}: {err}", config.bind()))?;
println!("NO WARRANTY — SpiderNet sn-exit (AGPL-3.0, no liability)");
println!();
if config.allowlist().is_loopback_only() {
println!("DEV MODE: no --allow given — only loopback clients are accepted.");
}
match config.quota() {
Some(policy) => println!(
"quota: {} MiB per {}h per neighbor (enforced at connection start)",
policy.max_bytes() / (1024 * 1024),
policy.window().as_secs() / 3600,
),
None => println!("quota: disabled — neighbors may use unlimited uplink traffic"),
}
println!(
"sharing: this household now offers its uplink (stop sn-exit to revoke = kill-switch)"
);
println!("listen: {}", config.bind());
sn_exit::proxy::serve(config, listener)
.await
.map_err(|err| err.to_string())
}
+167
View File
@@ -0,0 +1,167 @@
//! Minimal, strict HTTP/1.1 proxy-head parsing.
//!
//! The exit is a byte relay, so it only needs to understand the request
//! line (method + target) to find the origin — everything else is
//! forwarded verbatim inside the pipe. Strictness beats flexibility
//! here: anything that is not a clean proxy request line is refused.
use crate::error::ExitError;
/// How the client addressed the origin.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestTarget {
/// CONNECT authority form: `host:port` (TLS and any other TCP).
Tunnel,
/// Absolute URI: `http://host[:port]/…` (plain HTTP via proxy).
Forward,
}
/// One parsed proxy request head.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestHead {
pub target: RequestTarget,
pub host: String,
pub port: u16,
}
/// Largest accepted request head; anything above is refused instead of
/// buffered without bound.
pub const MAX_HEAD_BYTES: usize = 16 * 1024;
/// Parse the request line out of a raw head buffer. The buffer may
/// include the trailing `\r\n\r\n`; only the first line is examined.
///
/// `CONNECT host:port HTTP/1.1` → tunnel to `host:port`.
/// `GET http://host[:port]/path HTTP/1.1` → forward to `host:port`.
pub fn parse_request_head(head: &[u8]) -> Result<RequestHead, ExitError> {
let head_text = std::str::from_utf8(head)
.map_err(|_| ExitError::Protocol("request head is not valid utf-8"))?;
let request_line = head_text
.lines()
.next()
.ok_or(ExitError::Protocol("empty request head"))?;
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default();
let raw_target = parts.next().unwrap_or_default();
match method {
"CONNECT" => {
let (host, port) = parse_authority(raw_target)?;
Ok(RequestHead {
target: RequestTarget::Tunnel,
host,
port,
})
}
"GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "OPTIONS" => {
let rest = raw_target.strip_prefix("http://").ok_or(ExitError::Protocol(
"non-CONNECT requests must use absolute http:// URIs (proxy-capable client required)",
))?;
let authority = rest.split('/').next().unwrap_or_default();
let (host, port) = parse_authority(authority)?;
Ok(RequestHead {
target: RequestTarget::Forward,
host,
port,
})
}
_ => Err(ExitError::Protocol("unsupported method")),
}
}
/// Parse `host:port` or `[ipv6]:port`. Default port 80 when absent.
fn parse_authority(authority: &str) -> Result<(String, u16), ExitError> {
let authority = authority.trim();
if authority.is_empty() {
return Err(ExitError::Protocol("empty authority"));
}
// [ipv6]:port
if let Some(rest) = authority.strip_prefix('[') {
let close = rest
.find(']')
.ok_or(ExitError::Protocol("unterminated [ipv6] authority"))?;
let host = &rest[..close];
host.parse::<std::net::Ipv6Addr>()
.map_err(|_| ExitError::Protocol("bad ipv6 authority"))?;
let port = rest[close + 1..].strip_prefix(':').map_or(Ok(80), |p| {
p.parse::<u16>()
.map_err(|_| ExitError::Protocol("bad port"))
})?;
return Ok((host.to_string(), port));
}
// No brackets — IPv4 or hostname. rsplit, not split: only the last
// `:` separates the port.
let Some((host, port)) = authority.rsplit_once(':') else {
if authority.contains(':') {
return Err(ExitError::Protocol("bare ipv6 needs brackets and a port"));
}
return Ok((authority.to_string(), 80));
};
let port: u16 = port.parse().map_err(|_| ExitError::Protocol("bad port"))?;
if host.is_empty() {
return Err(ExitError::Protocol("empty host"));
}
Ok((host.to_string(), port))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn head(line: &str) -> Vec<u8> {
format!("{line}\r\nHost: example.com\r\n\r\n").into_bytes()
}
#[test]
fn parses_connect_authority() {
let parsed = parse_request_head(&head("CONNECT example.com:443 HTTP/1.1")).unwrap();
assert_eq!(
parsed,
RequestHead {
target: RequestTarget::Tunnel,
host: "example.com".to_string(),
port: 443,
}
);
}
#[test]
fn parses_connect_ipv6_authority() {
let parsed = parse_request_head(&head("CONNECT [200:1234::1]:8080 HTTP/1.1")).unwrap();
assert_eq!(parsed.host, "200:1234::1");
assert_eq!(parsed.port, 8080);
}
#[test]
fn parses_absolute_uri_forward() {
let parsed =
parse_request_head(&head("GET http://example.com/dir/file.bin HTTP/1.1")).unwrap();
assert_eq!(
parsed,
RequestHead {
target: RequestTarget::Forward,
host: "example.com".to_string(),
port: 80,
}
);
let parsed = parse_request_head(&head("GET http://example.com:8080/a HTTP/1.1")).unwrap();
assert_eq!(parsed.host, "example.com");
assert_eq!(parsed.port, 8080);
}
#[test]
fn rejects_origin_form_and_junk() {
// Origin-form (no absolute URI) is unusable for a proxy.
assert!(parse_request_head(&head("GET /file.bin HTTP/1.1")).is_err());
assert!(parse_request_head(&head("GET https://example.com/x HTTP/1.1")).is_err());
assert!(parse_request_head(&head("FTP http://example.com/ HTTP/1.1")).is_err());
assert!(parse_request_head(&head("CONNECT :443 HTTP/1.1")).is_err());
assert!(parse_request_head(&head("")).is_err());
assert!(parse_request_head(&[0xff, 0xfe, 0xfd]).is_err()); // not utf-8
}
}
+361
View File
@@ -0,0 +1,361 @@
//! The exit server: accept neighbor connections, admit them (allowlist,
//! quota), and relay bytes between neighbor and origin.
//!
//! Relay is verbatim: the exit never rewrites or inspects payload bytes.
//! TLS stays end-to-end between the neighbor's client and the origin —
//! the exit only sees which host is contacted and how many bytes flow.
use std::collections::HashMap;
use std::future::Future;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::config::{is_public_origin, ExitConfig, OriginPolicy, QuotaPolicy};
use crate::error::ExitError;
use crate::parse::{self, RequestTarget, MAX_HEAD_BYTES};
/// Time budget for reading the request head and connecting to the origin.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
const RESP_FORBIDDEN: &[u8] =
b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
const RESP_BAD_REQUEST: &[u8] =
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
const RESP_TUNNEL_OK: &[u8] = b"HTTP/1.1 200 Connection established\r\n\r\n";
/// Per-neighbor quota ledger: fixed windows anchored at the neighbor's
/// first use, accounting applied after each finished connection.
///
/// v0 limitation: enforcement happens when a connection starts; the
/// bytes a connection carries are accounted when it ends. One very large
/// connection can therefore overshoot the quota.
#[derive(Debug, Default)]
pub struct QuotaLedger {
clients: Mutex<HashMap<IpAddr, ClientWindow>>,
}
#[derive(Debug)]
struct ClientWindow {
window_started: Instant,
used_bytes: u64,
}
/// Admission decision for one connection attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmitDecision {
Allowed,
/// The neighbor's quota is exhausted; retry after this many seconds.
QuotaExhausted {
retry_after_secs: u64,
},
}
impl QuotaLedger {
pub fn new() -> Self {
Self::default()
}
/// Check whether a connection from `source` may start now.
pub fn admit(&self, source: IpAddr, quota: Option<QuotaPolicy>) -> AdmitDecision {
let Some(policy) = quota else {
return AdmitDecision::Allowed;
};
let mut clients = Self::lock_recovered(&self.clients);
let window = roll_window(clients.entry(source), policy, Instant::now());
if window.used_bytes >= policy.max_bytes() {
let elapsed = window.window_started.elapsed();
let remaining = policy
.window()
.checked_sub(elapsed)
.unwrap_or(Duration::ZERO);
AdmitDecision::QuotaExhausted {
retry_after_secs: remaining.as_secs().max(1),
}
} else {
AdmitDecision::Allowed
}
}
/// Account the traffic of one finished connection.
pub fn record(&self, source: IpAddr, bytes: u64, quota: Option<QuotaPolicy>) {
let Some(policy) = quota else {
return;
};
let mut clients = Self::lock_recovered(&self.clients);
let window = roll_window(clients.entry(source), policy, Instant::now());
window.used_bytes = window.used_bytes.saturating_add(bytes);
}
/// Total accounted bytes for one neighbor (visible in metrics later).
pub fn used_bytes(&self, source: IpAddr) -> u64 {
Self::lock_recovered(&self.clients)
.get(&source)
.map_or(0, |w| w.used_bytes)
}
/// Lock without panicking on a poisoned mutex: the ledger is
/// self-healing (windows roll over), so recovering the guard is
/// always safe here.
fn lock_recovered(
clients: &Mutex<HashMap<IpAddr, ClientWindow>>,
) -> std::sync::MutexGuard<'_, HashMap<IpAddr, ClientWindow>> {
clients
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
/// Get the client's window, rolling it over when the policy's window has
/// elapsed since its start.
fn roll_window(
entry: std::collections::hash_map::Entry<'_, IpAddr, ClientWindow>,
policy: QuotaPolicy,
now: Instant,
) -> &mut ClientWindow {
let window = entry.or_insert_with(|| ClientWindow {
window_started: now,
used_bytes: 0,
});
if now.duration_since(window.window_started) >= policy.window() {
window.window_started = now;
window.used_bytes = 0;
}
window
}
/// Accept loop. Runs until the listener errors fatally.
pub async fn serve(config: ExitConfig, listener: TcpListener) -> Result<(), ExitError> {
serve_until(config, listener, std::future::pending()).await
}
/// Like [`serve`], but stops accepting when `shutdown` completes (used
/// by the node daemon for kill-switch semantics). Connections already
/// accepted keep running in the background; the caller or process exit
/// owns them.
pub async fn serve_until(
config: ExitConfig,
listener: TcpListener,
shutdown: impl Future<Output = ()>,
) -> Result<(), ExitError> {
let config = Arc::new(config);
let ledger = Arc::new(QuotaLedger::new());
tokio::pin!(shutdown);
loop {
tokio::select! {
() = &mut shutdown => {
tracing::info!("exit: accept loop stopped (shutdown)");
return Ok(());
}
accepted = listener.accept() => {
let (stream, peer) = accepted?;
let config = Arc::clone(&config);
let ledger = Arc::clone(&ledger);
tokio::spawn(async move {
if let Err(error) = handle_connection(stream, peer.ip(), config, ledger).await {
tracing::debug!(%peer, "connection ended: {error}");
}
});
}
}
}
}
async fn handle_connection(
mut neighbor: TcpStream,
source: IpAddr,
config: Arc<ExitConfig>,
ledger: Arc<QuotaLedger>,
) -> Result<(), ExitError> {
// 1. Read the request head (bounded, with timeout).
let raw_head = read_head(&mut neighbor).await?;
// 2. Admission: allowlist, then quota.
if !config.allowlist().is_allowed(source) {
neighbor.write_all(RESP_FORBIDDEN).await.ok();
tracing::info!(%source, "refused: source not allowed");
return Ok(());
}
if let AdmitDecision::QuotaExhausted { retry_after_secs } = ledger.admit(source, config.quota())
{
let body = format!(
"HTTP/1.1 503 Service Unavailable\r\nRetry-After: {retry_after_secs}\r\n\
Content-Length: 0\r\nConnection: close\r\n\r\n"
);
neighbor.write_all(body.as_bytes()).await.ok();
tracing::info!(%source, "refused: quota exhausted, retry in {retry_after_secs}s");
return Ok(());
}
// 3. Parse and verify the origin is public (SSRF protection).
let Ok(head) = parse::parse_request_head(&raw_head) else {
neighbor.write_all(RESP_BAD_REQUEST).await.ok();
return Err(ExitError::Protocol("unparseable request head"));
};
ensure_origin_is_public(&head, config.origin_policy()).await?;
// 4. Ack tunnels, then relay — verbatim, both directions.
if head.target == RequestTarget::Tunnel {
neighbor.write_all(RESP_TUNNEL_OK).await?;
}
let origin = connect_origin(&head).await?;
// Counted relay: byte counters live in the wrappers, so traffic is
// accounted even when the connection errors or is aborted midway.
let mut neighbor_counted = CountingIo::new(neighbor);
let mut origin_counted = CountingIo::new(origin);
if head.target == RequestTarget::Forward {
// The neighbor's head is forwarded as the first bytes of the
// pipe; origin servers accept absolute-URI request targets.
origin_counted.write_all(&raw_head).await?;
}
let relay = tokio::io::copy_bidirectional(&mut neighbor_counted, &mut origin_counted).await;
// Uplink traffic = what the household's ISP sees: bytes to the
// origin (egress) + bytes to the neighbor (ingress from origin).
let up = origin_counted.written_bytes();
let down = neighbor_counted.written_bytes();
ledger.record(source, up + down, config.quota());
relay?;
tracing::debug!(
%source,
"target={:?} origin={}:{} closed ({up}+{down} bytes)",
head.target,
head.host,
head.port
);
Ok(())
}
/// Stream wrapper that counts all bytes read from and written to the
/// inner stream, so quota accounting does not depend on the relay's
/// outcome.
struct CountingIo<T> {
inner: T,
read: std::sync::atomic::AtomicU64,
written: std::sync::atomic::AtomicU64,
}
impl<T> CountingIo<T> {
fn new(inner: T) -> Self {
Self {
inner,
read: std::sync::atomic::AtomicU64::new(0),
written: std::sync::atomic::AtomicU64::new(0),
}
}
fn written_bytes(&self) -> u64 {
self.written.load(std::sync::atomic::Ordering::Relaxed)
}
}
impl<T: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for CountingIo<T> {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let before = buf.filled().len();
let result = std::pin::Pin::new(&mut self.inner).poll_read(cx, buf);
if matches!(result, std::task::Poll::Ready(Ok(()))) {
let after = buf.filled().len();
self.read.fetch_add(
(after - before) as u64,
std::sync::atomic::Ordering::Relaxed,
);
}
result
}
}
impl<T: tokio::io::AsyncWrite + Unpin> tokio::io::AsyncWrite for CountingIo<T> {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
let result = std::pin::Pin::new(&mut self.inner).poll_write(cx, buf);
if let std::task::Poll::Ready(Ok(n)) = result {
self.written
.fetch_add(n as u64, std::sync::atomic::Ordering::Relaxed);
}
result
}
fn poll_flush(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
/// Read up to [`MAX_HEAD_BYTES`] bytes until the head terminator
/// `\r\n\r\n`. Returns the raw head *including* the terminator.
async fn read_head(stream: &mut TcpStream) -> Result<Vec<u8>, ExitError> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
let deadline = tokio::time::Instant::now() + HANDSHAKE_TIMEOUT;
loop {
if head.len() >= MAX_HEAD_BYTES {
return Err(ExitError::Protocol("request head too large"));
}
match tokio::time::timeout_at(deadline, stream.read(&mut byte)).await {
Err(_) => return Err(ExitError::Io(std::io::ErrorKind::TimedOut.into())),
Ok(Ok(0)) => return Err(ExitError::Protocol("connection closed before head ended")),
Ok(Ok(_)) => {
head.push(byte[0]);
if head.ends_with(b"\r\n\r\n") {
return Ok(head);
}
}
Ok(Err(error)) => return Err(ExitError::Io(error)),
}
}
}
/// DNS resolution happens here so the blocklist sees the *resolved* IPs,
/// not just the hostname the neighbor typed.
async fn ensure_origin_is_public(
head: &parse::RequestHead,
policy: OriginPolicy,
) -> Result<(), ExitError> {
if policy == OriginPolicy::AnyForTesting {
return Ok(());
}
let resolved: Vec<std::net::SocketAddr> =
tokio::net::lookup_host((head.host.as_str(), head.port))
.await?
.collect();
if resolved.is_empty() {
return Err(ExitError::Protocol("origin could not be resolved"));
}
for addr in resolved {
if !is_public_origin(addr.ip()) {
return Err(ExitError::Protocol("origin is not a public address"));
}
}
Ok(())
}
async fn connect_origin(head: &parse::RequestHead) -> Result<TcpStream, ExitError> {
let stream = tokio::time::timeout(
HANDSHAKE_TIMEOUT,
TcpStream::connect((head.host.as_str(), head.port)),
)
.await
.map_err(|_| ExitError::Io(std::io::ErrorKind::TimedOut.into()))??;
Ok(stream)
}
+276
View File
@@ -0,0 +1,276 @@
//! Integration tests against a running exit server (loopback): plain
//! HTTP forwarding, CONNECT tunneling, quota enforcement and the source
//! allowlist.
#![forbid(unsafe_code)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use sn_exit::config::{ExitConfig, QuotaPolicy, SourceAllowlist};
use sn_exit::proxy::serve;
/// Spawn the exit server on a free loopback port; returns its address.
async fn spawn_exit(config: ExitConfig) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = serve(config, listener).await;
});
addr
}
/// Origin server that supports ranges: answers `GET http://origin/…`
/// absolute-URI requests with (parts of) a fixed body.
async fn spawn_origin(data: Arc<Vec<u8>>) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let data = Arc::clone(&data);
tokio::spawn(async move {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if socket.read(&mut byte).await.unwrap_or(0) == 0 {
return;
}
head.push(byte[0]);
}
let head_text = String::from_utf8_lossy(&head);
let range = head_text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if !name.eq_ignore_ascii_case("range") {
return None;
}
let value = value.trim().strip_prefix("bytes=")?;
let (start, end) = value.trim().split_once('-')?;
Some((start.parse::<u64>().ok()?, end.parse::<u64>().ok()?))
});
let (start, end_inclusive, status) = match range {
Some((start, end)) => (start, end, "206 Partial Content"),
None => (0, data.len() as u64 - 1, "200 OK"),
};
let slice = &data[start as usize..=(end_inclusive as usize).min(data.len() - 1)];
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
slice.len()
);
socket.write_all(response.as_bytes()).await.ok();
socket.write_all(slice).await.ok();
socket.shutdown().await.ok();
});
}
});
addr
}
/// Echo TCP server for the CONNECT tunnel test (stands in for a TLS
/// origin from the relay's perspective: opaque bytes both ways).
async fn spawn_echo_server() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buffer = [0u8; 512];
loop {
match socket.read(&mut buffer).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if socket.write_all(&buffer[..n]).await.is_err() {
break;
}
}
}
}
});
}
});
addr
}
async fn read_response_head(stream: &mut TcpStream) -> String {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
let n = stream.read(&mut byte).await.unwrap_or(0);
assert!(n > 0, "connection closed before response head ended");
head.push(byte[0]);
}
String::from_utf8(head).unwrap()
}
fn dev_config(quota: Option<QuotaPolicy>) -> ExitConfig {
ExitConfig::new(
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
SourceAllowlist::loopback_only(),
quota,
)
.with_origin_policy(sn_exit::config::OriginPolicy::AnyForTesting)
}
#[tokio::test]
async fn forward_request_relays_absolute_uri_get() {
let data: Vec<u8> = (0..32_000u32).map(|i| (i % 253) as u8).collect();
let origin = spawn_origin(Arc::new(data.clone())).await;
let exit = spawn_exit(dev_config(None)).await;
let mut neighbor = TcpStream::connect(exit).await.unwrap();
let request = format!("GET http://{origin}/file.bin HTTP/1.1\r\nHost: origin\r\n\r\n");
neighbor.write_all(request.as_bytes()).await.unwrap();
let response_head = read_response_head(&mut neighbor).await;
assert!(
response_head.starts_with("HTTP/1.1 200 OK"),
"{response_head}"
);
let mut body = Vec::new();
neighbor.read_to_end(&mut body).await.unwrap();
assert_eq!(body, data);
}
#[tokio::test]
async fn forward_request_passes_range_headers_through() {
let data: Vec<u8> = (0..16_000u32).map(|i| (i % 241) as u8).collect();
let origin = spawn_origin(Arc::new(data.clone())).await;
let exit = spawn_exit(dev_config(None)).await;
let mut neighbor = TcpStream::connect(exit).await.unwrap();
let request = format!("GET http://{origin}/file.bin HTTP/1.1\r\nRange: bytes=100-199\r\n\r\n");
neighbor.write_all(request.as_bytes()).await.unwrap();
let response_head = read_response_head(&mut neighbor).await;
assert!(
response_head.starts_with("HTTP/1.1 206 Partial Content"),
"{response_head}"
);
let mut body = Vec::new();
neighbor.read_to_end(&mut body).await.unwrap();
assert_eq!(&body, &data[100..=199]);
}
#[tokio::test]
async fn connect_tunnel_relays_bytes_both_ways() {
let echo = spawn_echo_server().await;
let exit = spawn_exit(dev_config(None)).await;
let mut neighbor = TcpStream::connect(exit).await.unwrap();
let request = format!("CONNECT {echo} HTTP/1.1\r\nHost: {echo}\r\n\r\n");
neighbor.write_all(request.as_bytes()).await.unwrap();
let response_head = read_response_head(&mut neighbor).await;
assert!(
response_head.starts_with("HTTP/1.1 200"),
"tunnel must be established: {response_head}"
);
// The tunnel is open: echo some opaque "TLS-like" bytes through it.
let payload: Vec<u8> = (0..=255u8).cycle().take(700).collect();
neighbor.write_all(&payload).await.unwrap();
let mut echoed = vec![0u8; payload.len()];
neighbor.read_exact(&mut echoed).await.unwrap();
assert_eq!(echoed, payload);
let _ = neighbor.shutdown().await;
}
#[tokio::test]
async fn quota_exhaustion_returns_503_with_retry_after() {
let data = Arc::new(vec![42u8; 8_000]);
let origin = spawn_origin(Arc::clone(&data)).await;
// 1 KiB quota, 1 h window: the first connection (~8 KB) exhausts it.
let quota = QuotaPolicy::try_new(1024, Duration::from_secs(3600)).unwrap();
let exit = spawn_exit(dev_config(Some(quota))).await;
let fetch = |exit: SocketAddr, origin: SocketAddr| async move {
let mut neighbor = TcpStream::connect(exit).await.unwrap();
let request = format!("GET http://{origin}/file.bin HTTP/1.1\r\nHost: o\r\n\r\n");
neighbor.write_all(request.as_bytes()).await.unwrap();
let head = read_response_head(&mut neighbor).await;
let mut body = Vec::new();
let _ = neighbor.read_to_end(&mut body).await;
(head, body)
};
let (first, body) = fetch(exit, origin).await;
assert!(
first.starts_with("HTTP/1.1 200 OK"),
"first request must pass: {first}"
);
assert_eq!(body.len(), 8_000, "first response body must be complete");
// `record()` runs after the first connection is fully torn down —
// poll briefly until the ledger has accounted the traffic.
let mut second = String::new();
for _ in 0..100 {
let (head, _body) = fetch(exit, origin).await;
second = head;
if second.starts_with("HTTP/1.1 503") {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(
second.starts_with("HTTP/1.1 503"),
"second request must be refused: {second}"
);
assert!(second.contains("Retry-After:"), "{second}");
}
#[tokio::test]
async fn disallowed_source_gets_403() {
// Explicit allowlist WITHOUT loopback → loopback clients refused.
let config = ExitConfig::new(
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
SourceAllowlist::new(vec![IpAddr::V6(Ipv6Addr::new(0x200, 1, 0, 0, 0, 0, 0, 2))]),
None,
);
let exit = spawn_exit(config).await;
let mut neighbor = TcpStream::connect(exit).await.unwrap();
neighbor
.write_all(b"GET http://93.184.216.34/x HTTP/1.1\r\nHost: o\r\n\r\n")
.await
.unwrap();
let response = read_response_head(&mut neighbor).await;
assert!(response.starts_with("HTTP/1.1 403"), "{response}");
}
#[tokio::test]
async fn origin_form_request_gets_400() {
let exit = spawn_exit(dev_config(None)).await;
let mut neighbor = TcpStream::connect(exit).await.unwrap();
neighbor
.write_all(b"GET /file.bin HTTP/1.1\r\nHost: o\r\n\r\n")
.await
.unwrap();
let response = read_response_head(&mut neighbor).await;
assert!(response.starts_with("HTTP/1.1 400"), "{response}");
}
#[tokio::test]
async fn oversized_head_gets_no_hang_and_no_panic() {
let exit = spawn_exit(dev_config(None)).await;
let mut neighbor = TcpStream::connect(exit).await.unwrap();
let big_line = "x".repeat(20 * 1024);
let request = format!("GET http://{big_line}/ HTTP/1.1\r\n\r\n");
neighbor.write_all(request.as_bytes()).await.unwrap();
// The server must close or answer — either way, no hang.
let mut buffer = Vec::new();
let _ = tokio::time::timeout(Duration::from_secs(5), neighbor.read_to_end(&mut buffer))
.await
.expect("no hang on oversized head");
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "sn-fair"
description = "Weighted max-min fairness scheduler for SpiderNet neighborhood networks"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
thiserror = { workspace = true }
clap = { workspace = true }
[lints]
workspace = true
+24
View File
@@ -0,0 +1,24 @@
//! Error type for the fairness scheduler.
use crate::types::ParticipantId;
#[derive(Debug, thiserror::Error)]
pub enum FairnessError {
#[error("capacity must be greater than zero")]
ZeroCapacity,
#[error("invalid participant id `{0}`: expected a decimal number greater than zero")]
InvalidParticipantId(String),
#[error("demand references unknown participant {0}")]
UnknownParticipant(ParticipantId),
#[error("duplicate demand for participant {0}")]
DuplicateDemand(ParticipantId),
#[error("duplicate participant {0}")]
DuplicateParticipant(ParticipantId),
#[error("conflicting participation classes for participant {0}")]
ConflictingParticipation(ParticipantId),
}
+578
View File
@@ -0,0 +1,578 @@
//! Two-class weighted max-min fairness allocation.
//!
//! Participants are split into two priority classes:
//!
//! 1. **Contributors** ([`Participation::Contributing`]) — households that
//! offer their own uplink to the pool. They are served first, with
//! shares shaped by their [`Weight`] via weighted max-min fairness
//! over the *full* capacity.
//! 2. **Leeches** ([`Participation::Leeching`]) — receive-only
//! households. Leech mode is explicitly allowed (the network exists
//! under neighbors, not against them). Leeches have the lowest but
//! still fair priority: when contributors saturate the capacity,
//! leeches get (near) nothing; when contributors are undersubscribed,
//! leeches benefit from the leftovers. Nobody is dropped from the
//! system.
//!
//! See the crate root for the phase overview.
use std::collections::{BTreeMap, BTreeSet};
use crate::error::FairnessError;
use crate::types::{Capacity, ParticipantId, Rate, Weight};
/// How a household takes part in the neighborhood network.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Participation {
/// The household offers its own uplink to the pool; `weight` shapes
/// its share of the capacity (1 = equal share).
Contributing { weight: Weight },
/// The household only receives (leech mode, explicitly allowed).
/// Leeches share whatever contributors leave unused, with implicit
/// equal weights, capped by their demand.
Leeching,
}
/// A participant of the allocation: an id plus its participation class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Participant {
id: ParticipantId,
participation: Participation,
}
impl Participant {
pub const fn new(id: ParticipantId, participation: Participation) -> Self {
Self { id, participation }
}
pub const fn id(&self) -> ParticipantId {
self.id
}
pub const fn participation(&self) -> Participation {
self.participation
}
}
/// Requested rate of a participant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Demand {
participant: ParticipantId,
rate: Rate,
}
impl Demand {
pub const fn new(participant: ParticipantId, rate: Rate) -> Self {
Self { participant, rate }
}
pub const fn participant(&self) -> ParticipantId {
self.participant
}
pub const fn rate(&self) -> Rate {
self.rate
}
}
/// Granted rate for a participant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Allocation {
participant: ParticipantId,
rate: Rate,
}
impl Allocation {
pub const fn new(participant: ParticipantId, rate: Rate) -> Self {
Self { participant, rate }
}
pub const fn participant(&self) -> ParticipantId {
self.participant
}
pub const fn rate(&self) -> Rate {
self.rate
}
}
/// Result of [`allocate`]: per-participant rates plus the totals.
///
/// Allocations are keyed by participant id in a `BTreeMap`, so iteration
/// is always in ascending id order (deterministic output).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FairShare {
allocations: BTreeMap<ParticipantId, Rate>,
total_allocated: Rate,
unused: Rate,
}
impl FairShare {
/// Allocations ordered by participant id. Participants without a
/// demand at all are absent; use [`FairShare::rate_of`] to read a
/// single participant's rate.
pub fn allocations(&self) -> &BTreeMap<ParticipantId, Rate> {
&self.allocations
}
/// Allocations as [`Allocation`] values, in participant-id order.
pub fn iter(&self) -> impl Iterator<Item = Allocation> + '_ {
self.allocations
.iter()
.map(|(&participant, &rate)| Allocation::new(participant, rate))
}
/// Allocated rate of one participant (zero if it received nothing).
pub fn rate_of(&self, participant: ParticipantId) -> Rate {
self.allocations
.get(&participant)
.copied()
.unwrap_or(Rate::new(0))
}
pub const fn total_allocated(&self) -> Rate {
self.total_allocated
}
pub const fn unused(&self) -> Rate {
self.unused
}
}
/// Allocates `capacity` among `participants` according to their `demands`.
///
/// Phase 1 runs weighted max-min fairness among contributors over the
/// full capacity; phase 2 runs weighted max-min among leeches (implicit
/// equal weights) over the capacity the contributors leave unused.
///
/// Empty `demands` is valid: nothing is allocated, everything stays
/// unused. Participants without a demand (or with demand 0) receive
/// nothing.
///
/// Errors:
/// - [`FairnessError::ZeroCapacity`] — capacity is zero (cannot happen
/// with a validated [`Capacity`])
/// - [`FairnessError::UnknownParticipant`] — a demand references an id
/// that is not among `participants`
/// - [`FairnessError::DuplicateDemand`] — two demands for the same id
/// - [`FairnessError::DuplicateParticipant`] — the same id appears twice
/// among `participants` with the same participation class
/// - [`FairnessError::ConflictingParticipation`] — the same id appears
/// twice among `participants` with different participation classes
pub fn allocate(
capacity: Capacity,
participants: &[Participant],
demands: &[Demand],
) -> Result<FairShare, FairnessError> {
let mut participation_of: BTreeMap<ParticipantId, Participation> = BTreeMap::new();
for participant in participants {
if let Some(previous) =
participation_of.insert(participant.id(), participant.participation())
{
if previous == participant.participation() {
return Err(FairnessError::DuplicateParticipant(participant.id()));
}
return Err(FairnessError::ConflictingParticipation(participant.id()));
}
}
for demand in demands {
if !participation_of.contains_key(&demand.participant()) {
return Err(FairnessError::UnknownParticipant(demand.participant()));
}
}
let mut demand_of: BTreeMap<ParticipantId, u64> = BTreeMap::new();
for demand in demands {
if demand_of
.insert(demand.participant(), demand.rate().value())
.is_some()
{
return Err(FairnessError::DuplicateDemand(demand.participant()));
}
}
let mut contributors = Vec::new();
let mut leeches = Vec::new();
for (&id, &participation) in &participation_of {
let demand = demand_of.get(&id).copied().unwrap_or(0);
match participation {
Participation::Contributing { weight } => contributors.push(Entry {
id,
demand,
weight: weight.value(),
}),
Participation::Leeching => leeches.push(Entry {
id,
demand,
weight: 1,
}),
}
}
let phase_one = weighted_max_min(contributors, capacity.value());
let allocated: u128 = phase_one.values().map(|rate| u128::from(*rate)).sum();
let leftover = capacity.value().saturating_sub(to_u64(allocated));
let phase_two = weighted_max_min(leeches, leftover);
let mut allocations = phase_one;
allocations.extend(phase_two);
let total: u128 = allocations.values().map(|rate| u128::from(*rate)).sum();
let total = to_u64(total);
let unused = capacity.value().saturating_sub(total);
Ok(FairShare {
allocations: allocations
.into_iter()
.map(|(id, rate)| (id, Rate::new(rate)))
.collect(),
total_allocated: Rate::new(total),
unused: Rate::new(unused),
})
}
/// Internal per-participant input for the max-min helper.
struct Entry {
id: ParticipantId,
demand: u64,
weight: u32,
}
/// Weighted max-min fairness over `entries` with (at most) `capacity` to
/// distribute. Deterministic: participants are processed in participant-id
/// order and the result is a `BTreeMap`.
///
/// Loop: each round hands out proportional shares
/// `share_i = capacity * weight_i / total_weight` (computed in `u128`,
/// floored). Participants whose demand fits their share are satisfied and
/// leave the active set together with their allocation; the rest re-share
/// the remaining capacity. When nobody's demand fits, everyone gets its
/// floor share and the integer-rounding remainder is distributed
/// deterministically in participant-id order, one unit at a time, only to
/// participants still below their demand.
///
/// Guarantees (asserted by tests): the sum of allocations never exceeds
/// `capacity`; when every remaining demand is at least its computed
/// share, the capacity is distributed in full.
fn weighted_max_min(entries: Vec<Entry>, capacity: u64) -> BTreeMap<ParticipantId, u64> {
let mut result = BTreeMap::new();
let mut active: Vec<Entry> = Vec::new();
// Zero demand is trivially satisfied with an allocation of zero.
for entry in entries {
if entry.demand == 0 {
result.insert(entry.id, 0);
} else {
active.push(entry);
}
}
active.sort_by_key(|entry| entry.id);
let mut capacity = capacity;
while capacity > 0 && !active.is_empty() {
let total_weight: u128 = active.iter().map(|entry| u128::from(entry.weight)).sum();
let shares: Vec<u128> = active
.iter()
.map(|entry| u128::from(capacity) * u128::from(entry.weight) / total_weight)
.collect();
let mut satisfied = Vec::new();
for (index, entry) in active.iter().enumerate() {
if u128::from(entry.demand) <= shares[index] {
satisfied.push(index);
}
}
if satisfied.is_empty() {
// Every demand is above its share: hand out the floor shares,
// then spread the rounding remainder one unit at a time. The
// remainder is smaller than the number of active participants
// and every one of them still has room, so it is distributed
// completely.
for (index, entry) in active.iter().enumerate() {
result.insert(entry.id, to_u64(shares[index]));
}
let distributed: u128 = shares.iter().sum();
let mut remainder = u128::from(capacity) - distributed;
while remainder > 0 {
let mut handed_out = 0;
for entry in &active {
if remainder == 0 {
break;
}
if let Some(allocation) = result.get_mut(&entry.id) {
if *allocation < entry.demand {
*allocation += 1;
remainder -= 1;
handed_out += 1;
}
}
}
if handed_out == 0 {
break;
}
}
active.clear();
break;
}
let allocated: u64 = satisfied.iter().map(|&index| active[index].demand).sum();
let satisfied_set: BTreeSet<usize> = satisfied.into_iter().collect();
let mut remaining = Vec::new();
for (index, entry) in active.into_iter().enumerate() {
if satisfied_set.contains(&index) {
result.insert(entry.id, entry.demand);
} else {
remaining.push(entry);
}
}
active = remaining;
capacity = capacity.saturating_sub(allocated);
}
// Capacity exhausted (or nothing to distribute): whoever is still
// active receives nothing.
for entry in active {
result.insert(entry.id, 0);
}
result
}
/// All values in the allocation arithmetic are bounded by the `u64`
/// capacity, so this conversion cannot truncate; `u64::MAX` is a safe
/// fallback that keeps the code panic-free.
fn to_u64(value: u128) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::num::{NonZeroU16, NonZeroU32};
use super::*;
fn id(value: u16) -> ParticipantId {
ParticipantId::new(NonZeroU16::new(value).unwrap())
}
fn contributing(participant: u16, weight: u32) -> Participant {
Participant::new(
id(participant),
Participation::Contributing {
weight: Weight::new(NonZeroU32::new(weight).unwrap()),
},
)
}
fn leeching(participant: u16) -> Participant {
Participant::new(id(participant), Participation::Leeching)
}
fn demand(participant: u16, rate: u64) -> Demand {
Demand::new(id(participant), Rate::new(rate))
}
fn capacity(value: u64) -> Capacity {
Capacity::try_new(value).unwrap()
}
fn rate_of(share: &FairShare, participant: u16) -> u64 {
share.rate_of(id(participant)).value()
}
#[test]
fn equal_demands_equal_weights_split_equally() {
let share = allocate(
capacity(800),
&[contributing(1, 1), contributing(2, 1)],
&[demand(1, 800), demand(2, 800)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 400);
assert_eq!(rate_of(&share, 2), 400);
assert_eq!(share.total_allocated().value(), 800);
assert_eq!(share.unused().value(), 0);
}
#[test]
fn small_demand_is_satisfied_first_then_remainder_is_redistributed() {
let share = allocate(
capacity(900),
&[contributing(1, 1), contributing(2, 1)],
&[demand(1, 100), demand(2, 900)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 100);
assert_eq!(rate_of(&share, 2), 800);
}
#[test]
fn weights_shape_the_split_two_to_one() {
let share = allocate(
capacity(900),
&[contributing(1, 2), contributing(2, 1)],
&[demand(1, 900), demand(2, 900)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 600);
assert_eq!(rate_of(&share, 2), 300);
}
#[test]
fn rounding_remainder_is_distributed_in_id_order() {
let share = allocate(
capacity(1001),
&[contributing(1, 1), contributing(2, 1)],
&[demand(1, 1000), demand(2, 1000)],
)
.unwrap();
// Shares are 500 + 500, the extra unit goes to the lower id.
assert_eq!(rate_of(&share, 1), 501);
assert_eq!(rate_of(&share, 2), 500);
assert_eq!(share.total_allocated().value(), 1001);
}
#[test]
fn saturated_contributor_leaves_nothing_for_the_leech() {
let share = allocate(
capacity(100),
&[contributing(1, 1), leeching(2)],
&[demand(1, 200), demand(2, 50)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 100);
assert_eq!(rate_of(&share, 2), 0);
}
#[test]
fn undersubscribed_contributor_leaves_leftover_for_the_leech() {
let share = allocate(
capacity(100),
&[contributing(1, 1), leeching(2)],
&[demand(1, 60), demand(2, 100)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 60);
assert_eq!(rate_of(&share, 2), 40);
}
#[test]
fn two_leeches_split_leftover_equally_capped_by_demand() {
let share = allocate(
capacity(100),
&[contributing(1, 1), leeching(2), leeching(3)],
&[demand(1, 50), demand(2, 100), demand(3, 20)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 50);
// Leftover 50: leech 3 (demand 20) is satisfied first, leech 2
// gets the rest.
assert_eq!(rate_of(&share, 2), 30);
assert_eq!(rate_of(&share, 3), 20);
}
#[test]
fn capacity_below_all_demands_yields_proportional_weighted_split() {
let share = allocate(
capacity(30),
&[contributing(1, 2), contributing(2, 1)],
&[demand(1, 100), demand(2, 100)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 20);
assert_eq!(rate_of(&share, 2), 10);
assert_eq!(share.unused().value(), 0);
}
#[test]
fn zero_demand_receives_zero_without_consuming_capacity() {
let share = allocate(
capacity(800),
&[contributing(1, 1), contributing(2, 1)],
&[demand(1, 0), demand(2, 500)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 0);
assert_eq!(share.allocations().get(&id(1)), Some(&Rate::new(0)));
assert_eq!(rate_of(&share, 2), 500);
assert_eq!(share.unused().value(), 300);
}
#[test]
fn empty_demands_allocates_nothing_and_everything_is_unused() {
let share = allocate(capacity(500), &[contributing(1, 1), leeching(2)], &[]).unwrap();
assert_eq!(share.total_allocated().value(), 0);
assert_eq!(share.unused().value(), 500);
assert!(share.allocations().values().all(|rate| rate.value() == 0));
}
#[test]
fn unknown_participant_in_demand_is_rejected() {
let error = allocate(capacity(100), &[contributing(1, 1)], &[demand(9, 50)]).unwrap_err();
assert!(matches!(error, FairnessError::UnknownParticipant(_)));
}
#[test]
fn duplicate_demand_is_rejected() {
let error = allocate(
capacity(100),
&[contributing(1, 1)],
&[demand(1, 50), demand(1, 20)],
)
.unwrap_err();
assert!(matches!(error, FairnessError::DuplicateDemand(_)));
}
#[test]
fn duplicate_participant_with_same_class_is_rejected() {
let error = allocate(
capacity(100),
&[contributing(1, 1), contributing(1, 1)],
&[],
)
.unwrap_err();
assert!(matches!(error, FairnessError::DuplicateParticipant(_)));
}
#[test]
fn duplicate_participant_with_different_class_is_rejected() {
let error = allocate(capacity(100), &[contributing(1, 1), leeching(1)], &[]).unwrap_err();
assert!(matches!(error, FairnessError::ConflictingParticipation(_)));
}
#[test]
fn all_demands_at_least_share_distributes_capacity_in_full() {
let share = allocate(
capacity(1000),
&[contributing(1, 1), contributing(2, 1), contributing(3, 2)],
&[demand(1, 1000), demand(2, 1000), demand(3, 1000)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 250);
assert_eq!(rate_of(&share, 2), 250);
assert_eq!(rate_of(&share, 3), 500);
assert_eq!(share.total_allocated().value(), 1000);
assert_eq!(share.unused().value(), 0);
}
/// Roadmap pilot scenario: two household downloads (contributing,
/// equal) plus one leech competing for the pooled uplinks.
#[test]
fn pilot_scenario_two_households_and_a_leech() {
let share = allocate(
capacity(600),
&[contributing(1, 1), contributing(2, 1), leeching(3)],
&[demand(1, 250), demand(2, 250), demand(3, 150)],
)
.unwrap();
assert_eq!(rate_of(&share, 1), 250);
assert_eq!(rate_of(&share, 2), 250);
assert_eq!(rate_of(&share, 3), 100);
assert_eq!(share.total_allocated().value(), 600);
assert_eq!(share.unused().value(), 0);
}
}
+38
View File
@@ -0,0 +1,38 @@
//! sn-fair — weighted max-min fairness scheduler for `SpiderNet`.
//!
//! Allocates a shared capacity (e.g. the pooled uplink bandwidth of a
//! neighborhood) among participants with a two-class weighted max-min
//! fairness scheme:
//!
//! 1. **Contributors** (households that offer capacity to the network,
//! with a configurable weight) are served first, via weighted max-min
//! fairness over the full capacity.
//! 2. **Leeches** (receive-only households; explicitly allowed — the
//! network exists under neighbors, not against them) share whatever
//! the contributors leave unused, weighted max-min with implicit equal
//! weights, capped by their demand.
//!
//! Rationale: leeches have the lowest but still fair priority. When
//! contributors saturate the capacity, leeches get (near) nothing;
//! otherwise they benefit from the leftovers. No one is dropped from the
//! system.
//!
//! Pure computation: no I/O, no async runtime.
//!
//! `SpiderNet` software carries no warranty and accepts no responsibility
//! for its usage or for transported content (AGPL-3.0, sections 15/16 —
//! responsibility for usage lies with the users).
#![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
// deny is the goal before the first release
// PoC scope: doc-annotation noise, not correctness. Revisit at release.
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
pub mod error;
pub mod fair;
pub mod types;
pub use error::FairnessError;
pub use fair::{allocate, Allocation, Demand, FairShare, Participant, Participation};
pub use types::{Capacity, ParticipantId, Rate, Weight};
+212
View File
@@ -0,0 +1,212 @@
//! CLI for the sn-fair fairness scheduler.
//!
//! KEINE GARANTIE — this software is provided "as is" without warranty of
//! any kind (AGPL-3.0, sections 15/16). Users are solely responsible for
//! what they transmit or receive with it.
//!
//! Pilot scope: simulates how pooled neighborhood capacity is split —
//! e.g. two household downloads plus a leech competing for the pooled
//! uplinks.
#![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
// PoC scope: doc-annotation noise, not correctness. Revisit at release.
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
use std::collections::BTreeMap;
use std::num::NonZeroU32;
use std::process::ExitCode;
use clap::Parser;
use sn_fair::fair::{Demand, Participant, Participation};
use sn_fair::types::{Capacity, ParticipantId, Rate, Weight};
use sn_fair::{allocate, FairShare};
/// Parses `id=contributing[:weight]` or `id=leeching`.
fn parse_participant_spec(spec: &str) -> Result<(ParticipantId, Participation), String> {
let (id_raw, class_raw) = spec.split_once('=').ok_or_else(|| {
format!("invalid participant spec `{spec}`, expected `id=contributing[:weight]` or `id=leeching`")
})?;
let participant = id_raw
.trim()
.parse::<ParticipantId>()
.map_err(|err| format!("invalid participant spec `{spec}`: {err}"))?;
let class = class_raw.trim();
let participation = match class.strip_prefix("contributing") {
Some(rest) => {
let weight = if rest.is_empty() {
Weight::default()
} else if let Some(weight_raw) = rest.strip_prefix(':') {
weight_raw
.trim()
.parse::<u32>()
.ok()
.and_then(NonZeroU32::new)
.map(Weight::new)
.ok_or_else(|| {
format!("invalid weight `{weight_raw}` in `{spec}` (must be a non-zero number)")
})?
} else {
return Err(format!(
"invalid participation `{class}` in `{spec}`, expected `contributing[:weight]` or `leeching`"
));
};
Participation::Contributing { weight }
}
None if class == "leeching" => Participation::Leeching,
None => {
return Err(format!(
"invalid participation `{class}` in `{spec}`, expected `contributing[:weight]` or `leeching`"
))
}
};
Ok((participant, participation))
}
/// Parses `id=rate`.
fn parse_demand_spec(spec: &str) -> Result<(ParticipantId, Rate), String> {
let (id_raw, rate_raw) = spec
.split_once('=')
.ok_or_else(|| format!("invalid demand spec `{spec}`, expected `id=rate`"))?;
let participant = id_raw
.trim()
.parse::<ParticipantId>()
.map_err(|err| format!("invalid demand spec `{spec}`: {err}"))?;
let rate = rate_raw.trim().parse::<u64>().map(Rate::new).map_err(|_| {
format!("invalid rate `{rate_raw}` in `{spec}` (must be a non-negative number)")
})?;
Ok((participant, rate))
}
#[derive(Debug, Parser)]
#[command(
name = "sn-fair",
version,
about = "SpiderNet weighted max-min fairness scheduler (pilot)"
)]
struct Args {
/// Total pooled capacity, in Mbit/s.
#[arg(long)]
capacity: u64,
/// Participant: `id=contributing[:weight]` (offers its uplink,
/// weight shapes the share, default weight 1) or `id=leeching`
/// (receive-only). Repeatable.
#[arg(
long = "participant",
value_name = "ID=CLASS[:WEIGHT]",
value_parser = parse_participant_spec
)]
participants: Vec<(ParticipantId, Participation)>,
/// Demand: `id=rate` in Mbit/s. Repeatable; participants without a
/// demand receive nothing.
#[arg(
long = "demand",
value_name = "ID=RATE",
value_parser = parse_demand_spec
)]
demands: Vec<(ParticipantId, Rate)>,
}
fn run() -> Result<(), String> {
let args = Args::parse();
let capacity = Capacity::try_new(args.capacity).map_err(|err| err.to_string())?;
let participants: Vec<Participant> = args
.participants
.into_iter()
.map(|(id, participation)| Participant::new(id, participation))
.collect();
let demand_of: BTreeMap<ParticipantId, u64> = args
.demands
.iter()
.map(|(participant, rate)| (*participant, rate.value()))
.collect();
let demands: Vec<Demand> = args
.demands
.into_iter()
.map(|(participant, rate)| Demand::new(participant, rate))
.collect();
let share = allocate(capacity, &participants, &demands).map_err(|err| err.to_string())?;
print_report(capacity, &participants, &demand_of, &share);
Ok(())
}
fn print_report(
capacity: Capacity,
participants: &[Participant],
demand_of: &BTreeMap<ParticipantId, u64>,
share: &FairShare,
) {
println!("NO WARRANTY — SpiderNet sn-fair (AGPL-3.0, no liability)");
println!();
println!("pooled capacity: {} Mbit/s", capacity.value());
println!();
let headers = ["participant", "class", "weight", "demand", "allocation"];
let mut rows = Vec::new();
for participant in participants {
let (class, weight) = match participant.participation() {
Participation::Contributing { weight } => ("contributing", weight.value().to_string()),
Participation::Leeching => ("leeching", "-".to_string()),
};
let demand = demand_of
.get(&participant.id())
.map_or_else(|| "0".to_string(), ToString::to_string);
rows.push([
participant.id().to_string(),
class.to_string(),
weight,
demand,
share.rate_of(participant.id()).to_string(),
]);
}
let mut widths = [0usize; 5];
for (index, header) in headers.iter().enumerate() {
widths[index] = header.len();
}
for row in &rows {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(cell.len());
}
}
print_row(&headers.map(ToString::to_string), &widths);
for row in &rows {
print_row(row, &widths);
}
println!();
println!("total allocated: {}", share.total_allocated());
println!("unused: {}", share.unused());
}
fn print_row(cells: &[String], widths: &[usize]) {
println!(
"{:<w0$} {:<w1$} {:>w2$} {:>w3$} {:>w4$}",
cells[0],
cells[1],
cells[2],
cells[3],
cells[4],
w0 = widths[0],
w1 = widths[1],
w2 = widths[2],
w3 = widths[3],
w4 = widths[4],
);
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(report) => {
eprintln!("NO WARRANTY — SpiderNet sn-fair (AGPL-3.0, no liability)");
eprintln!("error: {report}");
ExitCode::FAILURE
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Domain types: newtypes over raw primitives, validated at construction.
use std::fmt;
use std::num::{NonZeroU16, NonZeroU32, NonZeroU64};
use std::str::FromStr;
use crate::error::FairnessError;
/// Identifier of a participant (a household) in the fairness allocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ParticipantId(NonZeroU16);
impl ParticipantId {
pub const fn new(value: NonZeroU16) -> Self {
Self(value)
}
pub const fn value(self) -> u16 {
self.0.get()
}
}
impl fmt::Display for ParticipantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.get())
}
}
/// Parses a decimal id; rejects zero and non-numeric input.
impl FromStr for ParticipantId {
type Err = FairnessError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parsed = s.trim().parse::<u16>().ok().and_then(NonZeroU16::new);
parsed
.map(Self::new)
.ok_or_else(|| FairnessError::InvalidParticipantId(s.to_string()))
}
}
/// Relative share shaping of a contributing participant. 1 means equal
/// share; a participant with weight 2 gets twice the share of one with
/// weight 1 (as far as demands allow).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Weight(NonZeroU32);
impl Weight {
pub const fn new(value: NonZeroU32) -> Self {
Self(value)
}
pub const fn value(self) -> u32 {
self.0.get()
}
}
impl Default for Weight {
fn default() -> Self {
Self(NonZeroU32::MIN)
}
}
/// A traffic rate, unit-agnostic: bytes per second, Mbit/s — whatever
/// unit the caller works with (the CLI treats it as Mbit/s).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Rate(u64);
impl Rate {
pub const fn new(value: u64) -> Self {
Self(value)
}
pub const fn value(self) -> u64 {
self.0
}
}
impl fmt::Display for Rate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Total poolable capacity shared by all participants; strictly positive.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Capacity(NonZeroU64);
impl Capacity {
pub fn try_new(value: u64) -> Result<Self, FairnessError> {
NonZeroU64::new(value)
.map(Self)
.ok_or(FairnessError::ZeroCapacity)
}
pub const fn value(self) -> u64 {
self.0.get()
}
}
impl fmt::Display for Capacity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.get())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn participant_id_parses_decimal_and_rejects_zero_and_garbage() {
assert_eq!("7".parse::<ParticipantId>().unwrap().value(), 7);
assert_eq!(" 42 ".parse::<ParticipantId>().unwrap().value(), 42);
assert!("0".parse::<ParticipantId>().is_err());
assert!("abc".parse::<ParticipantId>().is_err());
assert!("-1".parse::<ParticipantId>().is_err());
assert!("70000".parse::<ParticipantId>().is_err());
assert!("".parse::<ParticipantId>().is_err());
}
#[test]
fn weight_defaults_to_one() {
assert_eq!(Weight::default().value(), 1);
}
#[test]
fn capacity_rejects_zero() {
assert!(Capacity::try_new(0).is_err());
assert_eq!(Capacity::try_new(5).unwrap().value(), 5);
}
}
+8
View File
@@ -47,4 +47,12 @@ pub enum FetchError {
#[error("invalid sha256 hex string")]
InvalidDigest,
#[error("segment {index}: all {attempts} attempt(s) failed; last error: {last_error}")]
SegmentAttemptsExhausted {
index: SegmentIndex,
attempts: u32,
#[source]
last_error: Box<FetchError>,
},
}
+326 -24
View File
@@ -1,7 +1,18 @@
//! Execution: download planned segments in parallel and reassemble the
//! target file.
//! Execution: download planned segments in parallel (with per-segment
//! retry and exit reassignment) and reassemble the target file.
//!
//! API note: [`SegmentStats`] keeps its original shape and `run_segments`
//! keeps its signature, so existing callers (runner.rs constructs
//! `SegmentStats` literals directly) stay source-compatible. The
//! retry-aware path [`run_segments_with_policy`] returns
//! [`SegmentAttemptStats`] instead, which additionally records the exit
//! that served the successful attempt and how many attempts (retries
//! included) the segment needed; `run_segments` converts via `From`.
use std::collections::BTreeMap;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
@@ -10,6 +21,7 @@ use tokio::io::AsyncWriteExt;
use crate::error::FetchError;
use crate::exit::ExitRegistry;
use crate::plan::FetchPlan;
use crate::progress::{ProgressEvent, ProgressSink};
use crate::types::{ByteRange, ExitId, FileSize, SegmentIndex, Sha256Digest, TargetUrl};
/// Statistics for one finished segment.
@@ -20,6 +32,50 @@ pub struct SegmentStats {
pub duration: Duration,
}
/// Retry behaviour for segment downloads.
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
/// Total tries per segment; attempt 1 is the initial try.
pub max_attempts: NonZeroU32,
}
impl RetryPolicy {
#[must_use]
pub const fn new(max_attempts: NonZeroU32) -> Self {
Self { max_attempts }
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: NonZeroU32::new(3).unwrap_or(NonZeroU32::MIN),
}
}
}
/// Statistics for one finished segment, including retry information.
/// `duration` spans all attempts (retries included); `exit` is the exit
/// that served the successful attempt.
#[derive(Debug, Clone)]
pub struct SegmentAttemptStats {
pub index: SegmentIndex,
pub exit: ExitId,
pub bytes: u64,
pub duration: Duration,
pub attempts: u32,
}
impl From<SegmentAttemptStats> for SegmentStats {
fn from(stats: SegmentAttemptStats) -> Self {
SegmentStats {
index: stats.index,
bytes: stats.bytes,
duration: stats.duration,
}
}
}
/// Result of a complete fetch.
#[derive(Debug, Clone)]
pub struct FetchReport {
@@ -41,19 +97,63 @@ pub async fn run_segments(
exits: &ExitRegistry,
parts_dir: &Path,
) -> Result<Vec<SegmentStats>, FetchError> {
let stats = run_segments_with_policy(
plan,
exits,
parts_dir,
&RetryPolicy::default(),
&ProgressSink::noop(),
)
.await?;
Ok(stats.into_iter().map(SegmentStats::from).collect())
}
/// Download every planned segment in parallel, retrying failed segments
/// according to `policy` — preferably on a different exit — and report
/// progress to `progress`. Each segment is written to its own part file
/// inside `parts_dir` (named `seg-NNNNNN.part`); the part file of a
/// failed attempt is removed before the next attempt.
///
/// Failures abort the whole fetch: a segment that exhausts all attempts
/// fails the fetch with [`FetchError::SegmentAttemptsExhausted`], a
/// non-retryable failure aborts immediately. A partially assembled file
/// is never presented as success.
pub async fn run_segments_with_policy(
plan: &FetchPlan,
exits: &ExitRegistry,
parts_dir: &Path,
policy: &RetryPolicy,
progress: &ProgressSink,
) -> Result<Vec<SegmentAttemptStats>, FetchError> {
tokio::fs::create_dir_all(parts_dir).await?;
// Validate that every planned exit exists, then snapshot the
// clients of all registered exits: tasks must fetch clients by id
// without borrowing the registry ('static spawn requirement).
for seg in &plan.segments {
exits.get(seg.exit)?;
}
let mut clients: BTreeMap<ExitId, reqwest::Client> = BTreeMap::new();
for id in exits.ids() {
clients.insert(id, exits.get(id)?.client().clone());
}
let clients = Arc::new(clients);
let selector = Arc::new(ExitSelector::new(exits));
let mut handles = Vec::with_capacity(plan.segments.len());
for seg in &plan.segments {
let client = exits.get(seg.exit)?.client().clone();
handles.push(tokio::spawn(download_segment(
plan.url.clone(),
seg.exit,
seg.index,
seg.range,
client,
parts_dir.to_path_buf(),
)));
let task = SegmentTask {
url: plan.url.clone(),
index: seg.index,
range: seg.range,
planned_exit: seg.exit,
clients: Arc::clone(&clients),
selector: Arc::clone(&selector),
parts_dir: parts_dir.to_path_buf(),
policy: *policy,
progress: progress.clone(),
};
handles.push(tokio::spawn(download_with_retry(task)));
}
let mut stats = Vec::with_capacity(handles.len());
@@ -67,18 +167,158 @@ pub async fn run_segments(
Ok(stats)
}
/// Download one byte range and write it to `parts_dir/seg-NNNNNN.part`.
async fn download_segment(
/// Everything one segment task needs, bundled to keep the task
/// signature small.
struct SegmentTask {
url: TargetUrl,
index: SegmentIndex,
range: ByteRange,
planned_exit: ExitId,
clients: Arc<BTreeMap<ExitId, reqwest::Client>>,
selector: Arc<ExitSelector>,
parts_dir: PathBuf,
policy: RetryPolicy,
progress: ProgressSink,
}
/// Download one segment with up to `policy.max_attempts` attempts,
/// reassigning to a different exit after a retryable failure (while the
/// registry offers more than one exit).
async fn download_with_retry(task: SegmentTask) -> Result<SegmentAttemptStats, FetchError> {
let SegmentTask {
url,
index,
range,
planned_exit,
clients,
selector,
parts_dir,
policy,
progress,
} = task;
let max_attempts = policy.max_attempts.get();
let started = Instant::now();
let mut exit = planned_exit;
let mut attempt: u32 = 0;
loop {
attempt += 1;
let client = clients
.get(&exit)
.ok_or(FetchError::UnknownExit(exit))?
.clone();
progress.on_event(&ProgressEvent::SegmentStarted {
index,
exit,
attempt,
});
tracing::debug!(segment = index.value(), exit = %exit, attempt, "segment attempt started");
match download_attempt(&url, exit, index, range, client, &parts_dir).await {
Ok(bytes) => {
let duration = started.elapsed();
progress.on_event(&ProgressEvent::SegmentFinished {
index,
exit,
bytes,
duration,
});
tracing::info!(
segment = index.value(),
exit = %exit,
attempts = attempt,
bytes,
"segment finished"
);
return Ok(SegmentAttemptStats {
index,
exit,
bytes,
duration,
attempts: attempt,
});
}
Err(err) => {
let retryable = is_retryable(&err);
let err_text = err.to_string();
progress.on_event(&ProgressEvent::SegmentFailed {
index,
exit,
attempt,
error: err_text,
});
remove_part_file(&parts_dir, index).await;
if !retryable {
tracing::warn!(
segment = index.value(),
exit = %exit,
attempt,
"segment failed with non-retryable error: {err}"
);
return Err(err);
}
selector.mark_failed(exit);
if attempt >= max_attempts {
tracing::warn!(
segment = index.value(),
attempts = attempt,
"segment failed after all attempts were exhausted: {err}"
);
return Err(FetchError::SegmentAttemptsExhausted {
index,
attempts: attempt,
last_error: Box::new(err),
});
}
tracing::warn!(
segment = index.value(),
exit = %exit,
attempt,
"segment attempt failed; retrying: {err}"
);
exit = selector.pick_retry(exit);
}
}
}
}
/// Retryable: transport-level failures (network, timeouts), 5xx
/// responses and length mismatches. Non-retryable: 4xx range rejections
/// (e.g. 416), corrupt parts, plan or digest errors — retrying those on
/// another exit cannot succeed.
fn is_retryable(error: &FetchError) -> bool {
match error {
FetchError::Http(err) => {
!err.is_status() || err.status().is_some_and(|status| status.is_server_error())
}
FetchError::Io(_) | FetchError::SegmentLengthMismatch { .. } => true,
FetchError::RangeRejected { status, .. } => *status >= 500,
FetchError::InvalidUrl(_)
| FetchError::ZeroFileSize
| FetchError::TotalLengthMismatch { .. }
| FetchError::HashMismatch { .. }
| FetchError::InvalidPlan(_)
| FetchError::UnknownExit(_)
| FetchError::CorruptPart(_)
| FetchError::InvalidDigest
| FetchError::SegmentAttemptsExhausted { .. } => false,
}
}
/// Download one byte range and write it to `parts_dir/seg-NNNNNN.part`.
/// Returns the number of bytes written. A failed attempt can leave a
/// partial part file behind; the retry loop removes it before the next
/// attempt.
async fn download_attempt(
url: &TargetUrl,
exit: ExitId,
index: SegmentIndex,
range: ByteRange,
client: reqwest::Client,
parts_dir: PathBuf,
) -> Result<SegmentStats, FetchError> {
let started = Instant::now();
let part_path = part_file(&parts_dir, index);
parts_dir: &Path,
) -> Result<u64, FetchError> {
let part_path = part_file(parts_dir, index);
let mut response = client
.get(url.inner().clone())
.header(reqwest::header::RANGE, range.http_value())
@@ -106,7 +346,7 @@ async fn download_segment(
file.flush().await?;
if written != range.len() {
tokio::fs::remove_file(&part_path).await.ok();
let _ = tokio::fs::remove_file(&part_path).await;
return Err(FetchError::SegmentLengthMismatch {
index,
got: written,
@@ -114,11 +354,68 @@ async fn download_segment(
});
}
Ok(SegmentStats {
index,
bytes: written,
duration: started.elapsed(),
})
Ok(written)
}
/// Chooses exits for retry attempts across all segment tasks of one
/// run. Preference: the exit that failed least recently (exits that
/// never failed first, ties broken by ascending id). The exit an
/// attempt just failed on is avoided while at least one other exit
/// exists; with a single-exit registry, retries stay on that exit.
#[derive(Debug)]
struct ExitSelector {
ids: Vec<ExitId>,
last_failed: Mutex<BTreeMap<ExitId, Instant>>,
}
impl ExitSelector {
fn new(exits: &ExitRegistry) -> Self {
Self {
ids: exits.ids(),
last_failed: Mutex::new(BTreeMap::new()),
}
}
fn mark_failed(&self, id: ExitId) {
let mut guard = self.lock();
guard.insert(id, Instant::now());
}
fn pick_retry(&self, avoid: ExitId) -> ExitId {
let guard = self.lock();
let mut best: Option<ExitId> = None;
for &id in &self.ids {
if id == avoid {
continue;
}
best = Some(match best {
None => id,
Some(current) => {
let prefer_id = match (guard.get(&current), guard.get(&id)) {
// tie or current never failed: keep the lower id
(None, None | Some(_)) => false,
(Some(_), None) => true, // id never failed: better
(Some(current_failed), Some(id_failed)) => *id_failed < *current_failed,
};
if prefer_id {
id
} else {
current
}
}
});
}
best.unwrap_or(avoid) // single-exit registry: retry on the same exit
}
/// std mutexes can be poisoned by a panicking holder; since the
/// map is only advisory (exit preference), keep using its contents.
fn lock(&self) -> MutexGuard<'_, BTreeMap<ExitId, Instant>> {
match self.last_failed.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
}
/// Reassemble part files (in index order) into the output file while
@@ -199,6 +496,11 @@ async fn collect_part_files(parts_dir: &Path) -> Result<Vec<PathBuf>, FetchError
Ok(out)
}
/// Best-effort removal of a failed attempt's part file.
async fn remove_part_file(parts_dir: &Path, index: SegmentIndex) {
let _ = tokio::fs::remove_file(part_file(parts_dir, index)).await;
}
fn part_file(parts_dir: &Path, index: SegmentIndex) -> PathBuf {
parts_dir.join(format!("seg-{:06}.part", index.value()))
}
+31
View File
@@ -48,6 +48,37 @@ impl HttpExit for LocalExit {
}
}
/// A neighbor's uplink, reached through an HTTP proxy over the mesh.
/// The proxy is expected to listen on the neighbor's Yggdrasil address,
/// so the transport between the households is encrypted end-to-end by
/// the overlay.
#[derive(Debug)]
pub struct MeshExit {
id: ExitId,
client: reqwest::Client,
}
impl MeshExit {
pub fn new(id: ExitId, proxy: reqwest::Url) -> Result<Self, FetchError> {
let proxy = reqwest::Proxy::all(proxy)?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(30))
.proxy(proxy)
.build()?;
Ok(Self { id, client })
}
}
impl HttpExit for MeshExit {
fn id(&self) -> ExitId {
self.id
}
fn client(&self) -> &reqwest::Client {
&self.client
}
}
/// All exits available for a fetch, keyed by id.
#[derive(Default, Clone)]
pub struct ExitRegistry {
+1
View File
@@ -18,5 +18,6 @@ pub mod exec;
pub mod exit;
pub mod plan;
pub mod probe;
pub mod progress;
pub mod runner;
pub mod types;
+28 -9
View File
@@ -15,7 +15,7 @@ use std::path::PathBuf;
use clap::Parser;
use sn_fetch::runner::{self, FetchOptions};
use sn_fetch::types::{
ExitId, ExitWeight, MinSegmentBytes, SegmentsPerExit, Sha256Digest, TargetUrl,
Egress, ExitId, ExitWeight, MinSegmentBytes, SegmentsPerExit, Sha256Digest, TargetUrl,
};
#[derive(Debug, Parser)]
@@ -28,12 +28,20 @@ struct Args {
#[arg(short, long)]
output: Option<PathBuf>,
/// Exits as `id=weight` pairs, comma separated, e.g. `local=1,local2=2`.
/// `PoC`: all ids map to plain local connections; the weights only shape
/// the plan. Multi-exit routing comes with the mesh integration.
#[arg(short = 'e', long = "exit", default_value = "local=1")]
/// Exits as `id=weight` pairs, comma separated, e.g. `1=2,2=1`.
/// A proxy route turns an exit into a neighbor's uplink over the
/// mesh overlay: `id=weight@http://[ygg-addr]:8080`. Without a
/// proxy, the exit is this machine's own connection.
/// `PoC`: weights only shape the plan; a real proxy service
/// (`sn-exit`) comes with the mesh integration.
#[arg(short = 'e', long = "exit", default_value = "1=1")]
exits: String,
/// Total tries per segment (initial try + retries; failed segments
/// are retried on a different exit when available).
#[arg(long, default_value_t = 3)]
max_attempts: u32,
/// Parallel sub-segments per exit.
#[arg(short = 's', long, default_value_t = 1)]
segments_per_exit: usize,
@@ -51,10 +59,10 @@ struct Args {
probe_only: bool,
}
fn parse_exits(spec: &str) -> Result<Vec<(ExitId, ExitWeight)>, String> {
fn parse_exits(spec: &str) -> Result<Vec<(ExitId, ExitWeight, Egress)>, String> {
spec.split(',')
.map(|pair| {
let (id, weight) = pair
let (id, rest) = pair
.split_once('=')
.ok_or_else(|| format!("invalid exit spec `{pair}`, expected `id=weight`"))?;
let id = id
@@ -63,13 +71,21 @@ fn parse_exits(spec: &str) -> Result<Vec<(ExitId, ExitWeight)>, String> {
.ok()
.and_then(NonZeroU8::new)
.ok_or_else(|| format!("invalid exit id `{id}` (must be a non-zero number)"))?;
let (weight, egress) = match rest.split_once('@') {
Some((weight, proxy)) => {
let egress = Egress::proxy(proxy.trim())
.map_err(|err| format!("invalid proxy in exit spec `{pair}`: {err}"))?;
(weight, egress)
}
None => (rest, Egress::Direct),
};
let weight = weight
.trim()
.parse::<u32>()
.ok()
.and_then(NonZeroU32::new)
.ok_or_else(|| format!("invalid exit weight `{weight}` (must be non-zero)"))?;
Ok((ExitId::new(id), ExitWeight::new(weight)))
Ok((ExitId::new(id), ExitWeight::new(weight), egress))
})
.collect()
}
@@ -131,6 +147,8 @@ async fn run() -> Result<(), String> {
NonZeroU64::new(args.min_segment_mib.saturating_mul(1024 * 1024))
.ok_or("min_segment_mib must be at least 1")?,
);
let max_attempts =
NonZeroU32::new(args.max_attempts).ok_or("max_attempts must be at least 1")?;
let expected = match &args.sha256 {
Some(hex) => Some(Sha256Digest::from_hex(hex).map_err(|err| err.to_string())?),
@@ -146,9 +164,10 @@ async fn run() -> Result<(), String> {
segments_per_exit: SegmentsPerExit::new(spe),
min_segment,
expected_sha256: expected,
retries: sn_fetch::exec::RetryPolicy::new(max_attempts),
};
let registry = runner::local_registry(&exit_specs).map_err(|err| err.to_string())?;
let registry = runner::registry_from_specs(&exit_specs).map_err(|err| err.to_string())?;
let parts_dir = std::env::temp_dir().join("sn-fetch-parts");
let report = runner::run(&target, &options, &registry, &parts_dir)
+8 -7
View File
@@ -2,8 +2,8 @@
use crate::error::FetchError;
use crate::types::{
ByteRange, ExitId, ExitWeight, FileSize, MinSegmentBytes, SegmentIndex, SegmentsPerExit,
TargetUrl,
ByteRange, Egress, ExitId, ExitWeight, FileSize, MinSegmentBytes, SegmentIndex,
SegmentsPerExit, TargetUrl,
};
use std::num::NonZeroUsize;
@@ -23,8 +23,9 @@ pub struct FetchPlan {
pub segments: Vec<SegmentPlan>,
}
/// Exits with their capacity weights.
pub type ExitSpecs = Vec<(ExitId, ExitWeight)>;
/// exit description, including how traffic leaves (`Direct` = own
/// uplink, `Proxy` = a neighbor's exit over the mesh).
pub type ExitSpecs = Vec<(ExitId, ExitWeight, Egress)>;
impl FetchPlan {
/// Build a validated plan.
@@ -56,12 +57,12 @@ impl FetchPlan {
let weakest = exits
.iter()
.enumerate()
.min_by_key(|(_, (id, weight))| (weight.value(), std::cmp::Reverse(id.value())))
.min_by_key(|(_, (id, weight, _))| (weight.value(), std::cmp::Reverse(id.value())))
.map_or(0, |(pos, _)| pos);
exits.remove(weakest);
}
// Deterministic boundary assignment order.
exits.sort_by_key(|(id, _)| *id);
exits.sort_by_key(|(id, _, _)| *id);
// If even one segment per exit would be too small, reduce to one
// segment per exit.
@@ -85,7 +86,7 @@ impl FetchPlan {
// Weighted split across exits, then even sub-split per exit.
let units: Vec<u128> = exits
.iter()
.map(|(_, w)| u128::from(w.value()) * spe.value() as u128)
.map(|(_, w, _)| u128::from(w.value()) * spe.value() as u128)
.collect();
let total_units: u128 = units.iter().sum();
+171
View File
@@ -0,0 +1,171 @@
//! Progress reporting for segmented downloads.
//!
//! [`ProgressSink`] is a cloneable callback handle rather than a trait:
//! spawned segment tasks need an owned, `'static` reporter, and a
//! callback handle can be cloned cheaply from a plain `&ProgressSink`
//! parameter without extra plumbing. [`ProgressEvent`] carries one
//! event per segment attempt; [`BytesTracker`] aggregates the bytes
//! finished across all segments.
#![allow(clippy::module_name_repetitions)] // `Progress*` types read naturally inside `mod progress`
use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use crate::types::{ExitId, SegmentIndex};
/// One event describing the state of a segment download attempt.
#[derive(Debug, Clone)]
pub enum ProgressEvent {
/// A segment attempt started (attempt 1 is the initial try).
SegmentStarted {
index: SegmentIndex,
exit: ExitId,
attempt: u32,
},
/// A segment finished successfully. `duration` spans the whole
/// segment, all attempts (retries included).
SegmentFinished {
index: SegmentIndex,
exit: ExitId,
bytes: u64,
duration: Duration,
},
/// A segment attempt failed. `error` is the formatted failure
/// reason; the typed error surfaces through the fetch's result.
SegmentFailed {
index: SegmentIndex,
exit: ExitId,
attempt: u32,
error: String,
},
}
impl fmt::Display for ProgressEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SegmentStarted {
index,
exit,
attempt,
} => {
write!(f, "segment {index} on {exit}: attempt {attempt} started")
}
Self::SegmentFinished {
index,
exit,
bytes,
duration,
} => {
write!(
f,
"segment {index} on {exit}: {bytes} bytes in {duration:.2?}"
)
}
Self::SegmentFailed {
index,
exit,
attempt,
error,
} => {
write!(
f,
"segment {index} on {exit}: attempt {attempt} failed: {error}"
)
}
}
}
}
/// Cloneable handle to a progress callback; `Send + Sync`, so it can be
/// shared across tokio tasks. All clones call the same callback.
#[derive(Clone)]
pub struct ProgressSink {
on_event: Arc<dyn Fn(&ProgressEvent) + Send + Sync>,
}
impl ProgressSink {
/// Wrap any event callback into a sink.
pub fn new<F>(on_event: F) -> Self
where
F: Fn(&ProgressEvent) + Send + Sync + 'static,
{
Self {
on_event: Arc::new(on_event),
}
}
/// A sink that discards every event.
#[must_use]
pub fn noop() -> Self {
Self::new(|_| ())
}
/// A sink that logs every event through `tracing`: started attempts
/// at debug, finished segments at info, failures at warn.
#[must_use]
pub fn tracing_log() -> Self {
Self::new(|event| match event {
ProgressEvent::SegmentStarted { .. } => tracing::debug!("{event}"),
ProgressEvent::SegmentFinished { .. } => tracing::info!("{event}"),
ProgressEvent::SegmentFailed { .. } => tracing::warn!("{event}"),
})
}
/// Combine two sinks: both receive every event.
#[must_use]
pub fn fanout(first: &Self, second: &Self) -> Self {
let first = first.clone();
let second = second.clone();
Self::new(move |event| {
first.on_event(event);
second.on_event(event);
})
}
/// Deliver one event to the underlying callback.
pub fn on_event(&self, event: &ProgressEvent) {
(self.on_event)(event);
}
}
impl fmt::Debug for ProgressSink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProgressSink").finish_non_exhaustive()
}
}
/// Counts the bytes of successfully finished segments across the whole
/// fetch. Cheap to clone: every clone observes the same total.
#[derive(Debug, Default, Clone)]
pub struct BytesTracker {
bytes: Arc<AtomicU64>,
}
impl BytesTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Total bytes reported as finished so far.
#[must_use]
pub fn total(&self) -> u64 {
self.bytes.load(Ordering::Relaxed)
}
/// A sink that feeds this tracker; the tracker stays queryable.
#[must_use]
pub fn sink(&self) -> ProgressSink {
let tracker = self.clone();
ProgressSink::new(move |event| tracker.record(event))
}
fn record(&self, event: &ProgressEvent) {
if let ProgressEvent::SegmentFinished { bytes, .. } = event {
self.bytes.fetch_add(*bytes, Ordering::Relaxed);
}
}
}
+29 -9
View File
@@ -10,12 +10,13 @@ use sha2::Digest;
use tokio::io::AsyncWriteExt;
use crate::error::FetchError;
use crate::exec::{self, FetchReport, SegmentStats};
use crate::exit::{ExitRegistry, LocalExit};
use crate::exec::{self, FetchReport, RetryPolicy, SegmentStats};
use crate::exit::{ExitRegistry, HttpExit, LocalExit, MeshExit};
use crate::plan::ExitSpecs;
use crate::probe::{self, ProbeInfo};
use crate::progress::ProgressSink;
use crate::types::{
FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit, Sha256Digest, TargetUrl,
Egress, FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit, Sha256Digest, TargetUrl,
};
/// All knobs for one fetch.
@@ -26,6 +27,8 @@ pub struct FetchOptions {
pub segments_per_exit: SegmentsPerExit,
pub min_segment: MinSegmentBytes,
pub expected_sha256: Option<Sha256Digest>,
/// How often a segment is retried (preferably on another exit).
pub retries: RetryPolicy,
}
/// Run a full fetch and produce a report.
@@ -74,7 +77,17 @@ async fn run_ranged(
tracing::info!("plan: {} segment(s)", plan.segments.len());
let started = Instant::now();
let segment_stats: Vec<SegmentStats> = exec::run_segments(&plan, exits, parts_dir).await?;
let segment_stats: Vec<SegmentStats> = exec::run_segments_with_policy(
&plan,
exits,
parts_dir,
&options.retries,
&ProgressSink::tracing_log(),
)
.await?
.into_iter()
.map(std::convert::From::from)
.collect();
let digest = exec::assemble(
parts_dir,
&options.output,
@@ -105,7 +118,7 @@ async fn run_whole(
let exit_id = options
.exits
.first()
.map(|(id, _)| *id)
.map(|(id, _, _)| *id)
.ok_or_else(|| FetchError::InvalidPlan("no exits given".to_string()))?;
let client = exits.get(exit_id)?.client().clone();
@@ -163,11 +176,18 @@ async fn run_whole(
})
}
/// Convenience: build a registry with one [`LocalExit`] per spec id.
pub fn local_registry(specs: &ExitSpecs) -> Result<ExitRegistry, FetchError> {
/// Build a registry from exit specs: `Egress::Direct` exits connect
/// locally (own uplink), `Egress::Proxy` exits route through the given
/// proxy — typically a neighbor's exit service over the Yggdrasil
/// overlay.
pub fn registry_from_specs(specs: &ExitSpecs) -> Result<ExitRegistry, FetchError> {
let mut registry = ExitRegistry::empty();
for (id, _) in specs {
registry.register(std::sync::Arc::new(LocalExit::new(*id)?));
for (id, _, egress) in specs {
let exit: std::sync::Arc<dyn HttpExit> = match egress {
Egress::Direct => std::sync::Arc::new(LocalExit::new(*id)?),
Egress::Proxy(url) => std::sync::Arc::new(MeshExit::new(*id, url.clone())?),
};
registry.register(exit);
}
Ok(registry)
}
+33
View File
@@ -230,3 +230,36 @@ pub enum RangeSupport {
/// Probe was inconclusive; treat as unsupported (safe fallback).
Unknown,
}
/// How a request physically leaves through an exit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Egress {
/// The household's own uplink: plain outgoing HTTP, no detour.
Direct,
/// Through an HTTP proxy — e.g. a neighbor's exit service reachable
/// over the mesh overlay (typically bound to their Yggdrasil
/// address).
Proxy(reqwest::Url),
}
impl Egress {
/// Parse a proxy URL for [`Egress::Proxy`]; only `http`/`https`
/// upstreams are accepted.
pub fn proxy(input: &str) -> Result<Self, FetchError> {
let url =
reqwest::Url::parse(input).map_err(|_| FetchError::InvalidUrl(input.to_string()))?;
match url.scheme() {
"http" | "https" => Ok(Self::Proxy(url)),
_ => Err(FetchError::InvalidUrl(input.to_string())),
}
}
}
impl fmt::Display for Egress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Direct => write!(f, "direct"),
Self::Proxy(url) => write!(f, "proxy:{url}"),
}
}
}
+110 -6
View File
@@ -1,5 +1,7 @@
//! A minimal HTTP server for integration tests. Supports HEAD, GET with
//! optional range handling (configurable), `Connection: close` semantics.
//! optional range handling (configurable), simulated failures for range
//! GETs (flaky/exhausted/non-retryable modes), and a broken exit whose
//! client fails at transport level, for retry tests.
#![forbid(unsafe_code)]
// Tests are allowed to unwrap/expect; production code denies them via
@@ -8,8 +10,36 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use sn_fetch::exit::HttpExit;
use sn_fetch::types::ExitId;
/// When the test origin should fail `Range` GETs (retry simulation).
/// Only used by the retry test binary; allow dead_code for the others.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[allow(dead_code)]
pub enum FailMode {
#[default]
Never,
/// Fail the first `n` range GETs with HTTP 500, then serve normally.
FirstN(u32),
/// Fail every range GET with HTTP 500.
Always,
/// Answer every range GET with the given status (e.g. 416 to
/// simulate a non-retryable range rejection).
AlwaysStatus(u16),
}
/// Knobs for the test origin.
#[derive(Debug, Clone, Copy, Default)]
#[allow(dead_code)]
pub struct ServerConfig {
pub fail_mode: FailMode,
}
pub struct TestServer {
pub addr: SocketAddr,
@@ -21,14 +51,25 @@ impl TestServer {
/// server behaves like a range-hostile origin: no `Accept-Ranges`
/// header, and `Range` requests are answered with the full body.
pub fn start(data: Vec<u8>, ranges: bool) -> Self {
Self::start_with(data, ranges, ServerConfig::default())
}
/// Start with a custom [`ServerConfig`] (failure simulation).
pub fn start_with(data: Vec<u8>, ranges: bool, config: ServerConfig) -> Self {
let data = Arc::new(data);
let config = Arc::new(config);
let range_get_count = Arc::new(AtomicU32::new(0));
let listener = TcpListener::bind("127.0.0.1:0").expect("bind in test");
let addr = listener.local_addr().expect("addr in test");
let addr = listener.local_addr().expect("local addr in test");
let handle = std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let data = Arc::clone(&data);
let (data, config, range_get_count) = (
Arc::clone(&data),
Arc::clone(&config),
Arc::clone(&range_get_count),
);
std::thread::spawn(move || {
let _ = handle_conn(stream, &data, ranges);
let _ = handle_conn(stream, &data, ranges, &config, &range_get_count);
});
}
});
@@ -43,7 +84,13 @@ impl TestServer {
}
}
fn handle_conn(stream: TcpStream, data: &[u8], ranges: bool) -> std::io::Result<()> {
fn handle_conn(
stream: TcpStream,
data: &[u8],
ranges: bool,
config: &ServerConfig,
range_get_count: &AtomicU32,
) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
@@ -64,7 +111,6 @@ fn handle_conn(stream: TcpStream, data: &[u8], ranges: bool) -> std::io::Result<
let mut stream = stream;
let len = data.len() as u64;
let method = request_line
.split_whitespace()
.next()
@@ -83,6 +129,10 @@ fn handle_conn(stream: TcpStream, data: &[u8], ranges: bool) -> std::io::Result<
let range = range_header.as_deref().and_then(parse_range).flatten();
match (ranges, range) {
(true, Some((start, end))) => {
if should_fail_range_get(config, range_get_count) {
write_fail_response(&mut stream, config.fail_mode)?;
return Ok(());
}
let slice_len = end - start + 1;
write!(
stream,
@@ -111,6 +161,28 @@ fn handle_conn(stream: TcpStream, data: &[u8], ranges: bool) -> std::io::Result<
Ok(())
}
/// Decide (and count) whether this range GET should fail. HEAD and
/// non-range GETs are never counted nor failed.
fn should_fail_range_get(config: &ServerConfig, range_get_count: &AtomicU32) -> bool {
match config.fail_mode {
FailMode::Never => false,
FailMode::Always | FailMode::AlwaysStatus(_) => true,
FailMode::FirstN(limit) => range_get_count.fetch_add(1, Ordering::Relaxed) < limit,
}
}
fn write_fail_response(stream: &mut TcpStream, mode: FailMode) -> std::io::Result<()> {
let status = match mode {
FailMode::AlwaysStatus(status) => status,
_ => 500,
};
write!(
stream,
"HTTP/1.1 {status} Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)?;
stream.flush()
}
/// Parse `bytes=a-b` into inclusive `(start, end)`. Anything else
/// (open-ended, malformed) is treated as absent.
fn parse_range(value: &str) -> Option<Option<(u64, u64)>> {
@@ -124,3 +196,35 @@ fn parse_range(value: &str) -> Option<Option<(u64, u64)>> {
Some(None)
}
}
/// An exit whose client sends every request through a proxy that
/// refuses connections — every request through this exit fails at
/// transport level (retryable), as needed for exit-reassignment tests.
#[allow(dead_code)]
pub struct BrokenProxyExit {
id: ExitId,
client: reqwest::Client,
}
impl BrokenProxyExit {
#[allow(dead_code)]
pub fn new(id: ExitId) -> Self {
let proxy = reqwest::Proxy::http("http://127.0.0.1:1").expect("static proxy url parses");
let client = reqwest::Client::builder()
.proxy(proxy)
.connect_timeout(Duration::from_secs(10))
.build()
.expect("client builds in test");
Self { id, client }
}
}
impl HttpExit for BrokenProxyExit {
fn id(&self) -> ExitId {
self.id
}
fn client(&self) -> &reqwest::Client {
&self.client
}
}
+151
View File
@@ -0,0 +1,151 @@
//! End-to-end test: a `MeshExit` fetches through a real HTTP proxy.
//!
//! The fake proxy is a minimal HTTP forward proxy: it accepts absolute-URI
//! requests (`GET http://origin/file.bin HTTP/1.1`), extracts the `Range`
//! header and serves the requested slice from an in-memory buffer —
//! exactly the shape of an `sn-exit` service bound to a neighbor's
//! Yggdrasil address (minus the overlay transport, which is transparent
//! to HTTP).
#![forbid(unsafe_code)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::num::{NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize};
use std::sync::Arc;
use sha2::{Digest, Sha256};
use tempfile::TempDir;
use sn_fetch::exit::{ExitRegistry, MeshExit};
use sn_fetch::plan::{ExitSpecs, FetchPlan};
use sn_fetch::types::{
Egress, ExitId, ExitWeight, FileSize, MinSegmentBytes, SegmentsPerExit, TargetUrl,
};
/// Minimal forward proxy serving one fixed object from memory.
async fn spawn_proxy(data: Arc<Vec<u8>>) -> std::net::SocketAddr {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let data = Arc::clone(&data);
tokio::spawn(async move {
// Read the request head.
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
if socket.read(&mut byte).await.unwrap_or(0) == 0 {
return;
}
head.push(byte[0]);
}
let head_text = String::from_utf8_lossy(&head);
// First line: `GET http://origin/path HTTP/1.1`.
let first_line = head_text.lines().next().unwrap_or_default();
if !first_line.starts_with("GET http://") {
socket
.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
.await
.ok();
return;
}
// Extract the requested byte range (if any). Header
// names arrive lowercased from some clients.
let range = head_text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if !name.eq_ignore_ascii_case("range") {
return None;
}
let value = value.trim().strip_prefix("bytes=")?;
let (start, end) = value.trim().split_once('-')?;
Some((start.parse::<u64>().ok()?, end.parse::<u64>().ok()?))
});
let (start, end_inclusive, status) = match range {
Some((start, end)) => (start, end, "206 Partial Content"),
None => (0, data.len() as u64 - 1, "200 OK"),
};
let slice = &data[start as usize..=(end_inclusive as usize).min(data.len() - 1)];
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n",
slice.len()
);
socket.write_all(response.as_bytes()).await.ok();
socket.write_all(slice).await.ok();
socket.shutdown().await.ok();
});
}
});
addr
}
#[tokio::test]
async fn mesh_exit_downloads_through_the_proxy() {
let data: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
let data = Arc::new(data.clone());
let proxy_addr = spawn_proxy(Arc::clone(&data)).await;
let proxy_url = format!("http://{proxy_addr}");
let specs: ExitSpecs = vec![(
ExitId::new(NonZeroU8::new(1).expect("1 is non-zero")),
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
Egress::proxy(&proxy_url).expect("proxy url is valid"),
)];
// The origin URL is fictional — the proxy serves the content; this
// proves the request really went through the proxy, not straight to
// an origin server.
let target = TargetUrl::parse("http://origin.invalid/file.bin").expect("valid url");
let file_size = FileSize::try_new(data.len() as u64).expect("non-zero in test");
let plan = FetchPlan::build(
target.clone(),
file_size,
&specs,
SegmentsPerExit::new(NonZeroUsize::new(2).expect("2 is non-zero")),
MinSegmentBytes::new(NonZeroU64::new(1024).expect("1024 is non-zero")),
)
.expect("plan builds");
plan.validate().expect("plan validates");
let mut registry = ExitRegistry::empty();
registry.register(Arc::new(
MeshExit::new(
ExitId::new(NonZeroU8::new(1).expect("1 is non-zero")),
reqwest::Url::parse(&proxy_url).expect("proxy url is valid"),
)
.expect("mesh exit builds"),
));
let parts = TempDir::new().expect("tempdir in test");
let stats = sn_fetch::exec::run_segments(&plan, &registry, parts.path())
.await
.expect("segments succeed through the proxy");
let total: u64 = stats.iter().map(|s| s.bytes).sum();
assert_eq!(total, data.len() as u64);
let output = parts.path().join("mesh.bin");
let expected_digest =
sn_fetch::types::Sha256Digest::from_bytes(Sha256::digest(data.as_ref().as_slice()).into());
let digest = sn_fetch::exec::assemble(parts.path(), &output, file_size, Some(&expected_digest))
.await
.expect("assembly verifies");
assert_eq!(digest, expected_digest);
}
/// The proxy's address (IPv4, arbitrary port) is not a valid proxy URL
/// unless it has an http scheme — Egress::proxy must reject junk.
#[test]
fn egress_rejects_non_http_proxy_schemes() {
assert!(Egress::proxy("ftp://proxy:2121").is_err());
assert!(Egress::proxy("not a url").is_err());
assert!(Egress::proxy("http://127.0.0.1:8080").is_ok());
assert!(Egress::proxy("https://proxy.example:3128").is_ok());
}
+428
View File
@@ -0,0 +1,428 @@
//! Retry behaviour against a local HTTP server: a flaky first attempt
//! (retried, reassigned to another exit), a consistently failing exit
//! (reassigned), retry exhaustion (whole fetch fails, no output file)
//! and a non-retryable status (immediate abort).
#![forbid(unsafe_code)]
// Tests are allowed to unwrap/expect; production code denies them via
// workspace lints (server/Cargo.toml).
#![allow(clippy::unwrap_used, clippy::expect_used)]
mod common;
use std::num::{NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize};
use std::path::Path;
use std::sync::{Arc, Mutex};
use sha2::{Digest, Sha256};
use tempfile::TempDir;
use sn_fetch::error::FetchError;
use sn_fetch::exec::{self, RetryPolicy};
use sn_fetch::exit::{ExitRegistry, LocalExit};
use sn_fetch::plan::{ExitSpecs, FetchPlan};
use sn_fetch::progress::{BytesTracker, ProgressEvent, ProgressSink};
use sn_fetch::types::{
Egress, ExitId, ExitWeight, FileSize, MinSegmentBytes, SegmentsPerExit, TargetUrl,
};
use common::{BrokenProxyExit, FailMode, ServerConfig, TestServer};
fn spec(id: u8) -> (ExitId, ExitWeight, Egress) {
(
ExitId::new(NonZeroU8::new(id).expect("test ids are 1..=255")),
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
Egress::Direct,
)
}
fn exit_id(id: u8) -> ExitId {
ExitId::new(NonZeroU8::new(id).expect("test ids are 1..=255"))
}
fn local_registry(specs: &ExitSpecs) -> ExitRegistry {
let mut registry = ExitRegistry::empty();
for (id, _, _) in specs {
registry.register(Arc::new(LocalExit::new(*id).expect("local exit")));
}
registry
}
/// Single-exit plan + registry, for exhaustion tests.
fn single_exit_setup(
url: &TargetUrl,
file_size: u64,
min_segment: u64,
) -> (FetchPlan, ExitRegistry) {
let specs: ExitSpecs = vec![spec(1)];
let plan = FetchPlan::build(
url.clone(),
FileSize::try_new(file_size).expect("non-zero in test"),
&specs,
SegmentsPerExit::new(NonZeroUsize::MIN),
MinSegmentBytes::new(NonZeroU64::new(min_segment).expect("non-zero in test")),
)
.expect("plan builds");
let registry = local_registry(&specs);
(plan, registry)
}
type RecordedEvents = Arc<Mutex<Vec<ProgressEvent>>>;
fn recording_sink() -> (ProgressSink, RecordedEvents) {
let events: RecordedEvents = Arc::new(Mutex::new(Vec::new()));
let sink = ProgressSink::new({
let events = Arc::clone(&events);
move |event| {
events
.lock()
.expect("mutex poisoned in test")
.push(event.clone())
}
});
(sink, events)
}
fn failed_events(events: &RecordedEvents) -> Vec<(u32, u32, String)> {
events
.lock()
.expect("mutex poisoned in test")
.iter()
.filter_map(|event| match event {
ProgressEvent::SegmentFailed {
index,
exit: _,
attempt,
error,
} => Some((index.value(), *attempt, error.clone())),
_ => None,
})
.collect()
}
/// Names of leftover part files in `parts_dir`.
fn part_files(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.expect("parts dir readable in test")
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".part"))
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect()
}
#[tokio::test]
async fn flaky_segment_retried_on_other_exit() {
let data: Vec<u8> = (0..(2 * 1024 * 1024)).map(|i| (i % 253) as u8).collect();
let server = TestServer::start_with(
data.clone(),
true,
ServerConfig {
fail_mode: FailMode::FirstN(1),
},
);
let url = TargetUrl::parse(&server.url("/file.bin")).expect("valid url");
let specs: ExitSpecs = vec![spec(1), spec(2)];
let plan = FetchPlan::build(
url,
FileSize::try_new(data.len() as u64).expect("non-zero in test"),
&specs,
SegmentsPerExit::new(NonZeroUsize::new(2).expect("2 is non-zero")),
MinSegmentBytes::new(NonZeroU64::new(1024).expect("1024 is non-zero")),
)
.expect("plan builds");
let registry = local_registry(&specs);
let parts = TempDir::new().expect("tempdir in test");
let (progress, events) = recording_sink();
let tracker = BytesTracker::new();
let combined = ProgressSink::fanout(&progress, &tracker.sink());
let stats = exec::run_segments_with_policy(
&plan,
&registry,
parts.path(),
&RetryPolicy::default(),
&combined,
)
.await
.expect("fetch succeeds despite one flaky attempt");
assert_eq!(stats.len(), plan.segments.len());
let total: u64 = stats.iter().map(|s| s.bytes).sum();
assert_eq!(total, data.len() as u64);
assert_eq!(
tracker.total(),
data.len() as u64,
"tracker must see all finished bytes"
);
let retried: Vec<_> = stats.iter().filter(|s| s.attempts > 1).collect();
assert_eq!(retried.len(), 1, "exactly one segment needed a retry");
let retried = retried[0];
let planned_exit = plan
.segments
.iter()
.find(|seg| seg.index == retried.index)
.expect("planned segment")
.exit;
assert_ne!(retried.exit, planned_exit, "retry must use another exit");
assert_eq!(retried.attempts, 2);
let started: Vec<(u8, u32)> = events
.lock()
.expect("mutex poisoned in test")
.iter()
.filter_map(|event| match event {
ProgressEvent::SegmentStarted {
index,
exit,
attempt,
} if index == &retried.index => Some((exit.value(), *attempt)),
_ => None,
})
.collect();
assert_eq!(
started,
vec![(planned_exit.value(), 1), (retried.exit.value(), 2)],
"the retried segment must start once per attempt, on different exits"
);
let failed = failed_events(&events);
assert_eq!(
failed,
vec![(retried.index.value(), 1, failed[0].2.clone())],
"exactly one failed attempt, recorded on attempt 1"
);
assert!(
failed[0].2.contains("500"),
"failure reason should mention the 500 status: {}",
failed[0].2
);
}
#[tokio::test]
async fn exhausted_retries_fail_without_output() {
let server = TestServer::start_with(
vec![7u8; 32 * 1024],
true,
ServerConfig {
fail_mode: FailMode::Always,
},
);
let url = TargetUrl::parse(&server.url("/file.bin")).expect("valid url");
let (plan, registry) = single_exit_setup(&url, 32 * 1024, 32 * 1024);
assert_eq!(plan.segments.len(), 1);
let parts = TempDir::new().expect("tempdir in test");
let (progress, events) = recording_sink();
let tracker = BytesTracker::new();
let combined = ProgressSink::fanout(&progress, &tracker.sink());
let result = exec::run_segments_with_policy(
&plan,
&registry,
parts.path(),
&RetryPolicy::default(),
&combined,
)
.await;
let err = result.expect_err("fetch must fail when every attempt fails");
assert!(
matches!(
err,
FetchError::SegmentAttemptsExhausted { attempts: 3, .. }
),
"unexpected error: {err}"
);
assert_eq!(
tracker.total(),
0,
"nothing finished, tracker stays at zero"
);
let failed: Vec<u32> = failed_events(&events)
.into_iter()
.map(|(_, attempt, error)| {
assert!(error.contains("500"), "failure reason: {error}");
attempt
})
.collect();
assert_eq!(failed, vec![1, 2, 3], "default policy means three attempts");
assert!(
part_files(parts.path()).is_empty(),
"failed part files must be cleaned up between attempts"
);
let output = parts.path().join("output.bin");
assert!(
!output.exists(),
"no output file may be created for a failed fetch"
);
}
#[tokio::test]
async fn single_attempt_policy_fails_fast() {
let server = TestServer::start_with(
vec![5u8; 16 * 1024],
true,
ServerConfig {
fail_mode: FailMode::Always,
},
);
let url = TargetUrl::parse(&server.url("/file.bin")).expect("valid url");
let (plan, registry) = single_exit_setup(&url, 16 * 1024, 16 * 1024);
let parts = TempDir::new().expect("tempdir in test");
let (progress, events) = recording_sink();
let policy = RetryPolicy::new(NonZeroU32::new(1).expect("1 is non-zero"));
let result =
exec::run_segments_with_policy(&plan, &registry, parts.path(), &policy, &progress).await;
let err = result.expect_err("fetch must fail after the single attempt");
assert!(
matches!(
err,
FetchError::SegmentAttemptsExhausted { attempts: 1, .. }
),
"unexpected error: {err}"
);
assert_eq!(failed_events(&events).len(), 1);
assert!(part_files(parts.path()).is_empty());
}
#[tokio::test]
async fn failing_exit_reassigned_to_working_exit() {
let data: Vec<u8> = (0..(1024 * 1024)).map(|i| (i % 241) as u8).collect();
let expected_digest = sn_fetch::types::Sha256Digest::from_bytes(Sha256::digest(&data).into());
let server = TestServer::start(data.clone(), true);
let url = TargetUrl::parse(&server.url("/file.bin")).expect("valid url");
let specs: ExitSpecs = vec![spec(1), spec(2)];
let plan = FetchPlan::build(
url,
FileSize::try_new(data.len() as u64).expect("non-zero in test"),
&specs,
SegmentsPerExit::new(NonZeroUsize::MIN),
MinSegmentBytes::new(NonZeroU64::new(1024).expect("1024 is non-zero")),
)
.expect("plan builds");
let mut registry = ExitRegistry::empty();
registry.register(Arc::new(BrokenProxyExit::new(exit_id(1))));
registry.register(Arc::new(LocalExit::new(exit_id(2)).expect("local exit")));
let parts = TempDir::new().expect("tempdir in test");
let (progress, events) = recording_sink();
let stats = exec::run_segments_with_policy(
&plan,
&registry,
parts.path(),
&RetryPolicy::default(),
&progress,
)
.await
.expect("fetch succeeds by reassigning off the broken exit");
for stat in &stats {
let planned = plan
.segments
.iter()
.find(|seg| seg.index == stat.index)
.expect("planned segment");
if planned.exit.value() == 1 {
assert_eq!(
stat.attempts, 2,
"segment planned on the broken exit needs exactly one retry"
);
assert_eq!(stat.exit.value(), 2, "retry must land on the working exit");
} else {
assert_eq!(stat.attempts, 1);
assert_eq!(stat.exit.value(), 2);
}
}
let started_exits: Vec<u8> = events
.lock()
.expect("mutex poisoned in test")
.iter()
.filter_map(|event| match event {
ProgressEvent::SegmentStarted { index, exit, .. } if index.value() == 0 => {
Some(exit.value())
}
_ => None,
})
.collect();
assert_eq!(
started_exits,
vec![1, 2],
"segment 0 must be tried on the broken exit first, then reassigned"
);
let output = parts.path().join("out.bin");
let digest = exec::assemble(
parts.path(),
&output,
FileSize::try_new(data.len() as u64).expect("non-zero in test"),
Some(&expected_digest),
)
.await
.expect("assembly verifies");
assert_eq!(digest, expected_digest);
}
#[tokio::test]
async fn non_retryable_status_aborts_immediately() {
let server = TestServer::start_with(
vec![3u8; 32 * 1024],
true,
ServerConfig {
fail_mode: FailMode::AlwaysStatus(416),
},
);
let url = TargetUrl::parse(&server.url("/file.bin")).expect("valid url");
let (plan, registry) = single_exit_setup(&url, 32 * 1024, 32 * 1024);
let parts = TempDir::new().expect("tempdir in test");
let (progress, events) = recording_sink();
let result = exec::run_segments_with_policy(
&plan,
&registry,
parts.path(),
&RetryPolicy::default(),
&progress,
)
.await;
let err = result.expect_err("416 must abort the fetch");
assert!(
matches!(
err,
FetchError::Http(ref http_err)
if http_err.status() == Some(reqwest::StatusCode::RANGE_NOT_SATISFIABLE)
),
"the raw 416 must surface unchanged, got: {err}"
);
// Non-retryable: one started attempt, one failure event, no retry.
let guard = events.lock().expect("mutex poisoned in test");
let started = guard
.iter()
.filter(|event| matches!(event, ProgressEvent::SegmentStarted { .. }))
.count();
assert_eq!(started, 1, "non-retryable failure must not be retried");
let failed = guard
.iter()
.filter(|event| matches!(event, ProgressEvent::SegmentFailed { .. }))
.count();
assert_eq!(failed, 1);
drop(guard);
assert!(
part_files(parts.path()).is_empty(),
"failed part files must be cleaned up"
);
}
+8 -4
View File
@@ -21,8 +21,8 @@ use sn_fetch::exit::LocalExit;
use sn_fetch::plan::{ExitSpecs, FetchPlan};
use sn_fetch::probe;
use sn_fetch::types::{
ByteRange, ExitId, ExitWeight, FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit,
Sha256Digest,
ByteRange, Egress, ExitId, ExitWeight, FileSize, MinSegmentBytes, RangeSupport,
SegmentsPerExit, Sha256Digest,
};
fn exit_specs(n: u8) -> ExitSpecs {
@@ -31,6 +31,7 @@ fn exit_specs(n: u8) -> ExitSpecs {
(
ExitId::new(NonZeroU8::new(i).expect("test ids are 1..=255")),
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
Egress::Direct,
)
})
.collect()
@@ -68,7 +69,7 @@ async fn segmented_download_reassembles_and_hashes() {
plan.validate().expect("plan validates");
let mut registry = sn_fetch::exit::ExitRegistry::empty();
for id in specs.iter().map(|(id, _)| *id) {
for id in specs.iter().map(|(id, _, _)| *id) {
registry.register(Arc::new(LocalExit::new(id).expect("local exit")));
}
@@ -109,8 +110,9 @@ async fn range_hostile_server_falls_back_to_single_stream() {
segments_per_exit: SegmentsPerExit::new(NonZeroUsize::new(1).expect("1 is non-zero")),
min_segment: MinSegmentBytes::new(NonZeroU64::new(1024).expect("1024 is non-zero")),
expected_sha256: None,
retries: sn_fetch::exec::RetryPolicy::default(),
};
let registry = sn_fetch::runner::local_registry(&specs).expect("registry");
let registry = sn_fetch::runner::registry_from_specs(&specs).expect("registry");
let parts = TempDir::new().expect("tempdir in test");
let report = sn_fetch::runner::run(&target, &options, &registry, parts.path())
@@ -131,10 +133,12 @@ fn plan_is_contiguous_and_weighted() {
(
ExitId::new(NonZeroU8::new(1).expect("1 is non-zero")),
ExitWeight::new(NonZeroU32::new(3).expect("3 is non-zero")),
Egress::Direct,
),
(
ExitId::new(NonZeroU8::new(2).expect("2 is non-zero")),
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
Egress::Direct,
),
];
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "sn-node"
description = "SpiderNet node helper: Yggdrasil overlay config generation and status queries"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { workspace = true }
sn-exit = { path = "../sn-exit" }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
+354
View File
@@ -0,0 +1,354 @@
//! Yggdrasil configuration generation for a `SpiderNet` node.
//!
//! Yggdrasil's own `-genconf` output is used as the base (so we never
//! hard-code version-specific defaults); the `SpiderNet` settings are then
//! patched into that JSON base as validated, typed values. The result is
//! written as JSON — Yggdrasil accepts JSON config via `-useconffile`.
use std::path::{Path, PathBuf};
use crate::error::NodeError;
use crate::yggdrasil::AdminEndpoint;
/// A validated Yggdrasil peering URI (`tcp://`, `tls://`, `quic://` or
/// `socks://`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerUri(String);
impl PeerUri {
pub fn parse(input: &str) -> Result<Self, NodeError> {
Ok(Self(validate_scheme(input, "peering")?))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A validated listener URI a node binds for incoming peerings
/// (`tcp://[::]:1337` style).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenUri(String);
impl ListenUri {
pub fn parse(input: &str) -> Result<Self, NodeError> {
let trimmed = input.trim();
if trimmed.starts_with("socks://") {
return Err(NodeError::InvalidUri {
uri: input.to_string(),
hint: "listeners bind an address: use tcp://, tls:// or quic://",
});
}
Ok(Self(validate_scheme(trimmed, "listen")?))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
const PEER_SCHEMES: [&str; 4] = ["tcp://", "tls://", "quic://", "socks://"];
fn validate_scheme(input: &str, kind: &'static str) -> Result<String, NodeError> {
let trimmed = input.trim();
let scheme = PEER_SCHEMES
.iter()
.find(|scheme| trimmed.starts_with(**scheme));
match scheme {
Some(scheme) if trimmed.len() > scheme.len() => Ok(trimmed.to_string()),
_ => Err(NodeError::InvalidUri {
uri: input.to_string(),
hint: match kind {
"peering" => "peer URIs must start with tcp://, tls://, quic:// or socks://",
_ => "listen URIs must start with tcp://, tls:// or quic://",
},
}),
}
}
/// A validated Yggdrasil public key: 64 hex characters.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublicKeyHex(String);
impl PublicKeyHex {
pub fn parse(input: &str) -> Result<Self, NodeError> {
let trimmed = input.trim().to_lowercase();
let valid = trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit());
if valid {
Ok(Self(trimmed))
} else {
Err(NodeError::InvalidPublicKey(input.to_string()))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Which TUN interface Yggdrasil should use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IfName {
/// `auto` — let Yggdrasil pick (default).
Auto,
/// `none` — headless router-only mode, no TUN device.
Headless,
Named(String),
}
/// Multicast (same-LAN) auto-peering mode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MulticastMode {
/// Leave Yggdrasil's generated default untouched.
PlatformDefault,
/// Disable multicast peering entirely.
Disabled,
/// Explicit interface entries (regex-matched).
Interfaces(Vec<MulticastInterface>),
}
/// One multicast peering entry, mirroring Yggdrasil's JSON shape.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MulticastInterface {
/// Interface name regex, e.g. `eth0` or `.*`.
pub regex: String,
pub beacon: bool,
pub listen: bool,
pub port: u16,
}
/// Everything `SpiderNet` wants to control in the Yggdrasil config.
/// Unlisted fields keep the values Yggdrasil's `-genconf` produced.
#[derive(Debug, Clone, Default)]
pub struct NodeSettings {
pub peers: Vec<PeerUri>,
pub listen: Vec<ListenUri>,
pub multicast: Option<MulticastMode>,
pub admin_listen: Option<AdminEndpoint>,
pub if_name: Option<IfName>,
/// Neighbor keys allowed to peer with us; empty means "allow all".
pub allowed_public_keys: Vec<PublicKeyHex>,
pub private_key_path: Option<PathBuf>,
}
/// Run `yggdrasil -genconf -json` and patch the `SpiderNet` settings into
/// the generated base config. Returns the pretty-printed JSON config.
pub fn generate_config(binary: &str, settings: &NodeSettings) -> Result<String, NodeError> {
let output = std::process::Command::new(binary)
.arg("-genconf")
.arg("-json")
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(NodeError::YggdrasilBinary {
binary: binary.to_string(),
stderr,
});
}
let mut base: serde_json::Value = serde_json::from_slice(&output.stdout)?;
apply_to_base_config(settings, &mut base)?;
Ok(serde_json::to_string_pretty(&base)?)
}
/// Patch the `SpiderNet` settings into an existing (parsed) Yggdrasil base
/// config. Pure function so it can be tested without the binary.
pub fn apply_to_base_config(
settings: &NodeSettings,
base: &mut serde_json::Value,
) -> Result<(), NodeError> {
let obj = base
.as_object_mut()
.ok_or(NodeError::UnexpectedConfigShape)?;
if !settings.peers.is_empty() {
let peers: Vec<&str> = settings.peers.iter().map(PeerUri::as_str).collect();
obj.insert("Peers".to_string(), serde_json::json!(peers));
}
if !settings.listen.is_empty() {
let listen: Vec<&str> = settings.listen.iter().map(ListenUri::as_str).collect();
obj.insert("Listen".to_string(), serde_json::json!(listen));
}
match settings.multicast.as_ref() {
None | Some(MulticastMode::PlatformDefault) => {}
Some(MulticastMode::Disabled) => {
obj.insert("MulticastInterfaces".to_string(), serde_json::json!([]));
}
Some(MulticastMode::Interfaces(interfaces)) => {
let entries: Vec<serde_json::Value> = interfaces
.iter()
.map(|iface| {
serde_json::json!({
"Beacon": iface.beacon,
"Listen": iface.listen,
"Port": iface.port,
"Regex": iface.regex,
})
})
.collect();
obj.insert(
"MulticastInterfaces".to_string(),
serde_json::json!(entries),
);
}
}
if let Some(admin_listen) = settings.admin_listen.as_ref() {
obj.insert(
"AdminListen".to_string(),
serde_json::json!(admin_listen.config_value()),
);
}
if let Some(if_name) = settings.if_name.as_ref() {
let value = match if_name {
IfName::Auto => "auto".to_string(),
IfName::Headless => "none".to_string(),
IfName::Named(name) => name.clone(),
};
obj.insert("IfName".to_string(), serde_json::json!(value));
}
if !settings.allowed_public_keys.is_empty() {
let keys: Vec<&str> = settings
.allowed_public_keys
.iter()
.map(PublicKeyHex::as_str)
.collect();
obj.insert("AllowedPublicKeys".to_string(), serde_json::json!(keys));
}
if let Some(private_key_path) = settings.private_key_path.as_ref() {
obj.insert(
"PrivateKeyPath".to_string(),
serde_json::json!(private_key_path.display().to_string()),
);
}
Ok(())
}
/// Write the config to `path` and restrict permissions to the owner
/// (the file contains the node's private key — it must never be readable
/// by other users, and it must never be committed to any repository).
pub fn write_config(path: &Path, json: &str) -> Result<(), NodeError> {
use std::io::Write as _;
let mut file = std::fs::File::create(path)?;
file.write_all(json.as_bytes())?;
file.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
/// Parse a TUN interface setting: `auto`, `none` (headless router-only)
/// or an explicit interface name.
pub fn parse_ifname(input: &str) -> Result<IfName, NodeError> {
match input.trim() {
"auto" => Ok(IfName::Auto),
"none" => Ok(IfName::Headless),
name if !name.is_empty() => Ok(IfName::Named(name.to_string())),
_ => Err(NodeError::InvalidIfName(input.to_string())),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn peer_uri_accepts_supported_schemes() {
for uri in [
"tcp://host:1337",
"tls://host:1337",
"quic://host:1337",
"socks://p:1/h:2",
] {
assert!(PeerUri::parse(uri).is_ok(), "{uri} should be valid");
}
}
#[test]
fn peer_uri_rejects_bare_or_unknown_schemes() {
for uri in ["host:1337", "http://host:80", "tcp://", ""] {
assert!(PeerUri::parse(uri).is_err(), "{uri} should be invalid");
}
}
#[test]
fn listen_uri_rejects_socks() {
assert!(ListenUri::parse("socks://[::]:1337").is_err());
assert!(ListenUri::parse("tcp://[::]:1337").is_ok());
}
#[test]
fn public_key_requires_64_hex_chars() {
assert!(PublicKeyHex::parse(&"a".repeat(64)).is_ok());
assert!(PublicKeyHex::parse(&"a".repeat(63)).is_err());
assert!(PublicKeyHex::parse(&"z".repeat(64)).is_err());
}
#[test]
fn admin_endpoint_parse_and_roundtrip() {
let tcp = AdminEndpoint::parse("tcp://localhost:9001").unwrap();
assert_eq!(
tcp,
AdminEndpoint::Tcp {
host: "localhost".into(),
port: 9001
}
);
assert_eq!(tcp.config_value(), "tcp://localhost:9001");
let unix = AdminEndpoint::parse("unix:///var/run/yggdrasil.sock").unwrap();
assert_eq!(unix.config_value(), "unix:///var/run/yggdrasil.sock");
assert!(AdminEndpoint::parse("http://localhost:9001").is_err());
assert!(AdminEndpoint::parse("tcp://localhost").is_err());
}
#[test]
fn patches_land_in_base_config() {
let mut base: serde_json::Value =
serde_json::from_str(r#"{"PrivateKey": "secret", "Peers": [], "Listen": []}"#).unwrap();
let settings = NodeSettings {
peers: vec![PeerUri::parse("tcp://[200:1234::1]:1337").unwrap()],
listen: vec![ListenUri::parse("tls://[::]:1337").unwrap()],
multicast: Some(MulticastMode::Disabled),
admin_listen: Some(AdminEndpoint::parse("tcp://localhost:9001").unwrap()),
if_name: Some(IfName::Headless),
allowed_public_keys: vec![PublicKeyHex::parse(&"ab".repeat(32)).unwrap()],
private_key_path: Some(PathBuf::from("/etc/spidernet/ygg.key")),
};
apply_to_base_config(&settings, &mut base).unwrap();
assert_eq!(base["Peers"].as_array().unwrap().len(), 1);
assert_eq!(base["Listen"][0], "tls://[::]:1337");
assert_eq!(base["MulticastInterfaces"].as_array().unwrap().len(), 0);
assert_eq!(base["AdminListen"], "tcp://localhost:9001");
assert_eq!(base["IfName"], "none");
assert_eq!(base["AllowedPublicKeys"][0], "ab".repeat(32));
assert_eq!(base["PrivateKeyPath"], "/etc/spidernet/ygg.key");
// The base's own keys survive the patch.
assert_eq!(base["PrivateKey"], "secret");
}
#[test]
fn multicast_interfaces_are_serialized_in_yggdrasil_shape() {
let mut base: serde_json::Value = serde_json::from_str("{}").unwrap();
let settings = NodeSettings {
multicast: Some(MulticastMode::Interfaces(vec![MulticastInterface {
regex: "eth0".to_string(),
beacon: true,
listen: true,
port: 1337,
}])),
..NodeSettings::default()
};
apply_to_base_config(&settings, &mut base).unwrap();
let entry = &base["MulticastInterfaces"][0];
assert_eq!(entry["Regex"], "eth0");
assert_eq!(entry["Beacon"], true);
assert_eq!(entry["Listen"], true);
assert_eq!(entry["Port"], 1337);
}
}
+337
View File
@@ -0,0 +1,337 @@
//! The `SpiderNet` node daemon: supervises the Yggdrasil overlay sidecar
//! and runs the optional exit service in-process.
//!
//! Responsibilities (kill-switch semantics):
//!
//! - manage the yggdrasil sidecar when `[yggdrasil] manage = true`:
//! persistent node key at `<state_dir>/node.key`, generated config at
//! `<state_dir>/yggdrasil.conf`, child process with graceful stop;
//! - wait for the overlay address via the admin socket and log it;
//! - run the exit service (`sn-exit`) in-process when
//! `[exit] enabled = true`; otherwise leech mode (overlay only,
//! receive without offering an exit);
//! - on shutdown (SIGINT/SIGTERM wired in by the CLI): stop the accept
//! loop, then stop the sidecar — SIGTERM first, `start_kill` after a
//! bounded grace period;
//! - if the sidecar dies unexpectedly: shut down and exit with an error
//! (supervisor duty).
//!
//! [`run_daemon`] takes the shutdown as a plain future so tests can drive
//! it without signals.
use std::future::Future;
use std::path::Path;
use std::pin::{pin, Pin};
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::process::Child;
use crate::config::{self, PublicKeyHex};
use crate::daemon_config::{ValidatedDaemonConfig, YGGDRASIL_CONF_FILE};
use crate::error::NodeError;
use crate::yggdrasil::{AdminClient, YggAddr};
/// Printed first by the daemon CLI; the software carries no warranty and
/// accepts no liability (AGPL-3.0, sections 15/16).
pub const NO_WARRANTY_BANNER: &str =
"NO WARRANTY — SpiderNet sn-node daemon (AGPL-3.0, no liability)";
/// How long the daemon waits for the overlay (admin socket) to come up.
const OVERLAY_WAIT: Duration = Duration::from_secs(60);
/// Interval between admin socket attempts while the overlay is down.
const OVERLAY_POLL: Duration = Duration::from_secs(1);
/// Grace period for SIGTERM-terminated yggdrasil before we kill it.
const TERMINATE_GRACE: Duration = Duration::from_secs(5);
/// Run the node daemon until `shutdown` completes or a fatal error
/// occurs. Returns `Ok(())` on a clean shutdown.
pub async fn run_daemon(
config: ValidatedDaemonConfig,
shutdown: impl Future<Output = ()> + Send,
) -> Result<(), NodeError> {
let mut shutdown = pin!(shutdown);
log_config_summary(&config);
let mut child = if config.manage() {
Some(spawn_sidecar(&config).await?)
} else {
None
};
let overlay = match wait_for_overlay(&config, &mut child, shutdown.as_mut()).await {
OverlayOutcome::Up(address) => address,
OverlayOutcome::Shutdown => {
terminate_sidecar(&mut child).await;
return Ok(());
}
OverlayOutcome::Failed(error) => {
kill_child(&mut child).await;
return Err(error);
}
};
if let Some(exit) = config.exit() {
let bind = exit.bind().resolve(overlay);
let listener = match TcpListener::bind(bind).await {
Ok(listener) => listener,
Err(source) => {
kill_child(&mut child).await;
return Err(NodeError::ExitListen { addr: bind, source });
}
};
tracing::info!("exit listening on {bind}");
if exit.allowlist().is_loopback_only() {
tracing::warn!(
"exit in DEV MODE: no neighbor allowlist — only loopback clients are accepted"
);
}
let exit_config =
sn_exit::config::ExitConfig::new(bind, exit.allowlist().clone(), exit.quota());
tokio::select! {
result = sn_exit::proxy::serve_until(exit_config, listener, shutdown.as_mut()) => {
if let Err(error) = result {
kill_child(&mut child).await;
return Err(NodeError::Exit(error));
}
}
status = child_exit(&mut child) => {
kill_child(&mut child).await;
return Err(NodeError::SidecarExited(
status.unwrap_or_else(|| "unknown".to_string()),
));
}
}
} else {
// Leech mode: overlay only, until signal or sidecar death.
tokio::select! {
() = shutdown.as_mut() => {}
status = child_exit(&mut child) => {
return Err(NodeError::SidecarExited(
status.unwrap_or_else(|| "unknown".to_string()),
));
}
}
}
terminate_sidecar(&mut child).await;
tracing::info!("SpiderNet node daemon stopped");
Ok(())
}
fn log_config_summary(config: &ValidatedDaemonConfig) {
let overlay = if config.manage() {
"managed sidecar"
} else {
"external (yggdrasil.manage = false)"
};
let exit = match config.exit() {
Some(_) => "enabled (sharing the uplink)",
None => "disabled (leech mode)",
};
tracing::info!(
admin = %config.admin_endpoint().config_value(),
state_dir = %config.state_dir().display(),
overlay,
exit,
"SpiderNet node daemon starting"
);
}
/// Prepare the state directory (node key + generated Yggdrasil config)
/// and spawn the sidecar process.
async fn spawn_sidecar(config: &ValidatedDaemonConfig) -> Result<Child, NodeError> {
let state_dir = config.state_dir().to_path_buf();
tokio::fs::create_dir_all(&state_dir).await?;
let key_path = state_dir.join(crate::daemon_config::NODE_KEY_FILE);
let settings = config.node_settings();
// One `yggdrasil -genconf -json` run provides both the base config
// and — on first boot — the fresh node key.
let json = config::generate_config(config.yggdrasil_binary(), &settings)?;
let base: serde_json::Value = serde_json::from_str(&json)?;
if key_path.exists() {
// The node identity must survive restarts: reuse the persisted
// key, never regenerate it.
//
// The base JSON still embeds a fresh `PrivateKey`, but per the
// official configuration reference PrivateKeyPath takes
// precedence when both are set
// (https://yggdrasil-network.github.io/configurationref.html),
// so yggdrasil runs with the persisted key.
let existing = tokio::fs::read_to_string(&key_path).await?;
let key = existing.trim();
PublicKeyHex::parse(key).map_err(|err| {
NodeError::DaemonConfig(format!(
"existing node key {} is invalid ({err}); the node identity \
must stay stable, so it is not regenerated",
key_path.display()
))
})?;
tracing::info!("node key loaded from {}", key_path.display());
} else {
let generated = base
.get("PrivateKey")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
NodeError::DaemonConfig(format!(
"`{} -genconf -json` output has no `PrivateKey` field",
config.yggdrasil_binary()
))
})?;
let key = PublicKeyHex::parse(generated)
.map_err(|err| NodeError::DaemonConfig(format!("generated node key: {err}")))?;
write_private_key(&key_path, key.as_str())?;
tracing::info!(
"node key generated and stored at {} (mode 600)",
key_path.display()
);
}
let conf_path = state_dir.join(YGGDRASIL_CONF_FILE);
config::write_config(&conf_path, &json)?;
tracing::info!("yggdrasil config written to {}", conf_path.display());
let mut command = tokio::process::Command::new(config.yggdrasil_binary());
command.arg("-useconffile").arg(&conf_path);
let child = command.spawn()?;
tracing::info!("yggdrasil sidecar spawned (pid {:?})", child.id());
Ok(child)
}
/// Write the node's private key with owner-only permissions (mode 600).
fn write_private_key(path: &Path, key: &str) -> Result<(), NodeError> {
use std::io::Write as _;
let mut file = std::fs::File::create(path)?;
file.write_all(key.as_bytes())?;
file.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
/// Result of waiting for the overlay.
enum OverlayOutcome {
Up(YggAddr),
/// Shutdown was requested while waiting.
Shutdown,
Failed(NodeError),
}
/// Poll the admin socket until the overlay reports the node's own
/// address. Aborts on shutdown and on unexpected sidecar death; the
/// sidecar child (if any) is reaped by the `child.wait()` arm.
async fn wait_for_overlay(
config: &ValidatedDaemonConfig,
child: &mut Option<Child>,
mut shutdown: Pin<&mut (impl Future<Output = ()> + Send)>,
) -> OverlayOutcome {
let admin = config.admin_endpoint();
let deadline = tokio::time::Instant::now() + OVERLAY_WAIT;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return OverlayOutcome::Failed(NodeError::OverlayNotUp {
endpoint: admin.config_value(),
timeout_secs: OVERLAY_WAIT.as_secs(),
});
}
let attempt_timeout = remaining.min(OVERLAY_POLL);
tokio::select! {
biased;
() = shutdown.as_mut() => return OverlayOutcome::Shutdown,
status = child_exit(&mut *child) => {
return OverlayOutcome::Failed(NodeError::SidecarExited(
status.unwrap_or_else(|| "unknown".to_string()),
));
}
result = AdminClient::get_self(admin, attempt_timeout) => match result {
Ok(info) => {
tracing::info!("overlay is up: address {}", info.address);
if let Ok(peers) = AdminClient::get_peers(admin, attempt_timeout).await {
tracing::info!("overlay peers: {}", peers.len());
} else {
tracing::info!("overlay peers: not reported yet");
}
return OverlayOutcome::Up(info.address);
}
Err(_) => tokio::time::sleep(OVERLAY_POLL).await,
}
}
}
}
/// Resolve when the sidecar process exits; never resolves without one.
/// Returns a human-readable exit status description.
async fn child_exit(child: &mut Option<Child>) -> Option<String> {
if let Some(child) = child {
return Some(match child.wait().await {
Ok(status) => status.to_string(),
Err(error) => format!("wait failed: {error}"),
});
}
std::future::pending::<()>().await;
None
}
/// Stop the sidecar gracefully: SIGTERM via `kill`, bounded grace, then
/// a hard kill.
async fn terminate_sidecar(child: &mut Option<Child>) {
let Some(child) = child.as_mut() else {
return;
};
let Some(pid) = child.id() else {
tracing::info!("yggdrasil sidecar already gone");
return;
};
tracing::info!("stopping yggdrasil sidecar (pid {pid}): sending SIGTERM");
match std::process::Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.status()
{
Ok(status) if status.success() => {}
Ok(status) => tracing::warn!("`kill -TERM {pid}` failed: {status}"),
Err(error) => tracing::warn!("could not run `kill -TERM {pid}`: {error}"),
}
match tokio::time::timeout(TERMINATE_GRACE, child.wait()).await {
Ok(Ok(status)) => tracing::info!("yggdrasil sidecar stopped ({status})"),
Ok(Err(error)) => tracing::warn!("waiting for yggdrasil failed: {error}"),
Err(_) => {
tracing::warn!(
"yggdrasil did not exit within {}s; killing",
TERMINATE_GRACE.as_secs()
);
if let Err(error) = child.start_kill() {
tracing::warn!("start_kill failed: {error}");
}
child.wait().await.ok();
tracing::info!("yggdrasil sidecar killed");
}
}
}
/// Kill the sidecar immediately (failure path); a no-op when it is
/// already gone.
async fn kill_child(child: &mut Option<Child>) {
let Some(child) = child.as_mut() else {
return;
};
if child.id().is_some() {
tracing::warn!("killing yggdrasil sidecar after failure");
if let Err(error) = child.start_kill() {
tracing::warn!("start_kill failed: {error}");
}
}
child.wait().await.ok();
}
+555
View File
@@ -0,0 +1,555 @@
//! Typed, validated configuration for the node daemon (`sn-node daemon`).
//!
//! The TOML file is parsed into raw optional sections and then validated
//! into types that carry their invariants ("parsed data is validated"):
//! URIs, keys, interface names and addresses reuse the existing parsers of
//! `sn-node` and `sn-exit`, so an invalid config fails with a clear error
//! at load time instead of a mid-run surprise.
//!
//! File schema (TOML):
//!
//! ```toml
//! [node]
//! admin_endpoint = "tcp://localhost:9001" # optional; also used as Yggdrasil AdminListen
//! state_dir = "spidernet-state" # optional; holds node.key + generated yggdrasil.conf
//!
//! [yggdrasil]
//! manage = true # optional, default true; false = an already-running yggdrasil daemon is used
//! binary = "yggdrasil" # optional, default "yggdrasil"; only relevant when manage = true
//! peers = [] # optional; list of PeerUri strings (tcp://, tls://, quic://, socks://)
//! listen = [] # optional; list of ListenUri strings (tcp://, tls://, quic://)
//! allow_keys = [] # optional; 64-hex neighbor public keys (AllowedPublicKeys ACL)
//! no_multicast = false # optional bool
//! ifname = "auto" # optional: "auto" | "none" | interface name
//!
//! [exit]
//! enabled = false # optional, default false (false = leech mode)
//! bind = "" # optional explicit bind address; default "[<yggdrasil-address>]:port"
//! port = 8080 # optional u16, default 8080 (used when bind is unset)
//! allow = [] # optional neighbor overlay addresses (sn-exit --allow semantics)
//! quota_mib = 1024 # optional u64, default 1024; 0 = no quota
//! window_hours = 24 # optional u64, default 24
//! ```
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use sn_exit::config::{parse_bind, parse_source, QuotaPolicy, SourceAllowlist};
use crate::config::{parse_ifname, IfName, ListenUri, NodeSettings, PeerUri, PublicKeyHex};
use crate::error::NodeError;
use crate::yggdrasil::{AdminEndpoint, YggAddr};
/// Default state directory (relative to the daemon's working directory).
pub const DEFAULT_STATE_DIR: &str = "spidernet-state";
/// Default yggdrasil binary (looked up in `PATH`).
pub const DEFAULT_YGGDRASIL_BINARY: &str = "yggdrasil";
/// Name of the persisted node private key inside the state directory.
pub const NODE_KEY_FILE: &str = "node.key";
/// Name of the generated Yggdrasil config inside the state directory.
pub const YGGDRASIL_CONF_FILE: &str = "yggdrasil.conf";
/// Default admin endpoint when `[node] admin_endpoint` is unset.
const DEFAULT_ADMIN_ENDPOINT: &str = "tcp://localhost:9001";
/// Default exit port when `[exit] bind` is unset.
const DEFAULT_EXIT_PORT: u16 = 8080;
/// Default per-neighbor quota in MiB.
const DEFAULT_QUOTA_MIB: u64 = 1024;
/// Default quota window in hours.
const DEFAULT_WINDOW_HOURS: u64 = 24;
const MIB: u64 = 1024 * 1024;
/// Where the exit service binds. Resolved at runtime because the overlay
/// address is only known once the overlay is up.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitBind {
/// An explicit address from the config.
Explicit(SocketAddr),
/// `[<overlay address>]:port` on the node's own overlay address.
Overlay { port: u16 },
}
impl ExitBind {
/// Resolve this bind against the node's overlay address.
pub const fn resolve(self, overlay: YggAddr) -> SocketAddr {
match self {
Self::Explicit(addr) => addr,
Self::Overlay { port } => SocketAddr::new(IpAddr::V6(overlay.inner()), port),
}
}
}
/// Validated settings for the exit service; present only when
/// `[exit] enabled = true`. Without it the daemon runs in leech mode
/// (overlay only, receive without offering an exit).
#[derive(Debug, Clone)]
pub struct ExitServiceSettings {
bind: ExitBind,
allowlist: SourceAllowlist,
quota: Option<QuotaPolicy>,
}
impl ExitServiceSettings {
/// Where the exit should bind.
pub const fn bind(&self) -> ExitBind {
self.bind
}
/// Which neighbor source addresses may use the exit.
pub const fn allowlist(&self) -> &SourceAllowlist {
&self.allowlist
}
/// Per-neighbor quota, or none when disabled (`quota_mib = 0`).
pub const fn quota(&self) -> Option<QuotaPolicy> {
self.quota
}
}
/// A fully validated daemon config. Construction goes through
/// [`parse`]/[`load`]; every field carries its invariant afterwards.
#[derive(Debug, Clone)]
pub struct ValidatedDaemonConfig {
admin_endpoint: AdminEndpoint,
state_dir: PathBuf,
manage: bool,
yggdrasil_binary: String,
peers: Vec<PeerUri>,
listen: Vec<ListenUri>,
allowed_keys: Vec<PublicKeyHex>,
no_multicast: bool,
if_name: IfName,
exit: Option<ExitServiceSettings>,
}
impl ValidatedDaemonConfig {
/// Admin socket endpoint; also used as Yggdrasil `AdminListen` when
/// the daemon manages the sidecar.
pub const fn admin_endpoint(&self) -> &AdminEndpoint {
&self.admin_endpoint
}
/// State directory: holds `node.key` and the generated yggdrasil
/// config.
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
/// Whether the daemon manages the yggdrasil sidecar process.
pub const fn manage(&self) -> bool {
self.manage
}
/// Path or name of the yggdrasil binary.
pub fn yggdrasil_binary(&self) -> &str {
&self.yggdrasil_binary
}
/// The Yggdrasil settings derived from this config, with the node key
/// at `<state_dir>/node.key`.
pub fn node_settings(&self) -> NodeSettings {
NodeSettings {
peers: self.peers.clone(),
listen: self.listen.clone(),
multicast: Some(if self.no_multicast {
crate::config::MulticastMode::Disabled
} else {
crate::config::MulticastMode::PlatformDefault
}),
admin_listen: Some(self.admin_endpoint.clone()),
if_name: Some(self.if_name.clone()),
allowed_public_keys: self.allowed_keys.clone(),
private_key_path: Some(self.state_dir.join(NODE_KEY_FILE)),
}
}
/// Exit service settings, or `None` in leech mode.
pub const fn exit(&self) -> Option<&ExitServiceSettings> {
self.exit.as_ref()
}
}
/// Parse a daemon config from TOML text.
pub fn parse(input: &str) -> Result<ValidatedDaemonConfig, NodeError> {
let raw: RawDaemonConfig = toml::from_str(input)
.map_err(|err| NodeError::DaemonConfig(format!("cannot parse config: {err}")))?;
validate(raw)
}
/// Parse a daemon config from the file at `path`.
pub fn load(path: &Path) -> Result<ValidatedDaemonConfig, NodeError> {
let text = std::fs::read_to_string(path).map_err(|err| {
NodeError::DaemonConfig(format!("cannot read config file {}: {err}", path.display()))
})?;
parse(&text)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawDaemonConfig {
#[serde(default)]
node: RawNodeSection,
#[serde(default)]
yggdrasil: RawYggdrasilSection,
#[serde(default)]
exit: RawExitSection,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawNodeSection {
admin_endpoint: Option<String>,
state_dir: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawYggdrasilSection {
manage: Option<bool>,
binary: Option<String>,
#[serde(default)]
peers: Option<Vec<String>>,
#[serde(default)]
listen: Option<Vec<String>>,
#[serde(default)]
allow_keys: Option<Vec<String>>,
no_multicast: Option<bool>,
ifname: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawExitSection {
enabled: Option<bool>,
bind: Option<String>,
port: Option<u16>,
#[serde(default)]
allow: Option<Vec<String>>,
quota_mib: Option<u64>,
window_hours: Option<u64>,
}
fn validate(raw: RawDaemonConfig) -> Result<ValidatedDaemonConfig, NodeError> {
let RawDaemonConfig {
node,
yggdrasil,
exit,
} = raw;
let admin_endpoint = match node.admin_endpoint.as_deref() {
None => AdminEndpoint::parse(DEFAULT_ADMIN_ENDPOINT)
.map_err(|err| NodeError::DaemonConfig(err.to_string()))?,
Some(value) => AdminEndpoint::parse(value)
.map_err(|err| NodeError::DaemonConfig(format!("node.admin_endpoint: {err}")))?,
};
let state_dir = node
.state_dir
.map_or_else(|| PathBuf::from(DEFAULT_STATE_DIR), PathBuf::from);
let manage = yggdrasil.manage.unwrap_or(true);
let yggdrasil_binary = yggdrasil
.binary
.unwrap_or_else(|| DEFAULT_YGGDRASIL_BINARY.to_string());
let peers = parse_each(
yggdrasil.peers.as_deref(),
"yggdrasil.peers",
PeerUri::parse,
)?;
let listen = parse_each(
yggdrasil.listen.as_deref(),
"yggdrasil.listen",
ListenUri::parse,
)?;
let allowed_keys = parse_each(
yggdrasil.allow_keys.as_deref(),
"yggdrasil.allow_keys",
PublicKeyHex::parse,
)?;
let no_multicast = yggdrasil.no_multicast.unwrap_or(false);
let if_name = yggdrasil.ifname.as_deref().unwrap_or("auto");
let if_name = parse_ifname(if_name)
.map_err(|err| NodeError::DaemonConfig(format!("yggdrasil.ifname: {err}")))?;
let exit = validate_exit(&exit)?;
if !manage && exit.is_none() {
return Err(NodeError::DaemonConfig(
"nothing to do: set `[yggdrasil] manage = true` or `[exit] enabled = true`".to_string(),
));
}
Ok(ValidatedDaemonConfig {
admin_endpoint,
state_dir,
manage,
yggdrasil_binary,
peers,
listen,
allowed_keys,
no_multicast,
if_name,
exit,
})
}
fn validate_exit(raw: &RawExitSection) -> Result<Option<ExitServiceSettings>, NodeError> {
let enabled = raw.enabled.unwrap_or(false);
let explicit_bind = match raw.bind.as_deref() {
None | Some("") => None,
Some(value) => Some(
parse_bind(value)
.map_err(|err| NodeError::DaemonConfig(format!("exit.bind: {err}")))?,
),
};
let allow_sources: Vec<IpAddr> = parse_each(raw.allow.as_deref(), "exit.allow", parse_source)?;
// Without a neighbor allowlist only loopback clients would be
// accepted — that is acceptable only for development, where an
// explicit loopback bind makes the intent unmistakable.
if enabled && allow_sources.is_empty() {
let dev_mode = explicit_bind.is_some_and(|addr| addr.ip().is_loopback());
if !dev_mode {
return Err(NodeError::DaemonConfig(
"exit enabled but no neighbor allowlist: add `exit.allow` entries, \
or (dev only) set `exit.bind` to a loopback address"
.to_string(),
));
}
}
let allowlist = if allow_sources.is_empty() {
SourceAllowlist::loopback_only()
} else {
SourceAllowlist::new(allow_sources)
};
let quota = match raw.quota_mib.unwrap_or(DEFAULT_QUOTA_MIB) {
0 => None,
mib => Some(
QuotaPolicy::try_new(
mib.saturating_mul(MIB),
Duration::from_secs(
raw.window_hours
.unwrap_or(DEFAULT_WINDOW_HOURS)
.saturating_mul(3600),
),
)
.map_err(|err| NodeError::DaemonConfig(format!("exit.quota: {err}")))?,
),
};
let bind = match explicit_bind {
Some(addr) => ExitBind::Explicit(addr),
None => ExitBind::Overlay {
port: raw.port.unwrap_or(DEFAULT_EXIT_PORT),
},
};
if enabled {
Ok(Some(ExitServiceSettings {
bind,
allowlist,
quota,
}))
} else {
Ok(None)
}
}
/// Parse every entry with `parse`, wrapping failures in a single
/// config error that names the offending section.
fn parse_each<T, E: std::fmt::Display>(
inputs: Option<&[String]>,
what: &str,
parse: fn(&str) -> Result<T, E>,
) -> Result<Vec<T>, NodeError> {
let inputs = inputs.unwrap_or(&[]);
inputs
.iter()
.map(|input| parse(input).map_err(|err| NodeError::DaemonConfig(format!("{what}: {err}"))))
.collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn error_for(toml_text: &str) -> String {
parse(toml_text)
.expect_err("config should be invalid")
.to_string()
}
#[test]
fn full_config_maps_into_validated_types() {
let config = parse(
r#"
[node]
admin_endpoint = "tcp://localhost:9001"
state_dir = "/var/lib/spidernet"
[yggdrasil]
manage = true
binary = "/usr/local/bin/yggdrasil"
peers = ["tcp://[200:1234::1]:1337", "socks://p:1/h:2"]
listen = ["tls://[::]:1337"]
allow_keys = ["00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"]
no_multicast = true
ifname = "eth0"
[exit]
enabled = true
bind = "127.0.0.1:18099"
allow = []
quota_mib = 2048
window_hours = 12
"#,
)
.unwrap();
assert_eq!(
config.admin_endpoint(),
&AdminEndpoint::parse("tcp://localhost:9001").unwrap()
);
assert_eq!(config.state_dir(), Path::new("/var/lib/spidernet"));
assert!(config.manage());
assert_eq!(config.yggdrasil_binary(), "/usr/local/bin/yggdrasil");
let settings = config.node_settings();
assert_eq!(settings.peers.len(), 2);
assert_eq!(settings.peers[0].as_str(), "tcp://[200:1234::1]:1337");
assert_eq!(settings.peers[1].as_str(), "socks://p:1/h:2");
assert_eq!(settings.listen.len(), 1);
assert_eq!(settings.listen[0].as_str(), "tls://[::]:1337");
assert_eq!(settings.allowed_public_keys.len(), 1);
assert!(matches!(
settings.if_name,
Some(IfName::Named(ref name)) if name == "eth0"
));
assert_eq!(
settings.private_key_path.as_deref(),
Some(Path::new("/var/lib/spidernet/node.key"))
);
let exit = config.exit().unwrap();
assert_eq!(
exit.bind(),
ExitBind::Explicit("127.0.0.1:18099".parse().unwrap())
);
assert!(exit.allowlist().is_loopback_only());
let quota = exit.quota().unwrap();
assert_eq!(quota.max_bytes(), 2048 * 1024 * 1024);
assert_eq!(quota.window(), Duration::from_hours(12));
}
#[test]
fn minimal_config_defaults_to_leech_mode() {
let config = parse("[exit]\nenabled = false\n").unwrap();
assert!(config.manage());
assert_eq!(config.yggdrasil_binary(), DEFAULT_YGGDRASIL_BINARY);
assert_eq!(config.state_dir(), Path::new(DEFAULT_STATE_DIR));
assert!(config.exit().is_none());
assert!(config.node_settings().peers.is_empty());
}
#[test]
fn load_missing_file_is_an_error() {
let error = load(Path::new("/nonexistent/spidernet-node.toml")).unwrap_err();
assert!(error.to_string().contains("cannot read config file"));
}
#[test]
fn load_valid_file_succeeds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("node.toml");
std::fs::write(&path, "[yggdrasil]\nmanage = true\n").unwrap();
assert!(load(&path).is_ok());
}
#[test]
fn broken_toml_gives_readable_error() {
let error = error_for("[node]\nadmin_endpoint = \n");
assert!(error.contains("cannot parse config"), "{error}");
}
#[test]
fn exit_without_allowlist_requires_loopback_bind() {
let no_bind = error_for("[exit]\nenabled = true\n");
assert!(
no_bind.contains("exit enabled but no neighbor allowlist"),
"{no_bind}"
);
let non_loopback = error_for("[exit]\nenabled = true\nbind = \"192.168.1.5:8080\"\n");
assert!(
non_loopback.contains("exit enabled but no neighbor allowlist"),
"{non_loopback}"
);
}
#[test]
fn exit_with_loopback_bind_is_valid_dev_mode() {
let config = parse("[exit]\nenabled = true\nbind = \"127.0.0.1:18099\"\n").unwrap();
let exit = config.exit().unwrap();
assert!(exit.allowlist().is_loopback_only());
assert_eq!(
exit.bind(),
ExitBind::Explicit("127.0.0.1:18099".parse().unwrap())
);
}
#[test]
fn manage_false_and_exit_disabled_is_rejected() {
let error = error_for("[yggdrasil]\nmanage = false\n");
assert!(error.contains("nothing to do"), "{error}");
}
#[test]
fn quota_zero_disables_quota() {
let config =
parse("[exit]\nenabled = true\nbind = \"[::1]:8080\"\nquota_mib = 0\n").unwrap();
assert_eq!(config.exit().unwrap().quota(), None);
}
#[test]
fn overlay_bind_defaults_to_port_8080_and_can_be_overridden() {
let config = parse("[exit]\nenabled = true\nallow = [\"200:5678::2\"]\n").unwrap();
let bind = config.exit().unwrap().bind();
assert_eq!(bind, ExitBind::Overlay { port: 8080 });
let overlay: std::net::Ipv6Addr = "200:1111::1".parse().unwrap();
assert_eq!(
bind.resolve(YggAddr::new(overlay)),
SocketAddr::new(IpAddr::V6(overlay), 8080)
);
let config =
parse("[exit]\nenabled = true\nallow = [\"200:5678::2\"]\nport = 9099\n").unwrap();
let bind = config.exit().unwrap().bind();
assert_eq!(
bind.resolve(YggAddr::new(overlay)),
SocketAddr::new(IpAddr::V6(overlay), 9099)
);
}
#[test]
fn invalid_entries_surface_reused_type_errors() {
for invalid in [
"[yggdrasil]\npeers = [\"banana\"]\n",
"[yggdrasil]\nlisten = [\"socks://[::]:1337\"]\n",
"[yggdrasil]\nallow_keys = [\"nothex\"]\n",
"[yggdrasil]\nifname = \"\"\n",
"[node]\nadmin_endpoint = \"banana\"\n",
"[exit]\nallow = [\"banana\"]\n",
"[exit]\nbind = \"banana\"\nenabled = true\n",
"[exit]\nport = 70000\n",
] {
let error = parse(invalid).expect_err("should be invalid").to_string();
assert!(error.contains("invalid daemon config"), "{error}");
}
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Error type for all sn-node operations.
#[derive(Debug, thiserror::Error)]
pub enum NodeError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("yggdrasil binary `{binary}` failed: {stderr}")]
YggdrasilBinary { binary: String, stderr: String },
#[error("invalid uri `{uri}`: {hint}")]
InvalidUri { uri: String, hint: &'static str },
#[error("invalid admin endpoint `{0}`: expected `tcp://host:port` or `unix:///path`")]
InvalidAdminEndpoint(String),
#[error("invalid public key `{0}`: expected 64 hex characters")]
InvalidPublicKey(String),
#[error("generated config base has unexpected shape (expected a JSON object)")]
UnexpectedConfigShape,
#[error("admin protocol violation: {0}")]
AdminProtocol(String),
#[error("admin socket answered with an error: {0}")]
AdminFailed(String),
#[error("admin socket operation timed out")]
Timeout,
#[error("invalid daemon config: {0}")]
DaemonConfig(String),
#[error("overlay did not come up within {timeout_secs}s (admin endpoint {endpoint})")]
OverlayNotUp { endpoint: String, timeout_secs: u64 },
#[error("yggdrasil sidecar exited unexpectedly: {0}")]
SidecarExited(String),
#[error("exit service cannot listen on {addr}: {source}")]
ExitListen {
addr: std::net::SocketAddr,
source: std::io::Error,
},
#[error("exit service error: {0}")]
Exit(#[from] sn_exit::error::ExitError),
#[error("invalid interface name `{0}`: expected `auto`, `none` or an interface name")]
InvalidIfName(String),
}
+33
View File
@@ -0,0 +1,33 @@
//! sn-node — `SpiderNet` node helper for the Yggdrasil overlay.
//!
//! `SpiderNet` is software for neighborhood fiber meshes: households peer
//! their internet uplinks and pool bandwidth. This crate is the node-side
//! glue for the overlay layer:
//!
//! - [`config`]: generate a Yggdrasil configuration file from validated,
//! typed `SpiderNet` settings (peers, listeners, admin socket, allowed
//! neighbor keys).
//! - [`yggdrasil`]: client for the Yggdrasil admin socket (JSON over
//! TCP/unix, default `localhost:9001`) to query node status (`getSelf`,
//! `getPeers`).
//! - [`daemon_config`]: the validated TOML config of the node daemon.
//! - [`daemon`]: the node daemon — supervises the yggdrasil sidecar and
//! runs the optional `sn-exit` exit service in-process.
//!
//! Yggdrasil provides encrypted end-to-end routing between households.
//! It is **not** an anonymity network, and `SpiderNet` never claims to
//! provide anonymity.
//!
//! `SpiderNet` software carries no warranty and accepts no responsibility
//! for its usage or for transported content (AGPL-3.0, sections 15/16).
#![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
// PoC scope: doc-annotation noise, not correctness. Revisit at release.
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
pub mod config;
pub mod daemon;
pub mod daemon_config;
pub mod error;
pub mod yggdrasil;
+270
View File
@@ -0,0 +1,270 @@
//! CLI entry point for sn-node.
//!
//! KEINE GARANTIE — this software is provided "as is" without warranty of
//! any kind (AGPL-3.0, sections 15/16). Users are solely responsible for
//! what they transmit or receive with it.
use std::path::PathBuf;
use std::time::Duration;
use clap::{Parser, Subcommand};
use sn_node::config::{self, ListenUri, MulticastMode, NodeSettings, PeerUri, PublicKeyHex};
use sn_node::daemon::{self, NO_WARRANTY_BANNER};
use sn_node::daemon_config;
use sn_node::yggdrasil::{self, AdminEndpoint};
#[derive(Debug, Parser)]
#[command(
name = "sn-node",
about = "SpiderNet node helper: Yggdrasil config generation + overlay status"
)]
struct Args {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Generate a Yggdrasil config file for this SpiderNet node.
Genconf {
/// Peering URIs to connect to, comma separated (tcp://, tls://,
/// quic://, socks://).
#[arg(long = "peer", value_delimiter = ',')]
peers: Vec<String>,
/// Listener URIs for incoming peerings, comma separated
/// (e.g. `tcp://[::]:1337`).
#[arg(long = "listen", value_delimiter = ',')]
listen: Vec<String>,
/// Admin socket endpoint (tcp://host:port or unix:///path).
#[arg(long, default_value = "tcp://localhost:9001")]
admin: String,
/// Public keys of neighbors allowed to peer with us (64 hex
/// characters, comma separated). Empty: any node may peer.
#[arg(long = "allow-key", value_delimiter = ',')]
allow_key: Vec<String>,
/// Store the node's private key in an external file instead of
/// embedding it in the config.
#[arg(long)]
private_key_path: Option<PathBuf>,
/// TUN interface: `auto`, `none` (headless router-only), or a name.
#[arg(long, default_value = "auto")]
ifname: String,
/// Disable multicast (same-LAN) auto-peering.
#[arg(long)]
no_multicast: bool,
/// Path to the yggdrasil binary.
#[arg(long, default_value = "yggdrasil")]
yggdrasil_bin: String,
/// Output path for the generated config.
#[arg(short, long, default_value = "yggdrasil.conf")]
out: PathBuf,
},
/// Query the local Yggdrasil node via the admin socket.
Status {
/// Admin socket endpoint (tcp://host:port or unix:///path).
#[arg(long, default_value = "tcp://localhost:9001")]
admin: String,
/// Operation timeout in seconds.
#[arg(long, default_value_t = 3)]
timeout_secs: u64,
},
/// Run the node daemon: supervise the Yggdrasil sidecar and the
/// optional in-process exit service. Stops on SIGINT/SIGTERM.
Daemon {
/// Path to the TOML config file.
#[arg(long, default_value = "node.toml")]
config: PathBuf,
/// Validate the config and exit without starting anything.
#[arg(long)]
check: bool,
},
}
fn parse_uris<T>(
inputs: &[String],
what: &str,
parse: fn(&str) -> Result<T, sn_node::error::NodeError>,
) -> Result<Vec<T>, String> {
inputs
.iter()
.map(|input| parse(input).map_err(|err| format!("{what}: {err}")))
.collect()
}
#[tokio::main]
async fn main() {
if let Err(report) = run().await {
eprintln!("NO WARRANTY — SpiderNet sn-node (AGPL-3.0, no liability)");
eprintln!("error: {report}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let args = Args::parse();
match args.command {
Command::Genconf {
peers,
listen,
admin,
allow_key,
private_key_path,
ifname,
no_multicast,
yggdrasil_bin,
out,
} => {
run_genconf(
peers,
listen,
admin,
allow_key,
private_key_path,
ifname,
no_multicast,
yggdrasil_bin,
out,
)
.await
}
Command::Status {
admin,
timeout_secs,
} => run_status(&admin, timeout_secs).await,
Command::Daemon { config, check } => run_daemon_command(&config, check).await,
}
}
#[allow(clippy::too_many_arguments)]
async fn run_genconf(
peers: Vec<String>,
listen: Vec<String>,
admin: String,
allow_key: Vec<String>,
private_key_path: Option<PathBuf>,
ifname: String,
no_multicast: bool,
yggdrasil_bin: String,
out: PathBuf,
) -> Result<(), String> {
let peers = parse_uris(&peers, "peer", PeerUri::parse)?;
let listen = parse_uris(&listen, "listen", ListenUri::parse)?;
let admin_listen = AdminEndpoint::parse(&admin).map_err(|err| err.to_string())?;
let allowed_public_keys = allow_key
.iter()
.map(|key| PublicKeyHex::parse(key).map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, String>>()?;
let if_name = config::parse_ifname(&ifname).map_err(|err| err.to_string())?;
let settings = NodeSettings {
peers,
listen,
multicast: Some(if no_multicast {
MulticastMode::Disabled
} else {
MulticastMode::PlatformDefault
}),
admin_listen: Some(admin_listen),
if_name: Some(if_name),
allowed_public_keys,
private_key_path,
};
let json = config::generate_config(&yggdrasil_bin, &settings).map_err(|err| err.to_string())?;
config::write_config(&out, &json).map_err(|err| err.to_string())?;
println!("NO WARRANTY — SpiderNet sn-node (AGPL-3.0, no liability)");
println!();
println!("config written: {}", out.display());
println!(
"mode: start with `yggdrasil -useconffile {}`",
out.display()
);
println!();
println!(
"NOTE: the config contains this node's PRIVATE key. Keep it at \
permission 600, never commit it, never share it."
);
Ok(())
}
async fn run_status(admin: &str, timeout_secs: u64) -> Result<(), String> {
let endpoint = AdminEndpoint::parse(admin).map_err(|err| err.to_string())?;
let timeout = Duration::from_secs(timeout_secs);
let info = yggdrasil::AdminClient::get_self(&endpoint, timeout)
.await
.map_err(|err| err.to_string())?;
println!("NO WARRANTY — SpiderNet sn-node (AGPL-3.0, no liability)");
println!();
println!("address: {}", info.address);
if let Some(subnet) = info.subnet.as_ref() {
println!("subnet: {subnet}");
}
if let Some(version) = info.build_version.as_ref() {
let name = info.build_name.as_deref().unwrap_or("yggdrasil");
println!("version: {name} {version}");
}
println!("key: {}", info.key);
let peers = yggdrasil::AdminClient::get_peers(&endpoint, timeout)
.await
.map_err(|err| err.to_string())?;
println!("peers: {}", peers.len());
for peer in &peers {
match peer.remote.as_ref() {
Some(remote) => println!(" {} via {remote}", peer.address),
None => println!(" {}", peer.address),
}
}
Ok(())
}
async fn run_daemon_command(config_path: &std::path::Path, check: bool) -> Result<(), String> {
println!("{NO_WARRANTY_BANNER}");
let validated = daemon_config::load(config_path).map_err(|err| err.to_string())?;
if check {
println!("config ok");
return Ok(());
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
daemon::run_daemon(validated, shutdown_signal())
.await
.map_err(|err| err.to_string())
}
/// Future that resolves when SIGINT or SIGTERM (unix) arrives.
async fn shutdown_signal() {
#[cfg(unix)]
{
if let Ok(mut sigterm) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
return;
}
}
tokio::signal::ctrl_c().await.ok();
}
+268
View File
@@ -0,0 +1,268 @@
//! Client for the Yggdrasil admin socket.
//!
//! Protocol (per the official docs): JSON stanza + `\n` over a TCP or unix
//! socket; the default endpoint is `localhost:9001`. A response always has
//! a `"status"` field (`"success"` or `"error"`), optionally a `"response"`
//! section and an `"error"` field. By default Yggdrasil closes the
//! connection after answering, so this client sends one request per
//! connection.
use std::net::Ipv6Addr;
use std::path::PathBuf;
use std::time::Duration;
use crate::error::NodeError;
/// Where the admin socket lives, in Yggdrasil endpoint syntax
/// (`tcp://host:port` or `unix:///path/to/socket`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminEndpoint {
Tcp { host: String, port: u16 },
Unix { path: PathBuf },
}
impl AdminEndpoint {
/// Yggdrasil's default admin endpoint: `tcp://localhost:9001`.
pub fn default_local() -> Self {
Self::Tcp {
host: "localhost".to_string(),
port: 9001,
}
}
pub fn parse(input: &str) -> Result<Self, NodeError> {
let trimmed = input.trim();
if let Some(rest) = trimmed.strip_prefix("tcp://") {
let (host, port) = rest
.rsplit_once(':')
.ok_or_else(|| NodeError::InvalidAdminEndpoint(trimmed.to_string()))?;
let port: u16 = port
.parse()
.map_err(|_| NodeError::InvalidAdminEndpoint(trimmed.to_string()))?;
if host.is_empty() {
return Err(NodeError::InvalidAdminEndpoint(trimmed.to_string()));
}
Ok(Self::Tcp {
host: host.to_string(),
port,
})
} else if let Some(rest) = trimmed.strip_prefix("unix://") {
if rest.is_empty() {
return Err(NodeError::InvalidAdminEndpoint(trimmed.to_string()));
}
Ok(Self::Unix {
path: PathBuf::from(rest),
})
} else {
Err(NodeError::InvalidAdminEndpoint(trimmed.to_string()))
}
}
/// Value for the `AdminListen` field of a Yggdrasil config file.
pub fn config_value(&self) -> String {
match self {
Self::Tcp { host, port } => format!("tcp://{host}:{port}"),
Self::Unix { path } => format!("unix://{}", path.display()),
}
}
}
/// The node's own Yggdrasil address — a validated IPv6 address inside the
/// Yggdrasil range.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct YggAddr(Ipv6Addr);
impl YggAddr {
pub const fn new(addr: Ipv6Addr) -> Self {
Self(addr)
}
pub const fn inner(self) -> Ipv6Addr {
self.0
}
}
impl std::fmt::Display for YggAddr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// One connection to the admin socket; one request per connection.
pub struct AdminClient {
stream: AdminStream,
}
enum AdminStream {
Tcp(tokio::net::TcpStream),
Unix(tokio::net::UnixStream),
}
async fn connect_stream(endpoint: &AdminEndpoint) -> Result<AdminStream, NodeError> {
match endpoint {
AdminEndpoint::Tcp { host, port } => tokio::net::TcpStream::connect((host.as_str(), *port))
.await
.map(AdminStream::Tcp)
.map_err(NodeError::Io),
AdminEndpoint::Unix { path } => tokio::net::UnixStream::connect(path)
.await
.map(AdminStream::Unix)
.map_err(NodeError::Io),
}
}
impl AdminClient {
pub async fn connect(endpoint: &AdminEndpoint, timeout: Duration) -> Result<Self, NodeError> {
let stream = tokio::time::timeout(timeout, connect_stream(endpoint))
.await
.map_err(|_| NodeError::Timeout)??;
Ok(Self { stream })
}
/// Send one request verb (e.g. `getself`, `getpeers`) and return the
/// raw response section.
pub async fn request(&mut self, verb: &str) -> Result<serde_json::Value, NodeError> {
// `verb` is a compile-time-known constant at every call site, not
// user input — no injection risk.
let request = format!("{{\"request\":\"{verb}\"}}\n");
let bytes = request.as_bytes();
match &mut self.stream {
AdminStream::Tcp(stream) => {
tokio::io::AsyncWriteExt::write_all(stream, bytes).await?;
let mut buffer = Vec::new();
tokio::io::AsyncReadExt::read_to_end(stream, &mut buffer).await?;
Self::parse_response(&buffer, verb)
}
AdminStream::Unix(stream) => {
tokio::io::AsyncWriteExt::write_all(stream, bytes).await?;
let mut buffer = Vec::new();
tokio::io::AsyncReadExt::read_to_end(stream, &mut buffer).await?;
Self::parse_response(&buffer, verb)
}
}
}
/// Query the node's own status (address, subnet, key, build info).
pub async fn get_self(
endpoint: &AdminEndpoint,
timeout: Duration,
) -> Result<SelfInfo, NodeError> {
let mut client = Self::connect(endpoint, timeout).await?;
let value = client.request("getself").await?;
SelfInfo::from_response(&value)
}
/// Query the node's active peer sessions.
pub async fn get_peers(
endpoint: &AdminEndpoint,
timeout: Duration,
) -> Result<Vec<PeerInfo>, NodeError> {
let mut client = Self::connect(endpoint, timeout).await?;
let value = client.request("getpeers").await?;
peers_from_response(&value)
}
fn parse_response(buffer: &[u8], verb: &str) -> Result<serde_json::Value, NodeError> {
let envelope: serde_json::Value = serde_json::from_slice(buffer)?;
let status = envelope
.get("status")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
NodeError::AdminProtocol(format!("{verb}: response has no `status` field"))
})?;
if status != "success" {
let error = envelope
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown error");
return Err(NodeError::AdminFailed(error.to_string()));
}
envelope
.get("response")
.cloned()
.ok_or_else(|| NodeError::AdminProtocol(format!("{verb}: response has no data")))
}
}
/// Information about the local Yggdrasil node (`getSelf`).
#[derive(Debug, Clone)]
pub struct SelfInfo {
pub address: YggAddr,
pub key: String,
pub subnet: Option<String>,
pub coords: Option<serde_json::Value>,
pub build_name: Option<String>,
pub build_version: Option<String>,
}
impl SelfInfo {
/// Parse the `getSelf` response section: exactly one record, keyed by
/// the node's IPv6 address.
pub fn from_response(value: &serde_json::Value) -> Result<Self, NodeError> {
let obj = value.as_object().ok_or_else(|| {
NodeError::AdminProtocol("getSelf: response section is not an object".to_string())
})?;
let (address, record) = obj.iter().next().ok_or_else(|| {
NodeError::AdminProtocol("getSelf: response contains no record".to_string())
})?;
let address = YggAddr::new(address.parse::<Ipv6Addr>().map_err(|_| {
NodeError::AdminProtocol(format!("getSelf: `{address}` is not an IPv6 address"))
})?);
let str_field = |name: &str| {
record
.get(name)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
};
Ok(Self {
address,
key: str_field("key").ok_or_else(|| {
NodeError::AdminProtocol("getSelf: record has no `key`".to_string())
})?,
subnet: str_field("subnet"),
coords: record.get("coords").cloned(),
build_name: str_field("build_name"),
build_version: str_field("build_version"),
})
}
}
/// One active peer session (`getPeers`).
#[derive(Debug, Clone)]
pub struct PeerInfo {
pub address: YggAddr,
pub key: Option<String>,
/// The peering URI of the remote peer, when reported.
pub remote: Option<String>,
pub coords: Option<serde_json::Value>,
}
/// Parse the `getPeers` response section: records keyed by IPv6 address.
/// The first record typically refers to the current node itself.
pub fn peers_from_response(value: &serde_json::Value) -> Result<Vec<PeerInfo>, NodeError> {
let obj = value.as_object().ok_or_else(|| {
NodeError::AdminProtocol("getPeers: response section is not an object".to_string())
})?;
let mut peers = Vec::with_capacity(obj.len());
for (address, record) in obj {
let address = YggAddr::new(address.parse::<Ipv6Addr>().map_err(|_| {
NodeError::AdminProtocol(format!("getPeers: `{address}` is not an IPv6 address"))
})?);
peers.push(PeerInfo {
address,
key: record
.get("key")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
remote: record
.get("remote")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
coords: record.get("coords").cloned(),
});
}
Ok(peers)
}
+126
View File
@@ -0,0 +1,126 @@
//! Integration tests for the Yggdrasil admin socket client, against a
//! fake admin server speaking the documented protocol.
#![forbid(unsafe_code)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use sn_node::error::NodeError;
use sn_node::yggdrasil::{peers_from_response, AdminClient, AdminEndpoint, SelfInfo};
const SELF_RESPONSE: &str = concat!(
r#"{"request": {"request": "getself"}, "status": "success", "response": {"#,
r#""200:1111:2222:3333:4444:5555:6666:7777": {"#,
r#""address": "200:1111:2222:3333:4444:5555:6666:7777","#,
r#""key": "0000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111","#,
r#""build_name": "yggdrasil", "build_version": "0.5.12","#,
r#""subnet": "300:1111:2222:3333::/64", "coords": [1, 2, 3]}}}"#
);
const PEERS_RESPONSE: &str = concat!(
r#"{"request": {"request": "getpeers"}, "status": "success", "response": {"#,
r#""200:1111:2222:3333:4444:5555:6666:7777": {"coords": [1]}, "#,
r#""200:9999:2222:3333:4444:5555:6666:7777": {"#,
r#""coords": [1, 2], "port": 1, "key": "ffff...", "#,
r#""remote": "tcp://[fe80::1%25eth0]:1337"}}}"#
);
const ERROR_RESPONSE: &str =
r#"{"request": {"request": "getself"}, "status": "error", "error": "boom"}"#;
/// Fake admin server: answers each connection's request line, then closes.
async fn fake_admin_server(response: &'static str) -> std::net::SocketAddr {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let mut line = Vec::new();
let mut byte = [0u8; 1];
while !line.ends_with(b"\n") {
if socket.read(&mut byte).await.unwrap_or(0) == 0 {
break;
}
line.push(byte[0]);
}
socket.write_all(response.as_bytes()).await.ok();
socket.shutdown().await.ok();
}
});
addr
}
fn tcp_endpoint(addr: std::net::SocketAddr) -> AdminEndpoint {
AdminEndpoint::Tcp {
host: addr.ip().to_string(),
port: addr.port(),
}
}
#[tokio::test]
async fn get_self_parses_address_subnet_and_version() {
let endpoint = tcp_endpoint(fake_admin_server(SELF_RESPONSE).await);
let info = SelfInfo::from_response(
&AdminClient::connect(&endpoint, Duration::from_secs(2))
.await
.unwrap()
.request("getself")
.await
.unwrap(),
)
.unwrap();
assert_eq!(
info.address.inner().to_string(),
"200:1111:2222:3333:4444:5555:6666:7777"
);
assert_eq!(info.subnet.as_deref(), Some("300:1111:2222:3333::/64"));
assert_eq!(info.build_version.as_deref(), Some("0.5.12"));
}
#[tokio::test]
async fn get_peers_lists_remote_uris() {
let endpoint = tcp_endpoint(fake_admin_server(PEERS_RESPONSE).await);
let value = AdminClient::connect(&endpoint, Duration::from_secs(2))
.await
.unwrap()
.request("getpeers")
.await
.unwrap();
let peers = peers_from_response(&value).unwrap();
assert_eq!(peers.len(), 2);
assert_eq!(
peers[1].remote.as_deref(),
Some("tcp://[fe80::1%25eth0]:1337")
);
}
#[tokio::test]
async fn admin_error_is_surfaced() {
let endpoint = tcp_endpoint(fake_admin_server(ERROR_RESPONSE).await);
let mut client = AdminClient::connect(&endpoint, Duration::from_secs(2))
.await
.unwrap();
let err = client.request("getself").await.unwrap_err();
assert!(matches!(err, NodeError::AdminFailed(ref text) if text == "boom"));
}
#[tokio::test]
async fn connection_refused_becomes_io_or_timeout_error() {
// Port 1 on localhost: virtually guaranteed to be closed.
let endpoint = AdminEndpoint::Tcp {
host: "127.0.0.1".into(),
port: 1,
};
let result = AdminClient::get_self(&endpoint, Duration::from_millis(500)).await;
assert!(matches!(
result,
Err(NodeError::Io(_)) | Err(NodeError::Timeout)
));
}
+331
View File
@@ -0,0 +1,331 @@
//! Integration tests for the node daemon, against a *fake* yggdrasil
//! (a shell script; real yggdrasil is not assumed to be installed) and
//! a fake admin socket server speaking the documented protocol.
//!
//! Unix-only: the fake yggdrasil is a shell script and the sidecar
//! handling relies on POSIX process semantics.
#![cfg(unix)]
#![forbid(unsafe_code)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::net::SocketAddr;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use sn_node::daemon_config::{self, ExitBind, ValidatedDaemonConfig};
use sn_node::error::NodeError;
use tokio::net::{TcpListener, TcpStream};
const FAKE_PRIVATE_KEY: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
const SELF_RESPONSE: &str = concat!(
r#"{"request": {"request": "getself"}, "status": "success", "response": {"#,
r#""200:1111:2222:3333:4444:5555:6666:7777": {"#,
r#""address": "200:1111:2222:3333:4444:5555:6666:7777","#,
r#""key": "0000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111","#,
r#""build_name": "yggdrasil", "build_version": "0.5.12","#,
r#""subnet": "300:1111:2222:3333::/64", "coords": [1, 2, 3]}}}"#
);
const PEERS_RESPONSE: &str = concat!(
r#"{"request": {"request": "getpeers"}, "status": "success", "response": {"#,
r#""200:1111:2222:3333:4444:5555:6666:7777": {"coords": [1]}, "#,
r#""200:9999:2222:3333:4444:5555:6666:7777": {"#,
r#""coords": [1, 2], "port": 1, "key": "ffff...", "#,
r#""remote": "tcp://[fe80::1%25eth0]:1337"}}}"#
);
/// What the fake yggdrasil does once started with `-useconffile`.
#[derive(Clone, Copy)]
enum SidecarBehavior {
/// Keep running until killed (test shutdown paths).
RunForever,
/// Exit shortly after start (test the supervisor death path).
DieSoon,
}
/// Fake admin server: answers `getself`/`getpeers` request lines, then
/// closes the connection (mirrors real yggdrasil's one-request behavior).
async fn fake_admin_server() -> SocketAddr {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let mut line = Vec::new();
let mut byte = [0u8; 1];
while !line.ends_with(b"\n") {
if socket.read(&mut byte).await.unwrap_or(0) == 0 {
break;
}
line.push(byte[0]);
}
let request = String::from_utf8_lossy(&line);
let response = if request.contains("getpeers") {
PEERS_RESPONSE
} else {
SELF_RESPONSE
};
socket.write_all(response.as_bytes()).await.ok();
socket.shutdown().await.ok();
}
});
addr
}
/// Write a shell script that impersonates the yggdrasil binary: it
/// answers `-genconf -json` with a fixed minimal config and busy-waits
/// (or dies) on `-useconffile`. The script path is returned.
fn write_fake_yggdrasil(dir: &Path, behavior: SidecarBehavior) -> PathBuf {
let pidfile = dir.join("fake-yggdrasil.pid");
let wait = match behavior {
SidecarBehavior::RunForever => "exec sleep 600",
SidecarBehavior::DieSoon => "exec sleep 0.3",
};
let script = format!(
r#"#!/bin/sh
# fake yggdrasil for the SpiderNet daemon tests
if [ "$1" = "-genconf" ]; then
printf '%s' '{{"PrivateKey": "{FAKE_PRIVATE_KEY}", "Peers": [], "Listen": []}}'
exit 0
fi
if [ "$1" = "-useconffile" ]; then
echo $$ > '{pidfile}'
{wait}
fi
exit 1
"#,
pidfile = pidfile.display(),
);
let path = dir.join("fake-yggdrasil");
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
/// Build a validated daemon config pointing at the fake infrastructure.
fn test_config(
admin: SocketAddr,
binary: &Path,
state_dir: &Path,
exit_toml: &str,
) -> ValidatedDaemonConfig {
let text = format!(
"[node]\nadmin_endpoint = \"tcp://{}:{}\"\nstate_dir = \"{}\"\n\
[yggdrasil]\nbinary = \"{}\"\n\
[exit]\n{exit_toml}\n",
admin.ip(),
admin.port(),
state_dir.display(),
binary.display(),
);
daemon_config::parse(&text).unwrap()
}
/// Poll `check` until it returns true or a deadline passes.
async fn wait_until(check: impl Fn() -> bool) -> bool {
for _ in 0..200 {
if check() {
return true;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
/// Poll a TCP connect until the daemon's exit listener is up.
async fn exit_is_accepting(addr: SocketAddr) -> bool {
wait_until_async(|| async { TcpStream::connect(addr).await.is_ok() }).await
}
async fn wait_until_async<F>(check: impl Fn() -> F) -> bool
where
F: std::future::Future<Output = bool>,
{
for _ in 0..200 {
if check().await {
return true;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
/// A shutdown future driven by a shared flag (avoids a tokio `sync`
/// dependency in tests; the 10 ms polling is plenty for tests).
async fn flag_shutdown(flag: Arc<AtomicBool>) {
loop {
if flag.load(Ordering::Relaxed) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
/// A shutdown flag plus a setter closure that flips it.
fn shutdown_flag() -> (Arc<AtomicBool>, impl Fn() + Send + Clone + 'static) {
let flag = Arc::new(AtomicBool::new(false));
let setter = {
let flag = Arc::clone(&flag);
move || flag.store(true, Ordering::Relaxed)
};
(flag, setter)
}
#[tokio::test]
async fn node_key_is_generated_once_and_reused() {
let tmp = tempfile::tempdir().unwrap();
let admin = fake_admin_server().await;
let binary = write_fake_yggdrasil(tmp.path(), SidecarBehavior::RunForever);
let state_dir = tmp.path().join("state");
let config = test_config(admin, &binary, &state_dir, "enabled = false");
// First run: the daemon generates the node key, the yggdrasil config
// and spawns the fake sidecar, then waits for shutdown.
let (flag, shutdown) = shutdown_flag();
let handle = tokio::spawn(sn_node::daemon::run_daemon(
config.clone(),
flag_shutdown(flag),
));
let key_path = state_dir.join("node.key");
assert!(
wait_for_file(&key_path).await,
"node key was not generated in time"
);
let first_key = std::fs::read_to_string(&key_path).unwrap();
assert_eq!(first_key.trim(), FAKE_PRIVATE_KEY);
assert_eq!(
std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777,
0o600,
"node key must be owner-only"
);
let conf = std::fs::read_to_string(state_dir.join("yggdrasil.conf")).unwrap();
assert!(
conf.contains("\"PrivateKeyPath\"") && conf.contains(&key_path.display().to_string()),
"generated config must reference the node key file:\n{conf}"
);
assert!(
conf.contains(&format!(
"\"AdminListen\": \"tcp://{}:{}\"",
admin.ip(),
admin.port()
)),
"generated config must use the configured admin endpoint:\n{conf}"
);
shutdown();
let result = tokio::time::timeout(Duration::from_secs(30), handle)
.await
.unwrap()
.unwrap();
assert!(result.is_ok(), "first run failed: {result:?}");
// Second run: the key must be reused, not regenerated.
let (flag, shutdown) = shutdown_flag();
let handle = tokio::spawn(sn_node::daemon::run_daemon(config, flag_shutdown(flag)));
shutdown();
let result = tokio::time::timeout(Duration::from_secs(30), handle)
.await
.unwrap()
.unwrap();
assert!(result.is_ok(), "second run failed: {result:?}");
let second_key = std::fs::read_to_string(&key_path).unwrap();
assert_eq!(second_key, first_key, "node identity must survive restarts");
}
async fn wait_for_file(path: &Path) -> bool {
wait_until(|| path.exists()).await
}
#[tokio::test]
async fn exit_service_listens_and_accepts_connections() {
let tmp = tempfile::tempdir().unwrap();
let admin = fake_admin_server().await;
let binary = write_fake_yggdrasil(tmp.path(), SidecarBehavior::RunForever);
let state_dir = tmp.path().join("state");
// Grab a free port, then release it for the daemon to bind.
let scratch = TcpListener::bind("127.0.0.1:0").await.unwrap();
let exit_addr = scratch.local_addr().unwrap();
drop(scratch);
let config = test_config(
admin,
&binary,
&state_dir,
&format!("enabled = true\nbind = \"127.0.0.1:{}\"", exit_addr.port()),
);
let (flag, shutdown) = shutdown_flag();
let handle = tokio::spawn(sn_node::daemon::run_daemon(config, flag_shutdown(flag)));
assert!(
exit_is_accepting(exit_addr).await,
"the exit service did not start listening in time"
);
shutdown();
let result = tokio::time::timeout(Duration::from_secs(30), handle)
.await
.unwrap()
.unwrap();
assert!(result.is_ok(), "daemon run failed: {result:?}");
}
#[tokio::test]
async fn immediate_shutdown_ends_run_daemon_cleanly() {
// No sidecar (manage = false): an already-running yggdrasil is
// assumed; the daemon must still exit cleanly on shutdown.
let config = daemon_config::parse(
"[yggdrasil]\nmanage = false\n[exit]\nenabled = true\nbind = \"127.0.0.1:0\"\n",
)
.unwrap();
assert!(matches!(
config.exit().unwrap().bind(),
ExitBind::Explicit(_)
));
let result = tokio::time::timeout(
Duration::from_secs(30),
sn_node::daemon::run_daemon(config, std::future::ready(())),
)
.await
.unwrap();
assert!(result.is_ok(), "daemon should end cleanly: {result:?}");
}
#[tokio::test]
async fn sidecar_death_fails_the_daemon() {
let tmp = tempfile::tempdir().unwrap();
// Admin endpoint points nowhere: only the supervisor's child-death
// detection can end the run.
let config = daemon_config::parse(&format!(
"[node]\nadmin_endpoint = \"tcp://127.0.0.1:1\"\nstate_dir = \"{}\"\n\
[yggdrasil]\nbinary = \"{}\"\n[exit]\nenabled = false\n",
tmp.path().join("state").display(),
write_fake_yggdrasil(tmp.path(), SidecarBehavior::DieSoon).display(),
))
.unwrap();
let result = tokio::time::timeout(
Duration::from_secs(30),
sn_node::daemon::run_daemon(config, std::future::pending()),
)
.await
.unwrap();
assert!(
matches!(result, Err(NodeError::SidecarExited(_))),
"expected SidecarExited error, got {result:?}"
);
}
+67
View File
@@ -0,0 +1,67 @@
# syntax=docker/dockerfile:1
#
# SpiderNet node container image (multi-stage).
#
# NO WARRANTY — SpiderNet is provided "as is", without warranty of any kind
# (AGPL-3.0, sections 15/16). Running a node is entirely at the operator's
# own responsibility, including all traffic sent and received with it.
#
# Build context is the `server/` directory (see docker-compose.yml):
# docker build -t spidernet-node -f docker/Dockerfile ..
# --- Stage 1: build the Rust workspace --------------------------------------
FROM rust:1-slim AS build
WORKDIR /server
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
# --locked: the build must match the committed Cargo.lock exactly.
RUN cargo build --release --locked --workspace
# --- Stage 2: runtime --------------------------------------------------------
# trixie, not bookworm: rust:1-slim is trixie-based, and binaries built
# against its glibc (2.41) need a matching runtime (bookworm only has 2.36).
FROM debian:trixie-slim
# Yggdrasil sidecar: current stable release of yggdrasil-go. Release v0.5.14
# ships official .deb packages (no raw tarballs). Binary inside: /usr/bin/yggdrasil.
# Digests from https://api.github.com/repos/yggdrasil-network/yggdrasil-go/releases/latest
ARG YGGDRASIL_VERSION=0.5.14
ARG TARGETARCH
# ca-certificates: TLS for sn-exit/sn-fetch (rustls) and tls:// peers.
# curl is only needed to fetch the yggdrasil .deb and is removed again.
# TARGETARCH is only set by BuildKit; fall back to the host arch otherwise
# (both use the same amd64/arm64 naming as the yggdrasil .deb assets).
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \
&& case "$ARCH" in \
amd64) YGGDRASIL_SHA256=72745496b09baa1c3f5a6741e1257a704cfdb9d00cdf3ddbef025987b239699d ;; \
arm64) YGGDRASIL_SHA256=ac8c9a9a76c54b878fd251aef5a2bbd20375c6109b28b32435683441462360c9 ;; \
*) echo "unsupported architecture: $ARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /tmp/yggdrasil.deb \
"https://github.com/yggdrasil-network/yggdrasil-go/releases/download/v${YGGDRASIL_VERSION}/yggdrasil-${YGGDRASIL_VERSION}-${ARCH}.deb" \
&& echo "${YGGDRASIL_SHA256} /tmp/yggdrasil.deb" | sha256sum -c - \
&& dpkg-deb -x /tmp/yggdrasil.deb /tmp/yggdrasil-root \
&& cp /tmp/yggdrasil-root/usr/bin/yggdrasil /usr/local/bin/yggdrasil \
&& rm -rf /tmp/yggdrasil.deb /tmp/yggdrasil-root \
&& apt-get purge -y curl \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /server/target/release/sn-node /usr/local/bin/sn-node
COPY --from=build /server/target/release/sn-exit /usr/local/bin/sn-exit
COPY --from=build /server/target/release/sn-fetch /usr/local/bin/sn-fetch
COPY --from=build /server/target/release/sn-fair /usr/local/bin/sn-fair
# WORKDIR matches the state volume in docker-compose.yml; a config that
# leaves `state_dir` at its default then resolves to the volume as well.
WORKDIR /var/lib/spidernet
ENTRYPOINT ["sn-node", "daemon", "--config", "/etc/spidernet/node.toml"]
+125
View File
@@ -0,0 +1,125 @@
# SpiderNet-Node mit Docker
Dieser Ordner enthält alles, um den SpiderNet-Node eines Haushalts als
Container zu betreiben: Multi-Stage-Dockerfile, ein Compose-Beispiel für
einen Host und eine kommentierte Beispiel-Konfiguration.
## Haftungsausschluss
> **KEINE GARANTIE.** Diese Software wird "as is" ohne jegliche Garantie
> bereitgestellt (AGPL §§ 15/16). Die Betreiber und Contributor:innen dieses
> Projekts übernehmen **keinerlei Verantwortung oder Haftung** dafür, wozu
> Nutzer:innen die Software verwenden oder welche Inhalte über sie
> übertragen werden. Die Verantwortung für gesendete und empfangene Inhalte
> liegt vollständig bei den Nutzer:innen.
## Quickstart
```sh
# 1. Konfiguration anlegen und anpassen
cp node.toml.example node.toml
$EDITOR node.toml
# 2. Image bauen und starten (Build-Kontext ist server/, daher "context: ..")
docker compose up --build -d
# 3. Beobachten
docker compose logs -f
# 4. Overlay-Status ansehen (Admin-Socket lauscht auf localhost:9001)
docker compose exec node sn-node status
# 5. Einen großen Download über die gepoolte Bandbreite laden
docker compose exec node sn-fetch "https://…/große-datei.iso" \
-o /tmp/out.iso -e "1=2,2=1@http://[<ygg-adresse-des-nachbarn>]:8080"
```
Image direkt bauen (ohne Compose), Build-Kontext `server/`:
```sh
docker build -t spidernet-node -f docker/Dockerfile ..
```
## Was die Container-Privilegien bedeuten
| Einstellung | Warum |
|---|---|
| `network_mode: host` | Yggdrasil muss auf dem realen LAN peeren und auf der Overlay-Seite erreichbar sein. Mit Bridge-NAT würden Peer-Verbindungen und der Exit-Port nicht stimmen. |
| `cap_add: [NET_ADMIN]` | Yggdrasil legt das TUN-Interface (`ifname`) an und konfiguriert es — das braucht `CAP_NET_ADMIN`. |
| `/dev/net/tun` | Das Kernel-TUN-Gerät, das Yggdrasil öffnet. |
| `init: true` | Sauberer PID 1: Reaping + SIGTERM erreicht den Daemon, damit `docker compose stop` als sauberer Kill-Switch funktioniert. |
Der Container läuft als `root` — für TUN im Container üblich. `NET_ADMIN`
ist bewusst nur als Capability gesetzt, ohne `privileged`.
## Zwei Haushalte verbinden
Beide Hosts laufen mit demselben Image; die Rolle (Exit oder Leech) steht
nur in der jeweiligen `node.toml`.
- **Die Mesh-Verbindung liegt außerhalb des Containers:** Glasfaser,
Switch oder WiFi zwischen den Haushalten. Der Container peeret über das
Host-Netzwerk.
- **Auto-Peering:** Im selben LAN (Multicast aktiv) finden sich
Yggdrasil-Nodes selbst — `peers = []` reicht.
- **Explizit:** Falls Multicast nicht geht (z. B. VLANs), trägt man in
`node.toml` bei `peers` URIs wie `tcp://<nachbar-lan-ip>:1337` ein.
- **ACL:** Die 64-Hex-Public-Keys der erlaubten Nachbarn gehören in
`allow_keys`, sonst kann jeder Node im Overlay peeren.
Die `node.toml` jedes Haushalts enthält persönliche Daten der Nachbarschaft
(Public Keys, Adressen) und später den Node-Private-Key im State-Verzeichnis:
**niemals committen**, außerhalb des Repos aufbewahren.
## Leech-Modus (Empfang ohne Exit)
Die Beispiel-Konfiguration startet sicher im **Leech-Modus** — ein
Haushalt, der nur empfangen will, muss nichts ändern:
```toml
[exit]
enabled = false
```
Das ist ausdrücklich legitim — Leeches werden bei Sättigung zuerst
gedrosselt, aber nie ausgeschlossen. Es gibt kein separates Leech-Image;
das Compose-Beispiel funktioniert unverändert.
Wer seinen Uplink als Exit anbieten will (opt-in), setzt:
```toml
[exit]
enabled = true
allow = ["<overlay-adresse-des-nachbarn>"]
```
`enabled = true` mit leerer `allow`-Liste ist ungültig — der Daemon
verweigert dann den Start (Ausnahme: Dev-Modus mit Loopback-bind).
## Kill-Switch
```sh
docker compose stop
```
Stoppen des Exits bzw. des Daemons ist der Kill-Switch: Sobald der Daemon
nicht läuft, stellt der Haushalt keinen Uplink mehr bereit. Der eigene Node
fällt auf den eigenen Internetanschluss zurück. Wichtig zum Verständnis:
SpiderNet v0 leitet **normalen Verkehr nicht um** — kein Routing, kein
NAT-Trick. Nur Downloads, die `sn-fetch` explizit über das Netz verteilt,
nutzen die gepoolte Bandbreite; alles andere geht wie gewohnt über den
eigenen Anschluss.
## Zustand
`node.key` und die generierte `yggdrasil.conf` liegen im State-Verzeichnis
(`/var/lib/spidernet`, Named Volume `spidernet-state` im Compose-Beispiel).
Der Node behält damit seine Identität über Container-Neustarts. Volume
löschen = neue Node-Identität.
## Noch offen / Hinweise
- Der `daemon`-Subcommand ist implementiert und die Beispiel-Konfiguration
wurde damit verifiziert (`sn-node daemon --check` → `config ok`).
- Images werden lokal gebaut, nicht in ein Registry gepusht (das Projekt
kennt keine zentrale Infrastruktur).
+74
View File
@@ -0,0 +1,74 @@
# SpiderNet node — single-household example (one node per host).
#
# NO WARRANTY — SpiderNet is provided "as is" without warranty of any kind
# (AGPL-3.0, sections 15/16). Running a node is entirely at the operator's
# own responsibility.
#
# The same image serves both roles:
# - exit household: [exit] enabled = true in node.toml (plus neighbor
# addresses in [exit].allow — enabled with an empty allowlist is
# rejected by the daemon)
# - leech household: [exit] enabled = false in node.toml (the example
# config default — safe, receive-only)
# There is no separate leech image; only the config differs.
#
# Quickstart:
# cp node.toml.example node.toml # then edit node.toml
# docker compose up --build -d
services:
node:
build:
# Build context is server/, matching the Dockerfile paths.
context: ..
dockerfile: docker/Dockerfile
image: spidernet-node
container_name: spidernet-node
# Proper PID 1: reaps zombies and makes sure SIGTERM reaches sn-node
# so `docker compose stop` behaves as the documented kill switch.
init: true
# Yggdrasil must bind its TUN interface and peer on the real LAN;
# both require the host network stack (no NAT translation).
network_mode: host
cap_add:
# Needed by yggdrasil to create/configure the TUN interface.
- NET_ADMIN
devices:
# The kernel TUN device used for the overlay interface.
- /dev/net/tun:/dev/net/tun
restart: unless-stopped
volumes:
# Config is read-only; state (node.key, yggdrasil.conf) goes to a
# named volume so the node identity survives container upgrades.
- ./node.toml:/etc/spidernet/node.toml:ro
- spidernet-state:/var/lib/spidernet
# ---------------------------------------------------------------------------
# EXIT VARIANT (offering the household's uplink as exit):
#
# The example config defaults to leech mode ([exit] enabled = false). To
# become an exit household set enabled = true in node.toml and add the
# neighbor overlay addresses to [exit].allow. This compose file works
# unchanged for both roles (kill switch: `docker compose stop`).
#
# If you prefer two service definitions (e.g. to toggle via profiles), note
# that `network_mode: host` allows only ONE node per host — stop the other
# service first:
#
# node-leech:
# build:
# context: ..
# dockerfile: docker/Dockerfile
# image: spidernet-node
# container_name: spidernet-node-leech
# init: true
# network_mode: host
# cap_add: [NET_ADMIN]
# devices: ["/dev/net/tun:/dev/net/tun"]
# restart: unless-stopped
# volumes:
# - ./node-leech.toml:/etc/spidernet/node.toml:ro
# - spidernet-state-leech:/var/lib/spidernet
volumes:
spidernet-state:
+95
View File
@@ -0,0 +1,95 @@
# Beispielkonfiguration für den SpiderNet-Node-Daemon (`sn-node daemon`).
#
# Vorlage: diese Datei nach `node.toml` kopieren und anpassen. Die
# Compose-Datei in diesem Ordner bindet `./node.toml` ein.
#
# KEINE GARANTIE — SpiderNet wird "as is" ohne jegliche Garantie
# bereitgestellt (AGPL §§ 15/16). Nutzung auf eigene Verantwortung;
# die Verantwortung für gesendete und empfangene Inhalte liegt bei den
# Nutzer:innen.
#
# WICHTIG: Die echte node.toml enthält persönliche Daten der Nachbarschaft
# (Public Keys, Adressen) und später den Node-Private-Key im state_dir.
# node.toml NIEMALS ins Repository committen.
[node]
# Admin-Socket des Yggdrasil-Knotens (tcp://host:port oder unix:///pfad).
# Wird auch als Yggdrasil-AdminListen verwendet, wenn der Daemon das
# Yggdrasil-Sidecar selbst verwaltet (siehe [yggdrasil].manage).
admin_endpoint = "tcp://localhost:9001"
# Verzeichnis für den Zustand des Nodes (node.key, generierte
# yggdrasil.conf). Optional, Standard "spidernet-state" — relativ zum
# Arbeitsverzeichnis des Daemons. Im Container besser absolut setzen,
# damit es auf das State-Volume fällt:
state_dir = "/var/lib/spidernet"
[yggdrasil]
# true = der Daemon startet und überwacht das Yggdrasil-Sidecar selbst.
# false = es wird ein bereits laufender Yggdrasil-Daemon verwendet.
manage = true
# Pfad/Name des Yggdrasil-Binary; nur relevant, wenn manage = true.
# Im Container ist yggdrasil unter /usr/local/bin/yggdrasil installiert.
binary = "yggdrasil"
# Peer-URIs (tcp://, tls://, quic://, socks://) für manuelles Peering.
# Über das Nachbarschafts-LAN peert Yggdrasil per Multicast automatisch;
# explizite Peers braucht man z. B. über WLAN-Brücken hinweg.
peers = []
# Listener-URIs für eingehende Peerings (tcp://, tls://, quic://).
listen = []
# Access-Control: 64-stellige Hex-Public-Keys der Nachbarn, die mit uns
# peeren dürfen (AllowedPublicKeys). Leere Liste = jeder darf peeren.
allow_keys = []
# true = Multicast-Auto-Peering im LAN deaktivieren.
no_multicast = false
# TUN-Interface: "auto" (Standard), "none" (headless, nur Router-Modus)
# oder ein fester Interface-Name.
ifname = "auto"
[exit]
# true = dieser Haushalt stellt seinen Uplink als Exit bereit (opt-in).
# false = Leech-Modus (Standard hier): der Node empfängt nur, bietet
# keinen Exit an — ausdrücklich legitim, niemand wird gedrosselt, nur
# fair priorisiert.
#
# Wer Exit anbieten will: enabled = true setzen UND in `allow` die
# Overlay-Adressen der Nachbarn eintragen — enabled = true mit leerer
# `allow`-Liste ist ungültig (der Daemon verweigert den Start; Ausnahme:
# Dev-Modus mit Loopback-bind).
enabled = false
# Explizite Bind-Adresse. Leer (Standard) = "[<Yggdrasil-Adresse>]:port",
# d. h. der Exit lauscht auf der eigenen Overlay-Adresse.
# UNGÜLTIG: allow = [] zusammen mit einem nicht-Loopback-bind — der Exit
# wäre sonst für beliebig viele Quellen offen (Schutz: ohne Allowlist nur
# Loopback, das ist der Dev-Modus).
bind = ""
# TCP-Port des Exit-Dienstes (u16, Standard 8080).
port = 8080
# Overlay-Adressen der Nachbarn, die diesen Exit nutzen dürfen.
# Leere Liste = nur Loopback (Dev-Modus); bei nicht-Loopback-bind Pflicht.
# Beispiel:
# allow = ["200:abcd:ef01::2", "200:abcd:ef01::3"]
allow = []
# Kontingent pro Nachbar: Bytes pro Zeitfenster (u64, Standard 1024 MiB).
# 0 = unbegrenzt (kein Kontingent).
quota_mib = 1024
# Länge des Kontingent-Zeitfensters in Stunden (fixed window).
window_hours = 24
# KILL-SWITCH: Wer nichts mehr teilen will, stoppt einfach den Exit bzw.
# den Daemon (Container: `docker compose stop`). Damit endet die
# Bereitstellung des eigenen Uplinks sofort — der eigene Node fällt auf
# den eigenen Internetanschluss zurück. SpiderNet v0 leitet normalen
# Verkehr nicht um: nur Downloads, die sn-fetch explizit über das Netz
# verteilt, nutzen die gepoolte Bandbreite.