Skip to main content

moq_auth/
request.rs

1use serde::{Deserialize, Serialize};
2use serde_with::{DurationSecondsWithFrac, TimestampSeconds, serde_as};
3use std::net::SocketAddr;
4use std::time::{Duration, SystemTime};
5
6use crate::lease::Reason;
7
8/// Everything a relay knows about a session, sent to the auth server on every event.
9///
10/// Nothing is parsed on the relay's behalf: the server keys policy on the raw
11/// [`path`](Self::path) and [`query`](Self::query), so no query parameter is special
12/// and a credential can be whatever the server understands. The same shape carries
13/// every [`Event`]; an `end` adds what the session did.
14#[serde_with::skip_serializing_none]
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[non_exhaustive]
17pub struct Request {
18	/// Random 128-bit hex, unique per session; the key every event for it shares.
19	pub id: String,
20
21	/// Which lifecycle event this is, with what an `end` carries.
22	#[serde(flatten)]
23	pub event: Event,
24
25	/// The operator's name for the relay asking.
26	pub node: String,
27
28	/// How the session reached the relay.
29	pub transport: Transport,
30
31	/// The peer's socket address, absent on a transport without one (a unix socket).
32	pub remote: Option<SocketAddr>,
33
34	/// The relay's socket address the session arrived on, absent likewise.
35	pub local: Option<SocketAddr>,
36
37	/// The SNI the client presented, when the transport carried TLS.
38	pub server_name: Option<String>,
39
40	/// The negotiated application protocol, including the moq version.
41	pub alpn: Option<String>,
42
43	/// The path exactly as dialed.
44	pub path: String,
45
46	/// The raw query string, without the leading `?`.
47	pub query: Option<String>,
48
49	/// The direction the client declared at SETUP; absent means both.
50	pub role: Option<Role>,
51
52	/// The verified client certificate, when one was presented.
53	pub tls: Option<Peer>,
54}
55
56impl Request {
57	/// A `connect` for a fresh session, minting a random 128-bit hex id.
58	///
59	/// Set the remaining fields on the returned value; the struct is
60	/// `#[non_exhaustive]`, so this stays the way to build one as fields are added.
61	pub fn new(node: impl Into<String>, transport: Transport, path: impl Into<String>) -> Self {
62		let mut bytes = [0u8; 16];
63		aws_lc_rs::rand::fill(&mut bytes).expect("failed to generate a session id");
64		let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
65		Self {
66			id,
67			event: Event::Connect,
68			node: node.into(),
69			transport,
70			remote: None,
71			local: None,
72			server_name: None,
73			alpn: None,
74			path: path.into(),
75			query: None,
76			role: None,
77			tls: None,
78		}
79	}
80}
81
82/// The lifecycle moment a [`Request`] reports.
83#[serde_as]
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85#[serde(tag = "event", rename_all = "lowercase")]
86pub enum Event {
87	/// A session was accepted and asks to be admitted.
88	Connect,
89	/// The grant asked to be re-checked on its cadence.
90	Revalidate,
91	/// The session closed.
92	End {
93		/// Why it closed.
94		reason: Reason,
95		/// How long it was admitted.
96		#[serde_as(as = "DurationSecondsWithFrac<f64>")]
97		duration: Duration,
98		/// What it moved.
99		bytes: Bytes,
100	},
101}
102
103/// How a session reached the relay. The names match `moq_tokio::server::Transport`,
104/// plus `http` for the relay's one-shot HTTP routes.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum Transport {
108	/// QUIC, either directly or through WebTransport over HTTP/3.
109	Quic,
110	/// An Iroh QUIC connection.
111	Iroh,
112	/// A WebSocket connection using qmux framing.
113	WebSocket,
114	/// A plaintext TCP connection using qmux framing.
115	Tcp,
116	/// A Unix domain socket using qmux framing.
117	Unix,
118	/// A one-shot HTTP request on the relay's web listener (`/fetch`, `/announced`),
119	/// admitted and ended within the request.
120	Http,
121}
122
123impl Transport {
124	/// The stable lowercase name, the same one the wire carries.
125	pub const fn as_str(self) -> &'static str {
126		match self {
127			Self::Quic => "quic",
128			Self::Iroh => "iroh",
129			Self::WebSocket => "websocket",
130			Self::Tcp => "tcp",
131			Self::Unix => "unix",
132			Self::Http => "http",
133		}
134	}
135}
136
137impl std::fmt::Display for Transport {
138	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139		f.write_str(self.as_str())
140	}
141}
142
143/// The single direction a client declared at SETUP.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum Role {
147	/// The client will publish; the relay consumes.
148	Publisher,
149	/// The client will subscribe; the relay publishes.
150	Subscriber,
151}
152
153/// The verified client certificate a session presented, as facts for the server to
154/// decide on. Presenting one admits nothing by itself.
155#[serde_as]
156#[serde_with::skip_serializing_none]
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158pub struct Peer {
159	/// The first SAN DNS name, else the CN, else the fingerprint, so it is never empty.
160	///
161	/// Those three sources fold into one string a server cannot tell apart; match on
162	/// [`fingerprint`](Self::fingerprint) when identity must be exact.
163	pub name: String,
164	/// SHA-256 of the leaf certificate, hex.
165	pub fingerprint: String,
166	/// The certificate's notAfter.
167	#[serde_as(as = "Option<TimestampSeconds<i64>>")]
168	pub expires: Option<SystemTime>,
169	/// The issuer's distinguished name.
170	pub issuer: String,
171}
172
173/// Byte totals for a session, both directions from the relay's point of view.
174#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
175pub struct Bytes {
176	/// Bytes the relay sent to the peer.
177	pub sent: u64,
178	/// Bytes the relay received from the peer.
179	pub received: u64,
180}
181
182#[cfg(test)]
183mod tests {
184	use super::*;
185
186	fn request() -> Request {
187		let mut request = Request::new("relay-1", Transport::Quic, "/demo/room");
188		request.id = "00ff".into();
189		request.remote = Some("203.0.113.9:4433".parse().unwrap());
190		request.local = Some("[::1]:443".parse().unwrap());
191		request.server_name = Some("relay.example".into());
192		request.alpn = Some("moq-lite-05".into());
193		request.query = Some("jwt=abc".into());
194		request.role = Some(Role::Publisher);
195		request.tls = Some(Peer {
196			name: "edge0".into(),
197			fingerprint: "ab".repeat(32),
198			expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)),
199			issuer: "CN=cluster".into(),
200		});
201		request
202	}
203
204	#[test]
205	fn connect_round_trips_flat() {
206		let request = request();
207		let json = serde_json::to_value(&request).unwrap();
208		assert_eq!(json["event"], "connect");
209		assert_eq!(json["transport"], "quic");
210		assert_eq!(json["role"], "publisher");
211		assert_eq!(json["remote"], "203.0.113.9:4433");
212		assert_eq!(json["tls"]["expires"], 4_102_444_800_i64);
213		assert!(json.get("reason").is_none());
214		assert_eq!(serde_json::from_value::<Request>(json).unwrap(), request);
215	}
216
217	#[test]
218	fn end_carries_its_facts_beside_the_rest() {
219		let mut request = request();
220		request.event = Event::End {
221			reason: Reason::Session("disconnected".into()),
222			duration: Duration::from_millis(1500),
223			bytes: Bytes { sent: 10, received: 20 },
224		};
225		let json = serde_json::to_value(&request).unwrap();
226		assert_eq!(json["event"], "end");
227		assert_eq!(json["reason"], "disconnected");
228		assert_eq!(json["duration"], 1.5);
229		assert_eq!(json["bytes"]["sent"], 10);
230		assert_eq!(serde_json::from_value::<Request>(json).unwrap(), request);
231	}
232
233	/// The exact bytes `js/auth/src/interop.test.ts` parses, so both languages read
234	/// one wire shape.
235	#[test]
236	fn end_serializes_to_the_cross_language_vector() {
237		let mut request = Request::new("relay-1", Transport::WebSocket, "/demo/room");
238		request.id = "00ff".into();
239		request.remote = Some("203.0.113.9:4433".parse().unwrap());
240		request.query = Some("jwt=abc".into());
241		request.event = Event::End {
242			reason: Reason::Expired,
243			duration: Duration::from_millis(1500),
244			bytes: Bytes { sent: 10, received: 20 },
245		};
246		assert_eq!(
247			serde_json::to_string(&request).unwrap(),
248			r#"{"id":"00ff","event":"end","reason":"expired","duration":1.5,"bytes":{"sent":10,"received":20},"node":"relay-1","transport":"websocket","remote":"203.0.113.9:4433","path":"/demo/room","query":"jwt=abc"}"#
249		);
250
251		request.event = Event::End {
252			reason: Reason::Invalid,
253			duration: Duration::from_millis(1500),
254			bytes: Bytes { sent: 10, received: 20 },
255		};
256		assert_eq!(
257			serde_json::to_string(&request).unwrap(),
258			r#"{"id":"00ff","event":"end","reason":"invalid","duration":1.5,"bytes":{"sent":10,"received":20},"node":"relay-1","transport":"websocket","remote":"203.0.113.9:4433","path":"/demo/room","query":"jwt=abc"}"#
259		);
260	}
261
262	#[test]
263	fn a_unix_session_has_no_addresses() {
264		let request = Request::new("relay-1", Transport::Unix, "");
265		let json = serde_json::to_value(&request).unwrap();
266		assert!(json.get("remote").is_none());
267		assert_eq!(json["transport"], "unix");
268		assert_eq!(serde_json::from_value::<Request>(json).unwrap(), request);
269	}
270
271	#[test]
272	fn new_mints_a_128_bit_hex_id() {
273		let a = Request::new("relay-1", Transport::Quic, "/");
274		let b = Request::new("relay-1", Transport::Quic, "/");
275		assert_eq!(a.id.len(), 32);
276		assert!(a.id.chars().all(|c| c.is_ascii_hexdigit()), "{}", a.id);
277		assert_ne!(a.id, b.id);
278	}
279}