Compare commits
11
Commits
f4e614acce
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
246b7969d6 | ||
|
|
57b9b44d8a | ||
|
|
f2f3609b87 | ||
|
|
d8bd14ab1b | ||
|
|
ecf8848ca4 | ||
|
|
afaeb76f6f | ||
|
|
c8ea5fc58c | ||
|
|
50e459ed79 | ||
|
|
c422deee7c | ||
|
|
23dc906da0 | ||
|
|
810529d502 |
@@ -42,7 +42,9 @@ Das Projekt besteht aus zwei Hälften:
|
|||||||
- Kernfeatures: Multi-Exit-Bandbreiten-Pooling, segmentierte Downloads,
|
- Kernfeatures: Multi-Exit-Bandbreiten-Pooling, segmentierte Downloads,
|
||||||
Fairness-Scheduler, Nachbarschafts-Cache, **Leech-Modus** (Empfangen ohne
|
Fairness-Scheduler, Nachbarschafts-Cache, **Leech-Modus** (Empfangen ohne
|
||||||
Exit-Bereitstellung ist ausdrücklich erlaubt — Netz unter Nachbarn)
|
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
|
## Git / Gitea
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -21,10 +21,12 @@ Nachbarschaft betreibt ihre eigene Instanz nach eigenen Regeln.
|
|||||||
| [[Ethos]] | Werte: Gemeingut, Fairness, Leechen erlaubt, legale Basis |
|
| [[Ethos]] | Werte: Gemeingut, Fairness, Leechen erlaubt, legale Basis |
|
||||||
| [[Idee]] | Kernidee + ehrlicher Realitätscheck |
|
| [[Idee]] | Kernidee + ehrlicher Realitätscheck |
|
||||||
| [[Architektur]] | Schichten: Physik → Routing → Pooling → Freenet-Inhalte |
|
| [[Architektur]] | Schichten: Physik → Routing → Pooling → Freenet-Inhalte |
|
||||||
|
| [[Overlay]] | Yggdrasil als Overlay: Auswahl, Deployment, Grenzen |
|
||||||
| [[Recht]] | Deutsche Rechtslage + Pflichtregeln für die Software |
|
| [[Recht]] | Deutsche Rechtslage + Pflichtregeln für die Software |
|
||||||
| [[Splitting]] | Was sich splitten lässt: Ranges, Flow-Verteilung, Realtime |
|
| [[Splitting]] | Was sich splitten lässt: Ranges, Flow-Verteilung, Realtime |
|
||||||
| [[Lizenz]] | Lizenz-Optionen (Code & Doku) |
|
| [[Lizenz]] | Lizenz-Optionen (Code & Doku) |
|
||||||
| [[Roadmap]] | Phasen: Pilot → MVP → Rollout → Freenet → Backbone |
|
| [[Roadmap]] | Phasen: Pilot → MVP → Rollout → Freenet → Backbone |
|
||||||
|
| [[Pilot]] | Pilot-Handbuch: 2 Haushalte verkabeln, messen, dokumentieren |
|
||||||
| [[Server-README|Server-Konzept]] | Software-Komponenten, Code-Standards |
|
| [[Server-README|Server-Konzept]] | Software-Komponenten, Code-Standards |
|
||||||
|
|
||||||
## Lesereihenfolge zum Einstieg
|
## Lesereihenfolge zum Einstieg
|
||||||
@@ -41,6 +43,10 @@ Nachbarschaft betreibt ihre eigene Instanz nach eigenen Regeln.
|
|||||||
- [[Recht]] §5 (Pflichtregeln) ↔ [[Server-README]] (Haftungsausschluss)
|
- [[Recht]] §5 (Pflichtregeln) ↔ [[Server-README]] (Haftungsausschluss)
|
||||||
- [[Idee]] (Realitätscheck) ↔ [[Architektur]] (link-agnostische Physik, `sn-fetch`)
|
- [[Idee]] (Realitätscheck) ↔ [[Architektur]] (link-agnostische Physik, `sn-fetch`)
|
||||||
- [[Splitting]] ↔ [[Architektur]] (Schicht 2) — was der Exit-Pool tatsächlich nutzen kann
|
- [[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)
|
- [[Lizenz]] ↔ [[Ethos]] §6 (Transparenz) und `freenet-core` (AGPL)
|
||||||
|
|
||||||
## Außerhalb der Doku
|
## Außerhalb der Doku
|
||||||
|
|||||||
@@ -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)
|
- [[Splitting]] — Was sich über mehrere Exits splitten/verteilen lässt (und was nicht)
|
||||||
- [[Lizenz]] — Lizenz-Optionen und Empfehlung
|
- [[Lizenz]] — Lizenz-Optionen und Empfehlung
|
||||||
- [[Roadmap]] — Phasenplan vom Pilot bis zum Nachbarschafts-Backbone
|
- [[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`, …)
|
- [[Server-README|Server-Konzept]] — geplante Software (`sn-node`, `sn-fetch`, …)
|
||||||
|
|
||||||
## Die Idee in einem Satz
|
## Die Idee in einem Satz
|
||||||
@@ -28,9 +29,14 @@ ist — ohne Mehrkosten und mit besserer Versorgung schwacher Haushalte.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Konzeptphase. Die Software in `server/` ist noch nicht implementiert; der
|
Prototypphase (siehe [[Roadmap]]): `sn-fetch` (segmentierter Multi-Exit-
|
||||||
erste Meilenstein ist ein PoC "segmentierter Download über zwei Haushalte"
|
Download mit Retry + Egress), `sn-node` v0 + Daemon (Yggdrasil-Config,
|
||||||
(siehe [[Roadmap]]).
|
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)
|
## Verwandte Projekte (Prior Art)
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ mehr Pooling-Gewinn.
|
|||||||
|
|
||||||
## Schicht 1 — Routing (Overlay über alle Haushalte)
|
## 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 |
|
| Kandidat | Pro | Con |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -132,6 +133,11 @@ siehe [[Splitting]].
|
|||||||
- Exit-Kontingente (Volumenlimit, Wartungszeit) werden respektiert
|
- Exit-Kontingente (Volumenlimit, Wartungszeit) werden respektiert
|
||||||
- Leech-Nodes: niedrigste Priorität bei Knappheit, nie ausgeschlossen
|
- 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`)
|
### Nachbarschafts-Cache (`sn-cache`)
|
||||||
|
|
||||||
- Transparenter Shared-Cache (lancache-Prinzip): große, populäre Objekte
|
- Transparenter Shared-Cache (lancache-Prinzip): große, populäre Objekte
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)*
|
||||||
@@ -14,23 +14,36 @@ endet mit etwas, das man benutzen kann.
|
|||||||
## Phase 1 — Pilot (2–3 Haushalte)
|
## Phase 1 — Pilot (2–3 Haushalte)
|
||||||
|
|
||||||
Ziel: Beweis, dass Pooling über zwei Anschlüsse real funktioniert.
|
Ziel: Beweis, dass Pooling über zwei Anschlüsse real funktioniert.
|
||||||
|
Handbuch mit Messprotokoll: [[Pilot]].
|
||||||
|
|
||||||
- [x] `sn-fetch` PoC (lokal): Range-Probe, gewichteter Planner, parallele
|
- [x] `sn-fetch` PoC (lokal): Range-Probe, gewichteter Planner, parallele
|
||||||
Segment-Downloads, Reassembly + SHA-256 — verifiziert gegen einen
|
Segment-Downloads, Reassembly + SHA-256 — verifiziert gegen einen
|
||||||
echten Server (Debian-Mirror); Multi-Exit-Routing folgt mit dem Mesh
|
echten Server (Debian-Mirror); Multi-Exit-Routing folgt mit dem Mesh
|
||||||
- [ ] SpiderNet-Node auf Mini-PC (Raspberry Pi / N100-Box), Yggdrasil-Overlay
|
- [x] `sn-fetch`: Segment-Retry mit Exit-Reassignment, Progress-Sinks,
|
||||||
zwischen 2 Haushalten (Kabel oder Kurzstrecke WLAN/60-GHz)
|
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
|
- [ ] `sn-fetch` PoC über 2 echte Exits, plus Baseline-Messung
|
||||||
(1-Exit vs. 2-Exit, gleiche Datei)
|
(1-Exit vs. 2-Exit, gleiche Datei — Protokoll in [[Pilot]])
|
||||||
- [ ] Fair-Share-Scheduler v0: zwei gleichzeitige Downloads, faire Aufteilung
|
- [ ] Doku: Messwerte, Erkenntnisse, Stolpersteine → in [[Pilot]] §Ergebnisse
|
||||||
- [ ] Doku: Messwerte, Erkenntnisse, Stolpersteine → hier in das Repo
|
|
||||||
|
|
||||||
## Phase 2 — MVP-Software
|
## Phase 2 — MVP-Software
|
||||||
|
|
||||||
Ziel: ein Paket, das 3–10 Haushalte installieren können.
|
Ziel: ein Paket, das 3–10 Haushalte installieren können.
|
||||||
|
|
||||||
- [ ] `sn-node` Daemon: Discovery, Peering, Metriken (per-Exit Bandbreite/
|
- [ ] `sn-node` Daemon v1: Metriken aus Yggdrasil-Peering (per-Exit
|
||||||
Latenz), Onboarding-Flow (QR/Knopfdruck)
|
Bandbreite/Latenz), Onboarding-Flow (QR/Knopfdruck)
|
||||||
- [ ] Exit-Pool: Kontingente, opt-in/opt-out, Leech-Modus, Kill-Switch
|
- [ ] Exit-Pool: Kontingente, opt-in/opt-out, Leech-Modus, Kill-Switch
|
||||||
- [ ] `sn-fetch`: API + CLI + einfache Browser-Integration
|
- [ ] `sn-fetch`: API + CLI + einfache Browser-Integration
|
||||||
- [ ] `sn-cache`: lancache-artiger transparenter Cache (DNS-basiert)
|
- [ ] `sn-cache`: lancache-artiger transparenter Cache (DNS-basiert)
|
||||||
|
|||||||
@@ -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}" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
Generated
+128
@@ -228,6 +228,12 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -329,6 +335,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -543,6 +555,16 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.2"
|
version = "2.12.2"
|
||||||
@@ -924,6 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -959,6 +982,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -997,6 +1029,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1009,6 +1051,25 @@ version = "1.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
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]]
|
[[package]]
|
||||||
name = "sn-fetch"
|
name = "sn-fetch"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1024,6 +1085,22 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"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]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -1162,6 +1239,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -1188,6 +1266,47 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1541,6 +1660,15 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/sn-fetch"]
|
members = ["crates/sn-fetch", "crates/sn-node", "crates/sn-exit", "crates/sn-fair"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -18,7 +18,10 @@ panic = "deny"
|
|||||||
unreachable = "deny"
|
unreachable = "deny"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[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"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|||||||
+98
-9
@@ -12,17 +12,106 @@ macht. Sprache: **Rust**.
|
|||||||
Exit, Mindest-Segmentgröße mit Merge-Fix, validated: lückenlose,
|
Exit, Mindest-Segmentgröße mit Merge-Fix, validated: lückenlose,
|
||||||
vollständige Abdeckung)
|
vollständige Abdeckung)
|
||||||
- Parallele Segment-Downloads (Streaming, eigener reqwest-Client pro Exit)
|
- 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
|
- Reassembly in Index-Reihenfolge + Streaming-SHA-256 + Längen-Check
|
||||||
- Fallback: range-hostile Server werden als single-stream geladen
|
- 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,
|
Verifiziert gegen einen echten Server (Debian-CD-Mirror, 756 MB, 8 Segmente,
|
||||||
SHA-256 entspricht der offiziellen `SHA256SUMS`).
|
SHA-256 entspricht der offiziellen `SHA256SUMS`).
|
||||||
|
|
||||||
**Noch nicht drin:** Multi-Exit-Routing über das Mesh (aktuell laufen alle
|
**`sn-node` v0 ist implementiert** (siehe `crates/sn-node/`):
|
||||||
Exits als lokale Verbindungen — die Struktur [`HttpExit`]-Trait,
|
|
||||||
Exit-Registry] ist dafür schon bereit), Retry bei Segment-Fehlern,
|
- `sn-node genconf`: erzeugt eine Yggdrasil-Config aus validierten
|
||||||
Progress-Anzeige während des Laufs.
|
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)
|
## Haftungsausschluss (muss im Produkt sichtbar sein)
|
||||||
|
|
||||||
@@ -52,14 +141,14 @@ Einplatinenrechner), der:
|
|||||||
|
|
||||||
| Crate/Binary | Zweck |
|
| Crate/Binary | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `sn-node` | Daemon: Discovery, Peering, Metriken, Onboarding |
|
| `sn-node` | Node-Glue: Yggdrasil-Config (`genconf`), Overlay-Status (`status`), Node-Daemon (`daemon`: Sidecar + Exit-Orchestrierung) |
|
||||||
| `sn-exit` | Exit-Dienst: kontingentierter Uplink-Proxy für Nachbarn (verschlüsselt) |
|
| `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-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-cache` | Transparenter Nachbarschafts-Cache (DNS-basiert) |
|
||||||
| `sn-freenet` | Integration von `freenet-core` (Contracts für Nachbarschafts-Inhalte) |
|
| `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:
|
Kleinster brauchbarer Schnitt:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,4 +47,12 @@ pub enum FetchError {
|
|||||||
|
|
||||||
#[error("invalid sha256 hex string")]
|
#[error("invalid sha256 hex string")]
|
||||||
InvalidDigest,
|
InvalidDigest,
|
||||||
|
|
||||||
|
#[error("segment {index}: all {attempts} attempt(s) failed; last error: {last_error}")]
|
||||||
|
SegmentAttemptsExhausted {
|
||||||
|
index: SegmentIndex,
|
||||||
|
attempts: u32,
|
||||||
|
#[source]
|
||||||
|
last_error: Box<FetchError>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
//! Execution: download planned segments in parallel and reassemble the
|
//! Execution: download planned segments in parallel (with per-segment
|
||||||
//! target file.
|
//! 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::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -10,6 +21,7 @@ use tokio::io::AsyncWriteExt;
|
|||||||
use crate::error::FetchError;
|
use crate::error::FetchError;
|
||||||
use crate::exit::ExitRegistry;
|
use crate::exit::ExitRegistry;
|
||||||
use crate::plan::FetchPlan;
|
use crate::plan::FetchPlan;
|
||||||
|
use crate::progress::{ProgressEvent, ProgressSink};
|
||||||
use crate::types::{ByteRange, ExitId, FileSize, SegmentIndex, Sha256Digest, TargetUrl};
|
use crate::types::{ByteRange, ExitId, FileSize, SegmentIndex, Sha256Digest, TargetUrl};
|
||||||
|
|
||||||
/// Statistics for one finished segment.
|
/// Statistics for one finished segment.
|
||||||
@@ -20,6 +32,50 @@ pub struct SegmentStats {
|
|||||||
pub duration: Duration,
|
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.
|
/// Result of a complete fetch.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FetchReport {
|
pub struct FetchReport {
|
||||||
@@ -41,19 +97,63 @@ pub async fn run_segments(
|
|||||||
exits: &ExitRegistry,
|
exits: &ExitRegistry,
|
||||||
parts_dir: &Path,
|
parts_dir: &Path,
|
||||||
) -> Result<Vec<SegmentStats>, FetchError> {
|
) -> 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?;
|
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());
|
let mut handles = Vec::with_capacity(plan.segments.len());
|
||||||
for seg in &plan.segments {
|
for seg in &plan.segments {
|
||||||
let client = exits.get(seg.exit)?.client().clone();
|
let task = SegmentTask {
|
||||||
handles.push(tokio::spawn(download_segment(
|
url: plan.url.clone(),
|
||||||
plan.url.clone(),
|
index: seg.index,
|
||||||
seg.exit,
|
range: seg.range,
|
||||||
seg.index,
|
planned_exit: seg.exit,
|
||||||
seg.range,
|
clients: Arc::clone(&clients),
|
||||||
client,
|
selector: Arc::clone(&selector),
|
||||||
parts_dir.to_path_buf(),
|
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());
|
let mut stats = Vec::with_capacity(handles.len());
|
||||||
@@ -67,18 +167,158 @@ pub async fn run_segments(
|
|||||||
Ok(stats)
|
Ok(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download one byte range and write it to `parts_dir/seg-NNNNNN.part`.
|
/// Everything one segment task needs, bundled to keep the task
|
||||||
async fn download_segment(
|
/// signature small.
|
||||||
|
struct SegmentTask {
|
||||||
url: TargetUrl,
|
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,
|
exit: ExitId,
|
||||||
index: SegmentIndex,
|
index: SegmentIndex,
|
||||||
range: ByteRange,
|
range: ByteRange,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
parts_dir: PathBuf,
|
parts_dir: &Path,
|
||||||
) -> Result<SegmentStats, FetchError> {
|
) -> Result<u64, FetchError> {
|
||||||
let started = Instant::now();
|
let part_path = part_file(parts_dir, index);
|
||||||
|
|
||||||
let part_path = part_file(&parts_dir, index);
|
|
||||||
let mut response = client
|
let mut response = client
|
||||||
.get(url.inner().clone())
|
.get(url.inner().clone())
|
||||||
.header(reqwest::header::RANGE, range.http_value())
|
.header(reqwest::header::RANGE, range.http_value())
|
||||||
@@ -106,7 +346,7 @@ async fn download_segment(
|
|||||||
file.flush().await?;
|
file.flush().await?;
|
||||||
|
|
||||||
if written != range.len() {
|
if written != range.len() {
|
||||||
tokio::fs::remove_file(&part_path).await.ok();
|
let _ = tokio::fs::remove_file(&part_path).await;
|
||||||
return Err(FetchError::SegmentLengthMismatch {
|
return Err(FetchError::SegmentLengthMismatch {
|
||||||
index,
|
index,
|
||||||
got: written,
|
got: written,
|
||||||
@@ -114,11 +354,68 @@ async fn download_segment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SegmentStats {
|
Ok(written)
|
||||||
index,
|
}
|
||||||
bytes: written,
|
|
||||||
duration: started.elapsed(),
|
/// 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(¤t), 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
|
/// 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)
|
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 {
|
fn part_file(parts_dir: &Path, index: SegmentIndex) -> PathBuf {
|
||||||
parts_dir.join(format!("seg-{:06}.part", index.value()))
|
parts_dir.join(format!("seg-{:06}.part", index.value()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// All exits available for a fetch, keyed by id.
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct ExitRegistry {
|
pub struct ExitRegistry {
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ pub mod exec;
|
|||||||
pub mod exit;
|
pub mod exit;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod probe;
|
pub mod probe;
|
||||||
|
pub mod progress;
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::path::PathBuf;
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use sn_fetch::runner::{self, FetchOptions};
|
use sn_fetch::runner::{self, FetchOptions};
|
||||||
use sn_fetch::types::{
|
use sn_fetch::types::{
|
||||||
ExitId, ExitWeight, MinSegmentBytes, SegmentsPerExit, Sha256Digest, TargetUrl,
|
Egress, ExitId, ExitWeight, MinSegmentBytes, SegmentsPerExit, Sha256Digest, TargetUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -28,12 +28,20 @@ struct Args {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
output: Option<PathBuf>,
|
output: Option<PathBuf>,
|
||||||
|
|
||||||
/// Exits as `id=weight` pairs, comma separated, e.g. `local=1,local2=2`.
|
/// Exits as `id=weight` pairs, comma separated, e.g. `1=2,2=1`.
|
||||||
/// `PoC`: all ids map to plain local connections; the weights only shape
|
/// A proxy route turns an exit into a neighbor's uplink over the
|
||||||
/// the plan. Multi-exit routing comes with the mesh integration.
|
/// mesh overlay: `id=weight@http://[ygg-addr]:8080`. Without a
|
||||||
#[arg(short = 'e', long = "exit", default_value = "local=1")]
|
/// 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,
|
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.
|
/// Parallel sub-segments per exit.
|
||||||
#[arg(short = 's', long, default_value_t = 1)]
|
#[arg(short = 's', long, default_value_t = 1)]
|
||||||
segments_per_exit: usize,
|
segments_per_exit: usize,
|
||||||
@@ -51,10 +59,10 @@ struct Args {
|
|||||||
probe_only: bool,
|
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(',')
|
spec.split(',')
|
||||||
.map(|pair| {
|
.map(|pair| {
|
||||||
let (id, weight) = pair
|
let (id, rest) = pair
|
||||||
.split_once('=')
|
.split_once('=')
|
||||||
.ok_or_else(|| format!("invalid exit spec `{pair}`, expected `id=weight`"))?;
|
.ok_or_else(|| format!("invalid exit spec `{pair}`, expected `id=weight`"))?;
|
||||||
let id = id
|
let id = id
|
||||||
@@ -63,13 +71,21 @@ fn parse_exits(spec: &str) -> Result<Vec<(ExitId, ExitWeight)>, String> {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(NonZeroU8::new)
|
.and_then(NonZeroU8::new)
|
||||||
.ok_or_else(|| format!("invalid exit id `{id}` (must be a non-zero number)"))?;
|
.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
|
let weight = weight
|
||||||
.trim()
|
.trim()
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(NonZeroU32::new)
|
.and_then(NonZeroU32::new)
|
||||||
.ok_or_else(|| format!("invalid exit weight `{weight}` (must be non-zero)"))?;
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -131,6 +147,8 @@ async fn run() -> Result<(), String> {
|
|||||||
NonZeroU64::new(args.min_segment_mib.saturating_mul(1024 * 1024))
|
NonZeroU64::new(args.min_segment_mib.saturating_mul(1024 * 1024))
|
||||||
.ok_or("min_segment_mib must be at least 1")?,
|
.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 {
|
let expected = match &args.sha256 {
|
||||||
Some(hex) => Some(Sha256Digest::from_hex(hex).map_err(|err| err.to_string())?),
|
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),
|
segments_per_exit: SegmentsPerExit::new(spe),
|
||||||
min_segment,
|
min_segment,
|
||||||
expected_sha256: expected,
|
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 parts_dir = std::env::temp_dir().join("sn-fetch-parts");
|
||||||
|
|
||||||
let report = runner::run(&target, &options, ®istry, &parts_dir)
|
let report = runner::run(&target, &options, ®istry, &parts_dir)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use crate::error::FetchError;
|
use crate::error::FetchError;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
ByteRange, ExitId, ExitWeight, FileSize, MinSegmentBytes, SegmentIndex, SegmentsPerExit,
|
ByteRange, Egress, ExitId, ExitWeight, FileSize, MinSegmentBytes, SegmentIndex,
|
||||||
TargetUrl,
|
SegmentsPerExit, TargetUrl,
|
||||||
};
|
};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
|
|
||||||
@@ -23,8 +23,9 @@ pub struct FetchPlan {
|
|||||||
pub segments: Vec<SegmentPlan>,
|
pub segments: Vec<SegmentPlan>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exits with their capacity weights.
|
/// exit description, including how traffic leaves (`Direct` = own
|
||||||
pub type ExitSpecs = Vec<(ExitId, ExitWeight)>;
|
/// uplink, `Proxy` = a neighbor's exit over the mesh).
|
||||||
|
pub type ExitSpecs = Vec<(ExitId, ExitWeight, Egress)>;
|
||||||
|
|
||||||
impl FetchPlan {
|
impl FetchPlan {
|
||||||
/// Build a validated plan.
|
/// Build a validated plan.
|
||||||
@@ -56,12 +57,12 @@ impl FetchPlan {
|
|||||||
let weakest = exits
|
let weakest = exits
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.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);
|
.map_or(0, |(pos, _)| pos);
|
||||||
exits.remove(weakest);
|
exits.remove(weakest);
|
||||||
}
|
}
|
||||||
// Deterministic boundary assignment order.
|
// 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
|
// If even one segment per exit would be too small, reduce to one
|
||||||
// segment per exit.
|
// segment per exit.
|
||||||
@@ -85,7 +86,7 @@ impl FetchPlan {
|
|||||||
// Weighted split across exits, then even sub-split per exit.
|
// Weighted split across exits, then even sub-split per exit.
|
||||||
let units: Vec<u128> = exits
|
let units: Vec<u128> = exits
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, w)| u128::from(w.value()) * spe.value() as u128)
|
.map(|(_, w, _)| u128::from(w.value()) * spe.value() as u128)
|
||||||
.collect();
|
.collect();
|
||||||
let total_units: u128 = units.iter().sum();
|
let total_units: u128 = units.iter().sum();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,13 @@ use sha2::Digest;
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
use crate::error::FetchError;
|
use crate::error::FetchError;
|
||||||
use crate::exec::{self, FetchReport, SegmentStats};
|
use crate::exec::{self, FetchReport, RetryPolicy, SegmentStats};
|
||||||
use crate::exit::{ExitRegistry, LocalExit};
|
use crate::exit::{ExitRegistry, HttpExit, LocalExit, MeshExit};
|
||||||
use crate::plan::ExitSpecs;
|
use crate::plan::ExitSpecs;
|
||||||
use crate::probe::{self, ProbeInfo};
|
use crate::probe::{self, ProbeInfo};
|
||||||
|
use crate::progress::ProgressSink;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit, Sha256Digest, TargetUrl,
|
Egress, FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit, Sha256Digest, TargetUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// All knobs for one fetch.
|
/// All knobs for one fetch.
|
||||||
@@ -26,6 +27,8 @@ pub struct FetchOptions {
|
|||||||
pub segments_per_exit: SegmentsPerExit,
|
pub segments_per_exit: SegmentsPerExit,
|
||||||
pub min_segment: MinSegmentBytes,
|
pub min_segment: MinSegmentBytes,
|
||||||
pub expected_sha256: Option<Sha256Digest>,
|
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.
|
/// Run a full fetch and produce a report.
|
||||||
@@ -74,7 +77,17 @@ async fn run_ranged(
|
|||||||
tracing::info!("plan: {} segment(s)", plan.segments.len());
|
tracing::info!("plan: {} segment(s)", plan.segments.len());
|
||||||
|
|
||||||
let started = Instant::now();
|
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(
|
let digest = exec::assemble(
|
||||||
parts_dir,
|
parts_dir,
|
||||||
&options.output,
|
&options.output,
|
||||||
@@ -105,7 +118,7 @@ async fn run_whole(
|
|||||||
let exit_id = options
|
let exit_id = options
|
||||||
.exits
|
.exits
|
||||||
.first()
|
.first()
|
||||||
.map(|(id, _)| *id)
|
.map(|(id, _, _)| *id)
|
||||||
.ok_or_else(|| FetchError::InvalidPlan("no exits given".to_string()))?;
|
.ok_or_else(|| FetchError::InvalidPlan("no exits given".to_string()))?;
|
||||||
let client = exits.get(exit_id)?.client().clone();
|
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.
|
/// Build a registry from exit specs: `Egress::Direct` exits connect
|
||||||
pub fn local_registry(specs: &ExitSpecs) -> Result<ExitRegistry, FetchError> {
|
/// 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();
|
let mut registry = ExitRegistry::empty();
|
||||||
for (id, _) in specs {
|
for (id, _, egress) in specs {
|
||||||
registry.register(std::sync::Arc::new(LocalExit::new(*id)?));
|
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)
|
Ok(registry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,3 +230,36 @@ pub enum RangeSupport {
|
|||||||
/// Probe was inconclusive; treat as unsupported (safe fallback).
|
/// Probe was inconclusive; treat as unsupported (safe fallback).
|
||||||
Unknown,
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! A minimal HTTP server for integration tests. Supports HEAD, GET with
|
//! 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)]
|
#![forbid(unsafe_code)]
|
||||||
// Tests are allowed to unwrap/expect; production code denies them via
|
// Tests are allowed to unwrap/expect; production code denies them via
|
||||||
@@ -8,8 +10,36 @@
|
|||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread::JoinHandle;
|
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 struct TestServer {
|
||||||
pub addr: SocketAddr,
|
pub addr: SocketAddr,
|
||||||
@@ -21,14 +51,25 @@ impl TestServer {
|
|||||||
/// server behaves like a range-hostile origin: no `Accept-Ranges`
|
/// server behaves like a range-hostile origin: no `Accept-Ranges`
|
||||||
/// header, and `Range` requests are answered with the full body.
|
/// header, and `Range` requests are answered with the full body.
|
||||||
pub fn start(data: Vec<u8>, ranges: bool) -> Self {
|
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 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 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 || {
|
let handle = std::thread::spawn(move || {
|
||||||
for stream in listener.incoming().flatten() {
|
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 || {
|
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 reader = BufReader::new(stream.try_clone()?);
|
||||||
let mut request_line = String::new();
|
let mut request_line = String::new();
|
||||||
reader.read_line(&mut request_line)?;
|
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 mut stream = stream;
|
||||||
let len = data.len() as u64;
|
let len = data.len() as u64;
|
||||||
|
|
||||||
let method = request_line
|
let method = request_line
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.next()
|
.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();
|
let range = range_header.as_deref().and_then(parse_range).flatten();
|
||||||
match (ranges, range) {
|
match (ranges, range) {
|
||||||
(true, Some((start, end))) => {
|
(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;
|
let slice_len = end - start + 1;
|
||||||
write!(
|
write!(
|
||||||
stream,
|
stream,
|
||||||
@@ -111,6 +161,28 @@ fn handle_conn(stream: TcpStream, data: &[u8], ranges: bool) -> std::io::Result<
|
|||||||
Ok(())
|
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
|
/// Parse `bytes=a-b` into inclusive `(start, end)`. Anything else
|
||||||
/// (open-ended, malformed) is treated as absent.
|
/// (open-ended, malformed) is treated as absent.
|
||||||
fn parse_range(value: &str) -> Option<Option<(u64, u64)>> {
|
fn parse_range(value: &str) -> Option<Option<(u64, u64)>> {
|
||||||
@@ -124,3 +196,35 @@ fn parse_range(value: &str) -> Option<Option<(u64, u64)>> {
|
|||||||
Some(None)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, ®istry, 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());
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
®istry,
|
||||||
|
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,
|
||||||
|
®istry,
|
||||||
|
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, ®istry, 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,
|
||||||
|
®istry,
|
||||||
|
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,
|
||||||
|
®istry,
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ use sn_fetch::exit::LocalExit;
|
|||||||
use sn_fetch::plan::{ExitSpecs, FetchPlan};
|
use sn_fetch::plan::{ExitSpecs, FetchPlan};
|
||||||
use sn_fetch::probe;
|
use sn_fetch::probe;
|
||||||
use sn_fetch::types::{
|
use sn_fetch::types::{
|
||||||
ByteRange, ExitId, ExitWeight, FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit,
|
ByteRange, Egress, ExitId, ExitWeight, FileSize, MinSegmentBytes, RangeSupport,
|
||||||
Sha256Digest,
|
SegmentsPerExit, Sha256Digest,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn exit_specs(n: u8) -> ExitSpecs {
|
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")),
|
ExitId::new(NonZeroU8::new(i).expect("test ids are 1..=255")),
|
||||||
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
|
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
|
||||||
|
Egress::Direct,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -68,7 +69,7 @@ async fn segmented_download_reassembles_and_hashes() {
|
|||||||
plan.validate().expect("plan validates");
|
plan.validate().expect("plan validates");
|
||||||
|
|
||||||
let mut registry = sn_fetch::exit::ExitRegistry::empty();
|
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")));
|
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")),
|
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")),
|
min_segment: MinSegmentBytes::new(NonZeroU64::new(1024).expect("1024 is non-zero")),
|
||||||
expected_sha256: None,
|
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 parts = TempDir::new().expect("tempdir in test");
|
||||||
|
|
||||||
let report = sn_fetch::runner::run(&target, &options, ®istry, parts.path())
|
let report = sn_fetch::runner::run(&target, &options, ®istry, parts.path())
|
||||||
@@ -131,10 +133,12 @@ fn plan_is_contiguous_and_weighted() {
|
|||||||
(
|
(
|
||||||
ExitId::new(NonZeroU8::new(1).expect("1 is non-zero")),
|
ExitId::new(NonZeroU8::new(1).expect("1 is non-zero")),
|
||||||
ExitWeight::new(NonZeroU32::new(3).expect("3 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")),
|
ExitId::new(NonZeroU8::new(2).expect("2 is non-zero")),
|
||||||
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
|
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
|
||||||
|
Egress::Direct,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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).
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user