Compare commits

...
2 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 192ab25bbd Support RUST_LOG, enable UPnP portmapper and add NAT traversal logging
Previously, `tsunagi-cli` configured clap with a fixed default_value and
`env = "TSUNAGI_LOG"`, which caused `RUST_LOG` to be completely ignored.
In addition, `iroh` was built with `default-features = false` and lacked the
`"portmapper"` feature, which caused it to compile a stub client and disable
UPnP-IGD, PCP, and NAT-PMP port mapping. Furthermore, there was no visibility
into STUN address discovery, UPnP port mapping status, or QUIC transmission path
switching (hole punch vs relay).

Key improvements:
- Enable the `"portmapper"` feature on `iroh` in workspace Cargo.toml, pulling in
  `portmapper` and `igd-next` to actively request port mappings on local routers.
- In `crates/tsunagi-cli/src/main.rs`, support `RUST_LOG` with fallback precedence
  (`--log` > `TSUNAGI_LOG` > `RUST_LOG` > default filter). When debug or trace
  filtering is active, automatically enable `.with_target(true)` so users can see
  which subsystem emitted the log (e.g., portmapper, iroh::magicsock, tsunagi::net).
- In `crates/tsunagi/src/net.rs`, monitor `endpoint.watch_addr()` and
  `endpoint.home_relay_status()`. Emit `DEBUG` logs for local socket binding,
  discovered STUN reflexive/UPnP addresses, and relay connections. If no direct
  addresses are discovered after probe grace, emit a concise `WARN`.
- In `crates/tsunagi/src/agent/session.rs`, monitor connection `path_events()`
  to log when transmission paths are opened, closed, or selected (switching between
  relay and direct hole-punched paths), with bounded task winding down on shutdown.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (349 tests passed).
- Live verification with `RUST_LOG=debug` confirmed SSDP broadcast to 239.255.255.250:1900,
  portmapper logs, STUN QAD discovery, and clean relay shutdown.
2026-09-30 23:08:23 +01:00
Aleksandr Bogomiakov c0a76f1942 Enable all available protocols by default with tcp-tls priority
By default, the agent now enables all available data plane protocols
starting with `tcp-tls,wg-quic`, removing the need to manually pass `--protocol`
to enable HTTPS-camouflage transport. The `--protocol` option remains available
to explicitly restrict or select specific protocols (or reorder their priority).

Furthermore, in `crates/tsunagi-tcp-tls`, if default port 443 is unavailable
(e.g., due to permission constraints or address in use), the transport now
attempts fallback port 8443, and if 8443 is also unavailable, gracefully falls
back to an ephemeral port rather than failing agent startup.

Key changes:
- In `crates/tsunagi-cli/src/main.rs`, update `UpArgs::protocols` default_value
  to `"tcp-tls,wg-quic"`.
- In `crates/tsunagi-cli/src/main.rs`, reorder `PROTOCOLS` registry with `tcp-tls`
  first, followed by `wg-quic`.
- In `crates/tsunagi-tcp-tls/src/transport.rs`, add multi-tier fallback
  (443 -> 8443 -> ephemeral) for unconstrained port bindings.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (349 tests passed).
- cargo build --release passed.
2026-09-30 22:56:08 +01:00
7 changed files with 307 additions and 31 deletions

No files matched your search

