async fn guard(State(state): State>, mut request: Request, next: Next) -> Response { let transport = request .extensions() .get::() .copied() .unwrap_or_default(); let peer = request.extensions().get::().copied(); let raw_host = match admin_request_authority(&request, transport.tls) { Ok(value) => value, Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), }; let headers = request.headers(); let scheme = match external_admin_scheme(&state, headers, peer, transport) { Ok(value) => value, Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")), }; let external_authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { Ok(value) => value, Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), }; if headers.get_all(ORIGIN).iter().count() > 1 || headers.get(ORIGIN).is_some_and(|h| { h.to_str() .ok() .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) }) { return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")); } if headers .get("sec-fetch-site") .is_some_and(|h| h == "cross-site") { return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")); } let path = request.uri().path().to_owned(); let public = anonymous_allowed(request.method(), &path); let unsafe_method = request.method() != Method::GET && request.method() != Method::HEAD && request.method() != Method::OPTIONS; let secret = auth::cookie_secret(headers); let certificate_api_session = if certificate_api_operation(request.method(), &path) { match certificate_api_credentials(headers) { Ok(None) => None, Ok(Some((key_id, token))) => { if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" { return secure(reject( StatusCode::FORBIDDEN, "certificate_api.https_required", )); } let token_hash = auth::secret_hash(&token); match state .store .verify_certificate_api_credential(&key_id, &token_hash) .await { Ok(true) => Some(Session { token_hash, csrf: String::new(), username: format!("certificate-api:{key_id}"), expires_at: auth::now().saturating_add(60), generation: 0, }), Ok(false) => { return secure(reject( StatusCode::UNAUTHORIZED, "certificate_api.invalid_token", )) } Err(error) => return secure(auth::internal(error).into_response()), } } Err(()) => { return secure(reject( StatusCode::UNAUTHORIZED, "certificate_api.invalid_token", )) } } } else { None }; let external_certificate_api = certificate_api_session.is_some(); let session_seconds = state .config .load() .envelope .config .administration .session_seconds(); // Panel document routes optionally authenticate so a direct refresh can choose // the application shell when the browser still has a valid cookie. Public static // login resources and capability discovery never load operational state. let session = if certificate_api_session.is_some() { certificate_api_session } else if !public || panel_document_path(&path) { match secret.as_deref() { Some(secret) => match state .store .session_at_with_browser_lifetime(secret, auth::now(), session_seconds) .await { Ok(value) => value, Err(error) => return secure(auth::internal(error).into_response()), }, None => None, } } else { None }; if !public && session.is_none() { return secure(unauthorized(&request)); } if unsafe_method && !external_certificate_api { if single_header(headers, "origin") .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) || single_header(headers, "x-rustproxy-request") != Some("1") { return secure(reject(StatusCode::FORBIDDEN, "auth.csrf")); } if !public && !session.as_ref().is_some_and(|session| { single_header(headers, "x-csrf-token") .is_some_and(|provided| auth::equal_secret(provided, &session.csrf)) }) { return secure(reject(StatusCode::FORBIDDEN, "auth.csrf")); } } if let Some(session) = session.clone() { request.extensions_mut().insert(session); } let admin_origin = AdminOrigin { secure: scheme == "https", }; request.extensions_mut().insert(admin_origin); request.extensions_mut().insert(transport); let mut response = secure(next.run(request).await); let refresh_session_cookie = !matches!( path.as_str(), "/api/auth/logout" | "/api/auth/logout-all" | "/api/auth/password" ); if refresh_session_cookie { if let (Some(secret), Some(session)) = (secret.as_deref(), session.as_ref()) { let remaining = session.expires_at.saturating_sub(auth::now()); if remaining > 0 { if let Ok(cookie) = HeaderValue::from_str(&auth::session_cookie( secret, admin_origin.secure, remaining, )) { response.headers_mut().append(SET_COOKIE, cookie); } } } } if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() { // Swagger UI sets inline layout styles. This exception is limited to the // authenticated documentation document; scripts remain self-only/no-eval. response.headers_mut().insert("content-security-policy", HeaderValue::from_static( "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")); } response } async fn auth_status(State(state): State>) -> Result, ApiError> { // The one bit needed to render first-account setup; no version, paths, names, // listeners, provider state, metrics or configuration before authentication. Ok(Json( json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}), )) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Setup { token: String, username: String, password: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Login { username: String, password: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct PasswordChange { current_password: String, new_password: String, } async fn setup( State(state): State>, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result, ApiError> { let Json(input) = input.map_err(public::invalid_json)?; state .auth .setup(&input.token, &input.username, input.password) .await?; Ok(Json(json!({"initialized":true}))) } fn session_payload(session: &Session, session_seconds: i64) -> Value { json!({"username":session.username, "csrf_token":session.csrf, "expires_at":session.expires_at, "idle_timeout_seconds":session_seconds, "session_lifetime_seconds":session_seconds}) } async fn login( State(state): State>, Extension(origin): Extension, headers: HeaderMap, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result { let Json(input) = input.map_err(public::invalid_json)?; let previous = auth::cookie_secret(&headers); let session_seconds = state .config .load() .envelope .config .administration .session_seconds(); let issued = state .auth .login( &input.username, input.password, previous.as_deref(), session_seconds, ) .await?; let mut response = Json(session_payload(&issued.session, session_seconds)).into_response(); response.headers_mut().insert( SET_COOKIE, auth::session_cookie(&issued.secret, origin.secure, session_seconds) .parse() .map_err(auth::internal)?, ); Ok(response) } async fn session( State(state): State>, Extension(session): Extension, ) -> Json { let session_seconds = state .config .load() .envelope .config .administration .session_seconds(); Json(session_payload(&session, session_seconds)) } const LIVE_LOG_COLUMNS: &[&str] = &[ "time", "request", "host", "source_ip", "vhost", "backend", "status", "duration", "cache", "outcome", "transport", "websocket", "upstream_error", ]; const BACKEND_TABLE_COLUMNS: &[&str] = &[ "vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery", ]; fn default_admin_theme() -> String { "dark".to_owned() } fn default_backend_table_columns() -> Vec { BACKEND_TABLE_COLUMNS .iter() .map(|value| (*value).to_owned()) .collect() } fn default_backend_table_sort() -> String { "vhost".to_owned() } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct AdminPreferences { live_log_columns: Vec, #[serde(default = "default_admin_theme")] theme: String, #[serde(default = "default_backend_table_columns")] backend_table_columns: Vec, #[serde(default = "default_backend_table_sort")] backend_table_sort: String, #[serde(default)] backend_table_descending: bool, } impl Default for AdminPreferences { fn default() -> Self { Self { live_log_columns: LIVE_LOG_COLUMNS .iter() .map(|value| (*value).to_owned()) .collect(), theme: default_admin_theme(), backend_table_columns: default_backend_table_columns(), backend_table_sort: default_backend_table_sort(), backend_table_descending: false, } } } fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> { if preferences.live_log_columns.is_empty() || preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len() { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "at least one live-log column must be visible".into(), )); } let mut seen = std::collections::HashSet::new(); for column in &preferences.live_log_columns { if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid live-log column preference".into(), )); } } if !matches!(preferences.theme.as_str(), "dark" | "light") { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration theme preference".into(), )); } if preferences.backend_table_columns.is_empty() || preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len() { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "at least one backend table column must be visible".into(), )); } let mut seen = std::collections::HashSet::new(); for column in &preferences.backend_table_columns { if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid backend table column preference".into(), )); } } if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid backend table sort preference".into(), )); } Ok(()) } async fn preferences( State(state): State>, Extension(session): Extension, ) -> Result, ApiError> { let stored = state .store .admin_preferences(&session.username) .await .map_err(auth::internal)?; let preferences = match stored { Some(document) => match serde_json::from_str::(&document) { Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences, Ok(_) | Err(_) => { tracing::warn!(user = %session.username, "invalid admin view preferences ignored"); AdminPreferences::default() } }, None => AdminPreferences::default(), }; Ok(Json(preferences)) } async fn update_preferences( State(state): State>, Extension(session): Extension, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result, ApiError> { let Json(preferences) = input.map_err(public::invalid_json)?; validate_admin_preferences(&preferences)?; let document = serde_json::to_string(&preferences).map_err(auth::internal)?; if !state .store .save_admin_preferences(&session.username, &document) .await .map_err(auth::internal)? { return Err(ApiError( StatusCode::UNAUTHORIZED, "auth.invalid_credentials".into(), )); } Ok(Json(preferences)) } fn logged_out(secure_cookie: bool) -> Response { let mut response = Json(json!({"logged_out":true})).into_response(); response.headers_mut().insert( SET_COOKIE, HeaderValue::from_str(&auth::expired_cookie(secure_cookie)) .expect("static cookie attributes"), ); response } async fn logout( State(state): State>, Extension(origin): Extension, Extension(session): Extension, ) -> Result { state .store .end_session(&session) .await .map_err(auth::internal)?; Ok(logged_out(origin.secure)) } async fn logout_all( State(state): State>, Extension(origin): Extension, Extension(session): Extension, ) -> Result { state .store .revoke_all_sessions(&session.username) .await .map_err(auth::internal)?; Ok(logged_out(origin.secure)) } async fn password( State(state): State>, Extension(origin): Extension, Extension(session): Extension, Json(input): Json, ) -> Result { state .auth .change_password(&session, input.current_password, input.new_password) .await?; Ok(logged_out(origin.secure)) }