1use std::time::Duration;
2
3use axum::Router;
4use axum::body::Body;
5use axum::extract::Request;
6use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, LAST_MODIFIED};
7use axum::http::{HeaderMap, Method, StatusCode};
8use axum::response::Response;
9use axum::routing::any;
10use reqwest::Url;
11use reqwest::redirect::Policy;
12use serde_json::json;
13
14use crate::adapter::{Adapter, Inner, json_response};
15use crate::manifest::percent_decode;
16
17const CONSOLE_BASE_PATH: &str = "/console";
18const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10);
19const MAX_REDIRECTS: usize = 5;
20
21#[derive(Clone)]
23struct ConsoleOrigin {
24 base: Url,
25 prefix: String,
26}
27
28impl ConsoleOrigin {
29 fn new(auth_server_url: &str) -> Option<Self> {
30 let base = Url::parse(auth_server_url).ok()?;
31 base.host_str()?;
32 let prefix = format!("{}{CONSOLE_BASE_PATH}", base.path().trim_end_matches('/'));
33 Some(ConsoleOrigin { base, prefix })
34 }
35
36 fn contains(&self, url: &Url) -> bool {
38 url.scheme() == self.base.scheme()
39 && url.host_str() == self.base.host_str()
40 && url.port_or_known_default() == self.base.port_or_known_default()
41 && (url.path() == self.prefix || url.path().starts_with(&format!("{}/", self.prefix)))
42 }
43
44 fn upstream(&self, subpath: &str, query: Option<&str>) -> Option<Url> {
49 let lower = subpath.to_ascii_lowercase();
50 if lower.contains("%2f") || lower.contains("%5c") || subpath.contains('\\') {
51 return None;
52 }
53 for segment in subpath.split('/') {
54 match percent_decode(segment) {
55 Some(decoded) if decoded != "." && decoded != ".." => {}
56 _ => return None,
57 }
58 }
59
60 let suffix = match subpath {
61 "" | "/" => String::new(),
62 s if s.starts_with('/') => s.to_string(),
63 s => format!("/{s}"),
64 };
65 let mut url = self.base.clone();
66 url.set_path(&format!("{}{suffix}", self.prefix));
67 url.set_query(query.filter(|q| !q.is_empty()));
68 self.contains(&url).then_some(url)
69 }
70}
71
72impl Adapter {
73 pub fn console_router<S>(&self) -> Router<S>
83 where
84 S: Clone + Send + Sync + 'static,
85 {
86 let inner = self.inner.clone();
87 let handler = move |req: Request| {
88 let inner = inner.clone();
89 async move { inner.serve_console(req).await }
90 };
91 Router::new()
92 .route(CONSOLE_BASE_PATH, any(handler.clone()))
93 .route(&format!("{CONSOLE_BASE_PATH}/"), any(handler.clone()))
94 .route(&format!("{CONSOLE_BASE_PATH}/{{*path}}"), any(handler))
95 }
96}
97
98impl Inner {
99 fn console_client(&self, origin: &ConsoleOrigin) -> Option<reqwest::Client> {
102 self.console_client
103 .get_or_init(|| {
104 let origin = origin.clone();
105 reqwest::Client::builder()
106 .timeout(UPSTREAM_TIMEOUT)
107 .redirect(Policy::custom(move |attempt| {
108 if attempt.previous().len() >= MAX_REDIRECTS {
109 attempt.error("too many redirects")
110 } else if origin.contains(attempt.url()) {
111 attempt.follow()
112 } else {
113 attempt.error("redirect leaves the console")
114 }
115 }))
116 .build()
117 .ok()
118 })
119 .clone()
120 }
121
122 async fn serve_console(&self, req: Request) -> Response {
123 let method = req.method().clone();
124 if method != Method::GET && method != Method::HEAD {
125 return json_response(
126 StatusCode::METHOD_NOT_ALLOWED,
127 &json!({ "error": "Method not allowed" }),
128 );
129 }
130
131 let invalid = || {
132 json_response(
133 StatusCode::BAD_REQUEST,
134 &json!({ "error": "Invalid console path" }),
135 )
136 };
137 let Some(origin) = ConsoleOrigin::new(&self.config.auth_server_url) else {
138 return invalid();
139 };
140 let subpath = req
141 .uri()
142 .path()
143 .strip_prefix(CONSOLE_BASE_PATH)
144 .unwrap_or_default();
145 let Some(upstream) = origin.upstream(subpath, req.uri().query()) else {
146 return invalid();
147 };
148 let Some(client) = self.console_client(&origin) else {
149 return json_response(
150 StatusCode::BAD_GATEWAY,
151 &json!({ "error": "Console upstream unreachable" }),
152 );
153 };
154
155 let res = match client.request(method.clone(), upstream).send().await {
156 Ok(res) => res,
157 Err(_) => {
158 return json_response(
159 StatusCode::BAD_GATEWAY,
160 &json!({ "error": "Console upstream unreachable" }),
161 );
162 }
163 };
164
165 let mut headers = HeaderMap::new();
166 for name in [CONTENT_TYPE, CACHE_CONTROL, ETAG, LAST_MODIFIED] {
167 if let Some(value) = res.headers().get(&name) {
168 headers.insert(name, value.clone());
169 }
170 }
171 let status = res.status();
172 let body = if method == Method::HEAD {
173 Body::empty()
174 } else {
175 Body::new(reqwest::Body::from(res))
176 };
177 let mut response = Response::new(body);
178 *response.status_mut() = status;
179 *response.headers_mut() = headers;
180 response
181 }
182}