Files
wt cf25e28c28
Rust verification / test (push) Successful in 4m10s
initial commit
2026-10-28 15:59:54 +07:00

225 lines
8.8 KiB
Rust

//! 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<AppState> {
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<AppState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
) -> Result<Json<Value>, 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<Value, ApiError> {
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<Value> {
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<AppState>,
Extension(user): Extension<AuthUser>,
) -> Result<Json<Value>, 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<AuthUser>) -> Result<Json<Value>, 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<AuthUser>) -> Result<Json<Value>, 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
}
);
}
}
}
}