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

Generated
+256 -2
View File
@@ -126,6 +126,45 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 2.0.20",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -201,6 +240,29 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "backon"
version = "1.6.0"
@@ -236,6 +298,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -357,6 +428,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -476,6 +549,15 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
@@ -722,6 +804,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -844,6 +940,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -1035,6 +1137,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.34"
@@ -1856,6 +1964,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.105"
@@ -2026,6 +2144,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.3"
@@ -2330,6 +2454,16 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "noq"
version = "1.3.0"
@@ -2401,12 +2535,31 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2528,6 +2681,15 @@ dependencies = [
"objc2-security",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2899,6 +3061,19 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rcgen"
version = "0.14.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
dependencies = [
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3044,6 +3219,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.5"
@@ -3063,6 +3247,7 @@ version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@@ -3127,6 +3312,7 @@ version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -3527,6 +3713,17 @@ dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -3964,9 +4161,38 @@ dependencies = [
"tracing",
"tracing-subscriber",
"tsunagi",
"tsunagi-tcp-tls",
"tsunagi-wg-quic",
]
[[package]]
name = "tsunagi-tcp-tls"
version = "0.1.0"
dependencies = [
"bytes",
"chacha20poly1305",
"hex",
"hkdf",
"iroh",
"netdev 0.46.3",
"postcard",
"rand 0.10.3",
"rcgen",
"rustls",
"serde",
"sha2",
"subtle",
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tracing",
"tracing-subscriber",
"tsunagi",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "tsunagi-wg-quic"
version = "0.1.0"
@@ -4631,6 +4857,34 @@ dependencies = [
"zeroize",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 2.0.20",
"time",
]
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -4651,7 +4905,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -4757,7 +5011,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+1
View File
@@ -18,6 +18,7 @@ doc = false
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
iroh.workspace = true
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
tracing.workspace = true
+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());
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "tsunagi-tcp-tls"
version = "0.1.0"
description = "TCP TLS 443 transport and E2EE tunnel protocol for tsunagi mesh networks."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
rustls = "0.23"
tokio-rustls = "0.26"
rcgen = { version = "0.14", default-features = false, features = ["ring"] }
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
sha2 = "0.11"
hkdf = "0.13"
rand = "0.10"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
bytes.workspace = true
serde.workspace = true
postcard.workspace = true
tracing.workspace = true
thiserror.workspace = true
hex.workspace = true
netdev = "0.46"
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
+143
View File
@@ -0,0 +1,143 @@
//! TLS certificate generation and verification.
//!
//! Generates a self-signed TLS certificate derived from the device's persistent
//! identity, and verifies remote certificates against the expected peer's
//! [`iroh::EndpointId`].
use std::sync::Arc;
use iroh::EndpointId;
use rustls::client::danger::{ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, Error as RustlsError, ServerConfig};
/// Verifies that the server certificate's public key matches the expected peer.
#[derive(Debug)]
pub struct PinnedEndpointVerifier {
expected: EndpointId,
}
impl PinnedEndpointVerifier {
/// Creates a verifier expecting `peer`.
pub fn new(peer: EndpointId) -> Self {
Self { expected: peer }
}
}
impl ServerCertVerifier for PinnedEndpointVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
// Parse the certificate's SPKI / public key
let cert_bytes = end_entity.as_ref();
let expected_bytes = self.expected.as_bytes();
// Check if the expected 32-byte Ed25519 public key is in the certificate
if cert_bytes
.windows(32)
.any(|window| window == expected_bytes)
{
Ok(ServerCertVerified::assertion())
} else {
Err(RustlsError::InvalidCertificate(
rustls::CertificateError::NotValidForName,
))
}
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
// We require TLS 1.3
Err(RustlsError::PeerIncompatible(
rustls::PeerIncompatible::NoSignatureSchemesInCommon,
))
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, RustlsError> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![rustls::SignatureScheme::ED25519]
}
}
/// Generates a self-signed certificate and private key for TLS 1.3 using
/// a given 32-byte Ed25519 secret key.
pub fn generate_self_signed_cert(
secret_key_bytes: &[u8; 32],
) -> Result<
(
CertificateDer<'static>,
rustls::pki_types::PrivateKeyDer<'static>,
),
Box<dyn std::error::Error + Send + Sync>,
> {
// PKCS#8 prefix for v1 Ed25519 private keys:
// 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 <32 bytes>
let mut pkcs8 = Vec::with_capacity(48);
pkcs8.extend_from_slice(&[
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
0x20,
]);
pkcs8.extend_from_slice(secret_key_bytes);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(pkcs8);
let key_pair = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(&key_der, &rcgen::PKCS_ED25519)?;
let mut params = rcgen::CertificateParams::new(vec!["tsunagi.local".to_string()])?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, "tsunagi-node");
let cert = params.self_signed(&key_pair)?;
let cert_der = CertificateDer::from(cert.der().to_vec());
let priv_key = rustls::pki_types::PrivateKeyDer::Pkcs8(key_der);
Ok((cert_der, priv_key))
}
/// Creates a `rustls::ServerConfig` for the node.
pub fn make_server_config(
cert: CertificateDer<'static>,
key: rustls::pki_types::PrivateKeyDer<'static>,
) -> Result<Arc<ServerConfig>, rustls::Error> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(vec![cert], key)?;
config.alpn_protocols = vec![b"tsunagi-tls/1".to_vec()];
Ok(Arc::new(config))
}
/// Creates a `rustls::ClientConfig` expecting a specific peer's `EndpointId`.
pub fn make_client_config(expected_peer: EndpointId) -> Result<Arc<ClientConfig>, rustls::Error> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let verifier = Arc::new(PinnedEndpointVerifier::new(expected_peer));
let mut config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
config.alpn_protocols = vec![b"tsunagi-tls/1".to_vec()];
Ok(Arc::new(config))
}
+104
View File
@@ -0,0 +1,104 @@
//! End-to-end packet encryption for `tcp-tls`.
//!
//! Uses X25519 Diffie-Hellman key exchange and ChaCha20-Poly1305 AEAD.
//! This ensures that even when traffic passes through intermediate relay nodes,
//! the intermediate nodes cannot inspect or tamper with the payload.
use std::sync::atomic::{AtomicU64, Ordering};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use hkdf::Hkdf;
use iroh::EndpointId;
use sha2::Sha256;
use tsunagi::dataplane::PluginError;
use x25519_dalek::{PublicKey, StaticSecret};
/// Length of the X25519 public key.
pub const KEY_LEN: usize = 32;
/// Domain separator for session key derivation.
const HKDF_SALT: &[u8] = b"tsunagi/e2ee/noise/v1";
/// A session cipher for communicating with one peer.
pub struct PeerCipher {
tx_cipher: ChaCha20Poly1305,
rx_cipher: ChaCha20Poly1305,
tx_nonce: AtomicU64,
}
impl PeerCipher {
/// Establishes a new cipher between `local_id` and `remote_id` given
/// our local secret and their public key.
pub fn new(
local_id: EndpointId,
remote_id: EndpointId,
local_secret: &StaticSecret,
remote_public: &PublicKey,
) -> Self {
let shared = local_secret.diffie_hellman(remote_public);
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared.as_bytes());
let mut key_a_to_b = [0u8; 32];
let mut key_b_to_a = [0u8; 32];
let _ = hk.expand(b"tsunagi-key-a-to-b", &mut key_a_to_b);
let _ = hk.expand(b"tsunagi-key-b-to-a", &mut key_b_to_a);
// Deterministic role: smaller EndpointId is party A.
let is_party_a = local_id.as_bytes() < remote_id.as_bytes();
let (tx_key, rx_key) = if is_party_a {
(key_a_to_b, key_b_to_a)
} else {
(key_b_to_a, key_a_to_b)
};
let tx_cipher = ChaCha20Poly1305::new(&tx_key.into());
let rx_cipher = ChaCha20Poly1305::new(&rx_key.into());
Self {
tx_cipher,
rx_cipher,
tx_nonce: AtomicU64::new(0),
}
}
/// Encrypts an outbound packet. Prefixes the ciphertext with the 8-byte nonce.
pub fn encrypt(&self, packet: &[u8]) -> Result<bytes::Bytes, PluginError> {
let counter = self.tx_nonce.fetch_add(1, Ordering::Relaxed);
let mut nonce_bytes = [0u8; 12];
nonce_bytes[4..12].copy_from_slice(&counter.to_be_bytes());
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.tx_cipher
.encrypt(nonce, packet)
.map_err(|err| PluginError::Other(format!("encryption failed: {err}")))?;
let mut out = Vec::with_capacity(8 + ciphertext.len());
out.extend_from_slice(&counter.to_be_bytes());
out.extend_from_slice(&ciphertext);
Ok(bytes::Bytes::from(out))
}
/// Decrypts an inbound packet prefixed with the 8-byte nonce.
pub fn decrypt(&self, payload: &[u8]) -> Result<bytes::Bytes, PluginError> {
if payload.len() < 8 + 16 {
return Err(PluginError::Other("payload too short".into()));
}
let counter_bytes: [u8; 8] = match payload[..8].try_into() {
Ok(b) => b,
Err(_) => return Err(PluginError::Other("invalid nonce".into())),
};
let mut nonce_bytes = [0u8; 12];
nonce_bytes[4..12].copy_from_slice(&counter_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = self
.rx_cipher
.decrypt(nonce, &payload[8..])
.map_err(|err| PluginError::Other(format!("decryption failed: {err}")))?;
Ok(bytes::Bytes::from(plaintext))
}
}
+9
View File
@@ -0,0 +1,9 @@
//! The `tcp-tls` protocol: TCP with TLS 1.3 on port 443 with Noise E2EE.
pub mod cert;
pub mod crypto;
pub mod plugin;
pub mod transport;
pub use plugin::{TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCodec, TcpTlsConfig, TcpTlsPlugin};
pub use transport::TcpTlsTransport;
+254
View File
@@ -0,0 +1,254 @@
//! The `tcp-tls` IP plugin implementation.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use x25519_dalek::{PublicKey, StaticSecret};
use tsunagi::dataplane::tunnel::{GenericTunnelPlugin, TunnelCodec};
use tsunagi::dataplane::{PluginError, ProtocolOption};
use tsunagi::identity::NetworkId;
use crate::crypto::PeerCipher;
use crate::transport::TcpTlsTransport;
/// The wire protocol identifier for `tcp-tls`.
pub const TCP_TLS_PROTOCOL: &str = "tcp-tls";
/// Wire version for `tcp-tls`.
pub const TCP_TLS_VERSION: u16 = 1;
/// Configuration for the `tcp-tls` protocol plugin.
#[derive(Debug, Clone, Default)]
pub struct TcpTlsConfig {
/// Requested port to bind.
pub port: Option<u16>,
}
impl TcpTlsConfig {
/// Creates a configuration with default settings.
pub fn new() -> Self {
Self::default()
}
/// Sets the TCP listen port.
pub fn with_port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
}
/// Announcement payload exchanged between peers for `tcp-tls`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TcpTlsAnnouncement {
/// X25519 public key for E2EE noise encryption.
pub noise_public: [u8; 32],
/// TCP port the peer is listening on.
pub port: u16,
/// Candidate IP addresses where this peer can be reached on `port`.
#[serde(default)]
pub addrs: Vec<std::net::IpAddr>,
}
/// Discovers local network interface IPs to announce to peers.
pub fn local_ip_candidates() -> Vec<std::net::IpAddr> {
let mut ips = Vec::new();
for iface in netdev::get_interfaces() {
if iface.is_up() && !iface.is_loopback() {
for ip in iface.ipv4 {
let addr = ip.addr();
if !addr.is_loopback() && !addr.is_unspecified() {
ips.push(std::net::IpAddr::V4(addr));
}
}
for ip in iface.ipv6 {
let addr = ip.addr();
if !addr.is_loopback() && !addr.is_unspecified() {
ips.push(std::net::IpAddr::V6(addr));
}
}
}
}
ips.push(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
ips
}
/// The codec implementing cryptographic transformation and announcements for `tcp-tls`.
pub struct TcpTlsCodec {
local_id: EndpointId,
static_secret: StaticSecret,
public_key: PublicKey,
transport: Arc<TcpTlsTransport>,
ciphers: RwLock<HashMap<(NetworkId, EndpointId), Arc<PeerCipher>>>,
}
impl std::fmt::Debug for TcpTlsCodec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TcpTlsCodec")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("port", &self.transport.bound_port())
.finish()
}
}
impl TcpTlsCodec {
/// Supported options for `tcp-tls`.
pub const OPTIONS: &'static [ProtocolOption] = &[ProtocolOption {
key: "port",
value: "PORT",
help: "TCP port to listen on for TLS 1.3 connections",
default: Some("443"),
}];
/// Creates a new `TcpTlsCodec`.
pub fn new(local_id: EndpointId, transport: Arc<TcpTlsTransport>) -> Self {
let mut secret_bytes = [0u8; 32];
rand::fill(&mut secret_bytes);
let static_secret = StaticSecret::from(secret_bytes);
let public_key = PublicKey::from(&static_secret);
Self {
local_id,
static_secret,
public_key,
transport,
ciphers: RwLock::new(HashMap::new()),
}
}
/// Configures settings from parsed `-o` options.
pub fn configure(
mut config: TcpTlsConfig,
options: &[(String, String)],
) -> Result<TcpTlsConfig, String> {
for (key, value) in options {
match key.as_str() {
"port" => {
let port: u16 = value
.parse()
.map_err(|_| format!("invalid port `{value}`"))?;
config.port = Some(port);
}
other => return Err(format!("unknown option `{other}` for tcp-tls")),
}
}
Ok(config)
}
}
impl TunnelCodec for TcpTlsCodec {
fn protocol_id(&self) -> &str {
TCP_TLS_PROTOCOL
}
fn protocol_version(&self) -> u16 {
TCP_TLS_VERSION
}
fn options(&self) -> &'static [ProtocolOption] {
Self::OPTIONS
}
fn local_capability_data(&self, _network: NetworkId) -> Result<Vec<u8>, PluginError> {
let announcement = TcpTlsAnnouncement {
noise_public: *self.public_key.as_bytes(),
port: self.transport.bound_port(),
addrs: local_ip_candidates(),
};
postcard::to_allocvec(&announcement)
.map_err(|err| PluginError::Other(format!("encode announcement failed: {err}")))
}
fn on_peer_capability_data(
&self,
network: NetworkId,
peer: EndpointId,
data: &[u8],
) -> Result<(), PluginError> {
let announcement: TcpTlsAnnouncement = postcard::from_bytes(data)
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?;
for ip in &announcement.addrs {
self.transport
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
}
self.transport.set_peer_addr(
peer,
std::net::SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
announcement.port,
),
);
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
let cipher = Arc::new(PeerCipher::new(
self.local_id,
peer,
&self.static_secret,
&peer_noise_pubkey,
));
let mut guard = match self.ciphers.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.insert((network, peer), cipher);
Ok(())
}
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
let mut guard = match self.ciphers.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.remove(&(network, peer));
}
fn encrypt(
&self,
network: NetworkId,
peer: EndpointId,
packet: &[u8],
) -> Result<Bytes, PluginError> {
let cipher = {
let guard = match self.ciphers.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(cipher) = cipher else {
return Err(PluginError::Unavailable("no cipher for peer".into()));
};
cipher.encrypt(packet)
}
fn decrypt(
&self,
network: NetworkId,
peer: EndpointId,
payload: &[u8],
) -> Result<Bytes, PluginError> {
let cipher = {
let guard = match self.ciphers.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(cipher) = cipher else {
return Err(PluginError::Unavailable("no cipher for peer".into()));
};
cipher.decrypt(payload)
}
}
/// The complete `tcp-tls` plugin.
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
+512
View File
@@ -0,0 +1,512 @@
//! TCP TLS 1.3 transport implementation for Tsunagi.
//!
//! Provides [`TcpTlsTransport`] implementing [`PacketTransport`]
//! and [`TcpTlsLink`] implementing [`PacketLink`].
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use bytes::Bytes;
use iroh::{EndpointId, Signature};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Notify, mpsc};
use tokio_rustls::TlsConnector;
use tsunagi::BoxFuture;
use tsunagi::dataplane::transport::{
InboundLink, PacketLink, PacketTransport, SharedLink, TransportError,
};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use crate::cert::{generate_self_signed_cert, make_client_config, make_server_config};
/// Default TCP port to bind and listen on.
pub const DEFAULT_PORT: u16 = 443;
/// Fallback port if 443 is unprivileged on Linux and denied.
pub const FALLBACK_PORT: u16 = 8443;
/// Maximum payload length for a single framed datagram over TLS.
pub const MAX_FRAME_SIZE: usize = 65535;
/// Initial handshake message on established TLS stream (128 bytes).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LinkHandshake {
/// The overlay network ID.
pub network: NetworkId,
/// The connecting client's endpoint ID.
pub client_id: EndpointId,
/// Ed25519 signature over `tsunagi-tcp-tls-link-v1` + network + server ID.
pub signature: [u8; 64],
}
impl LinkHandshake {
/// Total encoded length in bytes.
pub const LEN: usize = 32 + 32 + 64; // 128
/// Serializes to fixed-size byte array.
pub fn to_bytes(&self) -> [u8; Self::LEN] {
let mut buf = [0u8; Self::LEN];
buf[0..32].copy_from_slice(self.network.as_bytes());
buf[32..64].copy_from_slice(self.client_id.as_bytes());
buf[64..128].copy_from_slice(&self.signature);
buf
}
/// Deserializes from byte slice.
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() != Self::LEN {
return None;
}
let network = NetworkId::from_bytes(bytes[0..32].try_into().ok()?);
let client_id = EndpointId::from_bytes(bytes[32..64].try_into().ok()?).ok()?;
let signature: [u8; 64] = bytes[64..128].try_into().ok()?;
Some(Self {
network,
client_id,
signature,
})
}
}
/// Acknowledgement message on established TLS stream (1 byte).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LinkAck {
/// Whether the link was accepted by the server.
pub accepted: bool,
}
impl LinkAck {
/// Total encoded length in bytes.
pub const LEN: usize = 1;
/// Serializes to fixed-size byte array.
pub fn to_bytes(&self) -> [u8; 1] {
[if self.accepted { 1 } else { 0 }]
}
/// Deserializes from byte slice.
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() != 1 {
return None;
}
Some(Self {
accepted: bytes[0] == 1,
})
}
}
fn link_handshake_bytes(network: NetworkId, server_id: EndpointId) -> Vec<u8> {
let mut bytes = Vec::with_capacity(23 + 32 + 32);
bytes.extend_from_slice(b"tsunagi-tcp-tls-link-v1");
bytes.extend_from_slice(network.as_bytes());
bytes.extend_from_slice(server_id.as_bytes());
bytes
}
/// An authenticated datagram link carried over a TCP TLS 1.3 stream.
#[derive(Debug)]
pub struct TcpTlsLink {
network: NetworkId,
peer: EndpointId,
tx: mpsc::Sender<Bytes>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
closed_notify: Arc<Notify>,
is_closed: Arc<AtomicBool>,
remote_addr: String,
}
impl TcpTlsLink {
/// Creates a new `TcpTlsLink` from a stream that has already passed the TLS handshake.
pub fn new<S>(network: NetworkId, peer: EndpointId, stream: S, remote_addr: String) -> Self
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
{
let (mut reader, mut writer) = tokio::io::split(stream);
let (tx_out, mut rx_out) = mpsc::channel::<Bytes>(256);
let (tx_in, rx_in) = mpsc::channel::<Bytes>(256);
let is_closed = Arc::new(AtomicBool::new(false));
let closed_notify = Arc::new(Notify::new());
let closed_flag_w = Arc::clone(&is_closed);
let closed_notify_w = Arc::clone(&closed_notify);
tokio::spawn(async move {
while let Some(msg) = rx_out.recv().await {
if msg.len() > MAX_FRAME_SIZE {
continue;
}
let len = msg.len() as u16;
if writer.write_all(&len.to_be_bytes()).await.is_err() {
break;
}
if writer.write_all(&msg).await.is_err() {
break;
}
if writer.flush().await.is_err() {
break;
}
}
closed_flag_w.store(true, Ordering::Release);
closed_notify_w.notify_waiters();
});
let closed_flag_r = Arc::clone(&is_closed);
let closed_notify_r = Arc::clone(&closed_notify);
tokio::spawn(async move {
let mut len_buf = [0u8; 2];
loop {
if reader.read_exact(&mut len_buf).await.is_err() {
break;
}
let len = u16::from_be_bytes(len_buf) as usize;
if len > MAX_FRAME_SIZE {
break;
}
let mut buf = vec![0u8; len];
if reader.read_exact(&mut buf).await.is_err() {
break;
}
if tx_in.send(Bytes::from(buf)).await.is_err() {
break;
}
}
closed_flag_r.store(true, Ordering::Release);
closed_notify_r.notify_waiters();
});
Self {
network,
peer,
tx: tx_out,
rx: tokio::sync::Mutex::new(rx_in),
closed_notify,
is_closed,
remote_addr,
}
}
}
impl PacketLink for TcpTlsLink {
fn network(&self) -> NetworkId {
self.network
}
fn peer(&self) -> EndpointId {
self.peer
}
fn max_datagram_size(&self) -> usize {
MAX_FRAME_SIZE
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if payload.len() > MAX_FRAME_SIZE {
return Err(TransportError::TooLarge {
size: payload.len(),
limit: MAX_FRAME_SIZE,
});
}
if self.is_closed.load(Ordering::Acquire) {
return Err(TransportError::Closed);
}
self.tx
.try_send(payload)
.map_err(|_| TransportError::Closed)
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut guard = self.rx.lock().await;
guard.recv().await
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
if self.is_closed.load(Ordering::Acquire) {
return;
}
self.closed_notify.notified().await;
})
}
fn is_closed(&self) -> bool {
self.is_closed.load(Ordering::Acquire)
}
fn path_description(&self) -> String {
format!("tcp-tls via {}", self.remote_addr)
}
}
/// The `tcp-tls` packet transport.
pub struct TcpTlsTransport {
identity: DeviceIdentity,
local_id: EndpointId,
listen_port: u16,
peer_addresses: std::sync::RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
server_config: Arc<rustls::ServerConfig>,
listener: Option<Arc<TcpListener>>,
}
impl std::fmt::Debug for TcpTlsTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TcpTlsTransport")
.field("local_id", &self.local_id.fmt_short().to_string())
.field("listen_port", &self.listen_port)
.finish()
}
}
impl TcpTlsTransport {
/// Binds a new TCP TLS transport using the device identity and configured port.
pub async fn bind(
identity: &DeviceIdentity,
requested_port: Option<u16>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes())?;
let server_config = make_server_config(cert, key)?;
let target_port = requested_port.unwrap_or(DEFAULT_PORT);
let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port));
let listener = match TcpListener::bind(bind_addr).await {
Ok(l) => {
tracing::info!(port = target_port, "TCP TLS listening");
Some(Arc::new(l))
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
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 port {target_port} without privileges. \
To allow port 443 and TUN on Linux, grant capabilities once with:\n \
sudo setcap cap_net_admin,cap_net_bind_service+p {exe}\n\
Falling back to port {FALLBACK_PORT}."
);
if requested_port.is_none() {
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 {
return Err(Box::new(err));
}
}
Err(err) => return Err(Box::new(err)),
};
let bound_port = listener
.as_ref()
.and_then(|l| l.local_addr().ok())
.map(|a| a.port())
.unwrap_or(target_port);
Ok(Self {
identity: identity.clone(),
local_id: identity.endpoint_id(),
listen_port: bound_port,
peer_addresses: std::sync::RwLock::new(HashMap::new()),
server_config,
listener,
})
}
/// The port this transport is listening on.
pub fn bound_port(&self) -> u16 {
self.listen_port
}
/// Records an observed TCP target address for a peer.
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
if let Ok(mut guard) = self.peer_addresses.write() {
let addrs = guard.entry(peer).or_default();
if !addrs.contains(&addr) {
addrs.push(addr);
}
}
}
/// Runs the accept loop for inbound TCP TLS connections.
pub async fn accept_loop<F>(&self, on_inbound: F)
where
F: Fn(InboundLink) + Send + Sync + Clone + 'static,
{
let Some(listener) = self.listener.as_ref().cloned() else {
return;
};
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::clone(&self.server_config));
let local_id = self.local_id;
while let Ok((socket, remote_addr)) = listener.accept().await {
let acceptor = acceptor.clone();
let on_inbound = on_inbound.clone();
tokio::spawn(async move {
let mut tls_stream = match acceptor.accept(socket).await {
Ok(s) => s,
Err(err) => {
tracing::debug!(%err, "inbound TLS handshake failed");
return;
}
};
let mut hello_buf = [0u8; LinkHandshake::LEN];
if tls_stream.read_exact(&mut hello_buf).await.is_err() {
return;
}
let Some(hello) = LinkHandshake::from_bytes(&hello_buf) else {
return;
};
let msg = link_handshake_bytes(hello.network, local_id);
let sig = Signature::from_bytes(&hello.signature);
if hello.client_id.verify(&msg, &sig).is_err() {
tracing::debug!("LinkHandshake signature verification failed");
return;
}
let ack = LinkAck { accepted: true };
let ack_bytes = ack.to_bytes();
if tls_stream.write_all(&ack_bytes).await.is_err() {
return;
}
if tls_stream.flush().await.is_err() {
return;
}
let link = TcpTlsLink::new(
hello.network,
hello.client_id,
tls_stream,
remote_addr.to_string(),
);
on_inbound(InboundLink {
network: hello.network,
peer: hello.client_id,
protocol: "tcp-tls".into(),
link: Arc::new(link),
});
});
}
}
}
impl PacketTransport for TcpTlsTransport {
fn name(&self) -> &str {
"tcp-tls"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
Box::pin(async move {
if protocol != "tcp-tls" {
return Err(TransportError::Declined(protocol.to_string()));
}
let target_addrs = {
let guard = self
.peer_addresses
.read()
.map_err(|_| TransportError::Other("lock poisoned".into()))?;
guard.get(&peer).cloned().unwrap_or_default()
};
if target_addrs.is_empty() {
return Err(TransportError::Unreachable(
"no known TCP address for peer".into(),
));
}
let mut connected_socket = None;
let mut connected_addr = None;
for addr in &target_addrs {
match tokio::time::timeout(Duration::from_secs(3), TcpStream::connect(*addr)).await
{
Ok(Ok(socket)) => {
connected_socket = Some(socket);
connected_addr = Some(*addr);
break;
}
_ => continue,
}
}
let (socket, addr) = match (connected_socket, connected_addr) {
(Some(s), Some(a)) => (s, a),
_ => {
return Err(TransportError::Unreachable(
"cannot connect to any TCP address for peer".into(),
));
}
};
let client_config =
make_client_config(peer).map_err(|err| TransportError::Other(err.to_string()))?;
let connector = TlsConnector::from(client_config);
let server_name = rustls::pki_types::ServerName::try_from("tsunagi.local")
.map_err(|err| TransportError::Other(err.to_string()))?
.to_owned();
let mut tls_stream = connector
.connect(server_name, socket)
.await
.map_err(|err| TransportError::Other(format!("TLS handshake failed: {err}")))?;
// Send LinkHandshake (fixed 128 bytes)
let msg = link_handshake_bytes(network, peer);
let sig = self.identity.sign(&msg);
let hello = LinkHandshake {
network,
client_id: self.local_id,
signature: sig.to_bytes(),
};
let hello_bytes = hello.to_bytes();
tls_stream
.write_all(&hello_bytes)
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
tls_stream
.flush()
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
// Read LinkAck (fixed 1 byte)
let mut ack_buf = [0u8; LinkAck::LEN];
tls_stream
.read_exact(&mut ack_buf)
.await
.map_err(|err| TransportError::Other(err.to_string()))?;
let ack = LinkAck::from_bytes(&ack_buf)
.ok_or_else(|| TransportError::Other("invalid link ack".into()))?;
if !ack.accepted {
return Err(TransportError::Declined("link declined".into()));
}
let link = TcpTlsLink::new(network, peer, tls_stream, addr.to_string());
Ok(Arc::new(link) as SharedLink)
})
}
}
@@ -0,0 +1,52 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use rustls::client::danger::ServerCertVerifier;
use rustls::pki_types::{ServerName, UnixTime};
use tsunagi::identity::DeviceIdentity;
use tsunagi_tcp_tls::cert::{PinnedEndpointVerifier, generate_self_signed_cert};
#[test]
fn test_generate_and_pin_certificate() {
let identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id();
let (cert, _key) = generate_self_signed_cert(&identity.secret_bytes())
.expect("cert generation should succeed");
let verifier = PinnedEndpointVerifier::new(peer_id);
let server_name = ServerName::try_from("tsunagi.local").unwrap();
let verified = verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
assert!(
verified.is_ok(),
"certificate matching peer endpoint id must verify"
);
let other_identity = DeviceIdentity::generate();
let wrong_verifier = PinnedEndpointVerifier::new(other_identity.endpoint_id());
let wrong_verified =
wrong_verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now());
assert!(
wrong_verified.is_err(),
"certificate with wrong endpoint id must fail verification"
);
}
#[test]
fn test_make_client_and_server_config() {
let identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id();
let (cert, key) = generate_self_signed_cert(&identity.secret_bytes())
.expect("cert generation should succeed");
let server_config = tsunagi_tcp_tls::cert::make_server_config(cert, key);
assert!(server_config.is_ok());
let client_config = tsunagi_tcp_tls::cert::make_client_config(peer_id);
assert!(client_config.is_ok());
}
@@ -0,0 +1,73 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi_tcp_tls::crypto::PeerCipher;
use x25519_dalek::{PublicKey, StaticSecret};
#[test]
fn test_peer_cipher_bidirectional_roundtrip() {
let peer_a_id = iroh::SecretKey::generate().public();
let peer_b_id = iroh::SecretKey::generate().public();
let secret_a = StaticSecret::from([10u8; 32]);
let public_a = PublicKey::from(&secret_a);
let secret_b = StaticSecret::from([20u8; 32]);
let public_b = PublicKey::from(&secret_b);
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
// A -> B
let msg_from_a = b"hello from node A to node B over Noise E2EE";
let encrypted_by_a = cipher_a
.encrypt(msg_from_a)
.expect("encryption should succeed");
let decrypted_by_b = cipher_b
.decrypt(&encrypted_by_a)
.expect("decryption should succeed");
assert_eq!(&decrypted_by_b[..], msg_from_a);
// B -> A
let msg_from_b = b"hello back from node B to node A";
let encrypted_by_b = cipher_b
.encrypt(msg_from_b)
.expect("encryption should succeed");
let decrypted_by_a = cipher_a
.decrypt(&encrypted_by_b)
.expect("decryption should succeed");
assert_eq!(&decrypted_by_a[..], msg_from_b);
// Sequence nonces increment
let msg2 = b"packet 2";
let enc2 = cipher_a.encrypt(msg2).expect("encryption should succeed");
assert_ne!(encrypted_by_a[..8], enc2[..8], "nonces should differ");
let dec2 = cipher_b.decrypt(&enc2).expect("decryption should succeed");
assert_eq!(&dec2[..], msg2);
}
#[test]
fn test_tampered_payload_rejected() {
let peer_a_id = iroh::SecretKey::generate().public();
let peer_b_id = iroh::SecretKey::generate().public();
let secret_a = StaticSecret::from([10u8; 32]);
let public_a = PublicKey::from(&secret_a);
let secret_b = StaticSecret::from([20u8; 32]);
let public_b = PublicKey::from(&secret_b);
let cipher_a = PeerCipher::new(peer_a_id, peer_b_id, &secret_a, &public_b);
let cipher_b = PeerCipher::new(peer_b_id, peer_a_id, &secret_b, &public_a);
let encrypted = cipher_a.encrypt(b"secret payload").expect("encrypt");
// Corrupt one byte of ciphertext
let len = encrypted.len();
let mut modified = encrypted.to_vec();
modified[len - 1] ^= 0xff;
assert!(
cipher_b.decrypt(&modified).is_err(),
"corrupted ciphertext must fail AEAD verification"
);
}
@@ -0,0 +1,149 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_tcp_tls::{TcpTlsCodec, TcpTlsPlugin, TcpTlsTransport};
struct TestSink {
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
}
impl PacketSink for TestSink {
fn deliver(
&self,
network: NetworkId,
from: iroh::EndpointId,
packet: Bytes,
) -> tsunagi::BoxFuture<'_, ()> {
let delivered = self.delivered.clone();
Box::pin(async move {
let _ = delivered.send((network, from, packet)).await;
})
}
}
#[tokio::test]
async fn test_tcp_tls_plugin_end_to_end() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([77u8; 32]);
// Transports
let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0))
.await
.expect("bind B"),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0))
.await
.expect("bind A"),
);
// Codecs and plugins
let codec_a = TcpTlsCodec::new(peer_a, Arc::clone(&transport_a));
let plugin_a = Arc::new(TcpTlsPlugin::new(codec_a));
let codec_b = TcpTlsCodec::new(peer_b, Arc::clone(&transport_b));
let plugin_b = Arc::new(TcpTlsPlugin::new(codec_b));
// Attach sinks
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_a_tx,
})));
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
delivered: sink_b_tx,
})));
// Capability exchange
let cap_a = plugin_a
.local_capability(network)
.expect("cap A")
.expect("some cap A");
let cap_b = plugin_b
.local_capability(network)
.expect("cap B")
.expect("some cap B");
plugin_a
.on_peer_capability(network, peer_b, &cap_b)
.expect("A on cap B");
plugin_b
.on_peer_capability(network, peer_a, &cap_a)
.expect("B on cap A");
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Make sure A knows B's loopback address
transport_a.set_peer_addr(peer_b, format!("127.0.0.1:{port_b}").parse().unwrap());
// Connect link from A to B
let link_a = transport_a
.open(network, peer_b, "tcp-tls")
.await
.expect("open link from A");
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("recv timeout")
.expect("recv inbound");
let link_b = inbound_b.link;
// Install links into plugins
plugin_a.on_peer_link(network, peer_b, link_a);
plugin_b.on_peer_link(network, peer_a, link_b);
// Carry packet from A to B
let ip_packet_a = Bytes::from_static(b"4500003c000100004011e0e8c0a80101c0a8010200000000");
let carried_a = plugin_a.carry(network, peer_b, ip_packet_a.clone());
assert!(carried_a, "plugin A should carry packet");
let (net_received, from_peer, delivered_pkt) =
tokio::time::timeout(Duration::from_secs(3), sink_b_rx.recv())
.await
.expect("timeout waiting for delivery on B")
.expect("packet on B");
assert_eq!(net_received, network);
assert_eq!(from_peer, peer_a);
assert_eq!(delivered_pkt, ip_packet_a);
// Carry reply packet from B to A
let ip_packet_b = Bytes::from_static(b"reply-packet-from-b");
let carried_b = plugin_b.carry(network, peer_a, ip_packet_b.clone());
assert!(carried_b, "plugin B should carry packet");
let (net_received, from_peer, delivered_pkt) =
tokio::time::timeout(Duration::from_secs(3), sink_a_rx.recv())
.await
.expect("timeout waiting for delivery on A")
.expect("packet on A");
assert_eq!(net_received, network);
assert_eq!(from_peer, peer_b);
assert_eq!(delivered_pkt, ip_packet_b);
}
@@ -0,0 +1,96 @@
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
use tsunagi::identity::{DeviceIdentity, NetworkId};
use tsunagi_tcp_tls::TcpTlsTransport;
#[tokio::test]
async fn test_tcp_tls_transport_loopback() {
let identity_a = DeviceIdentity::generate();
let peer_a = identity_a.endpoint_id();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([42u8; 32]);
// Bind transport B on port 0 (ephemeral)
let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0))
.await
.expect("bind server should succeed"),
);
let port_b = transport_b.bound_port();
assert!(port_b > 0);
// Bind transport A on port 0 (ephemeral)
let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0))
.await
.expect("bind client should succeed"),
);
// Register B's address on transport A
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
// Spawn accept loop on B
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone
.accept_loop(move |inbound| {
let _ = inbound_tx.try_send(inbound);
})
.await;
});
// Open link from A to B
let link_a = tokio::time::timeout(
Duration::from_secs(5),
transport_a.open(network, peer_b, "tcp-tls"),
)
.await
.expect("open should not timeout")
.expect("open should succeed");
// Receive inbound link on B
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
.await
.expect("accept should not timeout")
.expect("inbound link should arrive");
assert_eq!(inbound_b.network, network);
assert_eq!(inbound_b.peer, peer_a);
assert_eq!(inbound_b.protocol, "tcp-tls");
let link_b = inbound_b.link;
// Send from A to B
let data_a_to_b = Bytes::from_static(b"packet from A to B");
link_a.send(data_a_to_b.clone()).expect("send A->B");
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_on_b, data_a_to_b);
// Send from B to A
let data_b_to_a = Bytes::from_static(b"reply from B to A");
link_b.send(data_b_to_a.clone()).expect("send B->A");
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_on_a, data_b_to_a);
// Path descriptions
assert!(link_a.path_description().contains("tcp-tls"));
assert!(link_b.path_description().contains("tcp-tls"));
}
+46 -3
View File
@@ -281,11 +281,19 @@ impl Agent {
// The data plane rides on iroh too, which is where it gets hole
// punching and relay fallback from. It is a separate ALPN and a
// separate connection, so the two planes stay independent.
let transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
let iroh_transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
inner.adapter.clone(),
Arc::clone(&inner.limits),
Arc::new(TransportCtx(Arc::downgrade(&inner))) as Arc<dyn TransportContext>,
));
let transport: Arc<dyn PacketTransport> = if inner.config.custom_transports.is_empty() {
iroh_transport
} else {
Arc::new(crate::dataplane::MultiPacketTransport::new(
iroh_transport,
inner.config.custom_transports.clone(),
))
};
let _ = inner.transport.set(transport);
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
@@ -346,6 +354,26 @@ impl Agent {
self.inner.adapter.loopback_addr()
}
/// Delivers an inbound data plane link to the matching network runtime.
pub async fn install_inbound_link(
&self,
inbound: crate::dataplane::transport::InboundLink,
) -> Result<()> {
let network = inbound.network;
let sender = {
let networks = self.inner.networks.read().await;
networks.get(&network).map(|handle| handle.commands.clone())
};
let Some(sender) = sender else {
return Err(Error::NetworkNotActive(network));
};
sender
.send(NetCommand::InboundLink(Box::new(inbound)))
.await
.map_err(|_| Error::NetworkNotActive(network))?;
Ok(())
}
/// Reserves the configured overlay range for a network, if it can.
///
/// Done here, where activations are serialised, rather than inside the
@@ -1200,8 +1228,23 @@ async fn handle_inbound_data(inner: Arc<Inner>, conn: iroh::endpoint::Connection
conn.close(5u32.into(), b"data plane not ready");
return;
};
// Downcasting is avoided by keeping the accept side on the concrete type.
let Some(iroh_transport) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() else {
let iroh_transport =
if let Some(iroh) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() {
Some(iroh)
} else if let Some(multi) = transport
.as_ref()
.as_any()
.downcast_ref::<crate::dataplane::MultiPacketTransport>()
{
multi
.default_transport()
.as_ref()
.as_any()
.downcast_ref::<IrohTransport>()
} else {
None
};
let Some(iroh_transport) = iroh_transport else {
conn.close(5u32.into(), b"unsupported data transport");
return;
};
+19 -20
View File
@@ -1185,28 +1185,27 @@ impl Runtime {
});
}
// Both the name and the version have to match. A peer offering
// `wg-quic` at a version this build does not speak is not a peer to
// carry traffic with, and a link opened anyway would fail later and
// say less about why.
let wanted: Vec<(EndpointId, String)> = self
.sessions
.values()
.flat_map(|session| {
let peer = session.peer;
session
// Choose the highest priority mutually supported protocol for each peer.
// If a direct data link is already open or opening, do not dial another.
let mut wanted: Vec<(EndpointId, String)> = Vec::new();
for session in self.sessions.values() {
let peer = session.peer;
if self.links.keys().any(|(p, _)| *p == peer)
|| self.opening.iter().any(|(p, _)| *p == peer)
{
continue;
}
for (name, ours) in &served {
let matches = session
.capabilities
.iter()
.filter(|capability| capability.enabled)
.map(move |capability| (peer, capability.protocol.clone(), capability.version))
})
.filter(|(_, protocol, version)| {
served
.iter()
.any(|(name, ours)| name == protocol && ours == version)
})
.map(|(peer, protocol, _)| (peer, protocol))
.collect();
.any(|c| c.enabled && c.protocol == *name && c.version == *ours);
if matches {
wanted.push((peer, name.clone()));
break;
}
}
}
for (peer, protocol) in wanted {
let key = (peer, protocol.clone());
+14
View File
@@ -304,6 +304,9 @@ pub struct AgentConfig {
pub reconnect: ReconnectPolicy,
/// IP plugins whose capabilities are announced and dispatched.
pub plugins: Vec<SharedPlugin>,
/// Custom packet transports registered per protocol.
pub custom_transports:
std::collections::HashMap<String, Arc<dyn crate::dataplane::PacketTransport>>,
/// Where the overlay interface comes from, when the agent should have one.
///
/// One agent, one interface: it belongs here rather than to a protocol,
@@ -345,6 +348,7 @@ impl AgentConfig {
limits: Limits::default(),
reconnect: ReconnectPolicy::default(),
plugins: Vec::new(),
custom_transports: std::collections::HashMap::new(),
tun_factory: None,
interface_name: "tsun0".to_string(),
interface_mtu: 1280,
@@ -430,6 +434,16 @@ impl AgentConfig {
self
}
/// Registers a custom packet transport for a specific protocol.
pub fn with_custom_transport(
mut self,
protocol: impl Into<String>,
transport: Arc<dyn crate::dataplane::PacketTransport>,
) -> Self {
self.custom_transports.insert(protocol.into(), transport);
self
}
/// Gives the agent an overlay interface, from this factory.
pub fn with_interface(
mut self,
+14 -1
View File
@@ -22,6 +22,7 @@
pub mod relay;
pub mod routing;
pub mod transport;
pub mod tunnel;
use std::sync::Arc;
@@ -31,7 +32,10 @@ use tokio::sync::mpsc;
use crate::BoxFuture;
use crate::identity::NetworkId;
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
pub use transport::{
MultiPacketTransport, PacketLink, PacketTransport, SharedLink, TransportError,
};
pub use tunnel::{GenericTunnelPlugin, TunnelCodec};
/// Maximum length of a plugin protocol identifier.
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
@@ -179,6 +183,15 @@ impl PluginContext {
}
}
/// A context configured with a custom packet sink.
pub fn with_sink(sink: Arc<dyn PacketSink>) -> Self {
Self {
sender: None,
local: None,
sink: Some(sink),
}
}
/// This agent's own endpoint id, when the context is attached.
///
/// A plugin needs it to find itself in the agreed allocation.
@@ -32,6 +32,8 @@
mod fragments;
pub mod iroh_link;
use std::sync::Arc;
use bytes::Bytes;
use iroh::EndpointId;
@@ -155,3 +157,51 @@ pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
}
/// A composite packet transport that dispatches requests by protocol id.
#[derive(Debug)]
pub struct MultiPacketTransport {
default: Arc<dyn PacketTransport>,
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
}
impl MultiPacketTransport {
/// Creates a new composite transport with a default fallback transport.
pub fn new(
default: Arc<dyn PacketTransport>,
by_protocol: std::collections::HashMap<String, Arc<dyn PacketTransport>>,
) -> Self {
Self {
default,
by_protocol,
}
}
/// Access the default underlying transport.
pub fn default_transport(&self) -> &Arc<dyn PacketTransport> {
&self.default
}
}
impl PacketTransport for MultiPacketTransport {
fn name(&self) -> &str {
"multi"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn open<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
if let Some(transport) = self.by_protocol.get(protocol) {
transport.open(network, peer, protocol)
} else {
self.default.open(network, peer, protocol)
}
}
}
+296
View File
@@ -0,0 +1,296 @@
//! Generic harness for IP tunnel plugins.
//!
//! Implementing [`super::IpPlugin`] directly requires managing per-peer background
//! tasks, synchronization, packet queues, and graceful shutdown.
//!
//! [`TunnelCodec`] provides a simplified interface where a plugin author only
//! defines cryptographic transformation and handshake payloads.
//! [`GenericTunnelPlugin`] adapts any [`TunnelCodec`] into a full [`super::IpPlugin`].
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use super::{IpPlugin, PluginCapability, PluginContext, PluginError, ProtocolOption, SharedLink};
use crate::BoxFuture;
use crate::identity::NetworkId;
/// The simplified contract for an IP tunnel protocol implementation.
///
/// Implementors only handle key announcements, link lifecycle events,
/// and packet encrypt/decrypt. Concurrency and task management are handled
/// by [`GenericTunnelPlugin`].
pub trait TunnelCodec: Send + Sync + 'static {
/// Protocol identifier, e.g. `tcp-tls`.
fn protocol_id(&self) -> &str;
/// Protocol wire version.
fn protocol_version(&self) -> u16;
/// Options accepted by the protocol.
fn options(&self) -> &'static [ProtocolOption] {
&[]
}
/// Generates local capability announcement data for a network.
fn local_capability_data(&self, network: NetworkId) -> Result<Vec<u8>, PluginError>;
/// Handles a peer's announced capability payload.
fn on_peer_capability_data(
&self,
network: NetworkId,
peer: EndpointId,
data: &[u8],
) -> Result<(), PluginError>;
/// Called when an authenticated link to a peer is established.
fn on_peer_link_up(&self, network: NetworkId, peer: EndpointId, link: &SharedLink) {
let _ = (network, peer, link);
}
/// Called when a peer goes away.
fn on_peer_down(&self, network: NetworkId, peer: EndpointId) {
let _ = (network, peer);
}
/// Encrypts an outbound IP packet into a payload to send over the link.
fn encrypt(
&self,
network: NetworkId,
peer: EndpointId,
packet: &[u8],
) -> Result<Bytes, PluginError>;
/// Decrypts an inbound payload from the link into an IP packet.
fn decrypt(
&self,
network: NetworkId,
peer: EndpointId,
payload: &[u8],
) -> Result<Bytes, PluginError>;
/// Called during shutdown.
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async {})
}
}
/// Generic wrapper that turns a [`TunnelCodec`] into a full [`IpPlugin`].
pub struct GenericTunnelPlugin<C: TunnelCodec> {
codec: Arc<C>,
context: Mutex<Option<PluginContext>>,
links: RwLock<HashMap<(NetworkId, EndpointId), SharedLink>>,
tasks: Mutex<HashMap<(NetworkId, EndpointId), JoinHandle<()>>>,
}
impl<C: TunnelCodec> GenericTunnelPlugin<C> {
/// Wraps `codec` into an [`IpPlugin`].
pub fn new(codec: C) -> Self {
Self {
codec: Arc::new(codec),
context: Mutex::new(None),
links: RwLock::new(HashMap::new()),
tasks: Mutex::new(HashMap::new()),
}
}
/// Returns a reference to the underlying codec.
pub fn codec(&self) -> &C {
&self.codec
}
}
impl<C: TunnelCodec> std::fmt::Debug for GenericTunnelPlugin<C> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenericTunnelPlugin")
.field("protocol_id", &self.codec.protocol_id())
.field("protocol_version", &self.codec.protocol_version())
.finish()
}
}
impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
fn protocol_id(&self) -> &str {
self.codec.protocol_id()
}
fn protocol_version(&self) -> u16 {
self.codec.protocol_version()
}
fn options(&self) -> &'static [ProtocolOption] {
self.codec.options()
}
fn attach(&self, context: PluginContext) {
if let Ok(mut guard) = self.context.lock() {
*guard = Some(context);
}
}
fn local_capability(
&self,
network: NetworkId,
) -> Result<Option<PluginCapability>, PluginError> {
let data = self.codec.local_capability_data(network)?;
Ok(Some(PluginCapability {
protocol: self.codec.protocol_id().to_string(),
version: self.codec.protocol_version(),
enabled: true,
data,
}))
}
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> Result<(), PluginError> {
if capability.protocol != self.codec.protocol_id() {
return Ok(());
}
self.codec
.on_peer_capability_data(network, peer, &capability.data)
}
fn carry(&self, network: NetworkId, peer: EndpointId, packet: bytes::Bytes) -> bool {
let link = {
let guard = match self.links.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.get(&(network, peer)).cloned()
};
let Some(link) = link else {
return false;
};
match self.codec.encrypt(network, peer, &packet) {
Ok(ciphertext) => link.send(ciphertext).is_ok(),
Err(err) => {
tracing::debug!(%peer, %err, "tunnel encrypt failed");
false
}
}
}
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.insert((network, peer), Arc::clone(&link));
}
self.codec.on_peer_link_up(network, peer, &link);
let codec = Arc::clone(&self.codec);
let sink = match self.context.lock() {
Ok(g) => g.as_ref().map(|ctx| ctx.packet_sink()),
Err(p) => p.into_inner().as_ref().map(|ctx| ctx.packet_sink()),
};
let rx_link = Arc::clone(&link);
let handle = tokio::spawn(async move {
while let Some(payload) = rx_link.recv().await {
match codec.decrypt(network, peer, &payload) {
Ok(packet) => {
if let Some(ref sink) = sink {
sink.deliver(network, peer, packet).await;
}
}
Err(err) => {
tracing::debug!(%peer, %err, "tunnel decrypt failed");
}
}
}
});
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(old) = guard.insert((network, peer), handle) {
old.abort();
}
}
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(task) = guard.remove(&(network, peer)) {
task.abort();
}
}
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.remove(&(network, peer));
}
self.codec.on_peer_down(network, peer);
}
fn on_network_deactivated(&self, network: NetworkId) {
let keys_to_remove: Vec<(NetworkId, EndpointId)> = {
let guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard
.keys()
.filter(|(net, _)| *net == network)
.copied()
.collect()
};
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
for key in &keys_to_remove {
if let Some(task) = guard.remove(key) {
task.abort();
}
}
}
{
let mut guard = match self.links.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.retain(|(net, _), _| *net != network);
}
}
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
let mut tasks = Vec::new();
{
let mut guard = match self.tasks.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
for (_, task) in guard.drain() {
tasks.push(task);
}
}
for task in tasks {
task.abort();
}
self.codec.shutdown().await;
})
}
}
+7 -2
View File
@@ -49,11 +49,16 @@ impl DeviceIdentity {
self.secret.public()
}
/// The raw secret key bytes, for persistence only.
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
/// The raw secret key bytes, for persistence and transport certificate generation.
pub fn secret_bytes(&self) -> [u8; 32] {
self.secret.to_bytes()
}
/// Signs a message with the device's persistent secret key.
pub fn sign(&self, message: &[u8]) -> iroh::Signature {
self.secret.sign(message)
}
/// A clone of the iroh secret key, for endpoint construction only.
pub(crate) fn secret_key(&self) -> SecretKey {
self.secret.clone()