mz_environmentd/http/
console.rs1use std::collections::BTreeMap;
13use std::sync::{Arc, LazyLock};
14
15use axum::Extension;
16use axum::Json;
17use axum::body::Body;
18use axum::http::{Request, StatusCode};
19use axum::response::{IntoResponse, Response};
20use http::header::{CONTENT_TYPE, COOKIE, HOST, LOCATION, ORIGIN, SET_COOKIE};
21use http::{HeaderMap, HeaderValue, Method};
22use hyper::Uri;
23use hyper_tls::HttpsConnector;
24use hyper_util::client::legacy::Client;
25use hyper_util::client::legacy::connect::HttpConnector;
26use hyper_util::rt::TokioExecutor;
27use mz_adapter_types::dyncfgs::{CONSOLE_OIDC_CLIENT_ID, CONSOLE_OIDC_SCOPES, OIDC_ISSUER};
28
29use crate::http::Delayed;
30
31const PREVIEW_BUILD_PARAM: &str = "preview_build";
34
35const PREVIEW_BUILD_COOKIE: &str = "mz_console_preview_build";
37
38const PREVIEW_BUILD_COOKIE_MAX_AGE_SECS: u64 = 60 * 60 * 24;
41
42pub(crate) struct ConsoleProxyConfig {
43 client: Client<HttpsConnector<HttpConnector>, Body>,
45
46 url: String,
48
49 route_prefix: String,
51
52 preview_host_suffix: Option<String>,
55}
56
57impl ConsoleProxyConfig {
58 pub(crate) fn new(proxy_url: Option<String>, route_prefix: String) -> Self {
59 let mut url = proxy_url.unwrap_or_else(|| "https://console.materialize.com".to_string());
60 if let Some(new) = url.strip_suffix('/') {
61 url = new.to_string();
62 }
63 let preview_host_suffix = Uri::try_from(url.as_str())
64 .ok()
65 .and_then(|uri| uri.host().map(|host| host.to_string()));
66 Self {
67 client: Client::builder(TokioExecutor::new()).build(HttpsConnector::new()),
68 url,
69 route_prefix,
70 preview_host_suffix,
71 }
72 }
73
74 fn preview_url(&self, label: &str) -> Option<String> {
82 let suffix = self.preview_host_suffix.as_deref()?;
83 if !is_valid_preview_build_label(label) {
84 return None;
85 }
86 Some(format!("https://{label}.{suffix}"))
87 }
88}
89
90const PREVIEW_BUILD_LABEL_PREFIX: &str = "console-git-";
94
95fn is_valid_preview_build_label(label: &str) -> bool {
99 label.len() <= 63
100 && label.starts_with(PREVIEW_BUILD_LABEL_PREFIX)
101 && label
102 .bytes()
103 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
104 && !label.ends_with('-')
105}
106
107static CONSOLE_CONFIG_VAR_NAMES: LazyLock<[&'static str; 3]> = LazyLock::new(|| {
109 [
110 OIDC_ISSUER.name(),
111 CONSOLE_OIDC_CLIENT_ID.name(),
112 CONSOLE_OIDC_SCOPES.name(),
113 ]
114});
115
116pub async fn handle_console_config(
119 Extension(adapter_client_rx): Extension<Delayed<mz_adapter::Client>>,
120) -> Result<Response, (StatusCode, String)> {
121 let adapter_client = adapter_client_rx.await.map_err(|_| {
122 (
123 StatusCode::INTERNAL_SERVER_ERROR,
124 "Adapter client unavailable".to_string(),
125 )
126 })?;
127
128 let system_vars = adapter_client.get_system_vars().await;
129 let mut config: BTreeMap<&str, String> = BTreeMap::new();
130 for var_name in CONSOLE_CONFIG_VAR_NAMES.iter() {
131 let value = system_vars.get(var_name).map(|v| v.value()).map_err(|_| {
132 (
133 StatusCode::INTERNAL_SERVER_ERROR,
134 format!("failed to retrieve system variable {var_name}"),
135 )
136 })?;
137 config.insert(var_name, value);
138 }
139
140 Ok((StatusCode::OK, Json(config)).into_response())
141}
142
143pub(crate) async fn handle_internal_console(
155 console_config: Extension<Arc<ConsoleProxyConfig>>,
156 mut req: Request<Body>,
157) -> Result<Response, StatusCode> {
158 if let Some(response) = preview_build_selection_response(&console_config, &req)? {
159 return Ok(response);
160 }
161
162 let preview_build = preview_build_from_cookie(req.headers())
163 .and_then(|label| console_config.preview_url(&label).map(|url| (label, url)));
164 let upstream_url = preview_build
165 .as_ref()
166 .map_or_else(|| console_config.url.clone(), |(_, url)| url.clone());
167
168 let path = req.uri().path();
169 let mut path_query = req
170 .uri()
171 .path_and_query()
172 .map(|v| v.as_str())
173 .unwrap_or(path);
174 if let Some(stripped_path_query) = path_query.strip_prefix(&console_config.route_prefix) {
175 path_query = stripped_path_query;
176 }
177
178 let uri = Uri::try_from(format!("{}{}", upstream_url, path_query)).unwrap();
179 let host = uri.host().unwrap().to_string();
180 *req.uri_mut() = uri;
182
183 req.headers_mut()
185 .insert(HOST, HeaderValue::from_str(&host).unwrap());
186
187 match console_config.client.request(req).await {
189 Ok(response) => Ok(response.into_response()),
190 Err(err) => {
191 tracing::warn!("Error retrieving console url: {}", err);
192 match preview_build {
195 Some((label, _)) => Ok(preview_build_unavailable_response(&label)),
196 None => Err(StatusCode::BAD_REQUEST),
197 }
198 }
199 }
200}
201
202fn preview_build_selection_response(
212 console_config: &ConsoleProxyConfig,
213 req: &Request<Body>,
214) -> Result<Option<Response>, StatusCode> {
215 let query = req.uri().query().unwrap_or("");
216 let mut selection = None;
217 let mut remaining_query = url::form_urlencoded::Serializer::new(String::new());
218 let mut any_remaining = false;
219 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
220 if key == PREVIEW_BUILD_PARAM {
221 selection = Some(value.into_owned());
222 } else {
223 remaining_query.append_pair(&key, &value);
224 any_remaining = true;
225 }
226 }
227 let is_post = *req.method() == Method::POST;
228 if is_post && !is_same_origin(req) {
229 return Err(StatusCode::FORBIDDEN);
230 }
231 let Some(label) = selection else {
232 if is_post {
235 return Err(StatusCode::METHOD_NOT_ALLOWED);
236 }
237 return Ok(None);
238 };
239
240 let cookie_attributes = format!(
241 "Path={}; Secure; HttpOnly; SameSite=Lax",
242 console_config.route_prefix
243 );
244 let cookie = if label.is_empty() {
245 format!("{PREVIEW_BUILD_COOKIE}=; Max-Age=0; {cookie_attributes}")
246 } else {
247 let Some(preview_url) = console_config.preview_url(&label) else {
248 return Err(StatusCode::BAD_REQUEST);
249 };
250 if !is_post {
251 return Ok(Some(preview_build_confirmation_response(
252 console_config,
253 &label,
254 &preview_url,
255 )));
256 }
257 format!(
258 "{PREVIEW_BUILD_COOKIE}={label}; \
259 Max-Age={PREVIEW_BUILD_COOKIE_MAX_AGE_SECS}; {cookie_attributes}"
260 )
261 };
262
263 let mut location = req.uri().path().to_string();
264 if any_remaining {
265 location.push('?');
266 location.push_str(&remaining_query.finish());
267 }
268 let response = Response::builder()
269 .status(StatusCode::SEE_OTHER)
270 .header(LOCATION, location)
271 .header(SET_COOKIE, cookie)
272 .body(Body::empty())
273 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
274 Ok(Some(response))
275}
276
277fn is_same_origin(req: &Request<Body>) -> bool {
284 if let Some(site) = req
285 .headers()
286 .get("sec-fetch-site")
287 .and_then(|v| v.to_str().ok())
288 {
289 return site == "same-origin" || site == "none";
290 }
291 let (Some(origin), Some(host)) = (req.headers().get(ORIGIN), req.headers().get(HOST)) else {
292 return true;
294 };
295 let (Ok(origin), Ok(host)) = (origin.to_str(), host.to_str()) else {
296 return false;
297 };
298 origin
299 .split_once("://")
300 .is_some_and(|(_, authority)| authority == host)
301}
302
303fn preview_build_confirmation_response(
306 console_config: &ConsoleProxyConfig,
307 label: &str,
308 preview_url: &str,
309) -> Response {
310 let route_prefix = &console_config.route_prefix;
311 let body = format!(
312 "<!DOCTYPE html>\n\
313 <html><head><title>Console preview build</title></head><body>\n\
314 <p>Serve console assets in this browser from <code>{preview_url}</code> \
315 for the next 24 hours?</p>\n\
316 <form method=\"post\"><button type=\"submit\">Use preview build {label}</button></form>\n\
317 <p><a href=\"{route_prefix}/\">Cancel</a></p>\n\
318 </body></html>\n"
319 );
320 (
321 StatusCode::OK,
322 [(CONTENT_TYPE, "text/html; charset=utf-8")],
323 body,
324 )
325 .into_response()
326}
327
328fn preview_build_unavailable_response(label: &str) -> Response {
331 let body = format!(
332 "<!DOCTYPE html>\n\
333 <html><head><title>Console preview build unavailable</title></head><body>\n\
334 <p>Failed to load console preview build <code>{label}</code>.</p>\n\
335 <p><a href=\"?{PREVIEW_BUILD_PARAM}=\">Return to the default console build</a></p>\n\
336 </body></html>\n"
337 );
338 (
339 StatusCode::BAD_GATEWAY,
340 [(CONTENT_TYPE, "text/html; charset=utf-8")],
341 body,
342 )
343 .into_response()
344}
345
346fn preview_build_from_cookie(headers: &HeaderMap) -> Option<String> {
350 for header in headers.get_all(COOKIE) {
351 let Ok(header) = header.to_str() else {
352 continue;
353 };
354 for pair in header.split(';') {
355 let mut parts = pair.trim().splitn(2, '=');
356 if parts.next() == Some(PREVIEW_BUILD_COOKIE) {
357 let label = parts.next().unwrap_or("");
358 if is_valid_preview_build_label(label) {
359 return Some(label.to_string());
360 }
361 }
362 }
363 }
364 None
365}