1use std::future::Future;
2use std::pin::Pin;
3use std::time::{Duration, Instant};
4
5use url::Url;
6
7use crate::lease::{self, Reason};
8use crate::{Bytes, Error, Event, Grant, Request};
9
10const TIMEOUT: Duration = Duration::from_secs(10);
12
13const BACKOFF_MAX: Duration = Duration::from_secs(60);
15
16#[derive(Clone)]
23pub struct Client {
24 http: reqwest::Client,
25 url: Url,
26}
27
28impl Client {
29 pub fn new(url: Url, tls: Option<rustls::ClientConfig>) -> crate::Result<Self> {
35 let builder = reqwest::Client::builder().timeout(TIMEOUT);
36
37 let (builder, url) = match url.scheme() {
38 "http" => {
39 let loopback = match url.host() {
40 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
41 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
42 Some(url::Host::Domain(host)) => host == "localhost",
43 None => false,
44 };
45 if !loopback {
46 return Err(Error::InsecureUrl(url.to_string()));
47 }
48 (builder, url)
49 }
50 "https" => match tls {
51 Some(tls) => (builder.use_preconfigured_tls(tls), url),
52 None => (builder, url),
53 },
54 #[cfg(unix)]
55 "unix" => {
56 let path = url.to_file_path().map_err(|()| Error::InvalidUrl(url.to_string()))?;
57 let target = Url::parse("http://localhost/").expect("a constant URL parses");
59 (builder.unix_socket(path), target)
60 }
61 _ => return Err(Error::InvalidUrl(url.to_string())),
62 };
63
64 Ok(Self {
65 http: builder.build()?,
66 url,
67 })
68 }
69
70 pub async fn connect(&self, request: Request) -> crate::Result<lease::Consumer> {
73 let mut request = request;
74 request.event = Event::Connect;
75
76 let grant = self.post(&request).await?;
77 let (producer, consumer) = lease::Producer::new(grant.clone());
78
79 let driver = Driver {
80 client: self.clone(),
81 request,
82 producer: Some(producer),
83 expires: grant.deadline(),
84 started: Instant::now(),
85 };
86 tokio::spawn(driver.run(grant));
87
88 Ok(consumer)
89 }
90
91 async fn post(&self, request: &Request) -> crate::Result<Grant> {
95 let response = self.http.post(self.url.clone()).json(request).send().await?;
96 let status = response.status();
97 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
98 return Err(Error::Refused);
99 }
100 if !status.is_success() {
101 return Err(Error::Unavailable(format!("auth server answered {status}")));
102 }
103 let grant: Grant = response.json().await?;
104 grant.validate().map_err(|err| match err {
105 Error::UselessGrant => Error::Refused,
107 other => other,
108 })?;
109 Ok(grant)
110 }
111}
112
113struct Driver {
115 client: Client,
116 request: Request,
117 producer: Option<lease::Producer>,
118 started: Instant,
119 expires: Option<tokio::time::Instant>,
120}
121
122impl Driver {
123 async fn run(mut self, grant: Grant) {
124 let (reason, bytes) = self.drive(grant).await;
125
126 let mut request = self.request.clone();
127 request.event = Event::End {
128 reason,
129 duration: self.started.elapsed(),
130 bytes,
131 };
132 if let Err(err) = self.client.post_end(&request).await {
134 tracing::warn!(id = %request.id, %err, "failed to report the session end");
135 }
136 }
137
138 async fn drive(&mut self, mut grant: Grant) -> (Reason, Bytes) {
140 let producer = self
141 .producer
142 .take()
143 .expect("the driver owns the producer until it ends");
144 let mut failures = 0u32;
145 let mut next = grant.revalidate.map(|cadence| tokio::time::Instant::now() + cadence);
146 let mut inflight: Option<Pin<Box<dyn Future<Output = crate::Result<Grant>> + Send>>> = None;
149 let mut pending = false;
152
153 loop {
154 let revalidate = async {
155 match next {
156 Some(at) => tokio::time::sleep_until(at).await,
157 None => std::future::pending().await,
158 }
159 };
160 let expire = async {
161 match self.expires {
162 Some(at) => tokio::time::sleep_until(at).await,
163 None => std::future::pending().await,
164 }
165 };
166 let reply = async {
167 match inflight.as_mut() {
168 Some(request) => request.await,
169 None => std::future::pending().await,
170 }
171 };
172
173 tokio::select! {
174 ended = producer.closed() => return ended,
175 () = expire => return producer.finish(Reason::Expired, Bytes::default()),
176 result = reply => {
177 inflight = None;
178 match result {
179 Ok(fresh) => {
180 failures = 0;
181 self.expires = fresh.deadline();
182 next = fresh.revalidate.map(|cadence| tokio::time::Instant::now() + cadence);
183 producer.update(fresh.clone());
184 grant = fresh;
185 }
186 Err(Error::Refused) => return producer.finish(Reason::Refused, Bytes::default()),
187 Err(Error::GrantExpired | Error::UnboundedRevalidate | Error::ZeroRevalidate) => {
188 return producer.finish(Reason::Invalid, Bytes::default());
189 }
190 Err(err) => {
191 failures += 1;
193 let delay = backoff(failures, grant.revalidate.unwrap_or(BACKOFF_MAX));
194 tracing::warn!(id = %self.request.id, %err, ?delay, "auth revalidation failed; retrying");
195 next = Some(tokio::time::Instant::now() + delay);
196 }
197 }
198 if pending {
199 pending = false;
200 next = None;
201 inflight = Some(self.post_revalidate());
202 }
203 }
204 () = revalidate => {
205 next = None;
207 inflight = Some(self.post_revalidate());
208 }
209 () = producer.revalidate_requested() => {
210 if inflight.is_some() {
211 pending = true;
212 } else {
213 next = None;
214 inflight = Some(self.post_revalidate());
215 }
216 }
217 }
218 }
219 }
220
221 fn post_revalidate(&self) -> Pin<Box<dyn Future<Output = crate::Result<Grant>> + Send>> {
222 let client = self.client.clone();
223 let mut request = self.request.clone();
224 request.event = Event::Revalidate;
225 Box::pin(async move { client.post(&request).await })
226 }
227}
228
229impl Client {
230 async fn post_end(&self, request: &Request) -> crate::Result<()> {
231 self.http
232 .post(self.url.clone())
233 .json(request)
234 .send()
235 .await?
236 .error_for_status()?;
237 Ok(())
238 }
239}
240
241fn backoff(failures: u32, cadence: Duration) -> Duration {
244 use rand::RngExt;
245 let base = Duration::from_secs(1) * 2u32.saturating_pow(failures.saturating_sub(1).min(16));
246 let base = base.min(cadence).min(BACKOFF_MAX);
247 let jitter = rand::rng().random_range(0.75..=1.25);
248 base.mul_f64(jitter)
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use moq_pattern::Patterns;
255 use std::task::Poll;
256 use std::time::SystemTime;
257 use wiremock::matchers::{method, path};
258 use wiremock::{Mock, MockServer, Request as Received, ResponseTemplate};
259
260 fn patterns(texts: &[&str]) -> Patterns {
261 texts.iter().map(|text| text.parse().unwrap()).collect()
262 }
263
264 fn request() -> Request {
265 let mut request = Request::new("relay-1", crate::Transport::Quic, "/demo/room");
266 request.id = "0123".into();
267 request
268 }
269
270 #[derive(Clone, Default)]
272 struct Log(kio::Shared<Vec<Request>>);
273
274 impl Log {
275 fn events(&self) -> Vec<Event> {
276 self.0.read().iter().map(|r| r.event.clone()).collect()
277 }
278
279 fn revalidates(&self) -> usize {
280 self.events()
281 .iter()
282 .filter(|event| **event == Event::Revalidate)
283 .count()
284 }
285
286 async fn until(&self, mut done: impl FnMut(&[Request]) -> bool + Unpin) {
288 self.0
289 .wait(|log| if done(log) { Poll::Ready(()) } else { Poll::Pending })
290 .await;
291 }
292
293 async fn end(&self) -> Request {
295 let is_end = |r: &Request| matches!(r.event, Event::End { .. });
296 self.until(|log| log.iter().any(is_end)).await;
297 self.0.read().iter().find(|r| is_end(r)).cloned().unwrap()
298 }
299 }
300
301 impl wiremock::Match for Log {
302 fn matches(&self, received: &Received) -> bool {
303 self.0.lock().push(received.body_json().unwrap());
304 true
305 }
306 }
307
308 async fn server(log: Log, respond: impl Fn(&Request) -> ResponseTemplate + Send + Sync + 'static) -> MockServer {
309 let server = MockServer::start().await;
310 Mock::given(method("POST"))
311 .and(path("/"))
312 .and(log)
313 .respond_with(move |received: &Received| respond(&received.body_json().unwrap()))
314 .mount(&server)
315 .await;
316 server
317 }
318
319 async fn clock_server(log: Log, grant: Grant, stall: bool) -> Client {
321 use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post};
322 let router = Router::new()
323 .route(
324 "/",
325 post(
326 |State((log, grant, stall)): State<(Log, Grant, bool)>, Json(request): Json<Request>| async move {
327 let event = request.event.clone();
328 log.0.lock().push(request);
329 match event {
330 Event::Connect => Json(grant).into_response(),
331 Event::Revalidate if stall => std::future::pending().await,
332 Event::Revalidate => StatusCode::SERVICE_UNAVAILABLE.into_response(),
333 Event::End { .. } => StatusCode::OK.into_response(),
334 }
335 },
336 ),
337 )
338 .with_state((log, grant, stall));
339 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
340 let url = format!("http://{}/", listener.local_addr().unwrap());
341 tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
342 Client {
345 http: reqwest::Client::builder().no_proxy().build().unwrap(),
346 url: url.parse().unwrap(),
347 }
348 }
349
350 fn client(server: &MockServer) -> Client {
351 Client::new(server.uri().parse().unwrap(), None).unwrap()
352 }
353
354 fn grant(expires_in: Option<Duration>, revalidate: Option<Duration>) -> Grant {
357 let mut grant = Grant::new(patterns(&["**"]), Patterns::new());
358 grant.expires = expires_in.map(|d| SystemTime::now() + d);
359 grant.revalidate = revalidate;
360 grant
361 }
362
363 #[tokio::test]
364 async fn connect_admits_and_end_follows_the_close() {
365 let log = Log::default();
366 let server = server(log.clone(), |_| {
367 ResponseTemplate::new(200).set_body_json(grant(None, None))
368 })
369 .await;
370
371 let consumer = client(&server).connect(request()).await.unwrap();
372 assert_eq!(consumer.grant().publish, patterns(&["**"]));
373 assert_eq!(log.events(), [Event::Connect]);
374
375 consumer.close("disconnected", Bytes { sent: 7, received: 11 });
376
377 let end = log.end().await;
378 assert_eq!(end.id, "0123");
379 match end.event {
380 Event::End { reason, bytes, .. } => {
381 assert_eq!(reason, Reason::Session("disconnected".into()));
382 assert_eq!(bytes, Bytes { sent: 7, received: 11 });
383 }
384 other => panic!("expected an end, got {other:?}"),
385 }
386 }
387
388 #[tokio::test]
389 async fn a_bare_drop_ends_as_dropped() {
390 let log = Log::default();
391 let server = server(log.clone(), |_| {
392 ResponseTemplate::new(200).set_body_json(grant(None, None))
393 })
394 .await;
395
396 let consumer = client(&server).connect(request()).await.unwrap();
397 drop(consumer);
398
399 match log.end().await.event {
400 Event::End {
401 reason: Reason::Dropped,
402 bytes,
403 ..
404 } => assert_eq!(bytes, Bytes::default()),
405 other => panic!("expected a dropped end, got {other:?}"),
406 }
407 }
408
409 #[tokio::test]
410 async fn refusals_and_outages_refuse_at_connect() {
411 for status in [401, 403, 400, 404, 408, 429, 500, 503] {
412 let server = server(Log::default(), move |_| ResponseTemplate::new(status)).await;
413 let err = client(&server).connect(request()).await.unwrap_err();
414 match status {
415 401 | 403 => assert!(matches!(err, Error::Refused), "{status}: {err}"),
416 _ => assert!(matches!(err, Error::Unavailable(_)), "{status}: {err}"),
417 }
418 }
419
420 let garbage = server(Log::default(), |_| ResponseTemplate::new(200).set_body_string("nope")).await;
421 let err = client(&garbage).connect(request()).await.unwrap_err();
422 assert!(matches!(err, Error::Unavailable(_)), "{err}");
423
424 let nothing = server(Log::default(), |_| {
425 ResponseTemplate::new(200).set_body_json(Grant::default())
426 })
427 .await;
428 let err = client(¬hing).connect(request()).await.unwrap_err();
429 assert!(matches!(err, Error::Refused), "{err}");
430 }
431
432 #[tokio::test]
433 async fn revalidate_runs_on_cadence_and_applies_the_reply() {
434 let log = Log::default();
435 let server = server(log.clone(), |request| {
436 let mut grant = grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)));
437 if request.event == Event::Revalidate {
438 grant.tier = Some("moved".into());
439 }
440 ResponseTemplate::new(200).set_body_json(grant)
441 })
442 .await;
443
444 let mut consumer = client(&server).connect(request()).await.unwrap();
445 let fresh = tokio::time::timeout(Duration::from_secs(3), consumer.changed())
446 .await
447 .expect("a re-check within the cadence")
448 .unwrap();
449 assert_eq!(fresh.tier.as_deref(), Some("moved"));
450 assert_eq!(log.events()[..2], [Event::Connect, Event::Revalidate]);
451 }
452
453 #[tokio::test]
454 async fn a_refusal_on_recheck_revokes() {
455 for answer in [
456 ResponseTemplate::new(401),
457 ResponseTemplate::new(403),
458 ResponseTemplate::new(200).set_body_json(Grant::default()),
459 ] {
460 let server = server(Log::default(), {
461 let answer = answer.clone();
462 move |request| match request.event {
463 Event::Connect => ResponseTemplate::new(200)
464 .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)))),
465 _ => answer.clone(),
466 }
467 })
468 .await;
469
470 let consumer = client(&server).connect(request()).await.unwrap();
471 let reason = tokio::time::timeout(Duration::from_secs(3), consumer.closed())
472 .await
473 .expect("revoked within the cadence");
474 assert_eq!(reason, Reason::Refused);
475 }
476 }
477
478 #[tokio::test]
479 async fn an_invalid_grant_on_recheck_revokes() {
480 let unbounded = grant(None, Some(Duration::from_secs(1)));
481 let server = server(Log::default(), move |request| match request.event {
482 Event::Connect => ResponseTemplate::new(200)
483 .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)))),
484 _ => ResponseTemplate::new(200).set_body_json(unbounded.clone()),
485 })
486 .await;
487
488 let consumer = client(&server).connect(request()).await.unwrap();
489 let reason = tokio::time::timeout(Duration::from_secs(3), consumer.closed())
490 .await
491 .expect("revoked within the cadence");
492 assert_eq!(reason, Reason::Invalid);
493 }
494
495 #[tokio::test]
496 async fn a_grant_within_clock_skew_stays_live() {
497 tokio::time::pause();
498 let mut grant = Grant::new(patterns(&["**"]), Patterns::new());
499 grant.expires = Some(SystemTime::now() - Duration::from_secs(1));
500 let client = clock_server(Log::default(), grant, false).await;
501 let consumer = client.connect(request()).await.unwrap();
502
503 tokio::time::sleep(Duration::from_millis(500)).await;
504 assert!(
505 tokio::time::timeout(Duration::from_millis(100), consumer.closed())
506 .await
507 .is_err(),
508 "still live inside the skew window"
509 );
510
511 let reason = tokio::time::timeout(crate::grant::CLOCK_SKEW + Duration::from_secs(1), consumer.closed())
512 .await
513 .expect("expired once the skew window ended");
514 assert_eq!(reason, Reason::Expired);
515 }
516
517 #[tokio::test]
518 async fn an_outage_keeps_the_grant_until_expires() {
519 tokio::time::pause();
520 let log = Log::default();
521 let client = clock_server(
522 log.clone(),
523 grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))),
524 false,
525 )
526 .await;
527 let consumer = client.connect(request()).await.unwrap();
528
529 tokio::time::sleep(Duration::from_millis(1500)).await;
530 assert!(log.revalidates() >= 1, "re-checks happened");
531 assert_eq!(
532 consumer.grant().publish,
533 patterns(&["**"]),
534 "the grant stands through the outage"
535 );
536
537 let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed())
538 .await
539 .expect("expired");
540 assert_eq!(reason, Reason::Expired);
541 assert!(matches!(
542 log.end().await.event,
543 Event::End {
544 reason: Reason::Expired,
545 ..
546 }
547 ));
548 }
549
550 #[tokio::test]
551 async fn expiry_fires_while_a_recheck_is_stalled() {
552 tokio::time::pause();
553 let client = clock_server(
554 Log::default(),
555 grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))),
556 true,
557 )
558 .await;
559 let consumer = client.connect(request()).await.unwrap();
560
561 let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed())
562 .await
563 .expect("expired while the re-check was in flight");
564 assert_eq!(reason, Reason::Expired);
565 }
566
567 #[tokio::test]
568 async fn a_close_is_reported_while_a_recheck_is_stalled() {
569 let log = Log::default();
570 let server = server(log.clone(), |request| match request.event {
571 Event::Connect => ResponseTemplate::new(200)
572 .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)))),
573 Event::Revalidate => ResponseTemplate::new(200)
574 .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(60))))
575 .set_delay(Duration::from_secs(30)),
576 Event::End { .. } => ResponseTemplate::new(200),
577 })
578 .await;
579
580 let consumer = client(&server).connect(request()).await.unwrap();
581 tokio::time::sleep(Duration::from_millis(1500)).await;
582 assert!(log.events().contains(&Event::Revalidate), "the re-check is in flight");
583 consumer.close("disconnected", Bytes::default());
584 assert!(matches!(
585 log.end().await.event,
586 Event::End {
587 reason: Reason::Session(_),
588 ..
589 }
590 ));
591 }
592
593 #[test]
594 fn url_schemes_are_checked_at_construction() {
595 assert!(Client::new("http://127.0.0.1:4440/".parse().unwrap(), None).is_ok());
596 assert!(Client::new("http://localhost:4440/".parse().unwrap(), None).is_ok());
597 assert!(Client::new("http://[::1]:4440/".parse().unwrap(), None).is_ok());
598 assert!(matches!(
599 Client::new("http://auth.example/".parse().unwrap(), None),
600 Err(Error::InsecureUrl(_))
601 ));
602 assert!(Client::new("https://auth.example/".parse().unwrap(), None).is_ok());
603 assert!(matches!(
604 Client::new("ftp://auth.example/".parse().unwrap(), None),
605 Err(Error::InvalidUrl(_))
606 ));
607 #[cfg(unix)]
608 assert!(Client::new("unix:///run/moq-auth.sock".parse().unwrap(), None).is_ok());
609 }
610
611 #[tokio::test]
614 async fn a_nudge_while_idle_posts_at_once() {
615 let log = Log::default();
616 let server = server(log.clone(), |_| {
617 ResponseTemplate::new(200)
618 .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(3600))))
619 })
620 .await;
621
622 let consumer = client(&server).connect(request()).await.unwrap();
623 assert_eq!(log.events(), [Event::Connect]);
624 consumer.revalidate();
625 tokio::time::timeout(
626 Duration::from_secs(2),
627 log.until(|log| log.iter().any(|r| r.event == Event::Revalidate)),
628 )
629 .await
630 .expect("a nudge while idle POSTs at once");
631 }
632
633 #[tokio::test]
634 async fn a_nudge_during_inflight_posts_once_more_when_the_reply_lands() {
635 let log = Log::default();
636 let server = server(log.clone(), |request| match request.event {
637 Event::Connect => ResponseTemplate::new(200)
638 .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(3600)))),
639 Event::Revalidate => ResponseTemplate::new(200)
640 .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(3600))))
641 .set_delay(Duration::from_millis(400)),
642 Event::End { .. } => ResponseTemplate::new(200),
643 })
644 .await;
645
646 let consumer = client(&server).connect(request()).await.unwrap();
647 consumer.revalidate();
648 tokio::time::timeout(
649 Duration::from_secs(2),
650 log.until(|log| log.iter().any(|r| r.event == Event::Revalidate)),
651 )
652 .await
653 .expect("the first re-check is in flight");
654
655 consumer.revalidate();
656 consumer.revalidate();
657 tokio::time::timeout(
660 Duration::from_secs(3),
661 log.until(|log| log.iter().filter(|r| r.event == Event::Revalidate).count() >= 2),
662 )
663 .await
664 .expect("the in-flight nudge POSTs once more when the reply lands");
665 consumer.close("disconnected", Bytes::default());
666 log.end().await;
667 assert_eq!(
668 log.revalidates(),
669 2,
670 "a burst during an in-flight re-check is one extra POST"
671 );
672 }
673
674 #[test]
675 fn backoff_grows_and_stays_bounded() {
676 let cadence = Duration::from_secs(30);
677 let first = backoff(1, cadence);
678 assert!(
679 first >= Duration::from_millis(750) && first <= Duration::from_millis(1250),
680 "{first:?}"
681 );
682 let later = backoff(10, cadence);
683 assert!(later <= cadence.mul_f64(1.25), "{later:?}");
684 assert!(backoff(40, Duration::from_secs(3600)) <= BACKOFF_MAX.mul_f64(1.25));
685 }
686}