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}
172
173impl Listener {
174	/// Bind a Unix socket at `path`, replacing a stale socket file left by a
175	/// previous run.
176	///
177	/// Refuses to unlink the path if it exists and is not a socket, to avoid
178	/// clobbering an unrelated file.
179	pub async fn bind(path: impl AsRef<Path>) -> Result<Self> {
180		let path = path.as_ref().to_path_buf();
181
182		// A leftover socket from a crashed run would make bind() fail with
183		// EADDRINUSE, so unlink it first. Anything that isn't a socket we leave
184		// alone and error out.
185		match fs::symlink_metadata(&path) {
186			Ok(meta) if meta.file_type().is_socket() => fs::remove_file(&path)?,
187			Ok(_) => return Err(Error::NotASocket(path)),
188			Err(err) if err.kind() == io::ErrorKind::NotFound => {}
189			Err(err) => return Err(err.into()),
190		}
191
192		let listener = tokio::net::UnixListener::bind(&path)?;
193		Ok(Self {
194			listener,
195			path,
196			protocols: Vec::new(),
197		})
198	}
199
200	/// Advertise these application protocols (moq ALPNs) for in-band negotiation,
201	/// in preference order. The first server entry the client also offers wins.
202	pub fn with_protocols<I, S>(mut self, protocols: I) -> Self
203	where
204		I: IntoIterator<Item = S>,
205		S: Into<String>,
206	{
207		self.protocols = protocols.into_iter().map(Into::into).collect();
208		self
209	}
210
211	/// Set the socket file's permission bits (e.g. `0o660`).
212	pub fn set_mode(&self, mode: u32) -> Result<()> {
213		fs::set_permissions(&self.path, fs::Permissions::from_mode(mode))?;
214		Ok(())
215	}
216
217	/// The bound socket path.
218	pub fn path(&self) -> &Path {
219		&self.path
220	}
221
222	/// Accept the next connection, returning the session and the peer's credentials.
223	///
224	/// Returns `None` only if the listener itself is gone; a per-connection
225	/// failure is yielded as `Some(Err(..))` so the accept loop keeps running.
226	pub async fn accept(&self) -> Option<Result<(qmux::Session, PeerCred)>> {
227		match self.listener.accept().await {
228			Ok((stream, _addr)) => {
229				let cred = match stream.peer_cred() {
230					Ok(cred) => PeerCred {
231						uid: cred.uid(),
232						gid: cred.gid(),
233						pid: cred.pid(),
234					},
235					Err(err) => return Some(Err(err.into())),
236				};
237				let session = qmux::uds::Config::new(WIRE_VERSION)
238					.protocols(self.protocols.iter().map(String::as_str))
239					.accept(stream)
240					.await
241					.map_err(Error::Accept);
242				Some(session.map(|session| (session, cred)))
243			}
244			Err(err) => Some(Err(err.into())),
245		}
246	}
247}
248
249impl Drop for Listener {
250	fn drop(&mut self) {
251		// Best-effort: don't leave a stale socket file behind.
252		let _ = fs::remove_file(&self.path);
253	}
254}