1use std::collections::{BTreeSet, HashMap};
36use std::net::{IpAddr, SocketAddr};
37use std::sync::{Arc, Mutex};
38use std::time::{Duration, Instant};
39
40use axum::body::Body;
41use axum::extract::{ConnectInfo, Request, State};
42use axum::http::{HeaderMap, StatusCode};
43use axum::middleware::Next;
44use axum::response::{IntoResponse, Response};
45use axum::routing::get;
46
47use crate::context::SelfIdentity;
48
49pub const DEFAULT_BIND: &str = "127.0.0.1:8449";
56
57pub const MAX_BODY_BYTES: usize = 4 << 20;
69
70pub const RATE_BURST: u32 = 120;
72
73pub const RATE_WINDOW: Duration = Duration::from_secs(60);
75
76pub const HEALTH_PATH: &str = "/health";
78
79pub const MCP_PATH: &str = "/mcp";
81
82#[derive(Debug, Clone)]
84pub struct Guard {
85 token: Option<Arc<tailscale_rest::Secret>>,
87 hosts: Arc<BTreeSet<String>>,
90 origins: Arc<BTreeSet<String>>,
92 limiter: Arc<Mutex<RateLimiter>>,
93 peers: Arc<HashMap<IpAddr, String>>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Refusal {
103 UnknownHost,
104 ForbiddenOrigin,
105 RateLimited,
106 BadToken,
107}
108
109impl Refusal {
110 pub const fn status(self) -> StatusCode {
111 match self {
112 Self::UnknownHost | Self::ForbiddenOrigin => StatusCode::FORBIDDEN,
116 Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
117 Self::BadToken => StatusCode::UNAUTHORIZED,
118 }
119 }
120
121 pub const fn message(self) -> &'static str {
122 match self {
123 Self::UnknownHost => {
124 "this server does not answer for that `Host`; add it with `--http-allow-host`"
125 }
126 Self::ForbiddenOrigin => {
127 "this server does not answer requests from a browser page; add the origin with \
128 `--http-allow-origin` if that is what you meant"
129 }
130 Self::RateLimited => "too many requests from this address; wait and try again",
131 Self::BadToken => "a bearer token is required and did not match",
132 }
133 }
134}
135
136impl IntoResponse for Refusal {
137 fn into_response(self) -> Response {
138 (self.status(), format!("{}\n", self.message())).into_response()
141 }
142}
143
144#[derive(Debug, Clone)]
151pub struct Caller {
152 pub address: IpAddr,
154 pub name: Option<String>,
157}
158
159impl Caller {
160 pub fn describe(&self) -> String {
162 match &self.name {
163 Some(name) => format!("{name} ({})", self.address),
164 None => self.address.to_string(),
165 }
166 }
167}
168
169impl Guard {
170 pub fn for_session(
177 settings: &crate::config::HttpConfig,
178 identity: &SelfIdentity,
179 peers: HashMap<IpAddr, String>,
180 ) -> Self {
181 Self::new(
182 settings.token.clone(),
183 &settings.allow_hosts,
184 &settings.allow_origins,
185 identity,
186 peers,
187 )
188 }
189
190 pub fn new(
197 token: Option<tailscale_rest::Secret>,
198 extra_hosts: &[String],
199 origins: &[String],
200 identity: &SelfIdentity,
201 peers: HashMap<IpAddr, String>,
202 ) -> Self {
203 let mut hosts: BTreeSet<String> = ["localhost", "127.0.0.1", "[::1]", "::1"]
204 .iter()
205 .map(|host| (*host).to_owned())
206 .collect();
207 if let Some(dns_name) = &identity.dns_name {
211 let full = dns_name.trim_end_matches('.').to_ascii_lowercase();
212 if let Some(short) = full.split('.').next() {
213 hosts.insert(short.to_owned());
214 }
215 hosts.insert(full);
216 }
217 hosts.extend(identity.addresses.iter().map(|a| bracketed(a)));
218 hosts.extend(extra_hosts.iter().map(|host| normalise_host(host)));
219
220 Self {
221 token: token.map(Arc::new),
222 hosts: Arc::new(hosts),
223 origins: Arc::new(origins.iter().map(|o| normalise_origin(o)).collect()),
224 limiter: Arc::new(Mutex::new(RateLimiter::default())),
225 peers: Arc::new(peers),
226 }
227 }
228
229 pub fn hosts(&self) -> Vec<&str> {
232 self.hosts.iter().map(String::as_str).collect()
233 }
234
235 pub fn admit(&self, headers: &HeaderMap, address: IpAddr, now: Instant) -> Result<(), Refusal> {
241 let host = headers
242 .get(axum::http::header::HOST)
243 .and_then(|value| value.to_str().ok())
244 .unwrap_or_default();
245 if !self.hosts.contains(&normalise_host(host)) {
246 return Err(Refusal::UnknownHost);
247 }
248
249 if let Some(origin) = headers.get(axum::http::header::ORIGIN) {
250 let origin = normalise_origin(origin.to_str().unwrap_or_default());
251 if !self.origins.contains(&origin) {
252 return Err(Refusal::ForbiddenOrigin);
253 }
254 }
255
256 if !self
257 .limiter
258 .lock()
259 .map(|mut limiter| limiter.allow(address, now))
260 .unwrap_or(true)
261 {
262 return Err(Refusal::RateLimited);
263 }
264
265 let Some(expected) = &self.token else {
266 return Ok(());
267 };
268 let given = headers
269 .get(axum::http::header::AUTHORIZATION)
270 .and_then(|value| value.to_str().ok())
271 .and_then(|value| {
272 value
273 .strip_prefix("Bearer ")
274 .or_else(|| value.strip_prefix("bearer "))
275 })
276 .unwrap_or_default();
277 if same_secret(given.as_bytes(), expected.expose().as_bytes()) {
278 Ok(())
279 } else {
280 Err(Refusal::BadToken)
281 }
282 }
283
284 pub fn caller(&self, address: IpAddr) -> Caller {
286 Caller {
287 address,
288 name: self.peers.get(&address).cloned(),
289 }
290 }
291}
292
293fn normalise_host(host: &str) -> String {
298 let host = host.trim().to_ascii_lowercase();
299 if let Some(rest) = host.strip_prefix('[') {
300 let closed = rest.split_once(']').map(|(inside, _)| inside);
301 return closed.map_or(host.clone(), |inside| format!("[{inside}]"));
302 }
303 host.split_once(':')
304 .map_or(host.clone(), |(name, _)| name.to_owned())
305}
306
307fn normalise_origin(origin: &str) -> String {
316 let origin = origin.trim();
317 let Some((scheme, rest)) = origin.split_once("://") else {
318 return origin.to_ascii_lowercase();
319 };
320 let scheme = scheme.to_ascii_lowercase();
321 let authority = normalise_host(rest.split('/').next().unwrap_or_default());
322 let port = rest
323 .split('/')
324 .next()
325 .and_then(|a| a.rsplit_once(':'))
326 .filter(|(before, _)| !before.ends_with(':') && !before.is_empty())
327 .and_then(|(_, port)| port.parse::<u16>().ok())
328 .filter(|port| !matches!((scheme.as_str(), port), ("http", 80) | ("https", 443)));
329 match port {
330 Some(port) => format!("{scheme}://{authority}:{port}"),
331 None => format!("{scheme}://{authority}"),
332 }
333}
334
335fn bracketed(address: &str) -> String {
337 if address.contains(':') {
338 format!("[{}]", address.to_ascii_lowercase())
339 } else {
340 address.to_ascii_lowercase()
341 }
342}
343
344fn same_secret(given: &[u8], expected: &[u8]) -> bool {
349 let mut difference = (given.len() ^ expected.len()) as u32;
350 let longest = given.len().max(expected.len());
351 for i in 0..longest {
352 let a = given.get(i).copied().unwrap_or(0);
353 let b = expected.get(i).copied().unwrap_or(0);
354 difference |= u32::from(a ^ b);
355 }
356 difference == 0
357}
358
359#[derive(Debug, Default)]
361struct RateLimiter {
362 seen: HashMap<IpAddr, Bucket>,
363}
364
365#[derive(Debug, Clone, Copy)]
366struct Bucket {
367 left: f64,
369 at: Instant,
371}
372
373impl RateLimiter {
374 fn allow(&mut self, address: IpAddr, now: Instant) -> bool {
376 let rate = f64::from(RATE_BURST) / RATE_WINDOW.as_secs_f64();
377 self.seen.retain(|_, bucket| {
384 let refill = now.saturating_duration_since(bucket.at).as_secs_f64() * rate;
385 refill < f64::from(RATE_BURST) - bucket.left
386 });
387
388 let bucket = self.seen.entry(address).or_insert(Bucket {
389 left: f64::from(RATE_BURST),
390 at: now,
391 });
392 let refill = now.saturating_duration_since(bucket.at).as_secs_f64() * rate;
393 bucket.left = (bucket.left + refill).min(f64::from(RATE_BURST));
394 bucket.at = now;
395 if bucket.left < 1.0 {
396 return false;
397 }
398 bucket.left -= 1.0;
399 true
400 }
401}
402
403async fn admission(
405 State(guard): State<Guard>,
406 ConnectInfo(peer): ConnectInfo<SocketAddr>,
407 request: Request,
408 next: Next,
409) -> Response {
410 let address = peer.ip();
411 if let Err(refusal) = guard.admit(request.headers(), address, Instant::now()) {
412 tracing::warn!(
413 caller = guard.caller(address).describe(),
414 refusal = ?refusal,
415 "refused an HTTP request"
416 );
417 return refusal.into_response();
418 }
419 let caller = guard.caller(address);
423 tracing::info!(caller = caller.describe(), path = %request.uri().path(), "http request");
424 let mut request = request;
425 request.extensions_mut().insert(caller);
426 next.run(request).await
427}
428
429pub fn router<S>(guard: Guard, mcp: S) -> axum::Router
431where
432 S: tower::Service<Request<Body>, Response = Response, Error = std::convert::Infallible>
433 + Clone
434 + Send
435 + Sync
436 + 'static,
437 S::Future: Send + 'static,
438{
439 axum::Router::new()
440 .route_service(MCP_PATH, mcp)
441 .layer(axum::middleware::from_fn_with_state(
442 guard.clone(),
443 admission,
444 ))
445 .route(HEALTH_PATH, get(health))
448 .with_state(guard)
449}
450
451pub async fn serve(
460 settings: &crate::config::HttpConfig,
461 guard: Guard,
462 server: crate::server::TailscaleMcpServer,
463) -> std::io::Result<()> {
464 use rmcp::transport::streamable_http_server::{
465 StreamableHttpService, session::local::LocalSessionManager,
466 };
467
468 let transport = StreamableHttpService::new(
469 move || Ok(server.clone()),
470 Arc::new(LocalSessionManager::default()),
471 rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default()
472 .with_allowed_hosts(guard.hosts())
473 .with_max_request_body_bytes(MAX_BODY_BYTES)
474 .with_legacy_session_mode(settings.stateful),
475 );
476 let transport = tower::util::ServiceExt::<Request<Body>>::map_response(transport, |response| {
479 axum::http::Response::map(response, Body::new)
480 });
481
482 let listener = tokio::net::TcpListener::bind(settings.bind).await?;
483 tracing::info!(
484 address = %settings.bind,
485 authenticated = guard.token.is_some(),
486 sessions = settings.stateful,
487 "serving MCP over HTTP"
488 );
489 axum::serve(
490 listener,
491 router(guard, transport).into_make_service_with_connect_info::<SocketAddr>(),
492 )
493 .await
494}
495
496async fn health() -> impl IntoResponse {
497 (
498 StatusCode::OK,
499 [("content-type", "application/json")],
500 concat!(
504 "{\"status\":\"ok\",\"server\":\"",
505 env!("CARGO_PKG_NAME"),
506 "\"}\n"
507 ),
508 )
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn identity() -> SelfIdentity {
516 SelfIdentity {
517 node_id: Some("n1111111CNTRL".to_owned()),
518 numeric_id: None,
519 addresses: vec!["100.64.0.1".to_owned(), "fd7a:115c:a1e0::1".to_owned()],
520 dns_name: Some("workstation.example-tailnet.ts.net.".to_owned()),
521 }
522 }
523
524 fn guard(token: Option<&str>, origins: &[&str]) -> Guard {
525 Guard::new(
526 token.map(tailscale_rest::Secret::new),
527 &[],
528 &origins.iter().map(|o| (*o).to_owned()).collect::<Vec<_>>(),
529 &identity(),
530 HashMap::new(),
531 )
532 }
533
534 fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
535 let mut headers = HeaderMap::new();
536 for (name, value) in pairs {
537 headers.insert(
538 axum::http::HeaderName::from_bytes(name.as_bytes()).expect("a header name"),
539 value.parse().expect("a header value"),
540 );
541 }
542 headers
543 }
544
545 fn here() -> IpAddr {
546 IpAddr::from([127, 0, 0, 1])
547 }
548
549 #[test]
550 fn this_nodes_own_names_are_allowed_without_configuration() {
551 let guard = guard(None, &[]);
552 for host in [
553 "localhost",
554 "127.0.0.1:8449",
555 "workstation",
556 "workstation.example-tailnet.ts.net",
557 "WORKSTATION.EXAMPLE-TAILNET.TS.NET:8449",
558 "100.64.0.1:8449",
559 "[fd7a:115c:a1e0::1]:8449",
560 ] {
561 assert_eq!(
562 guard.admit(&headers(&[("host", host)]), here(), Instant::now()),
563 Ok(()),
564 "`{host}` is one of this node's own names"
565 );
566 }
567 }
568
569 #[test]
570 fn a_host_this_server_does_not_answer_for_is_refused_before_anything_else() {
571 let guard = guard(Some("s3cret-token-value"), &[]);
574 assert_eq!(
575 guard.admit(
576 &headers(&[
577 ("host", "evil.example"),
578 ("authorization", "Bearer s3cret-token-value")
579 ]),
580 here(),
581 Instant::now()
582 ),
583 Err(Refusal::UnknownHost),
584 "and refused for the host, not for the token, which was right"
585 );
586 }
587
588 #[test]
589 fn the_token_has_to_match_and_a_missing_one_is_the_same_answer_as_a_wrong_one() {
590 let guard = guard(Some("s3cret-token-value"), &[]);
591 let host = ("host", "localhost");
592
593 assert_eq!(
594 guard.admit(
595 &headers(&[host, ("authorization", "Bearer s3cret-token-value")]),
596 here(),
597 Instant::now()
598 ),
599 Ok(())
600 );
601 assert_eq!(
602 guard.admit(&headers(&[host]), here(), Instant::now()),
603 Err(Refusal::BadToken)
604 );
605 assert_eq!(
606 guard.admit(
607 &headers(&[host, ("authorization", "Bearer wrong")]),
608 here(),
609 Instant::now()
610 ),
611 Err(Refusal::BadToken)
612 );
613 assert_eq!(
615 guard.admit(
616 &headers(&[host, ("authorization", "Bearer s3cret-token-valu")]),
617 here(),
618 Instant::now()
619 ),
620 Err(Refusal::BadToken)
621 );
622 }
623
624 #[test]
625 fn a_lowercase_bearer_scheme_is_admitted_like_the_capitalised_one() {
626 let guard = guard(Some("s3cret-token-value"), &[]);
627 assert_eq!(
628 guard.admit(
629 &headers(&[
630 ("host", "localhost"),
631 ("authorization", "bearer s3cret-token-value")
632 ]),
633 here(),
634 Instant::now()
635 ),
636 Ok(())
637 );
638 }
639
640 #[test]
641 fn comparing_a_secret_folds_the_length_in_rather_than_checking_it_first() {
642 assert!(same_secret(b"abc", b"abc"));
643 assert!(!same_secret(b"abc", b"abd"));
644 assert!(!same_secret(b"ab", b"abc"));
645 assert!(!same_secret(b"abcd", b"abc"));
646 assert!(same_secret(b"", b""));
647 assert!(!same_secret(b"abc", b"abc\0"));
650 }
651
652 #[test]
653 fn the_rate_limit_triggers_and_then_recovers() {
654 let mut limiter = RateLimiter::default();
655 let start = Instant::now();
656 for i in 0..RATE_BURST {
657 assert!(
658 limiter.allow(here(), start),
659 "request {i} is inside the burst"
660 );
661 }
662 assert!(
663 !limiter.allow(here(), start),
664 "and one more is not, at the same instant"
665 );
666
667 let later = start + RATE_WINDOW / RATE_BURST + Duration::from_millis(1);
669 assert!(limiter.allow(here(), later), "a bucket refills with time");
670 assert!(!limiter.allow(here(), later), "one at a time, though");
671
672 assert!(limiter.allow(IpAddr::from([127, 0, 0, 2]), start));
674 }
675
676 #[test]
677 fn an_address_that_has_gone_quiet_is_forgotten() {
678 let mut limiter = RateLimiter::default();
679 let start = Instant::now();
680 assert!(limiter.allow(here(), start));
681 assert_eq!(limiter.seen.len(), 1, "while it is still spending");
682
683 assert!(limiter.allow(IpAddr::from([127, 0, 0, 2]), start + RATE_WINDOW * 2));
686 assert_eq!(
687 limiter.seen.keys().collect::<Vec<_>>(),
688 vec![&IpAddr::from([127, 0, 0, 2])],
689 "a server up for a year should not hold one entry per address that ever reached it"
690 );
691 }
692
693 #[test]
694 fn an_origin_is_compared_as_an_origin_and_not_as_a_string() {
695 assert_eq!(
698 normalise_origin("https://App.Example"),
699 "https://app.example"
700 );
701 assert_eq!(
702 normalise_origin("https://app.example:443"),
703 "https://app.example",
704 "the default port for the scheme is not part of the origin"
705 );
706 assert_eq!(normalise_origin("http://localhost:80"), "http://localhost");
707 assert_eq!(
708 normalise_origin("http://localhost:3000/some/page"),
709 "http://localhost:3000",
710 "a path is not part of an origin either"
711 );
712 assert_eq!(normalise_origin("null"), "null");
713
714 let guard = guard(None, &["https://app.example"]);
715 assert_eq!(
716 guard.admit(
717 &headers(&[("host", "localhost"), ("origin", "https://App.Example:443")]),
718 here(),
719 Instant::now()
720 ),
721 Ok(()),
722 "listing an origin lists it however a browser spells it"
723 );
724 }
725}