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,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