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