commit
cf25e28c28
156 files changed
+38669
No files matched your search
+224
@@ -0,0 +1,224 @@
|
||||
//! 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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user