//! Administrator runtime diagnostics and maintenance for the native uncached runtime. use crate::{ApiError, AppState, auth::AuthUser}; use axum::{ Extension, Json, Router, extract::State, http::{HeaderMap, StatusCode}, routing::get, }; use serde_json::{Value, json}; pub fn routes() -> Router { Router::new() .route("/system/infos", get(infos)) .route("/system/latestRelease", get(latest_release)) .route("/system/optimize", get(optimize)) .route("/system/clear-cache", get(clear)) } fn admin(user: &AuthUser) -> Result<(), ApiError> { if user.is_admin { Ok(()) } else { Err(ApiError( StatusCode::FORBIDDEN, "This action is unauthorized.", )) } } async fn infos( State(state): State, Extension(user): Extension, headers: HeaderMap, ) -> Result, ApiError> { admin(&user)?; let settings = crate::settings::all(&state.db).await?; let guard = std::env::var("AUTH_GUARD") .or_else(|_| std::env::var("AUTHENTICATION_GUARD")) .unwrap_or_else(|_| "web-guard".into()); let mut common = json!({"Date":chrono::Utc::now().to_rfc2822(),"userAgent":headers.get("user-agent").and_then(|v|v.to_str().ok()),"Version":format!("twofauth-rs {}",env!("CARGO_PKG_VERSION")),"API target":"2FAuth 8.0.3","Environment":std::env::var("APP_ENV").unwrap_or_else(|_|"production".into()),"Install path":format!("/{}",std::env::var("APP_SUBDIRECTORY").unwrap_or_default().trim_matches('/')),"Debug":std::env::var("APP_DEBUG").unwrap_or_else(|_|"false".into()),"Cache driver":"none","Log channel":"tracing","Log level":std::env::var("RUST_LOG").unwrap_or_else(|_|"info".into()),"DB driver":"sqlite","Operating system":std::env::consts::OS,"interface":"native Rust","Auth guard":guard,"webauthn user verification":std::env::var("WEBAUTHN_USER_VERIFICATION").unwrap_or_else(|_|"preferred".into()),"Trusted proxies":std::env::var("TRUSTED_PROXIES").ok().filter(|s|!s.is_empty()).unwrap_or_else(||"none".into()),"lastRadarScan":settings["lastRadarScan"]}); if guard == "reverse-proxy-guard" { let config = crate::proxy_auth::ProxyConfig::from_env(); common["Auth proxy logout url"] = json!(std::env::var("PROXY_LOGOUT_URL").ok()); common["Auth proxy header for user"] = json!(config.user_header); common["Auth proxy header for email"] = json!(config.email_header); } Ok(Json(json!({"common":common}))) } fn release_number(tag: &str) -> Option<[u64; 3]> { let pattern = regex::Regex::new(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") .expect("fixed version regex"); let groups = pattern.captures(tag)?; Some([ groups[1].parse().ok()?, groups[2].parse().ok()?, groups[3].parse().ok()?, ]) } async fn record_release(db: &sqlx::SqlitePool, data: &Value) -> Result { let mut tx = db.begin().await?; sqlx::query("INSERT INTO options(key,value) VALUES('lastRadarScan',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(chrono::Utc::now().timestamp().to_string()).execute(&mut *tx).await?; let mut result = json!(false); if let Some(tag) = data["tag_name"].as_str() && let Some(number) = release_number(tag) { if number > [8, 0, 3] && data["prerelease"] == false && data["draft"] == false { sqlx::query("INSERT INTO options(key,value) VALUES('latestRelease',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(tag).execute(&mut *tx).await?; result = json!(tag); } else { sqlx::query("DELETE FROM options WHERE key='latestRelease'") .execute(&mut *tx) .await?; } } tx.commit().await?; Ok(result) } async fn fetch_release() -> Option { let mut builder = reqwest::Client::builder() .user_agent(concat!("twofauth-rs/", env!("CARGO_PKG_VERSION"))) .timeout(std::time::Duration::from_secs(10)); if let Ok(proxy) = std::env::var("PROXY_FOR_OUTGOING_REQUESTS") && !proxy.is_empty() { builder = builder.proxy(reqwest::Proxy::all(proxy).ok()?); } let client = builder.build().ok()?; for attempt in 0..3 { if let Ok(response) = client .get("https://api.github.com/repos/Bubka/2FAuth/releases/latest") .send() .await && let Ok(mut response) = response.error_for_status() { if response .content_length() .is_some_and(|len| len > 1024 * 1024) { return None; } let mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await.ok()? { if bytes.len() + chunk.len() > 1024 * 1024 { return None; } bytes.extend_from_slice(&chunk); } // A successful non-JSON response still records a completed scan, matching upstream. return Some(serde_json::from_slice(&bytes).unwrap_or(Value::Null)); } if attempt < 2 { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } } None } async fn latest_release( State(state): State, Extension(user): Extension, ) -> Result, ApiError> { admin(&user)?; let result = match fetch_release().await { Some(data) => record_release(&state.db, &data).await?, None => Value::Null, }; Ok(Json(json!({"newRelease":result}))) } // Configuration is read per request; routes/events are compiled in the binary and // frontend assets are read directly. There are no generated cache files to stale. async fn optimize(Extension(user): Extension) -> Result, ApiError> { admin(&user)?; Ok(Json( json!({"config-exit-code":0,"route-exit-code":0,"event-exit-code":0,"view-exit-code":0}), )) } async fn clear(Extension(user): Extension) -> Result, ApiError> { admin(&user)?; Ok(Json(json!({"exit-code":0}))) } #[cfg(test)] mod tests { use super::*; use axum::{body::Body, http::Request}; use tower::ServiceExt; #[tokio::test] async fn release_scan_updates_cache_and_clears_old_version() { let db = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::db::init(&db).await.unwrap(); assert_eq!( record_release( &db, &json!({"tag_name":"v8.1.0","prerelease":false,"draft":false}) ) .await .unwrap(), json!("v8.1.0") ); let value: String = sqlx::query_scalar("SELECT value FROM options WHERE key='latestRelease'") .fetch_one(&db) .await .unwrap(); assert_eq!(value, "v8.1.0"); assert_eq!( record_release( &db, &json!({"tag_name":"v8.0.3","prerelease":false,"draft":false}) ) .await .unwrap(), json!(false) ); let count: i64 = sqlx::query_scalar("SELECT count(*) FROM options WHERE key='latestRelease'") .fetch_one(&db) .await .unwrap(); assert_eq!(count, 0); assert_eq!( record_release( &db, &json!({"tag_name":"v9.0.0-beta","prerelease":true,"draft":false}) ) .await .unwrap(), json!(false) ); } #[tokio::test] async fn diagnostic_and_cache_routes_require_admin_and_report_actual_runtime() { let db = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::db::init(&db).await.unwrap(); let state = AppState { db, cipher: None, encrypt_db: std::sync::atomic::AtomicBool::new(false).into(), }; for is_admin in [false, true] { let app = routes() .layer(Extension(AuthUser { id: 1, is_admin })) .with_state(state.clone()); for path in ["/system/infos", "/system/optimize", "/system/clear-cache"] { let response = app .clone() .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!( response.status(), if !is_admin { StatusCode::FORBIDDEN } else { StatusCode::OK } ); } } } }