diff --git a/README.md b/README.md index c273ab6..b3ad96d 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,11 @@ location / { | Flag | Default | Beschreibung | | ---- | ------- | ------------ | -| `--to` (server) | `127.0.0.1:3000` | lokales Forward-Ziel | +| `--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` | lokaler TCP-Listen-Port | +| `--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 `: erzeugt (falls nötig) eine Key-Datei und gibt @@ -139,20 +140,54 @@ iroh-forward server --to 127.0.0.1:3000 --key-file node.key \ Besonders wichtig, sobald sensible Dienste (SSH, DB) statt nur eines HTTP-Upstreams getunnelt werden. -## Sicherheit & Grenzen (v1) +## 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). + +```bash +# 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 + +# Nginx-Host — lokale Ports, je einem Service zugeordnet: +iroh-forward client --peer --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 ` (auf beiden Seiten) +wird stattdessen ein eigener [`iroh-relay`](https://www.iroh.computer/docs) verwendet; die +EndpointId-Discovery läuft weiterhin über n0-DNS. + +```bash +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](#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. +- **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-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. +- Allowlist/Service-Zuordnung pro Client (welcher Client welche Services nutzen darf). diff --git a/src/client.rs b/src/client.rs index e2e2727..715b016 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,55 +1,80 @@ -//! Client-Rolle (Nginx-Host): lauscht auf einem lokalen TCP-Port und leitet -//! jede Verbindung über einen QUIC-Stream zum Peer (Serving Host) weiter. +//! Client-Rolle (Nginx-Host): lauscht auf lokalen TCP-Ports und leitet jede +//! Verbindung über einen QUIC-Stream zum Peer (Serving Host) weiter. Pro Listener +//! wird ein Service-Name mitgesendet, den der Server einem Ziel zuordnet. use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; -use iroh::endpoint::{presets, Connection}; +use iroh::endpoint::Connection; use iroh::{Endpoint, EndpointId}; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; +use tokio::task::JoinSet; use tracing::{info, warn}; use crate::key::load_or_create_secret_key; -use crate::proxy::splice; -use crate::ALPN; +use crate::net::build_endpoint; +use crate::proxy::{splice, write_service}; +use crate::{NamedAddr, ALPN}; pub async fn run( - listen: String, + listen: Vec, peer: EndpointId, key_file: PathBuf, relay: Option, ) -> Result<()> { let secret_key = load_or_create_secret_key(&key_file)?; + let endpoint = build_endpoint(secret_key, relay).await?; - 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. + // Eine wiederverwendete Iroh-Connection; QUIC multiplext alle Streams (auch + // über mehrere Listener hinweg) darüber. let conn: Arc>> = Arc::new(Mutex::new(None)); + // Alle Listener vorab binden, damit Bind-Fehler sofort auffallen. + let mut listeners = Vec::new(); + for m in &listen { + let l = TcpListener::bind(&m.addr) + .await + .with_context(|| format!("TCP-Listener binden: {} (service {})", m.addr, m.name))?; + info!(service = %m.name, listen = %m.addr, %peer, "Listener aktiv"); + listeners.push((m.name.clone(), l)); + } + + let mut tasks = JoinSet::new(); + for (service, listener) in listeners { + let endpoint = endpoint.clone(); + let conn = conn.clone(); + tasks.spawn(accept_loop(endpoint, conn, peer, service, listener)); + } + + // Läuft normalerweise endlos; bricht ab, sobald ein Listener einen Fehler liefert. + while let Some(res) = tasks.join_next().await { + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(e) => return Err(anyhow::anyhow!("Listener-Task abgebrochen: {e}")), + } + } + Ok(()) +} + +async fn accept_loop( + endpoint: Endpoint, + conn: Arc>>, + peer: EndpointId, + service: String, + listener: TcpListener, +) -> Result<()> { loop { let (tcp, addr) = listener.accept().await.context("TCP accept")?; let endpoint = endpoint.clone(); let conn = conn.clone(); + let service = service.clone(); tokio::spawn(async move { - if let Err(e) = forward(endpoint, conn, peer, tcp).await { - warn!("Forward für {addr} fehlgeschlagen: {e:#}"); + if let Err(e) = forward(endpoint, conn, peer, &service, tcp).await { + warn!(%service, "Forward für {addr} fehlgeschlagen: {e:#}"); } }); } @@ -59,10 +84,12 @@ async fn forward( endpoint: Endpoint, conn: Arc>>, peer: EndpointId, - tcp: tokio::net::TcpStream, + service: &str, + tcp: TcpStream, ) -> Result<()> { let connection = get_or_connect(&endpoint, &conn, peer).await?; - let (send, recv) = connection.open_bi().await.context("QUIC-Stream öffnen")?; + let (mut send, recv) = connection.open_bi().await.context("QUIC-Stream öffnen")?; + write_service(&mut send, service).await?; splice(tcp, send, recv).await } diff --git a/src/main.rs b/src/main.rs index 97e8f5b..f7f9f02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,12 +2,13 @@ //! //! 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. +//! lokale TCP-Ports weiter (ein oder mehrere benannte Ziele). +//! * `client` läuft auf dem Nginx-Host, lauscht auf lokalen TCP-Ports und öffnet +//! pro Verbindung einen QUIC-Stream zum Serving Host. mod client; mod key; +mod net; mod proxy; mod server; @@ -18,7 +19,33 @@ 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"; +/// `/1`: pro Bi-Stream wird ein Service-Name-Vorspann gesendet (Multi-Port). +pub const ALPN: &[u8] = b"iroh-forward/1"; + +/// Ein benannter Adress-Eintrag aus der CLI: `NAME=ADDR` oder bloß `ADDR` (Name = `default`). +#[derive(Clone, Debug)] +pub struct NamedAddr { + pub name: String, + pub addr: String, +} + +fn parse_named_addr(s: &str) -> Result { + match s.split_once('=') { + Some((name, addr)) => { + if name.is_empty() || addr.is_empty() { + return Err("Format: NAME=ADDR (z.B. web=127.0.0.1:3000)".to_string()); + } + Ok(NamedAddr { + name: name.to_string(), + addr: addr.to_string(), + }) + } + None => Ok(NamedAddr { + name: "default".to_string(), + addr: s.to_string(), + }), + } +} #[derive(Parser)] #[command(name = "iroh-forward", about, version)] @@ -29,33 +56,33 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Serving Host: nimmt Iroh-Verbindungen an und leitet sie an einen lokalen Port weiter. + /// Serving Host: nimmt Iroh-Verbindungen an und leitet sie an lokale Ziele weiter. Server { - /// Lokales Ziel, an das weitergeleitet wird. - #[arg(long, default_value = "127.0.0.1:3000")] - to: String, + /// Forward-Ziel als `NAME=ADDR` (mehrfach für mehrere Dienste). Bloßes `ADDR` = Name `default`. + #[arg(long = "to", value_parser = parse_named_addr, default_value = "127.0.0.1:3000")] + to: Vec, /// 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, - /// Optionale Custom-Relay-URL (v1: noch nicht ausgewertet). + /// Self-hosted Relay-URL (z.B. https://relay.example.com). Ohne Angabe: n0-Default-Relays. #[arg(long)] relay: Option, }, /// 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, + /// Listener als `NAME=ADDR` (mehrfach). `NAME` muss einem `--to`-Namen des Servers entsprechen. + #[arg(long = "listen", value_parser = parse_named_addr, default_value = "127.0.0.1:9080")] + listen: Vec, /// 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). + /// Self-hosted Relay-URL (z.B. https://relay.example.com). Ohne Angabe: n0-Default-Relays. #[arg(long)] relay: Option, }, diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..cb5eaaa --- /dev/null +++ b/src/net.rs @@ -0,0 +1,26 @@ +//! Gemeinsamer Endpoint-Aufbau für Server und Client (inkl. optionalem Custom-Relay). + +use anyhow::{Context, Result}; +use iroh::endpoint::presets; +use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl, SecretKey}; + +use crate::ALPN; + +/// Baut einen Iroh-Endpoint. +/// +/// Standard: `presets::N0` = n0-Default-Relays + DNS-Discovery + Crypto-Provider. +/// Mit `relay` wird der Relay-Transport durch einen self-hosted Relay ersetzt; die +/// n0-DNS-Discovery (EndpointId → aktuelle Adresse) bleibt erhalten. +pub async fn build_endpoint(secret_key: SecretKey, relay: Option) -> Result { + let mut builder = Endpoint::builder(presets::N0) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]); + + if let Some(url) = relay { + let url: RelayUrl = url.parse().context("ungültige Relay-URL (erwartet z.B. https://relay.example.com)")?; + // relay_mode() ersetzt den vom Preset gesetzten Relay-Transport. + builder = builder.relay_mode(RelayMode::Custom(RelayMap::from(url))); + } + + builder.bind().await.context("Iroh-Endpoint binden") +} diff --git a/src/proxy.rs b/src/proxy.rs index 1ea6609..9d83a5f 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,10 +1,42 @@ -//! Bidirektionales Kopieren zwischen einem TCP-Stream und einem QUIC-Bi-Stream. +//! Bidirektionales Kopieren zwischen einem TCP-Stream und einem QUIC-Bi-Stream, +//! plus ein kleines Stream-Vorspann-Protokoll zur Auswahl des Ziel-Service. -use anyhow::Result; +use anyhow::{bail, Context, Result}; use iroh::endpoint::{RecvStream, SendStream}; use tokio::io::AsyncWriteExt; use tokio::net::TcpStream; +/// Schreibt den Service-Namen als Stream-Vorspann: `[u8 Länge][Name-Bytes]`. +/// +/// Der Client sendet das zu Beginn jedes Bi-Streams, damit der Server weiß, an +/// welches der konfigurierten Ziele weitergeleitet werden soll. +pub async fn write_service(send: &mut SendStream, name: &str) -> Result<()> { + let bytes = name.as_bytes(); + if bytes.len() > u8::MAX as usize { + bail!("Service-Name zu lang (max 255 Bytes): {name}"); + } + send.write_all(&[bytes.len() as u8]) + .await + .context("Service-Header schreiben")?; + send.write_all(bytes) + .await + .context("Service-Name schreiben")?; + Ok(()) +} + +/// Liest den vom Client gesendeten Service-Namen (Gegenstück zu [`write_service`]). +pub async fn read_service(recv: &mut RecvStream) -> Result { + let mut len = [0u8; 1]; + recv.read_exact(&mut len) + .await + .context("Service-Header lesen")?; + let mut buf = vec![0u8; len[0] as usize]; + recv.read_exact(&mut buf) + .await + .context("Service-Name lesen")?; + String::from_utf8(buf).context("Service-Name ist kein gültiges UTF-8") +} + /// Pumpt Daten in beide Richtungen, bis eine Seite EOF liefert oder ein Fehler auftritt. /// /// `tcp` <-> (`quic_send`, `quic_recv`). Beendet beide Richtungen sauber. diff --git a/src/server.rs b/src/server.rs index aa08e6e..2171e8d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,44 +1,48 @@ //! Server-Rolle (Serving Host): nimmt Iroh-Verbindungen an und leitet jeden -//! QUIC-Stream an einen lokalen TCP-Port weiter. +//! QUIC-Stream an das per Service-Name gewählte lokale TCP-Ziel weiter. +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use anyhow::{Context, Result}; -use iroh::endpoint::{presets, VarInt}; -use iroh::{Endpoint, EndpointId}; +use anyhow::{bail, Context, Result}; +use iroh::endpoint::VarInt; +use iroh::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; +use crate::net::build_endpoint; +use crate::proxy::{read_service, splice}; +use crate::NamedAddr; /// QUIC-Close-Code, mit dem nicht erlaubte Clients abgewiesen werden. const CLOSE_NOT_ALLOWED: u32 = 1; pub async fn serve( - target: String, + to: Vec, key_file: PathBuf, allow: Vec, relay: Option, ) -> 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"); + // Service-Name -> Ziel-Adresse; doppelte Namen sind ein Konfigurationsfehler. + let mut targets: HashMap = HashMap::new(); + for m in to { + if targets.insert(m.name.clone(), m.addr.clone()).is_some() { + bail!("doppelter --to-Name: {}", m.name); + } } - // 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 targets = Arc::new(targets); + + let secret_key = load_or_create_secret_key(&key_file)?; + let endpoint = build_endpoint(secret_key, relay).await?; let id = endpoint.id(); - info!(%id, %target, "Server läuft. Diesen Public Key an den Client geben:"); + info!(%id, "Server läuft. Diesen Public Key an den Client geben:"); info!(" --peer {id}"); + for (name, addr) in targets.iter() { + info!(service = %name, target = %addr, "Forward-Ziel"); + } let allow: Arc<[EndpointId]> = allow.into(); if allow.is_empty() { @@ -48,10 +52,10 @@ pub async fn serve( } while let Some(incoming) = endpoint.accept().await { - let target = target.clone(); + let targets = targets.clone(); let allow = allow.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(incoming, &target, &allow).await { + if let Err(e) = handle_connection(incoming, targets, &allow).await { warn!("Verbindung beendet: {e:#}"); } }); @@ -61,7 +65,7 @@ pub async fn serve( async fn handle_connection( incoming: iroh::endpoint::Incoming, - target: &str, + targets: Arc>, allow: &[EndpointId], ) -> Result<()> { let connection = incoming.await.context("Connection-Handshake")?; @@ -77,22 +81,34 @@ async fn handle_connection( loop { // Ein Bi-Stream pro weitergeleiteter TCP-Verbindung. - let (send, recv) = match connection.accept_bi().await { + let (send, mut recv) = match connection.accept_bi().await { Ok(s) => s, Err(e) => { info!("Verbindung geschlossen: {e}"); return Ok(()); } }; - let target = target.to_string(); + let targets = targets.clone(); tokio::spawn(async move { - match TcpStream::connect(&target).await { + // Service-Name-Vorspann lesen und passendes Ziel bestimmen. + let name = match read_service(&mut recv).await { + Ok(n) => n, + Err(e) => { + warn!("Service-Header ungültig: {e:#}"); + return; + } + }; + let Some(target) = targets.get(&name) else { + warn!(service = %name, "unbekannter Service angefragt — Stream verworfen"); + return; + }; + 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}"), + Err(e) => error!(service = %name, %target, "Ziel nicht erreichbar: {e}"), } }); }