Add tcp-tls transport and tunnel protocol disguised as HTTPS

Implement the `tcp-tls` protocol crate and generic tunnel codec abstractions
alongside `wg-quic` to circumvent deep packet inspection (DPI/TSPU), UDP
blocking, and QUIC throttling. Traffic uses standard TCP on port 443 with
TLS 1.3 framing, looking identical to regular HTTPS traffic to network
observers.

Architectural highlights:
- TunnelCodec & GenericTunnelPlugin: Provide an extensible plugin abstraction
  in `crates/tsunagi/src/dataplane/tunnel.rs` that encapsulates background task
  lifecycles, per-peer channels, and sink dispatch, so new transport plugins
  only define cryptographic transformations and handshake payloads.
- MultiPacketTransport & Protocol Priority Selection: Support multiple
  simultaneous transports in `AgentConfig` and dynamically dispatch `open()`
  calls by protocol identifier. In `ensure_links`, choose the highest-priority
  mutually supported protocol per peer to avoid duplicate links.
- TLS 1.3 & Certificate Pinning: Generate self-signed Ed25519 X.509 certificates
  derived from the persistent `DeviceIdentity` secret. Clients pin peer
  certificates using `PinnedEndpointVerifier` against `iroh::EndpointId`.
- End-to-end Encryption: `PeerCipher` uses X25519 static-static ECDH, HKDF-SHA256,
  and ChaCha20-Poly1305 with incrementing 64-bit nonces. Relayed traffic across
  intermediate nodes remains confidential and tamper-proof.
- Privilege handling: Default to TCP port 443 with fallback to 8443 on
  `PermissionDenied`, providing an actionable message recommending:
  `sudo setcap cap_net_admin,cap_net_bind_service+p <path>`.

Validation on Linux:
- `cargo fmt --all -- --check` passed.
- `cargo clippy --locked --workspace --all-targets -- -D warnings` passed.
- `cargo test --locked --workspace --all-targets` passed (all workspace unit,
  integration, CLI, and new `tsunagi-tcp-tls` tests passed).
This commit is contained in:
Aleksandr Bogomiakov committed 2026-09-30 16:41:51 +01:00
1 parent db9015b26c
commit fac3bd7bb9
20 files changed
+2195 -36

No files matched your search

+62 -8
View File
@@ -23,6 +23,9 @@ use tsunagi::iroh_types::EndpointAddr;
use tsunagi::overlay::{MemoryTunFactory, TunFactory};
use tsunagi::state::Ipv4Range;
use tsunagi::{Agent, NetworkId};
use tsunagi_tcp_tls::{
TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCodec, TcpTlsConfig, TcpTlsPlugin, TcpTlsTransport,
};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// A small agent for private mesh networks.
@@ -1164,13 +1167,21 @@ struct ProtocolSpec {
}
/// Every protocol this build has.
const PROTOCOLS: &[ProtocolSpec] = &[ProtocolSpec {
name: tsunagi_wg_quic::WIREGUARD_PROTOCOL,
version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION,
summary: "WireGuard's cryptography carried in iroh's QUIC datagrams, so it \
crosses NAT and survives where plain WireGuard is blocked",
options: WireguardPlugin::OPTIONS,
}];
const PROTOCOLS: &[ProtocolSpec] = &[
ProtocolSpec {
name: tsunagi_wg_quic::WIREGUARD_PROTOCOL,
version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION,
summary: "WireGuard's cryptography carried in iroh's QUIC datagrams, so it \
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.
struct Setting {
@@ -3375,7 +3386,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
}
let discovery: Arc<dyn NetworkDiscovery> =
Arc::new(CompositeDiscovery::new([
Arc::new(StaticBootstrap::new(bootstrap)) as Arc<dyn NetworkDiscovery>,
Arc::new(StaticBootstrap::new(bootstrap.clone())) as Arc<dyn NetworkDiscovery>,
]));
let mut config = AgentConfig::new(paths.clone())
@@ -3436,6 +3447,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
// The interface belongs to the agent, so it is configured once whatever
// was selected to carry traffic over it.
let mut wireguard = None;
let mut tcp_tls_transport = None;
if !wanted.is_empty() {
let tun_factory: Arc<dyn TunFactory> = if args.no_tun {
Arc::new(MemoryTunFactory::new())
@@ -3459,6 +3471,32 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
config = config.with_plugin(plugin.clone() as Arc<dyn IpPlugin>);
wireguard = Some(plugin);
}
TCP_TLS_PROTOCOL => {
let tls_cfg = TcpTlsConfig::new();
let tls_cfg = TcpTlsCodec::configure(tls_cfg, &options)?;
let identity = tsunagi::storage::StateStore::open(paths.state_db())?
.load_or_create_device_identity()?;
let transport = Arc::new(
TcpTlsTransport::bind(&identity, tls_cfg.port)
.await
.map_err(|err| err.to_string())?,
);
for ep_addr in &bootstrap {
for ip in ep_addr.ip_addrs() {
transport.set_peer_addr(ep_addr.id, *ip);
}
}
let codec = TcpTlsCodec::new(identity.endpoint_id(), Arc::clone(&transport));
let plugin = Arc::new(TcpTlsPlugin::new(codec));
config = config
.with_plugin(plugin as Arc<dyn IpPlugin>)
.with_custom_transport(
TCP_TLS_PROTOCOL,
Arc::clone(&transport)
as Arc<dyn tsunagi::dataplane::transport::PacketTransport>,
);
tcp_tls_transport = Some(transport);
}
other => return Err(format!("`{other}` is listed but not built in").into()),
}
}
@@ -3495,6 +3533,22 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
// is never dropped without being closed.
let mut events = agent.subscribe();
if let Some(transport) = tcp_tls_transport {
let agent_inbound = agent.clone();
tokio::spawn(async move {
transport
.accept_loop(move |inbound| {
let agent = agent_inbound.clone();
tokio::spawn(async move {
if let Err(err) = agent.install_inbound_link(inbound).await {
tracing::debug!(%err, "failed to install inbound tcp-tls link");
}
});
})
.await;
});
}
println!("tsunagi is up");
println!(" endpoint id {}", agent.endpoint_id());
println!(" hostname {}", agent.hostname());