Skip to main content

moq_native/
unix.rs

1//! Unix-domain-socket qmux transport, reachable via the `unix://` URL scheme.
2//!
3//! Runs the QMux wire format over an `AF_UNIX` stream. Unlike the `tcp://`
4//! transport, the kernel reports the connecting process's credentials
5//! (`SO_PEERCRED` / `LOCAL_PEERCRED`), so a server can authenticate the peer's
6//! uid/gid/pid without a shared secret. Unix-only.
7
8use std::os::unix::fs::{FileTypeExt, PermissionsExt};
9use std::path::{Path, PathBuf};
10use std::{fs, io};
11
12use url::Url;
13
14/// The QMux wire-format version both ends speak. Fixed (not negotiated) since a
15/// raw stream has no TLS ALPN to carry it.
16const WIRE_VERSION: qmux::Version = qmux::Version::QMux01;
17
18/// Plaintext Unix-socket qmux listener settings, with an optional
19/// peer-credential allowlist.
20///
21/// Flattened onto [`crate::ServerConfig::unix`].
22// The derived arg group is named after the struct, so it needs an explicit id to
23// stay unique across the flattened sections.
24#[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
25#[group(id = "server-unix")]
26#[serde(deny_unknown_fields, default)]
27#[non_exhaustive]
28pub struct Config {
29	/// Bind a plaintext qmux Unix-socket listener at this path.
30	#[arg(long = "server-unix-bind", id = "server-unix-bind", env = "MOQ_SERVER_UNIX_BIND")]
31	#[serde(default, skip_serializing_if = "Option::is_none")]
32	pub bind: Option<PathBuf>,
33
34	/// Peer-credential allowlist. `None` (the default) enforces nothing, so the
35	/// socket's filesystem permissions are the only gate.
36	#[command(flatten)]
37	#[serde(default, skip_serializing_if = "Option::is_none")]
38	pub allow: Option<Allow>,
39}
40
41/// Peer-credential allowlist for a `unix://` listener.
42///
43/// The kernel reports the connecting process's credentials. Each populated list
44/// constrains the corresponding credential (AND across the three, OR within
45/// each); all empty means no check.
46#[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
47#[group(id = "server-unix-allow")]
48#[serde(deny_unknown_fields, default)]
49#[non_exhaustive]
50pub struct Allow {
51	/// Allowed peer user IDs. Empty means any uid.
52	#[arg(
53		long = "server-unix-allow-uid",
54		env = "MOQ_SERVER_UNIX_ALLOW_UID",
55		value_delimiter = ','
56	)]
57	#[serde(default, skip_serializing_if = "Vec::is_empty")]
58	pub uid: Vec<u32>,
59
60	/// Allowed peer group IDs. Empty means any gid.
61	#[arg(
62		long = "server-unix-allow-gid",
63		env = "MOQ_SERVER_UNIX_ALLOW_GID",
64		value_delimiter = ','
65	)]
66	#[serde(default, skip_serializing_if = "Vec::is_empty")]
67	pub gid: Vec<u32>,
68
69	/// Allowed peer PIDs. Empty means any pid; a populated list rejects peers
70	/// whose PID the platform doesn't report.
71	#[arg(
72		long = "server-unix-allow-pid",
73		env = "MOQ_SERVER_UNIX_ALLOW_PID",
74		value_delimiter = ','
75	)]
76	#[serde(default, skip_serializing_if = "Vec::is_empty")]
77	pub pid: Vec<i32>,
78}
79
80impl Allow {
81	/// Whether any field is populated (i.e. the allowlist enforces something).
82	pub(crate) fn is_empty(&self) -> bool {
83		self.uid.is_empty() && self.gid.is_empty() && self.pid.is_empty()
84	}
85
86	/// Whether `cred` satisfies every populated field (AND across fields, OR
87	/// within a field). A required pid is unsatisfiable when the platform
88	/// reports none.
89	pub(crate) fn permits(&self, cred: &PeerCred) -> bool {
90		let uid_ok = self.uid.is_empty() || self.uid.contains(&cred.uid);
91		let gid_ok = self.gid.is_empty() || self.gid.contains(&cred.gid);
92		let pid_ok = self.pid.is_empty() || cred.pid.is_some_and(|pid| self.pid.contains(&pid));
93		uid_ok && gid_ok && pid_ok
94	}
95}
96
97/// Errors specific to the Unix-domain-socket qmux transport.
98#[derive(Debug, thiserror::Error)]
99#[non_exhaustive]
100pub enum Error {
101	/// The socket failed to bind, accept, connect, or chmod.
102	#[error(transparent)]
103	Io(#[from] io::Error),
104
105	/// The `unix://` URL had no socket path.
106	#[error("missing socket path in unix:// URL")]
107	MissingPath,
108
109	/// The qmux handshake failed while dialing.
110	#[error("qmux connect failed")]
111	Connect(#[source] qmux::Error),
112
113	/// The qmux handshake failed while accepting.
114	#[error("qmux accept failed")]
115	Accept(#[source] qmux::Error),
116
117	/// The bind path already exists and is not a socket, so we refuse to unlink it.
118	#[error("refusing to replace existing non-socket file at {0}")]
119	NotASocket(PathBuf),
120}
121
122type Result<T> = std::result::Result<T, Error>;
123
124/// Credentials of a connected Unix-socket peer.
125///
126/// `pid` is `None` on platforms that don't report it (e.g. some macOS versions);
127/// `uid`/`gid` are always available.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub struct PeerCred {
130	/// The peer process's effective user ID.
131	pub uid: u32,
132	/// The peer process's effective group ID.
133	pub gid: u32,
134	/// The peer process's PID, if the platform reports it.
135	pub pid: Option<i32>,
136}
137
138/// Dial a `unix://<path>` URL, advertising `protocols` for in-band ALPN
139/// negotiation. Returns a qmux session over the socket.
140///
141/// The path is taken from the URL path, so use a triple slash for an absolute
142/// path: `unix:///run/moq/internal.sock`.
143pub(crate) async fn connect(url: Url, protocols: &[&str]) -> Result<qmux::Session> {
144	let path = socket_path(&url).ok_or(Error::MissingPath)?;
145	tracing::debug!(%url, "connecting via Unix socket");
146	qmux::uds::Config::new(WIRE_VERSION)
147		.protocols(protocols.iter().copied())
148		.connect(path)
149		.await
150		.map_err(Error::Connect)
151}
152
153fn socket_path(url: &Url) -> Option<PathBuf> {
154	let path = url.path();
155	if path.is_empty() {
156		None
157	} else {
158		Some(PathBuf::from(path))
159	}
160}
161
162/// Listens for incoming qmux connections on a Unix domain socket.
163///
164/// Each accepted connection yields the session plus the peer's [`PeerCred`], so
165/// the caller can enforce a uid/gid/pid allowlist. The socket file is removed on
166/// drop.
167pub struct Listener {
168	listener: tokio::net::UnixListener,
169	path: PathBuf,
170	protocols: Vec<String>,
171	health: crate::accept::Health,
172}
173
174impl Listener {
175	/// Bind a Unix socket at `path`, replacing a stale socket file left by a
176	/// previous run.
177	///
178	/// Refuses to unlink the path if it exists and is not a socket, to avoid
179	/// clobbering an unrelated file.
180	pub async fn bind(path: impl AsRef<Path>) -> Result<Self> {
181		let path = path.as_ref().to_path_buf();
182
183		// A leftover socket from a crashed run would make bind() fail with
184		// EADDRINUSE, so unlink it first. Anything that isn't a socket we leave
185		// alone and error out.
186		match fs::symlink_metadata(&path) {
187			Ok(meta) if meta.file_type().is_socket() => fs::remove_file(&path)?,
188			Ok(_) => return Err(Error::NotASocket(path)),
189			Err(err) if err.kind() == io::ErrorKind::NotFound => {}
190			Err(err) => return Err(err.into()),
191		}
192
193		let listener = tokio::net::UnixListener::bind(&path)?;
194		Ok(Self {
195			listener,
196			path,
197			protocols: Vec::new(),
198			health: crate::accept::Health::new("unix"),
199		})
200	}
201
202	/// A live handle to this listener's accept-loop health, for an embedder that
203	/// publishes it (see [`crate::accept`]).
204	pub fn accept_health(&self) -> crate::accept::Health {
205		self.health.clone()
206	}
207
208	/// Report into `health` instead of the one this listener made for itself.
209	///
210	/// For an owner that has to hand the handle out *before* the listener exists:
211	/// [`crate::Server`] binds these lazily (they need a runtime), but an embedder
212	/// registering them with a metrics endpoint does so at startup.
213	pub fn with_accept_health(mut self, health: crate::accept::Health) -> Self {
214		self.health = health;
215		self
216	}
217
218	/// Advertise these application protocols (moq ALPNs) for in-band negotiation,
219	/// in preference order. The first server entry the client also offers wins.
220	pub fn with_protocols<I, S>(mut self, protocols: I) -> Self
221	where
222		I: IntoIterator<Item = S>,
223		S: Into<String>,
224	{
225		self.protocols = protocols.into_iter().map(Into::into).collect();
226		self
227	}
228
229	/// Set the socket file's permission bits (e.g. `0o660`).
230	pub fn set_mode(&self, mode: u32) -> Result<()> {
231		fs::set_permissions(&self.path, fs::Permissions::from_mode(mode))?;
232		Ok(())
233	}
234
235	/// The bound socket path.
236	pub fn path(&self) -> &Path {
237		&self.path
238	}
239
240	/// Accept the next connection, returning the session and the peer's credentials.
241	///
242	/// A failed `accept(2)` is handled here rather than yielded: it is classified,
243	/// counted, logged, and paced by [`accept_health`](Self::accept_health), then
244	/// retried, because the caller has no better answer than to ask again. A
245	/// per-connection failure is still yielded as `Some(Err(..))`.
246	///
247	/// As in [`crate::tcp`], the `Option` has no `None` case left to report.
248	pub async fn accept(&self) -> Option<Result<(qmux::Session, PeerCred)>> {
249		let stream = self.accept_socket().await;
250		let cred = match stream.peer_cred() {
251			Ok(cred) => PeerCred {
252				uid: cred.uid(),
253				gid: cred.gid(),
254				pid: cred.pid(),
255			},
256			Err(err) => return Some(Err(err.into())),
257		};
258		let session = qmux::uds::Config::new(WIRE_VERSION)
259			.protocols(self.protocols.iter().map(String::as_str))
260			.accept(stream)
261			.await
262			.map_err(Error::Accept);
263		Some(session.map(|session| (session, cred)))
264	}
265
266	/// The `accept(2)` half: keep asking until a connection comes back.
267	async fn accept_socket(&self) -> tokio::net::UnixStream {
268		loop {
269			match self.listener.accept().await {
270				Ok((stream, _addr)) => {
271					self.health.accepted();
272					return stream;
273				}
274				Err(err) => {
275					if let Some(delay) = self.health.failed(&err) {
276						tokio::time::sleep(delay).await;
277					}
278				}
279			}
280		}
281	}
282}
283
284impl Drop for Listener {
285	fn drop(&mut self) {
286		// Best-effort: don't leave a stale socket file behind.
287		let _ = fs::remove_file(&self.path);
288	}
289}