Generated
+104
View File
@@ -234,6 +234,18 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "attohttpc"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
dependencies = [
"base64 0.22.1",
"http",
"log",
"url",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -1112,6 +1124,12 @@ dependencies = [
"spin 0.9.9",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1341,6 +1359,25 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1473,6 +1510,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1656,6 +1694,26 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "igd-next"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581"
dependencies = [
"attohttpc",
"bytes",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"rand 0.10.3",
"tokio",
"url",
"xmltree",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -1748,6 +1806,7 @@ dependencies = [
"papaya",
"pin-project",
"portable-atomic",
"portmapper",
"rand 0.10.3",
"reqwest",
"rustc-hash",
@@ -2936,6 +2995,35 @@ dependencies = [
"serde",
]
[[package]]
name = "portmapper"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca97242f016e090a25330613bc2382aab65eb22f9149dbe84d8f8e711d49a530"
dependencies = [
"base64 0.22.1",
"bytes",
"derive_more",
"hyper-util",
"igd-next",
"iroh-metrics",
"libc",
"n0-error",
"n0-future",
"netwatch",
"num_enum",
"rand 0.10.3",
"serde",
"smallvec",
"socket2",
"time",
"tokio",
"tokio-util",
"tower-layer",
"tracing",
"url",
]
[[package]]
name = "postcard"
version = "1.1.3"
@@ -4138,6 +4226,7 @@ dependencies = [
"thiserror 2.0.20",
"tld",
"tokio",
"tokio-stream",
"tracing",
"tracing-subscriber",
"tsunagi",
@@ -4876,6 +4965,21 @@ dependencies = [
"time",
]
[[package]]
name = "xml-rs"
version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xmltree"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb"
dependencies = [
"xml-rs",
]
[[package]]
name = "yasna"
version = "0.6.0"
+1 -1
View File
@@ -27,7 +27,7 @@ expect_used = "warn"
panic = "warn"
[workspace.dependencies]
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
iroh = { version = "1.2", default-features = false, features = ["tls-ring", "portmapper"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
+23 -16
View File
@@ -33,13 +33,10 @@ use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
#[command(name = "tsunagi", version, about, long_about = None)]
struct Cli {
/// Log filter, for example `info` or `tsunagi=debug`.
#[arg(
long,
global = true,
env = "TSUNAGI_LOG",
default_value = "info,iroh=warn,quinn=warn,rustls=warn,boringtun=warn,mainline=off"
)]
log: String,
///
/// Can also be set via the `TSUNAGI_LOG` or `RUST_LOG` environment variables.
#[arg(long, global = true)]
log: Option<String>,
#[command(subcommand)]
command: Command,
@@ -424,7 +421,7 @@ struct UpArgs {
long = "protocol",
value_name = "LIST",
value_delimiter = ',',
default_value = "wg-quic",
default_value = "tcp-tls,wg-quic",
help_heading = "Transport"
)]
protocols: Vec<String>,
@@ -696,9 +693,19 @@ fn parse_peer(text: &str) -> Result<EndpointAddr, String> {
fn main() -> std::process::ExitCode {
let cli = Cli::parse();
let log_filter = cli
.log
.or_else(|| std::env::var("TSUNAGI_LOG").ok())
.or_else(|| std::env::var("RUST_LOG").ok())
.unwrap_or_else(|| {
"info,iroh=warn,quinn=warn,rustls=warn,boringtun=warn,mainline=off".to_string()
});
let is_debug = log_filter.contains("debug") || log_filter.contains("trace");
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(&cli.log))
.with_target(false)
.with_env_filter(tracing_subscriber::EnvFilter::new(&log_filter))
.with_target(is_debug)
.compact()
.with_writer(std::io::stderr)
.init();
@@ -1175,6 +1182,12 @@ struct ProtocolSpec {
/// Every protocol this build has.
const PROTOCOLS: &[ProtocolSpec] = &[
ProtocolSpec {
name: TCP_TLS_PROTOCOL,
version: TCP_TLS_VERSION,
summary: "Noise E2EE carried over TCP TLS 1.3 (port 443) disguised as HTTPS traffic",
options: TcpTlsCodec::OPTIONS,
},
ProtocolSpec {
name: tsunagi_wg_quic::WIREGUARD_PROTOCOL,
version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION,
@@ -1182,12 +1195,6 @@ const PROTOCOLS: &[ProtocolSpec] = &[
crosses NAT and survives where plain WireGuard is blocked",
options: WireguardPlugin::OPTIONS,
},
ProtocolSpec {
name: TCP_TLS_PROTOCOL,
version: TCP_TLS_VERSION,
summary: "Noise E2EE carried over TCP TLS 1.3 (port 443) disguised as HTTPS traffic",
options: TcpTlsCodec::OPTIONS,
},
];
/// One `-o` setting, and the protocol it was aimed at.
+38 -12
View File
@@ -354,12 +354,12 @@ impl TcpTlsTransport {
tracing::info!(port = target_port, "TCP TLS listening");
Some(Arc::new(l))
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
Err(err) if requested_port.is_none() => {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "tsunagi".into());
if requested_port.is_none() {
if err.kind() == std::io::ErrorKind::PermissionDenied {
tracing::warn!(
port = target_port,
%err,
@@ -368,23 +368,49 @@ impl TcpTlsTransport {
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}\n\
Falling back to port {FALLBACK_PORT}."
);
let fallback_addr = SocketAddr::from(([0, 0, 0, 0], FALLBACK_PORT));
let l = TcpListener::bind(fallback_addr).await?;
tracing::info!(port = FALLBACK_PORT, "TCP TLS listening on fallback port");
Some(Arc::new(l))
} else {
tracing::warn!(
port = target_port,
%err,
"Cannot bind to requested port {target_port} without privileges. \
To allow port {target_port} and TUN on Linux, grant capabilities once with:\n \
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}"
"Cannot bind to default port {target_port}: {err}. \
Falling back to port {FALLBACK_PORT}."
);
return Err(Box::new(err));
}
let fallback_addr = SocketAddr::from(([0, 0, 0, 0], FALLBACK_PORT));
match TcpListener::bind(fallback_addr).await {
Ok(l) => {
tracing::info!(port = FALLBACK_PORT, "TCP TLS listening on fallback port");
Some(Arc::new(l))
}
Err(fallback_err) => {
tracing::warn!(
port = FALLBACK_PORT,
err = %fallback_err,
"Cannot bind to fallback port {FALLBACK_PORT}: {fallback_err}. \
Falling back to ephemeral port."
);
let ephemeral_addr = SocketAddr::from(([0, 0, 0, 0], 0));
let l = TcpListener::bind(ephemeral_addr).await?;
let bound = l.local_addr()?.port();
tracing::info!(port = bound, "TCP TLS listening on ephemeral port");
Some(Arc::new(l))
}
}
}
Err(err) => return Err(Box::new(err)),
Err(err) => {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "tsunagi".into());
tracing::warn!(
port = target_port,
%err,
"Cannot bind to requested port {target_port}: {err}. \
To allow port {target_port} and TUN on Linux, grant capabilities once with:\n \
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}"
);
return Err(Box::new(err));
}
};
let bound_port = listener
+1
View File
@@ -38,6 +38,7 @@ iroh.workspace = true
# `net` for the local control interface: a Unix socket on Unix, a named pipe
# on Windows. Both live behind the same `net` feature.
tokio = { workspace = true, features = ["net"] }
tokio-stream = "0.1"
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
+55 -2
View File
@@ -80,11 +80,12 @@ pub(crate) struct Session {
pub(crate) bytes_received: u64,
reader: JoinHandle<()>,
writer: JoinHandle<()>,
path_monitor: JoinHandle<()>,
shutdown: Shutdown,
}
impl Session {
/// Signals both tasks to stop and waits for them, with a bounded grace
/// Signals all tasks to stop and waits for them, with a bounded grace
/// period.
///
/// A peer that stops reading must not be able to hold up shutdown, so the
@@ -94,6 +95,7 @@ impl Session {
conn,
reader,
writer,
path_monitor,
shutdown,
..
} = self;
@@ -103,23 +105,27 @@ impl Session {
let reader_abort = reader.abort_handle();
let writer_abort = writer.abort_handle();
let path_abort = path_monitor.abort_handle();
let joined = tokio::time::timeout(STOP_GRACE, async move {
let _ = reader.await;
let _ = writer.await;
let _ = path_monitor.await;
})
.await;
if joined.is_err() {
tracing::debug!("session tasks did not wind down in time; aborting them");
reader_abort.abort();
writer_abort.abort();
path_abort.abort();
}
}
/// Aborts both tasks without waiting. Used on replacement.
/// Aborts all tasks without waiting. Used on replacement.
pub(crate) fn abort(&self) {
self.shutdown.trigger();
self.reader.abort();
self.writer.abort();
self.path_monitor.abort();
}
}
@@ -155,6 +161,52 @@ pub(crate) fn spawn(
parent_shutdown.clone(),
));
let path_conn = conn.clone();
let path_shutdown = shutdown.clone();
let path_parent = parent_shutdown.clone();
let path_monitor = tokio::spawn(async move {
use tokio_stream::StreamExt;
let mut events = path_conn.path_events();
loop {
tokio::select! {
biased;
_ = path_shutdown.wait() => break,
_ = path_parent.wait() => break,
ev = events.next() => {
match ev {
Some(iroh::endpoint::PathEvent::Selected { remote_addr, local_addr, .. }) => {
tracing::debug!(
peer = %peer.fmt_short(),
remote = %remote_addr,
local = ?local_addr,
"Connection path selected (direct / relay / hole punch switch)"
);
}
Some(iroh::endpoint::PathEvent::Opened { remote_addr, .. }) => {
tracing::debug!(
peer = %peer.fmt_short(),
remote = %remote_addr,
"Network path opened to peer"
);
}
Some(iroh::endpoint::PathEvent::Closed { remote_addr, .. }) => {
tracing::debug!(
peer = %peer.fmt_short(),
remote = %remote_addr,
"Network path closed for peer"
);
}
Some(iroh::endpoint::PathEvent::Lagged { missed, .. }) => {
tracing::trace!(peer = %peer.fmt_short(), missed, "Path events lagged");
}
Some(_) => {}
None => break,
}
}
}
}
});
let reader = tokio::spawn(reader_task(
id,
network_id,
@@ -182,6 +234,7 @@ pub(crate) fn spawn(
bytes_received: 0,
reader,
writer,
path_monitor,
shutdown,
}
}
+85
View File
@@ -259,6 +259,91 @@ impl EndpointAdapter {
.await
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
tracing::debug!(
endpoint = %endpoint.id().fmt_short(),
bound_sockets = ?endpoint.bound_sockets(),
"iroh endpoint bound locally"
);
let ep_id = endpoint.id();
let mut addr_watcher = endpoint.watch_addr();
let mut relay_watcher = endpoint.home_relay_status();
let close_token = endpoint.clone();
tokio::spawn(async move {
use iroh::Watcher;
let mut last_addrs: Vec<SocketAddr> = Vec::new();
let mut last_relays: Vec<String> = Vec::new();
let warn_timer = tokio::time::sleep(Duration::from_secs(12));
tokio::pin!(warn_timer);
let mut warned = false;
loop {
tokio::select! {
biased;
_ = close_token.closed() => {
break;
}
() = &mut warn_timer, if !warned => {
warned = true;
if last_addrs.is_empty() && !last_relays.is_empty() {
tracing::warn!(
endpoint = %ep_id.fmt_short(),
relays = ?last_relays,
"No direct reflexive or UPnP addresses discovered after probe; communications will rely on relay servers"
);
} else if last_addrs.is_empty() && last_relays.is_empty() {
tracing::warn!(
endpoint = %ep_id.fmt_short(),
"No reachability discovered (neither direct reflexive/UPnP addresses nor relay servers available)"
);
}
}
res = addr_watcher.updated() => {
let addr = match res {
Ok(a) => a,
Err(_) => break,
};
let addrs: Vec<SocketAddr> = addr.ip_addrs().copied().collect();
let relays: Vec<String> = addr.relay_urls().map(|u| u.to_string()).collect();
if addrs != last_addrs || relays != last_relays {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
direct_addrs = ?addrs,
relays = ?relays,
"Discovered endpoint reachability (STUN/UPnP/local/relay)"
);
last_addrs = addrs;
last_relays = relays;
}
}
res = relay_watcher.updated() => {
let relays = match res {
Ok(r) => r,
Err(_) => break,
};
for relay in &relays {
if relay.is_connected() {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
relay = %relay.url(),
"Connected to home relay server"
);
} else if let Some(err) = relay.last_error() {
tracing::debug!(
endpoint = %ep_id.fmt_short(),
relay = %relay.url(),
error = %err,
"Relay connection attempt failed"
);
}
}
}
}
}
});
Ok(Self { endpoint })
}