Skip to main content

moq_native/
tcp.rs

1//! Plain-TCP qmux transport, reachable via the `tcp://` URL scheme.
2//!
3//! Runs the QMux wire format directly over TCP with no TLS or WebSocket
4//! framing. There is no transport encryption and no authentication, so only
5//! use this on a trusted network (loopback, a private VPC interface, etc.).
6//!
7//! TCP has no TLS handshake, so the application protocol (the moq ALPN) is
8//! negotiated in-band: pass the offered/supported protocols and the resulting
9//! `qmux::Session::protocol()` is populated before connect/accept returns.
10
11use std::net;
12use url::Url;
13
14/// The QMux wire-format version both ends speak over a raw stream. Fixed (not
15/// negotiated) since there's no TLS ALPN to carry it.
16const WIRE_VERSION: qmux::Version = qmux::Version::QMux01;
17
18/// Plaintext-TCP qmux listener settings (no TLS, no UDP).
19///
20/// Flattened onto [`crate::ServerConfig::tcp`]. TCP carries no peer identity, so
21/// the listener must only be reachable from trusted clients. Bind it to loopback
22/// or a private interface; a non-loopback bind logs a warning but is allowed.
23// The derived arg group is named after the struct, so it needs an explicit id to
24// stay unique across the flattened sections.
25#[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
26#[group(id = "server-tcp")]
27#[serde(deny_unknown_fields, default)]
28#[non_exhaustive]
29pub struct Config {
30	/// Bind a plaintext qmux TCP listener on this address.
31	#[arg(long = "server-tcp-bind", id = "server-tcp-bind", env = "MOQ_SERVER_TCP_BIND")]
32	#[serde(default, skip_serializing_if = "Option::is_none")]
33	pub bind: Option<net::SocketAddr>,
34}
35
36/// Errors specific to the plain-TCP qmux transport.
37#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum Error {
40	/// The TCP socket failed to bind, accept, or connect.
41	#[error(transparent)]
42	Io(#[from] std::io::Error),
43
44	/// The `tcp://` URL had no host.
45	#[error("missing hostname")]
46	MissingHostname,
47
48	/// The `tcp://` URL had no port. Unlike `https`, there is no default.
49	#[error("missing port")]
50	MissingPort,
51
52	/// The qmux handshake failed while dialing.
53	#[error("qmux connect failed")]
54	Connect(#[source] qmux::Error),
55
56	/// The qmux handshake failed while accepting.
57	#[error("qmux accept failed")]
58	Accept(#[source] qmux::Error),
59}
60
61type Result<T> = std::result::Result<T, Error>;
62
63/// Dial a `tcp://host:port` URL, advertising `protocols` for in-band ALPN
64/// negotiation. Returns a qmux session over plain TCP.
65///
66/// The port is required; there is no default for the `tcp` scheme.
67pub(crate) async fn connect(url: Url, protocols: &[&str]) -> Result<qmux::Session> {
68	let host = url.host_str().ok_or(Error::MissingHostname)?;
69	let port = url.port().ok_or(Error::MissingPort)?;
70
71	tracing::debug!(%url, "connecting via TCP");
72	qmux::tcp::Config::new(WIRE_VERSION)
73		.protocols(protocols.iter().copied())
74		.connect((host, port))
75		.await
76		.map_err(Error::Connect)
77}
78
79/// Listens for incoming plain-TCP qmux connections on a TCP port.
80pub struct Listener {
81	listener: tokio::net::TcpListener,
82	protocols: Vec<String>,
83}
84
85impl Listener {
86	/// Bind a TCP listener to the given address.
87	pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
88		let listener = tokio::net::TcpListener::bind(addr).await?;
89		Ok(Self {
90			listener,
91			protocols: Vec::new(),
92		})
93	}
94
95	/// Advertise these application protocols (moq ALPNs) for in-band negotiation,
96	/// in preference order. The first server entry the client also offers wins.
97	pub fn with_protocols<I, S>(mut self, protocols: I) -> Self
98	where
99		I: IntoIterator<Item = S>,
100		S: Into<String>,
101	{
102		self.protocols = protocols.into_iter().map(Into::into).collect();
103		self
104	}
105
106	/// The local address the listener is bound to.
107	pub fn local_addr(&self) -> Result<net::SocketAddr> {
108		Ok(self.listener.local_addr()?)
109	}
110
111	/// Accept the next connection, performing the qmux handshake over plain TCP.
112	///
113	/// Returns `None` only if the listener itself is gone; a per-connection
114	/// failure is yielded as `Some(Err(..))` so the accept loop keeps running.
115	pub async fn accept(&self) -> Option<Result<qmux::Session>> {
116		match self.listener.accept().await {
117			Ok((stream, addr)) => {
118				tracing::debug!(%addr, "accepted TCP connection");
119				let session = qmux::tcp::Config::new(WIRE_VERSION)
120					.protocols(self.protocols.iter().map(String::as_str))
121					.accept(stream)
122					.await
123					.map_err(Error::Accept);
124				Some(session)
125			}
126			Err(e) => Some(Err(e.into())),
127		}
128	}
129}