Compare commits
3
Commits
d8bd14ab1b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
246b7969d6 | ||
|
|
57b9b44d8a | ||
|
|
f2f3609b87 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,14 @@ Prototyp (Konzeptphase + erster Code):
|
|||||||
mit `sn-fetch` gegen einen echten Server verifiziert
|
mit `sn-fetch` gegen einen echten Server verifiziert
|
||||||
- **`sn-fair` v0** funktioniert: Fairness-Engine (gewichtetes Max-Min,
|
- **`sn-fair` v0** funktioniert: Fairness-Engine (gewichtetes Max-Min,
|
||||||
Leech-Politik) + Demo-CLI — die Soll-Aufteilung für den Piloten
|
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
|
- **Nächste Schritte:** Pilot mit 2–3 Haushalten (Yggdrasil-Overlay
|
||||||
verkabeln, Messwerte — Handbuch:
|
verkabeln, Messwerte — Handbuch:
|
||||||
[pilot.md](documentation/ideen/pilot.md)), `sn-node`-Daemon — siehe
|
[pilot.md](documentation/ideen/pilot.md)) — siehe
|
||||||
[Roadmap](documentation/ideen/roadmap.md)
|
[Roadmap](documentation/ideen/roadmap.md)
|
||||||
|
|
||||||
## Doku lesen (Obsidian)
|
## Doku lesen (Obsidian)
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ ist — ohne Mehrkosten und mit besserer Versorgung schwacher Haushalte.
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Prototypphase (siehe [[Roadmap]]): `sn-fetch` (segmentierter Multi-Exit-
|
Prototypphase (siehe [[Roadmap]]): `sn-fetch` (segmentierter Multi-Exit-
|
||||||
Download mit Retry + Egress), `sn-node` v0 (Yggdrasil-Config + Status),
|
Download mit Retry + Egress), `sn-node` v0 + Daemon (Yggdrasil-Config,
|
||||||
`sn-exit` v0 (kontingentierter Uplink-Proxy mit SSRF-Schutz) und `sn-fair`
|
Status, Sidecar-/Exit-Orchestrierung), `sn-exit` v0 (kontingentierter
|
||||||
v0 (Fairness-Engine + Demo-CLI) sind implementiert und getestet —
|
Uplink-Proxy mit SSRF-Schutz) und `sn-fair` v0 (Fairness-Engine +
|
||||||
Details in [`server/README.md`](../server/README.md). Nächster
|
Demo-CLI) sind implementiert und getestet — Details in
|
||||||
Meilenstein: Pilot mit 2–3 Haushalten (Handbuch: [[Pilot]]).
|
[`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)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ cargo build --release --workspace
|
|||||||
# → target/release/sn-node, sn-exit, sn-fetch, sn-fair
|
# → 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)
|
### Vor dem Termin (Checkliste)
|
||||||
|
|
||||||
- [ ] ISP-AGB der Teilnehmenden geprüft (Weitergabe an Dritte,
|
- [ ] ISP-AGB der Teilnehmenden geprüft (Weitergabe an Dritte,
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ Handbuch mit Messprotokoll: [[Pilot]].
|
|||||||
end-to-end getestet
|
end-to-end getestet
|
||||||
- [x] `sn-node` v0: Yggdrasil-Config-Generierung (`genconf`) + Admin-Socket-
|
- [x] `sn-node` v0: Yggdrasil-Config-Generierung (`genconf`) + Admin-Socket-
|
||||||
Client (`status`) — die Overlay-Anbindung beginnt
|
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
|
- [ ] Yggdrasil-Overlay zwischen 2 Haushalten (Kabel oder Kurzstrecke
|
||||||
WLAN/60-GHz), `sn-node`-Daemon orchestriert den Node
|
WLAN/60-GHz) aufsetzen
|
||||||
- [x] `sn-exit` v0: HTTP-Proxy auf der Yggdrasil-Adresse (Kontingente,
|
- [x] `sn-exit` v0: HTTP-Proxy auf der Yggdrasil-Adresse (Kontingente,
|
||||||
SSRF-Schutz, Kill-Switch = Prozess stoppen)
|
SSRF-Schutz, Kill-Switch = Prozess stoppen)
|
||||||
- [x] `sn-fair` v0: Fairness-Engine (gewichtetes Max-Min über zwei
|
- [x] `sn-fair` v0: Fairness-Engine (gewichtetes Max-Min über zwei
|
||||||
|
|||||||
@@ -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
+99
@@ -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"
|
||||||
@@ -1048,9 +1090,15 @@ name = "sn-node"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sn-exit",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1191,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",
|
||||||
@@ -1217,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"
|
||||||
@@ -1570,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"
|
||||||
|
|||||||
+3
-1
@@ -18,8 +18,10 @@ panic = "deny"
|
|||||||
unreachable = "deny"
|
unreachable = "deny"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "time", "process", "signal"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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"
|
||||||
|
|||||||
+35
-3
@@ -75,8 +75,40 @@ SHA-256 entspricht der offiziellen `SHA256SUMS`).
|
|||||||
- Runtime-Integration in `sn-fetch` (echte gleichzeitige Downloads
|
- Runtime-Integration in `sn-fetch` (echte gleichzeitige Downloads
|
||||||
drosseln) ist Phase 2, siehe [[Roadmap]]
|
drosseln) ist Phase 2, siehe [[Roadmap]]
|
||||||
|
|
||||||
**Noch nicht drin:** `sn-node`-Daemon (Supervisor, der yggdrasil +
|
**`sn-node` Daemon v1 ist implementiert** (`sn-node daemon`, siehe
|
||||||
sn-exit + sn-fetch orchestriert), Freenet-Integration, Cache.
|
`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:
|
Details zum Overlay: [[Overlay]]. Pilot-Handbuch mit Messprotokoll:
|
||||||
[[Pilot]].
|
[[Pilot]].
|
||||||
@@ -109,7 +141,7 @@ Einplatinenrechner), der:
|
|||||||
|
|
||||||
| Crate/Binary | Zweck |
|
| Crate/Binary | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `sn-node` | Node-Glue: Yggdrasil-Config (`genconf`), Overlay-Status (`status`), später Daemon/Supervisor |
|
| `sn-node` | Node-Glue: Yggdrasil-Config (`genconf`), Overlay-Status (`status`), Node-Daemon (`daemon`: Sidecar + Exit-Orchestrierung) |
|
||||||
| `sn-exit` | Exit-Dienst: kontingentierter Uplink-Proxy auf der Yggdrasil-Adresse (v0: Byte-Relay + Quota + SSRF-Schutz) |
|
| `sn-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) — v0: zwei-Klassen-Max-Min-Engine + Demo-CLI |
|
| `sn-fair` | Fairness-Scheduler (max-min, Exit-Kontingente, Karma, Leech-Modus) — v0: zwei-Klassen-Max-Min-Engine + Demo-CLI |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! the exit only sees which host is contacted and how many bytes flow.
|
//! the exit only sees which host is contacted and how many bytes flow.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -128,10 +129,29 @@ fn roll_window(
|
|||||||
|
|
||||||
/// Accept loop. Runs until the listener errors fatally.
|
/// Accept loop. Runs until the listener errors fatally.
|
||||||
pub async fn serve(config: ExitConfig, listener: TcpListener) -> Result<(), ExitError> {
|
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 config = Arc::new(config);
|
||||||
let ledger = Arc::new(QuotaLedger::new());
|
let ledger = Arc::new(QuotaLedger::new());
|
||||||
|
tokio::pin!(shutdown);
|
||||||
loop {
|
loop {
|
||||||
let (stream, peer) = listener.accept().await?;
|
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 config = Arc::clone(&config);
|
||||||
let ledger = Arc::clone(&ledger);
|
let ledger = Arc::clone(&ledger);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -140,6 +160,8 @@ pub async fn serve(config: ExitConfig, listener: TcpListener) -> Result<(), Exit
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_connection(
|
async fn handle_connection(
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
|
sn-exit = { path = "../sn-exit" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -239,6 +239,17 @@ pub fn write_config(path: &Path, json: &str) -> Result<(), NodeError> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,4 +31,25 @@ pub enum NodeError {
|
|||||||
|
|
||||||
#[error("admin socket operation timed out")]
|
#[error("admin socket operation timed out")]
|
||||||
Timeout,
|
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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
//! - [`yggdrasil`]: client for the Yggdrasil admin socket (JSON over
|
//! - [`yggdrasil`]: client for the Yggdrasil admin socket (JSON over
|
||||||
//! TCP/unix, default `localhost:9001`) to query node status (`getSelf`,
|
//! TCP/unix, default `localhost:9001`) to query node status (`getSelf`,
|
||||||
//! `getPeers`).
|
//! `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.
|
//! Yggdrasil provides encrypted end-to-end routing between households.
|
||||||
//! It is **not** an anonymity network, and `SpiderNet` never claims to
|
//! It is **not** an anonymity network, and `SpiderNet` never claims to
|
||||||
@@ -24,5 +27,7 @@
|
|||||||
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
|
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod daemon;
|
||||||
|
pub mod daemon_config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod yggdrasil;
|
pub mod yggdrasil;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use std::path::PathBuf;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use sn_node::config::{
|
use sn_node::config::{self, ListenUri, MulticastMode, NodeSettings, PeerUri, PublicKeyHex};
|
||||||
self, IfName, ListenUri, MulticastMode, NodeSettings, PeerUri, PublicKeyHex,
|
use sn_node::daemon::{self, NO_WARRANTY_BANNER};
|
||||||
};
|
use sn_node::daemon_config;
|
||||||
use sn_node::yggdrasil::{self, AdminEndpoint};
|
use sn_node::yggdrasil::{self, AdminEndpoint};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -77,15 +77,17 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3)]
|
||||||
timeout_secs: u64,
|
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,
|
||||||
|
|
||||||
fn parse_ifname(input: &str) -> Result<IfName, String> {
|
/// Validate the config and exit without starting anything.
|
||||||
match input.trim() {
|
#[arg(long)]
|
||||||
"auto" => Ok(IfName::Auto),
|
check: bool,
|
||||||
"none" => Ok(IfName::Headless),
|
},
|
||||||
name if !name.is_empty() => Ok(IfName::Named(name.to_string())),
|
|
||||||
_ => Err("--ifname must be `auto`, `none` or an interface name".to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_uris<T>(
|
fn parse_uris<T>(
|
||||||
@@ -139,6 +141,7 @@ async fn run() -> Result<(), String> {
|
|||||||
admin,
|
admin,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
} => run_status(&admin, timeout_secs).await,
|
} => run_status(&admin, timeout_secs).await,
|
||||||
|
Command::Daemon { config, check } => run_daemon_command(&config, check).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ async fn run_genconf(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|key| PublicKeyHex::parse(key).map_err(|err| err.to_string()))
|
.map(|key| PublicKeyHex::parse(key).map_err(|err| err.to_string()))
|
||||||
.collect::<Result<Vec<_>, String>>()?;
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
let if_name = parse_ifname(&ifname)?;
|
let if_name = config::parse_ifname(&ifname).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let settings = NodeSettings {
|
let settings = NodeSettings {
|
||||||
peers,
|
peers,
|
||||||
@@ -227,3 +230,41 @@ async fn run_status(admin: &str, timeout_secs: u64) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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,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