Statt der statischen Liste in der README verweist diese nun auf die Issues. Erstellt: #1 (systemd-Units), #2 (Pro-Client-Service-Autorisierung). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
iroh-forward
TCP-Forwarding über das Iroh-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.
Architektur-Vergleich
Klassischer Nginx-Reverse-Proxy
Nginx leitet per proxy_pass an eine geroutete IP des Serving Host weiter. Der Serving Host
muss dafür über das Netz erreichbar sein — also feste IP bzw. ein VPN-Tunnel und ein eingehender
Port. Standortwechsel, CGNAT oder dynamische IPs brechen das Setup.
flowchart LR
Client([Client]) -->|HTTPS| OPN[OPNsense]
OPN --> NGX[Nginx]
subgraph NH [Nginx-Host]
NGX
end
NGX -->|"proxy_pass<br/>http://192.168.254.111:3000<br/>(geroutetes Netz / VPN)"| APP
subgraph SH ["Serving Host — feste IP / VPN + eingehender Port nötig"]
APP[App :3000]
end
Mit iroh-forward
Nginx zeigt auf einen lokalen Port; der iroh-forward client baut die Verbindung über das
Iroh-Netz (QUIC, adressiert per Public Key) zum iroh-forward server auf. Beide Seiten verbinden
sich ausgehend (über n0-Relay oder direktes Hole-Punching) — der Serving Host braucht keinen
eingehenden Port und darf hinter CGNAT / mit dynamischer IP an beliebigem Standort laufen.
flowchart LR
Client([Client]) -->|HTTPS| OPN[OPNsense]
OPN --> NGX[Nginx]
subgraph NH [Nginx-Host]
NGX -->|"proxy_pass<br/>http://127.0.0.1:9080"| IFC[iroh-forward client]
end
IFC <-->|"QUIC / Iroh · TLS 1.3<br/>dial by Public Key<br/>n0-Relay oder Hole-Punch"| IFS
subgraph SH ["Serving Host — kein eingehender Port · CGNAT / dyn. IP ok"]
IFS[iroh-forward server] -->|"127.0.0.1:3000"| APP[App :3000]
end
QUIC multiplext beliebig viele Streams über eine Connection → parallele Requests skalieren ohne Tricks.
Build
Voraussetzung: eine Rust-Toolchain (Rust 1.82+). Falls noch nicht vorhanden:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Release-Binary bauen:
cargo build --release
Das fertige Binary liegt unter ./target/release/iroh-forward. Direkt ausführen:
./target/release/iroh-forward --help
# oder ohne expliziten Build:
cargo run --release -- --help
Systemweit installieren (nach ~/.cargo/bin):
cargo install --path .
Releases (Gitea Actions)
Der Workflow .gitea/workflows/release.yaml baut bei einem SemVer-Tag ein statisches
musl-Binary und veröffentlicht es als Release-Asset (inkl. .sha256 und auto-generierten
Release-Notes aus den Commits seit dem letzten Tag).
# nach SemVer taggen und pushen — das löst den Build + Release aus:
git tag v0.1.0
git push origin v0.1.0
Tags mit Suffix (v1.2.3-rc1) werden automatisch als Pre-Release markiert. Die Paketversion
in Cargo.toml wird im Build an den Tag angeglichen, sodass iroh-forward --version passt.
Voraussetzungen: In den Repo-Einstellungen müssen Actions aktiviert und ein
act_runner registriert sein. Der Workflow nutzt
den automatischen GITHUB_TOKEN; falls dieser keine Release-Schreibrechte hat, ein Repo-Secret
RELEASE_TOKEN (Personal Access Token mit write:repository) anlegen.
Statisches Binary fürs Deployment (optional)
Für ein weitgehend abhängigkeitsfreies Binary, das auf jedem Linux-Host ohne passende glibc läuft,
gegen musl bauen. Da iroh standardmäßig ring (C-Crypto) nutzt, wird die musl-C-Toolchain
benötigt:
# Debian/Ubuntu:
sudo apt install musl-tools
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# Binary: ./target/x86_64-unknown-linux-musl/release/iroh-forward
Nutzung
Serving Host (Ziel)
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)
iroh-forward client \
--listen 127.0.0.1:9080 \
--peer 7f2bba1e... \
--key-file /etc/iroh/client.key
Nginx-vhost:
location / {
proxy_pass http://127.0.0.1:9080;
}
Optionen
| Flag | Default | Beschreibung |
|---|---|---|
--to (server) |
127.0.0.1:3000 |
Forward-Ziel als NAME=ADDR, mehrfach angebbar. Bloßes ADDR ⇒ Name default |
--allow (server) |
— | erlaubte Client-EndpointId; mehrfach angebbar. Ohne Angabe: alle erlaubt |
--listen (client) |
127.0.0.1:9080 |
Listener als NAME=ADDR, mehrfach angebbar. NAME muss einem --to-Namen des Servers entsprechen |
--peer (client) |
— | EndpointId des Serving Host (Pflicht) |
--relay (beide) |
— | self-hosted Relay-URL (z.B. https://relay.example.com). Ohne Angabe: n0-Default-Relays |
--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.
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:
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:
# 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.
Multi-Port: mehrere Dienste über einen Tunnel
Server und Client können mehrere benannte Forwards bedienen. Jeder Client-Listener sendet seinen
Service-Namen als Stream-Vorspann; der Server ordnet ihn dem passenden --to-Ziel zu. Alle
Forwards laufen über eine geteilte QUIC-Connection (Multiplexing).
# Serving Host — z.B. Web-App + SSH:
iroh-forward server --key-file node.key \
--to web=127.0.0.1:3000 \
--to ssh=127.0.0.1:22 \
--allow <client-id>
# Nginx-Host — lokale Ports, je einem Service zugeordnet:
iroh-forward client --peer <server-id> --key-file client.key \
--listen web=127.0.0.1:9080 \
--listen ssh=127.0.0.1:2222
Dann nginx → 127.0.0.1:9080 (web) und ssh -p 2222 user@127.0.0.1 (ssh). Ein Listener mit
einem Service-Namen, den der Server nicht kennt, wird serverseitig verworfen.
Es funktioniert jedes TCP-Protokoll (SSH, MySQL, PostgreSQL, Redis, …). Der einfache Fall ohne
NAME= nutzt automatisch den Service default auf beiden Seiten.
Self-hosted Relay
Standardmäßig werden die öffentlichen n0-Relays genutzt. Mit --relay <URL> (auf beiden Seiten)
wird stattdessen ein eigener iroh-relay verwendet; die
EndpointId-Discovery läuft weiterhin über n0-DNS.
iroh-forward server --relay https://relay.example.com ...
iroh-forward client --relay https://relay.example.com ...
Der Relay sieht nur verschlüsselte Bytes. Praktisch wird er meist nur fürs initiale Rendezvous und bei symmetrischem NAT gebraucht — die meisten Verbindungen laufen nach dem Hole-Punching direkt P2P.
Sicherheit & Grenzen
- Vertrauensanker: siehe Schlüsselmodell. Ohne
--allowist die EndpointId faktisch ein Zugangs-Token → geheim halten; Key-Dateien0600. - Nur TCP: reine UDP-Protokolle werden nicht getunnelt.
- Client-IP: das Ziel sieht als Quelle immer den
server-Prozess (127.0.0.1), nicht die echte Client-IP —X-Forwarded-Forggf. davor an der Edge setzen.
Geplante Erweiterungen werden als Issues verfolgt.