Initial commit: iroh-forward TCP-over-Iroh forwarder
TCP-Forwarding über das Iroh-Netz (QUIC), adressiert per Public Key statt IP (Umsetzung von Option B aus recherche-vpn-vs-p2p-netzwerk.md). - server: nimmt Iroh-Verbindungen an, leitet QUIC-Streams an lokalen Port weiter - client: lauscht lokal auf TCP, öffnet pro Verbindung einen QUIC-Stream zum Peer - keygen: erzeugt Key-Datei und gibt EndpointId aus (Vorab-Verteilung) - Allowlist (--allow) erlaubter Client-EndpointIds - QUIC-Multiplexing, Reconnect mit Backoff, n0-Default-Relays + DNS-Discovery Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
*.key
|
||||
Generated
+4162
File diff suppressed because it is too large
Load Diff
+17
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "iroh-forward"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "TCP-over-Iroh forwarder: dial a serving host by public key, forward a local TCP port over QUIC"
|
||||
|
||||
[dependencies]
|
||||
iroh = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
data-encoding = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,158 @@
|
||||
# iroh-forward
|
||||
|
||||
TCP-Forwarding über das [Iroh](https://www.iroh.computer/)-Netz (QUIC), adressiert per
|
||||
**Public Key statt IP**. Macht einen *Serving Host* standortunabhängig erreichbar (auch hinter
|
||||
CGNAT / dynamischer IP), ohne VPN-Daemon und ohne Änderung an OPNsense/Nginx.
|
||||
|
||||
Umsetzung von **Option B** aus `recherche-vpn-vs-p2p-netzwerk.md`.
|
||||
|
||||
```
|
||||
[Nginx] proxy_pass http://127.0.0.1:9080
|
||||
│ (lokales TCP)
|
||||
▼
|
||||
iroh-forward client --listen 127.0.0.1:9080 --peer <SERVER_ENDPOINT_ID>
|
||||
│ QUIC / Iroh (TLS 1.3, n0-Relay oder Hole-Punch); 1 QUIC-Stream pro TCP-Verbindung
|
||||
▼
|
||||
iroh-forward server --to 127.0.0.1:3000
|
||||
│
|
||||
▼
|
||||
[App :3000]
|
||||
```
|
||||
|
||||
QUIC multiplext beliebig viele Streams über **eine** Connection → parallele Requests skalieren
|
||||
ohne Tricks.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
# Binary: ./target/release/iroh-forward
|
||||
```
|
||||
|
||||
## Nutzung
|
||||
|
||||
### Serving Host (Ziel)
|
||||
|
||||
```bash
|
||||
iroh-forward server --to 127.0.0.1:3000 --key-file /etc/iroh/node.key
|
||||
```
|
||||
|
||||
Beim Start wird die **EndpointId** (= Public Key = Adresse) geloggt:
|
||||
|
||||
```
|
||||
Server läuft ... id=7f2bba1e... --peer 7f2bba1e...
|
||||
```
|
||||
|
||||
Diese EndpointId einmalig **out-of-band** (Vault, Ansible-Secret, …) an den Nginx-Host übergeben.
|
||||
Der `--key-file` persistiert die Identität → nach Neustart bleibt die EndpointId gleich.
|
||||
|
||||
### Nginx-Host (Client)
|
||||
|
||||
```bash
|
||||
iroh-forward client \
|
||||
--listen 127.0.0.1:9080 \
|
||||
--peer 7f2bba1e... \
|
||||
--key-file /etc/iroh/client.key
|
||||
```
|
||||
|
||||
Nginx-vhost:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9080;
|
||||
}
|
||||
```
|
||||
|
||||
## Optionen
|
||||
|
||||
| Flag | Default | Beschreibung |
|
||||
| ---- | ------- | ------------ |
|
||||
| `--to` (server) | `127.0.0.1:3000` | lokales Forward-Ziel |
|
||||
| `--allow` (server) | — | erlaubte Client-EndpointId; mehrfach angebbar. Ohne Angabe: alle erlaubt |
|
||||
| `--listen` (client) | `127.0.0.1:9080` | lokaler TCP-Listen-Port |
|
||||
| `--peer` (client) | — | EndpointId des Serving Host (Pflicht) |
|
||||
| `--key-file` | `./node.key` / `./client.key` | persistierter SecretKey (Datei wird mit `0600` angelegt) |
|
||||
|
||||
Zusätzlicher Subcommand `keygen --key-file <PATH>`: erzeugt (falls nötig) eine Key-Datei und gibt
|
||||
die EndpointId aus, ohne den Dienst zu starten — siehe
|
||||
[Schlüssel vorab erzeugen](#schlüssel-vorab-erzeugen-verteilung-vereinfachen).
|
||||
|
||||
Logging über `RUST_LOG`, z.B. `RUST_LOG=iroh_forward=info,iroh=warn`.
|
||||
|
||||
## Schlüsselmodell: EndpointId vs. SecretKey
|
||||
|
||||
Häufiges Missverständnis: Die **EndpointId ist die Adresse, nicht das Secret.** Es sind zwei
|
||||
verschiedene Dinge (asymmetrische Kryptografie wie bei SSH):
|
||||
|
||||
| | Was | Geheim? | Rolle |
|
||||
| --- | --- | --- | --- |
|
||||
| **SecretKey** (`*.key`-Datei) | 32-Byte Ed25519-Privatschlüssel | **JA — niemals teilen** | Beweist „ich *bin* dieser Endpoint" |
|
||||
| **EndpointId** | der daraus abgeleitete Public Key | **Nein — öffentlich** | Adresse, an die man dialt (`--peer`) |
|
||||
|
||||
Die EndpointId ist mathematisch der Public Key zum SecretKey (`SecretKey::public()`). Sie zu
|
||||
kennen erlaubt **nicht**, den Server zu imitieren — dafür braucht man die Key-Datei. Im Gegenteil:
|
||||
Wenn der Client `--peer <EndpointId>` dialt, verifiziert QUIC/TLS kryptografisch, dass die
|
||||
Gegenstelle wirklich den passenden SecretKey besitzt. Genau das ist „dial by key" und macht MITM
|
||||
unmöglich. Die EndpointId darf bedenkenlos ins Config-Repo committet werden.
|
||||
|
||||
**Warum sie sich trotzdem wie ein Secret „anfühlt":** Ohne Allowlist ist die EndpointId das
|
||||
*einzige* Tor, um sich überhaupt zu verbinden — jeder, der sie kennt, darf eine Verbindung
|
||||
*aufbauen*. Sie ist dann kein kryptografisches Secret, aber faktisch ein **Zugangs-Token**. Mit
|
||||
gesetzter `--allow`-Allowlist verliert die EndpointId diese Sonderrolle und ist nur noch eine
|
||||
harmlose Adresse.
|
||||
|
||||
### Schlüssel vorab erzeugen (Verteilung vereinfachen)
|
||||
|
||||
Man kann sich keine *beliebige* Wunsch-ID aussuchen (sie ist der Public Key des Keypairs), aber
|
||||
man kann die Keypairs **vorab** erzeugen, sodass die EndpointId schon vor dem ersten Start bekannt
|
||||
ist — kein „booten → Log lesen → ID kopieren" nötig:
|
||||
|
||||
```bash
|
||||
iroh-forward keygen --key-file serving-host.key
|
||||
# → gibt die EndpointId auf stdout aus, legt serving-host.key (0600) an
|
||||
```
|
||||
|
||||
Dann im Provisioning:
|
||||
|
||||
- `serving-host.key` (das Secret) → per Vault/Ansible-Secret nur auf den Serving Host, `0600`
|
||||
- die EndpointId (öffentlich) → direkt in die Client-Config / ins Git-Repo
|
||||
|
||||
Die Discovery (n0-DNS) löst die EndpointId zur jeweils aktuellen Netzwerkposition auf — es werden
|
||||
**nie IPs** verteilt, nur einmalig die öffentliche EndpointId (wie ein gepinnter SSH-Hostkey).
|
||||
`keygen` ist idempotent: bei vorhandener Datei wird die bestehende EndpointId ausgegeben.
|
||||
|
||||
## Zugriffskontrolle (Allowlist)
|
||||
|
||||
Ohne `--allow` darf jeder, der die Server-EndpointId kennt, sich verbinden. Mit einer oder mehreren
|
||||
`--allow`-Angaben akzeptiert der Server **nur** die genannten Client-EndpointIds; alle anderen
|
||||
werden direkt nach dem Handshake abgewiesen:
|
||||
|
||||
```bash
|
||||
# Client-Key vorab erzeugen, dessen EndpointId notieren:
|
||||
iroh-forward keygen --key-file client.key # -> 33c17794...
|
||||
|
||||
# Server nur für diesen Client öffnen (mehrfach für mehrere Clients):
|
||||
iroh-forward server --to 127.0.0.1:3000 --key-file node.key \
|
||||
--allow 33c17794... --allow <weitere-client-id>
|
||||
```
|
||||
|
||||
Besonders wichtig, sobald sensible Dienste (SSH, DB) statt nur eines HTTP-Upstreams getunnelt
|
||||
werden.
|
||||
|
||||
## Sicherheit & Grenzen (v1)
|
||||
|
||||
- **Vertrauensanker:** siehe [Schlüsselmodell](#schlüsselmodell-endpointid-vs-secretkey). Ohne
|
||||
`--allow` ist die EndpointId faktisch ein Zugangs-Token → geheim halten; Key-Dateien `0600`.
|
||||
- **Relay:** es werden die öffentlichen n0-Relays genutzt (`presets::N0`). Der Relay sieht nur
|
||||
verschlüsselte Bytes. Das `--relay`-Flag ist als Platzhalter vorhanden, aber noch nicht
|
||||
ausgewertet.
|
||||
- **Nur TCP:** reine UDP-Protokolle werden nicht getunnelt. Eine Instanz forwardet genau einen
|
||||
Port auf genau ein Ziel.
|
||||
- **Client-IP:** das Ziel sieht als Quelle immer den `server`-Prozess (`127.0.0.1`), nicht die
|
||||
echte Client-IP — `X-Forwarded-For` ggf. davor an der Edge setzen.
|
||||
|
||||
### Geplante Erweiterungen
|
||||
|
||||
- Self-hosted `iroh-relay` statt n0-Relays (`--relay` funktionsfähig machen).
|
||||
- Mehrere Port-Mappings pro Prozess.
|
||||
- systemd-Units für beide Rollen.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Recherche: VPN vs. P2P-Netzwerk
|
||||
|
||||
---
|
||||
|
||||
## Teil 1 – Vergleichende Übersicht
|
||||
|
||||
### Klassifikation nach Betriebsmodell
|
||||
|
||||
| Begriff | Bedeutung |
|
||||
| ------- | --------- |
|
||||
| **Netzwerk-Tunnel / Daemon** | Läuft als System-Dienst, erzeugt ein virtuelles Netzwerkinterface (tun/tap). Routing erfolgt OS-seitig. |
|
||||
| **Overlay-Netzwerk / Mesh** | Spannt ein virtuelles Netz über bestehende Infrastruktur auf. Jeder Knoten kennt (ggf. über Koordination) alle anderen. |
|
||||
| **App-Konnektivität / P2P-SDK** | Keine Netzwerkschnittstelle im OS. Konnektivität wird direkt in die Applikation eingebettet. Verbindung über kryptographische Schlüssel, nicht IPs. |
|
||||
|
||||
---
|
||||
|
||||
### Protokoll-Vergleich
|
||||
|
||||
| Protokoll | OSI-Schicht | Betriebsmodell | Koordination | Verschlüsselung | Besonderheiten |
|
||||
| --------- | ----------- | -------------- | ------------ | --------------- | -------------- |
|
||||
| **IPSec** | L3–L4 (Netzwerk/Transport) | Netzwerk-Tunnel / Kernel | Manuell (IKEv2/IKEv1) oder PKI | AES-GCM, ChaCha20 (via Suite B) | Standard in Enterprise-Firewalls; komplex in Konfiguration; NAT-Probleme (NAT-T nötig); zwei Modi: Transport (L4-Header bleibt) und Tunnel (ganzes IP-Paket) |
|
||||
| **OpenVPN** | L4-Tunnel → präsentiert L2/L3 | Netzwerk-Tunnel / Daemon | CA-basiert (TLS-Zertifikate) | TLS (OpenSSL/mbedTLS) | TUN=L3, TAP=L2; läuft über UDP oder TCP; sehr verbreitet, aber langsam (Userspace); gut durch Firewalls tunnelbar (TCP:443) |
|
||||
| **WireGuard** | L3 (Netzwerk) | Overlay-Netzwerk / Kernel-Modul | Manuell (Pre-shared Keys + Public Keys) | ChaCha20-Poly1305, Curve25519 | Minimaler Kernel-Code (\~4000 Zeilen); kein Handshake-Overhead im Betrieb; keine eingebaute Key-Discovery; bildet Basis für Tailscale/Netbird |
|
||||
| **Yggdrasil** | L3-Overlay (IPv6) | Overlay-Netzwerk / Mesh-Daemon | Selbstorganisierend (distributed routing) | X25519 + Ed25519 | Adresse = Hash des Public Key; kein zentraler Koordinator; schlechte Performance bei sehr großem Mesh; experimentell |
|
||||
| **Tailscale** | L3-Overlay | Overlay-Netzwerk / Managed Mesh | Zentraler Koordinations-Server (control plane) | WireGuard darunter | „Zero-config VPN"; DERP-Relay-Netz von Tailscale für NAT-Traversal; SaaS-Modell (oder Headscale self-hosted); gut für Team-Zugriff auf interne Dienste |
|
||||
| **Iroh** | L4–L7 (QUIC/Application) | App-Konnektivität / P2P-SDK | Relay-Server (optional, betreibbar) | TLS 1.3 über QUIC | Kein Netzwerkinterface; Bibliothek (Rust, Python, Node.js, Swift, Kotlin); Adressierung über Public Keys; 95 % direkte Verbindungen; QUIC Multipath; kein Flottenmanagement eingebaut |
|
||||
|
||||
---
|
||||
|
||||
### L4 vs. L7 – was bedeutet das hier konkret?
|
||||
|
||||
**L4 (Transport):**
|
||||
|
||||
- IPSec Tunnel-Modus, OpenVPN, WireGuard und Tailscale operieren auf dieser Ebene oder darunter
|
||||
- Das OS erhält ein virtuelles Interface (`wg0`, `tun0`) und routet IP-Traffic darüber
|
||||
- Alle Anwendungen profitieren automatisch – keine Code-Änderung nötig
|
||||
|
||||
**L7 (Applikation):**
|
||||
|
||||
- Iroh arbeitet ausschließlich auf Applikationsebene
|
||||
- Die Anwendung selbst öffnet eine Iroh-Verbindung (QUIC-Session) zu einem Remote-Node via dessen Public Key
|
||||
- Kein System-Routing, kein Interface – die App muss Iroh aktiv nutzen
|
||||
- Vergleichbar mit WebRTC: im Browser transparent, aber nur für eingebettete Anwendungen sinnvoll
|
||||
|
||||
---
|
||||
|
||||
### Wann welches Modell?
|
||||
|
||||
| Szenario | Empfehlung |
|
||||
| -------- | ---------- |
|
||||
| Site-to-Site zwischen Firewalls | IPSec (native OPNsense-Support) |
|
||||
| Remote-Access für Mitarbeiter | Tailscale oder WireGuard |
|
||||
| Self-hosted Mesh ohne SaaS | WireGuard + Netbird/Headscale |
|
||||
| P2P in eigener Anwendung gebaut | Iroh |
|
||||
| Gerät soll von überall erreichbar sein, ohne feste IP | Iroh oder Tailscale |
|
||||
| Experimentelles dezentrales Netz | Yggdrasil |
|
||||
|
||||
---
|
||||
|
||||
## Teil 2 – Iroh-Integration in bestehendes Setup
|
||||
|
||||
### Ausgangslage
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
[OPNsense] ──── Firewall / Router
|
||||
│
|
||||
[Nginx] ──── Reverse Proxy (terminiert TLS, routet vhosts)
|
||||
│
|
||||
[Serving Host] ──── Anwendungsserver (bisher feste IP / internes Netz)
|
||||
```
|
||||
|
||||
Das Problem: Der Serving Host muss derzeit über ein geroutetes Netz (VPN oder direkt) erreichbar sein. Ziel ist es, ihn **standortunabhängig** zu betreiben – ohne VPN-Konfiguration auf Netzwerkebene.
|
||||
|
||||
---
|
||||
|
||||
### Iroh-Konzept: Dial by Key, not by IP
|
||||
|
||||
Jeder Iroh-Node hat ein Ed25519-Schlüsselpaar. Der **Public Key ist die Adresse**. Solange ein Node seinen Relay kennt (oder direkten QUIC-Hole-Punch schafft), ist er erreichbar – unabhängig von Netzwerkposition, NAT oder dynamischer IP.
|
||||
|
||||
---
|
||||
|
||||
### Verfügbare Iroh-Tools (ohne eigenen Code)
|
||||
|
||||
| Tool | Funktion | Einsatzbarkeit |
|
||||
| ---- | -------- | -------------- |
|
||||
| **dumbpipe** | Bidirektionale Byte-Pipe zwischen zwei Iroh-Nodes (wie `nc` über Iroh) | Ja – als TCP-Tunnel mit socat kombinierbar |
|
||||
| **sendme** | Einmalige Dateiübertragung | Nein – kein dauerhafter Dienst |
|
||||
| **iroh-relay** | Eigener Relay-Server (DERP-ähnlich) | Ja – für selbst-gehostetes Setup |
|
||||
|
||||
**dumbpipe** ist die einzige Komponente, die einen dauerhaften Kanal aufbauen kann – aber sie überträgt einen Byte-Stream, kein TCP-Multiplexing. Für einen HTTP-Upstream im Nginx braucht man ein TCP-Forward-Setup.
|
||||
|
||||
---
|
||||
|
||||
### Architektur-Optionen
|
||||
|
||||
#### Option A: dumbpipe + socat als TCP-Proxy-Kette
|
||||
|
||||
```
|
||||
[Client]
|
||||
↓ HTTPS
|
||||
[Nginx] → upstream: localhost:8080
|
||||
↓
|
||||
socat LISTEN:8080 → dumbpipe connect <serving-host-key>
|
||||
↓ [Iroh-Netz]
|
||||
dumbpipe listen → socat → localhost:3000 (App)
|
||||
[Serving Host]
|
||||
```
|
||||
|
||||
**Vorgehen:**
|
||||
|
||||
```
|
||||
# Serving Host
|
||||
dumbpipe listen | socat - TCP:localhost:3000
|
||||
# → gibt Node-Key aus, z.B. "ki2..."
|
||||
|
||||
# Nginx-Host
|
||||
socat TCP-LISTEN:8080,fork EXEC:"dumbpipe connect ki2..."
|
||||
# Nginx: proxy_pass http://127.0.0.1:8080;
|
||||
```
|
||||
|
||||
**Problem:** dumbpipe ist eine Single-Connection-Pipe, kein TCP-Listener. Für parallele HTTP-Verbindungen müsste man pro Request eine neue Pipe öffnen oder ein Connection-Multiplexer vorschalten. **Für produktiven Einsatz ungeeignet** ohne eigenen Code.
|
||||
|
||||
---
|
||||
|
||||
#### Option B: Eigenes Iroh-TCP-Forward-Tool (empfohlen)
|
||||
|
||||
Ein kleines Tool in Rust oder Python (ca. 100–200 Zeilen), das:
|
||||
|
||||
- **Serving Host**: Iroh-Endpunkt öffnet, akzeptiert Verbindungen, leitet an lokalen Port weiter
|
||||
- **Nginx-Host**: Lauscht auf lokalem TCP-Port, öffnet pro Verbindung eine neue Iroh-QUIC-Stream
|
||||
|
||||
Iroh unterstützt nativ **multiple Streams pro Connection** (QUIC), daher skaliert das ohne Tricks.
|
||||
|
||||
```
|
||||
[Nginx]
|
||||
proxy_pass http://127.0.0.1:9080
|
||||
↓
|
||||
[iroh-forward-client] lauscht :9080, öffnet QUIC-Stream zu Serving Host
|
||||
↓ QUIC / Iroh
|
||||
[iroh-forward-server] auf Serving Host, leitet weiter an :3000
|
||||
↓
|
||||
[App]
|
||||
```
|
||||
|
||||
Aufwand: \~1–2 Tage Entwicklung in Rust mit der `iroh`-Crate. Python-Bindings sind verfügbar, aber QUIC-Performance ist in Rust besser.
|
||||
|
||||
---
|
||||
|
||||
#### Option C: OPNsense bleibt unberührt, Iroh nur Nginx ↔ Serving Host
|
||||
|
||||
Dies ist das sauberste Setup: OPNsense braucht nichts von Iroh zu wissen. Der Iroh-Tunnel existiert **nur auf Applikationsebene** zwischen Nginx-Host und Serving Host.
|
||||
|
||||
```
|
||||
Internet → OPNsense → Nginx (Port 443) → iroh-forward-client → [Iroh] → Serving Host
|
||||
```
|
||||
|
||||
OPNsense sieht nur: Nginx-Host macht ausgehende QUIC-Verbindungen (UDP) zu Iroh-Relays. Kein eingehender Traffic auf dem Serving Host nötig.
|
||||
|
||||
---
|
||||
|
||||
### Credential-Verteilung
|
||||
|
||||
| Komponente | Credential | Wo gespeichert | Übergabe |
|
||||
| ---------- | ---------- | -------------- | -------- |
|
||||
| Iroh Node Key (Serving Host) | Ed25519-Keypair | `/etc/iroh/node.key` (Serving Host) | Public Key wird einmalig aus dem Log extrahiert |
|
||||
| Iroh Node Key (Nginx-Host) | Ed25519-Keypair | `/etc/iroh/client.key` (Nginx-Host) | Automatisch generiert beim ersten Start |
|
||||
| Serving Host Public Key | Public Key (String) | Nginx-Host Config / `.env` | Out-of-band: z.B. über Ansible/Vault, Gitea-Secret, o.ä. |
|
||||
| Relay-URL | URL | beide Hosts | Im iroh-config oder Env-Variable |
|
||||
| App-Credentials | DB-Passwörter etc. | Serving Host lokal | Unverändert – Iroh ändert nichts daran |
|
||||
|
||||
**Wichtig:** Der Public Key des Serving Host muss sicher und authentisch übertragen werden – er ist der einzige Vertrauensanker. Wird er kompromittiert (Man-in-the-Middle beim Austausch), kann ein Angreifer eine eigene Verbindung vortäuschen.
|
||||
|
||||
---
|
||||
|
||||
### Vor- und Nachteile
|
||||
|
||||
#### Vorteile
|
||||
|
||||
- **Standortunabhängigkeit**: Serving Host kann in Hetzner, Zuhause, AWS oder hinter CGNAT laufen – kein eingehender Port nötig
|
||||
- **Kein VPN-Daemon**: Keine WireGuard-Konfiguration, keine IKEv2-PKI – Aufwand sinkt deutlich
|
||||
- **OPNsense bleibt unberührt**: Firewall-Regeln, Routing-Tabellen – alles unverändert
|
||||
- **Ende-zu-Ende-verschlüsselt**: TLS 1.3 über QUIC; Relay sieht nur verschlüsselte Bytes
|
||||
- **Selbst-hostbarer Relay**: Kein SaaS-Abhängigkeit wie bei Tailscale; `iroh-relay` ist Open Source
|
||||
- **Direkte Verbindungen** wo möglich: 95 % der Verbindungen gehen ohne Relay-Hop durch (Iroh-Angabe)
|
||||
|
||||
#### Nachteile
|
||||
|
||||
- **L7-only**: Nginx muss explizit konfiguriert werden; kein transparentes OS-Routing
|
||||
- **Kein Flottenmanagement**: Key-Rotation, Revocation, Inventar – alles selbst gebaut
|
||||
- **dumbpipe reicht nicht** für HTTP-Upstream: Eigene Entwicklung nötig (Option B)
|
||||
- **QUIC-Firewall-Probleme**: Manche Netze blockieren UDP; WebSocket-Fallback existiert, aber nicht alle Clients unterstützen ihn
|
||||
- **Relay-Abhängigkeit** bei symmetrischem NAT: Eigener Relay nötig für vollständige Unabhängigkeit
|
||||
- **Fehlerdiagnose schwieriger**: Kein `tcpdump` auf dem Iroh-Interface; Debugging läuft über Iroh-eigene Logs
|
||||
- **Nginx kennt keine echte Client-IP**: Bei Relay-Routing kommt Traffic von der Relay-IP; `X-Forwarded-For` muss manuell gesetzt werden
|
||||
|
||||
---
|
||||
|
||||
### Empfehlung
|
||||
|
||||
Für ein **produktives Setup** ist Option B (eigenes Iroh-Forward-Tool) der richtige Weg. Der Aufwand ist überschaubar und man bekommt ein sauberes TCP-Forwarding mit QUIC-Multiplexing, ohne auf dumbpipe-Hacks angewiesen zu sein.
|
||||
|
||||
Für einen ersten **Proof of Concept** reicht Option A mit dumbpipe + socat aus, um die Prinzipien zu validieren, bevor eigener Code entsteht.
|
||||
|
||||
OPNsense und Nginx müssen in keinem der Szenarien strukturell verändert werden – Iroh ist eine reine Ergänzung auf Applikationsebene.
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Client-Rolle (Nginx-Host): lauscht auf einem lokalen TCP-Port und leitet
|
||||
//! jede Verbindung über einen QUIC-Stream zum Peer (Serving Host) weiter.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::endpoint::{presets, Connection};
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::key::load_or_create_secret_key;
|
||||
use crate::proxy::splice;
|
||||
use crate::ALPN;
|
||||
|
||||
pub async fn run(
|
||||
listen: String,
|
||||
peer: EndpointId,
|
||||
key_file: PathBuf,
|
||||
relay: Option<String>,
|
||||
) -> Result<()> {
|
||||
let secret_key = load_or_create_secret_key(&key_file)?;
|
||||
|
||||
if relay.is_some() {
|
||||
warn!("--relay wird in v1 noch nicht ausgewertet; es werden die n0-Default-Relays genutzt");
|
||||
}
|
||||
// presets::N0 = n0-Default-Relays + DNS-Discovery + Crypto-Provider.
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
.secret_key(secret_key)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.bind()
|
||||
.await
|
||||
.context("Iroh-Endpoint binden")?;
|
||||
|
||||
let listener = TcpListener::bind(&listen)
|
||||
.await
|
||||
.with_context(|| format!("TCP-Listener binden: {listen}"))?;
|
||||
info!(%listen, %peer, "Client läuft, leitet an Peer weiter");
|
||||
|
||||
// Eine wiederverwendete Iroh-Connection; QUIC multiplext alle Streams darüber.
|
||||
let conn: Arc<Mutex<Option<Connection>>> = Arc::new(Mutex::new(None));
|
||||
|
||||
loop {
|
||||
let (tcp, addr) = listener.accept().await.context("TCP accept")?;
|
||||
let endpoint = endpoint.clone();
|
||||
let conn = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = forward(endpoint, conn, peer, tcp).await {
|
||||
warn!("Forward für {addr} fehlgeschlagen: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward(
|
||||
endpoint: Endpoint,
|
||||
conn: Arc<Mutex<Option<Connection>>>,
|
||||
peer: EndpointId,
|
||||
tcp: tokio::net::TcpStream,
|
||||
) -> Result<()> {
|
||||
let connection = get_or_connect(&endpoint, &conn, peer).await?;
|
||||
let (send, recv) = connection.open_bi().await.context("QUIC-Stream öffnen")?;
|
||||
splice(tcp, send, recv).await
|
||||
}
|
||||
|
||||
/// Liefert eine lebende Connection; baut bei Bedarf (oder nach Abriss) neu auf.
|
||||
async fn get_or_connect(
|
||||
endpoint: &Endpoint,
|
||||
conn: &Arc<Mutex<Option<Connection>>>,
|
||||
peer: EndpointId,
|
||||
) -> Result<Connection> {
|
||||
let mut guard = conn.lock().await;
|
||||
|
||||
// Bestehende Connection prüfen: close_reason() == None heißt "noch offen".
|
||||
if let Some(existing) = guard.as_ref() {
|
||||
if existing.close_reason().is_none() {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut backoff = Duration::from_millis(200);
|
||||
loop {
|
||||
match endpoint.connect(peer, ALPN).await {
|
||||
Ok(c) => {
|
||||
info!("Iroh-Verbindung zum Peer aufgebaut");
|
||||
*guard = Some(c.clone());
|
||||
return Ok(c);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Connect fehlgeschlagen ({e}), retry in {backoff:?}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
//! Laden/Erzeugen und Persistieren des Iroh-SecretKeys (Node-Identität).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use data_encoding::HEXLOWER;
|
||||
use iroh::SecretKey;
|
||||
|
||||
/// Lädt den SecretKey aus `path` oder erzeugt einen neuen und speichert ihn.
|
||||
///
|
||||
/// Der Key bestimmt die EndpointId (= Public Key = Adresse im Iroh-Netz). Persistenz
|
||||
/// ist wichtig, damit der Serving Host nach einem Neustart unter derselben Adresse
|
||||
/// erreichbar bleibt.
|
||||
pub fn load_or_create_secret_key(path: &Path) -> Result<SecretKey> {
|
||||
if path.exists() {
|
||||
let hex = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Key-Datei lesen: {}", path.display()))?;
|
||||
let bytes = HEXLOWER
|
||||
.decode(hex.trim().as_bytes())
|
||||
.context("Key-Datei ist kein gültiges Hex")?;
|
||||
let arr: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.context("Key-Datei hat nicht 32 Bytes")?;
|
||||
Ok(SecretKey::from_bytes(&arr))
|
||||
} else {
|
||||
let secret_key = SecretKey::generate();
|
||||
let hex = HEXLOWER.encode(&secret_key.to_bytes());
|
||||
write_secret(path, &hex)
|
||||
.with_context(|| format!("Key-Datei schreiben: {}", path.display()))?;
|
||||
Ok(secret_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_secret(path: &Path, hex: &str) -> Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
f.write_all(hex.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_secret(path: &Path, hex: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
std::fs::write(path, hex)?;
|
||||
Ok(())
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
//! iroh-forward: TCP-Forwarding über das Iroh-Netz (QUIC), adressiert per Public Key.
|
||||
//!
|
||||
//! Zwei Rollen:
|
||||
//! * `server` läuft auf dem Serving Host und leitet eingehende QUIC-Streams an
|
||||
//! einen lokalen TCP-Port weiter.
|
||||
//! * `client` läuft auf dem Nginx-Host, lauscht auf einem lokalen TCP-Port und
|
||||
//! öffnet pro Verbindung einen QUIC-Stream zum Serving Host.
|
||||
|
||||
mod client;
|
||||
mod key;
|
||||
mod proxy;
|
||||
mod server;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use iroh::EndpointId;
|
||||
|
||||
/// ALPN-Kennung für dieses Protokoll; Server und Client müssen übereinstimmen.
|
||||
pub const ALPN: &[u8] = b"iroh-forward/0";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "iroh-forward", about, version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Serving Host: nimmt Iroh-Verbindungen an und leitet sie an einen lokalen Port weiter.
|
||||
Server {
|
||||
/// Lokales Ziel, an das weitergeleitet wird.
|
||||
#[arg(long, default_value = "127.0.0.1:3000")]
|
||||
to: String,
|
||||
/// Datei mit dem persistierten SecretKey (bestimmt die EndpointId/Adresse).
|
||||
#[arg(long, default_value = "./node.key")]
|
||||
key_file: PathBuf,
|
||||
/// Erlaubte Client-EndpointId (mehrfach angebbar). Ohne Angabe: alle erlaubt.
|
||||
#[arg(long = "allow")]
|
||||
allow: Vec<EndpointId>,
|
||||
/// Optionale Custom-Relay-URL (v1: noch nicht ausgewertet).
|
||||
#[arg(long)]
|
||||
relay: Option<String>,
|
||||
},
|
||||
/// Nginx-Host: lauscht lokal auf TCP und leitet über Iroh zum Peer weiter.
|
||||
Client {
|
||||
/// Lokale TCP-Listen-Adresse (Nginx zeigt per proxy_pass hierauf).
|
||||
#[arg(long, default_value = "127.0.0.1:9080")]
|
||||
listen: String,
|
||||
/// EndpointId (Public Key) des Serving Host.
|
||||
#[arg(long)]
|
||||
peer: EndpointId,
|
||||
/// Datei mit dem persistierten SecretKey des Clients.
|
||||
#[arg(long, default_value = "./client.key")]
|
||||
key_file: PathBuf,
|
||||
/// Optionale Custom-Relay-URL (v1: noch nicht ausgewertet).
|
||||
#[arg(long)]
|
||||
relay: Option<String>,
|
||||
},
|
||||
/// Erzeugt (falls nötig) eine Key-Datei und gibt die zugehörige EndpointId aus.
|
||||
Keygen {
|
||||
/// Zieldatei für den SecretKey (wird mit 0600 angelegt, falls nicht vorhanden).
|
||||
#[arg(long, default_value = "./node.key")]
|
||||
key_file: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "iroh_forward=info,warn".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::Server {
|
||||
to,
|
||||
key_file,
|
||||
allow,
|
||||
relay,
|
||||
} => server::serve(to, key_file, allow, relay).await,
|
||||
Command::Client {
|
||||
listen,
|
||||
peer,
|
||||
key_file,
|
||||
relay,
|
||||
} => client::run(listen, peer, key_file, relay).await,
|
||||
Command::Keygen { key_file } => {
|
||||
let secret_key = key::load_or_create_secret_key(&key_file)?;
|
||||
// EndpointId (Public Key) auf stdout, damit sie leicht weiterverarbeitbar ist.
|
||||
println!("{}", secret_key.public());
|
||||
eprintln!("Key-Datei: {}", key_file.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Bidirektionales Kopieren zwischen einem TCP-Stream und einem QUIC-Bi-Stream.
|
||||
|
||||
use anyhow::Result;
|
||||
use iroh::endpoint::{RecvStream, SendStream};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// Pumpt Daten in beide Richtungen, bis eine Seite EOF liefert oder ein Fehler auftritt.
|
||||
///
|
||||
/// `tcp` <-> (`quic_send`, `quic_recv`). Beendet beide Richtungen sauber.
|
||||
pub async fn splice(tcp: TcpStream, quic_send: SendStream, quic_recv: RecvStream) -> Result<()> {
|
||||
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||
let mut quic_send = quic_send;
|
||||
let mut quic_recv = quic_recv;
|
||||
|
||||
// TCP -> QUIC
|
||||
let upload = async {
|
||||
let n = tokio::io::copy(&mut tcp_read, &mut quic_send).await;
|
||||
// Stream finalisieren, damit die Gegenseite EOF sieht.
|
||||
let _ = quic_send.finish();
|
||||
n
|
||||
};
|
||||
|
||||
// QUIC -> TCP
|
||||
let download = async {
|
||||
let n = tokio::io::copy(&mut quic_recv, &mut tcp_write).await;
|
||||
let _ = tcp_write.shutdown().await;
|
||||
n
|
||||
};
|
||||
|
||||
let (up, down) = tokio::join!(upload, download);
|
||||
up?;
|
||||
down?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Server-Rolle (Serving Host): nimmt Iroh-Verbindungen an und leitet jeden
|
||||
//! QUIC-Stream an einen lokalen TCP-Port weiter.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::endpoint::{presets, VarInt};
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::key::load_or_create_secret_key;
|
||||
use crate::proxy::splice;
|
||||
use crate::ALPN;
|
||||
|
||||
/// QUIC-Close-Code, mit dem nicht erlaubte Clients abgewiesen werden.
|
||||
const CLOSE_NOT_ALLOWED: u32 = 1;
|
||||
|
||||
pub async fn serve(
|
||||
target: String,
|
||||
key_file: PathBuf,
|
||||
allow: Vec<EndpointId>,
|
||||
relay: Option<String>,
|
||||
) -> Result<()> {
|
||||
let secret_key = load_or_create_secret_key(&key_file)?;
|
||||
|
||||
if relay.is_some() {
|
||||
warn!("--relay wird in v1 noch nicht ausgewertet; es werden die n0-Default-Relays genutzt");
|
||||
}
|
||||
// presets::N0 = n0-Default-Relays + DNS-Discovery + Crypto-Provider.
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
.secret_key(secret_key)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.bind()
|
||||
.await
|
||||
.context("Iroh-Endpoint binden")?;
|
||||
|
||||
let id = endpoint.id();
|
||||
info!(%id, %target, "Server läuft. Diesen Public Key an den Client geben:");
|
||||
info!(" --peer {id}");
|
||||
|
||||
let allow: Arc<[EndpointId]> = allow.into();
|
||||
if allow.is_empty() {
|
||||
warn!("keine Allowlist gesetzt (--allow) — jeder, der die EndpointId kennt, darf sich verbinden");
|
||||
} else {
|
||||
info!(erlaubte_clients = allow.len(), "Allowlist aktiv");
|
||||
}
|
||||
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
let target = target.clone();
|
||||
let allow = allow.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(incoming, &target, &allow).await {
|
||||
warn!("Verbindung beendet: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
incoming: iroh::endpoint::Incoming,
|
||||
target: &str,
|
||||
allow: &[EndpointId],
|
||||
) -> Result<()> {
|
||||
let connection = incoming.await.context("Connection-Handshake")?;
|
||||
let remote = connection.remote_id();
|
||||
|
||||
// Allowlist durchsetzen: leere Liste = alle erlaubt.
|
||||
if !allow.is_empty() && !allow.contains(&remote) {
|
||||
warn!(%remote, "Client nicht in Allowlist — abgewiesen");
|
||||
connection.close(VarInt::from_u32(CLOSE_NOT_ALLOWED), b"not allowed");
|
||||
return Ok(());
|
||||
}
|
||||
info!(%remote, "neue Iroh-Verbindung");
|
||||
|
||||
loop {
|
||||
// Ein Bi-Stream pro weitergeleiteter TCP-Verbindung.
|
||||
let (send, recv) = match connection.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
info!("Verbindung geschlossen: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let target = target.to_string();
|
||||
tokio::spawn(async move {
|
||||
match TcpStream::connect(&target).await {
|
||||
Ok(tcp) => {
|
||||
if let Err(e) = splice(tcp, send, recv).await {
|
||||
warn!("Stream-Forward-Fehler: {e:#}");
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Ziel {target} nicht erreichbar: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user