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:
sko
2026-06-21 14:25:15 +00:00
co-authored by Claude Opus 4.8
commit ec7c96b092
10 changed files with 4942 additions and 0 deletions
+99
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
}
}
+35
View File
@@ -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(())
}
+99
View File
@@ -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}"),
}
});
}
}