keep latest healthcheck errors, basic admin login

This commit is contained in:
4cuck committed 2023-10-16 23:32:10 +02:00
1 parent cc70134d9d
commit 759963e7b2
32 files changed
+1761 -308

No files matched your search

+367
View File
@@ -0,0 +1,367 @@
use std::collections::HashSet;
use std::sync::Arc;
use axum::Form;
use axum::Json;
use axum::extract::Path;
use axum::extract::State;
use axum::response::Html;
use axum::response::IntoResponse;
use axum::response::Redirect;
use entities::check_errors;
use entities::health_check;
use entities::state::AppState;
use hyper::HeaderMap;
use hyper::StatusCode;
use hyper::header::REFERER;
use reqwest::Client;
use reqwest::Url;
use sea_orm::DatabaseConnection;
use sea_orm::EntityTrait;
use sea_orm::QueryOrder;
use sea_orm::QuerySelect;
use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
use tower_sessions::Session;
use constant_time_eq::constant_time_eq;
use sha2::{Sha256, Digest};
use entities::host;
use sea_orm::ColumnTrait;
use sea_orm::QueryFilter;
use trust_dns_resolver::AsyncResolver;
use trust_dns_resolver::config::ResolverConfig;
use trust_dns_resolver::config::ResolverOpts;
use crate::ADMIN_OVERVIEW_URL;
use crate::Config;
use crate::LOGIN_URL;
use crate::Result;
use crate::ServerError;
#[derive(Serialize, Deserialize, Default)]
pub struct ActiveLogin {
/// Hosts this session has access to.
hosts: HashSet<i32>
}
const LOGIN_KEY: &'static str = "LOGIN";
#[derive(Error, Debug)]
pub enum LoginError {
#[error("Couldn't get login file at '{0}': {1}")]
HttpFailure(Url, reqwest::Error),
#[error("Invalid instance URL '{0}'")]
InstanceUrl(String),
#[error("Invalid response at '{0}'")]
InvalidResponse(Url,String),
#[error("Key mismatch")]
KeyMismatch,
#[error("No public instance host found with domain '{0}'")]
HostNotFound(String),
#[error("Server responded with status code '{0}'")]
ServerResponse(u16, String),
#[error("Invalid hash found")]
InvalidHash(String),
#[error("Failed to fetch DNS TXT records")]
DNSError(#[from] trust_dns_resolver::error::ResolveError),
#[error("No valid DNS TXT entry found for your key")]
DNSNoValidEntry(String),
}
type LoginResult<T> = std::result::Result<T, LoginError>;
pub async fn logout(session: Session) -> Result<axum::response::Response> {
session.delete();
Ok(Redirect::temporary(LOGIN_URL).into_response())
}
pub async fn login(
State(ref template): State<Arc<tera::Tera>>,
State(ref config): State<Arc<crate::Config>>,
State(ref login_client): State<Client>,
State(ref db): State<DatabaseConnection>,
session: Session,
Form(input): Form<LoginInput>
) -> Result<axum::response::Response> {
tracing::debug!(login=?input);
let host = host::Entity::find().filter(host::Column::Domain.eq(input.domain.trim()))
.one(db)
.await?;
if host.as_ref().map(|host|get_session_login(&session).map(|login|login.hosts.contains(&host.id)).is_ok()).unwrap_or_default() {
// already logged in
return Ok(Redirect::temporary(&input.referrer).into_response());
}
match login_inner(config, login_client, &input, host).await {
Ok(host) => {
let session_value = match get_session_login(&session) {
Ok(mut session) => {
session.hosts.insert(host.id);
session
},
Err(_) => {
let mut ids = HashSet::with_capacity(1);
ids.insert(host.id);
ActiveLogin {
hosts: ids,
}
}
};
session.insert(LOGIN_KEY,session_value)?;
let referrer = input.referrer.trim();
let location = match referrer.trim().is_empty() && referrer != LOGIN_URL {
true => ADMIN_OVERVIEW_URL,
false => input.referrer.trim(),
};
let mut res = Redirect::temporary(location).into_response();
*res.status_mut() = StatusCode::FOUND;
Ok(res)
},
Err(e) => {
tracing::debug!(login_error=?e);
let mut context = tera::Context::new();
context.insert("REFERRER", &input.referrer);
context.insert("ERROR", &e.to_string());
context.insert("LOGIN_METHOD", &input.verification_method);
context.insert("DOMAIN", &input.domain);
context.insert("VERIFY_TOKEN_NAME", &config.login_token_name);
match e {
LoginError::InvalidResponse(_, val)
| LoginError::ServerResponse(_,val)
| LoginError::DNSNoValidEntry(val)
| LoginError::InvalidHash(val) => context.insert("QUOTE", &val),
_ => (),
}
let mut res = Html(template.render("login.html.j2", &context)?).into_response();
*res.status_mut() = StatusCode::FORBIDDEN;
Ok(res.into_response())
},
}
}
async fn login_inner(
config: &crate::Config,
login_client: &Client,
input: &LoginInput,
host: Option<host::Model>,
) -> LoginResult<host::Model> {
let host = host.ok_or_else(||LoginError::HostNotFound(input.domain.clone()))?;
match input.verification_method {
VerificationMethod::DNS => {
let entries = fetch_host_dns(&host.domain, &config).await?;
for entry in &entries {
if let Ok(_) = verify_key(entry, &input.key) {
return Ok(host);
}
}
Err(LoginError::DNSNoValidEntry(entries.join(",")))
},
VerificationMethod::HTTP => {
let fetched_key = fetch_host_txt(&host.url, login_client, &config).await?;
verify_key(&fetched_key, &input.key).map(|_|host)
}
}
}
#[derive(Deserialize, Debug)]
pub struct LoginInput {
domain: String,
key: String,
referrer: String,
verification_method: VerificationMethod
}
#[derive(Serialize, Deserialize, Debug)]
enum VerificationMethod {
DNS,
HTTP
}
async fn fetch_host_txt(instance_url: &str, client: &Client, config: &Config) -> LoginResult<String> {
let mut request_url = Url::parse(&instance_url).map_err(|_|LoginError::InstanceUrl(instance_url.to_string()))?;
request_url.set_path(&[".",&config.login_token_name].concat());
request_url.set_query(None);
let result = client.get(request_url.clone()).send().await.map_err(|e|LoginError::HttpFailure(request_url.clone(), e))?;
match true { // result.status().is_success()
true => Ok(result.text().await.map_err(|e: reqwest::Error|LoginError::InvalidResponse(request_url,e.to_string()))?),
false => Err(LoginError::ServerResponse(result.status().as_u16(),result.text().await.unwrap_or_default())),
}
}
async fn fetch_host_dns(instance_domain: &str, config: &Config) -> LoginResult<Vec<String>> {
// TODO: cache resolver ?
let resolver = AsyncResolver::tokio(ResolverConfig::cloudflare_tls(), ResolverOpts::default());
let hashed_key = resolver
.txt_lookup(format!("{}.{}.",&config.login_token_name, instance_domain))
.await?;
let mut entries = Vec::with_capacity(2);
for record in hashed_key.iter() {
for data in record.iter() {
match std::str::from_utf8(data) {
Ok(val) => entries.push(val.to_owned()),
Err(_) => (),
}
}
}
Ok(entries)
}
/// Verify a key with its public available hash
/// Key is in base16 (hex) and has to match the hash passed in after SHA2 encoding it.
fn verify_key(hash: &str, key: &str) -> LoginResult<()> {
let mut hasher: Sha256 = Sha256::new();
hasher.update(key);
let res = hasher.finalize();
let mut decoded_hash = [0;32];
base16ct::mixed::decode(hash, &mut decoded_hash).map_err(|e|{
tracing::trace!(hash_error=?e);
LoginError::InvalidHash(hash.to_owned())
})?;
match constant_time_eq(&res,&decoded_hash) {
false => Err(LoginError::KeyMismatch),
true => Ok(())
}
}
pub async fn login_view(
State(ref template): State<Arc<tera::Tera>>,
State(ref config): State<Arc<crate::Config>>,
headers: HeaderMap,
) -> Result<axum::response::Response> {
tracing::debug!(headers=?headers);
let referrer = headers.get(REFERER).and_then(|v|v.to_str().ok());
let mut context = tera::Context::new();
context.insert("REFERRER", &referrer);// FIXME: won't work, handle this in the error part to extract the current situation
context.insert("VERIFY_TOKEN_NAME", &config.login_token_name);
let res = Html(template.render("login.html.j2", &context)?).into_response();
Ok(res)
}
pub async fn overview(
State(ref app_state): State<AppState>,
State(ref template): State<Arc<tera::Tera>>,
State(ref db): State<DatabaseConnection>,
session: Session
) -> Result<axum::response::Response> {
tracing::info!(?session);
let hosts = get_all_login_hosts(&session, db).await?;
let mut context = tera::Context::new();
let res = {
let guard = app_state.cache.read().map_err(|_| ServerError::MutexFailure)?;
let time = guard.last_update.format("%Y.%m.%d %H:%M").to_string();
context.insert("last_updated", &time);
context.insert("instances", &hosts);
let res = Html(template.render("admin.html.j2", &context)?).into_response();
drop(guard);
res
};
Ok(res)
}
pub async fn history_json(
State(ref db): State<DatabaseConnection>,
Path(host): Path<i32>,
session: Session
) -> Result<axum::response::Response> {
let host = get_specific_login_host(host,&session,db).await?;
let history: Vec<health_check::Model> = health_check::Entity::find().filter(health_check::Column::Host.eq(host.id))
.order_by_asc(health_check::Column::Time).limit(20).all(db).await?;
Ok(Json(history).into_response())
}
pub async fn history_view(
State(ref app_state): State<AppState>,
State(ref template): State<Arc<tera::Tera>>,
State(ref db): State<DatabaseConnection>,
Path(host): Path<i32>,
session: Session
) -> Result<axum::response::Response> {
tracing::info!(?session);
let host = get_specific_login_host(host,&session,db).await?;
let mut context = tera::Context::new();
let res = {
let guard = app_state.cache.read().map_err(|_| ServerError::MutexFailure)?;
let time = guard.last_update.format("%Y.%m.%d %H:%M").to_string();
context.insert("last_updated", &time);
context.insert("HOST_DOMAIN", &host.domain);
let res = Html(template.render("errors_admin.html.j2", &context)?).into_response();
drop(guard);
res
};
Ok(res)
}
pub async fn errors_view(
State(ref app_state): State<AppState>,
State(ref template): State<Arc<tera::Tera>>,
State(ref db): State<DatabaseConnection>,
Path(host): Path<i32>,
session: Session
) -> Result<axum::response::Response> {
tracing::info!(?session);
let host = get_specific_login_host(host,&session,db).await?;
let errors = check_errors::Entity::find().filter(check_errors::Column::Host.eq(host.id))
.order_by_desc(check_errors::Column::Time).limit(20).all(db).await?;
let mut context = tera::Context::new();
let res = {
let guard = app_state.cache.read().map_err(|_| ServerError::MutexFailure)?;
let time = guard.last_update.format("%Y.%m.%d %H:%M").to_string();
context.insert("last_updated", &time);
context.insert("ERRORS", &errors);
context.insert("HOST_DOMAIN", &host.domain);
let res = Html(template.render("errors_admin.html.j2", &context)?).into_response();
drop(guard);
res
};
Ok(res)
}
/// Get all [host::Model] for current [Session]
async fn get_all_login_hosts(session: &Session, db: &DatabaseConnection) -> Result<Vec<host::Model>> {
let login = get_session_login(&session)?;
let host_res = host::Entity::find().filter(host::Column::Id.is_in(login.hosts.iter().map(|v|*v)))
.all(db).await?;
Ok(host_res)
}
/// Get wanted [host::Model] for current [Session] if valid for this user
async fn get_specific_login_host(wanted_host_id: i32, session: &Session, db: &DatabaseConnection) -> Result<host::Model> {
let login = get_session_login(&session)?;
if !login.hosts.contains(&wanted_host_id) {
return Err(ServerError::MissingPermission);
}
let host_res = host::Entity::find().filter(host::Column::Id.eq(wanted_host_id)).one(db).await?;
match host_res {
None => {
session.delete();
return Err(ServerError::HostNotFound(wanted_host_id));
},
Some(host) => Ok(host),
}
}
/// Check for valid session and return the stored [Login] data
fn get_session_login(session: &Session) -> Result<ActiveLogin> {
if session.active() {
if let Some(u) = session.get(LOGIN_KEY).map_err(|_|ServerError::NoLogin)? {
return Ok(u);
}
}
Err(ServerError::NoLogin)
}
+3 -3
View File
@@ -2,16 +2,16 @@
use crate::{Result, ServerError};
use axum::response::IntoResponse;
use axum::{extract::State, Json};
use entities::state::Cache;
use entities::state::{Cache, AppState};
use hyper::http::HeaderValue;
use std::sync::Arc;
pub async fn instances(
State(ref cache): State<Cache>,
State(ref app_state): State<AppState>,
State(ref config): State<Arc<crate::Config>>,
) -> Result<axum::response::Response> {
let mut res = {
let guard = cache.read().map_err(|_| ServerError::MutexFailure)?;
let guard = app_state.cache.read().map_err(|_| ServerError::MutexFailure)?;
let res = Json(&*guard).into_response();
drop(guard);
res
+85 -9
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
use std::{borrow::Cow, net::SocketAddr, sync::Arc};
use axum::{extract::DefaultBodyLimit, http::HeaderValue, response::Html, routing::{get, get_service}, Router};
use entities::state::{scanner::ScannerConfig, Cache};
use axum::{extract::DefaultBodyLimit, http::HeaderValue, response::{Html, Redirect}, routing::{get, get_service}, Router, error_handling::HandleErrorLayer};
use entities::state::{scanner::ScannerConfig, AppState};
use hyper::{header, StatusCode};
use reqwest::Client;
use sea_orm::DatabaseConnection;
use tera::Tera;
use thiserror::Error;
@@ -12,23 +13,31 @@ use tower_http::{
cors::CorsLayer, limit::RequestBodyLimitLayer, services::{ServeDir, ServeFile},
set_header::SetResponseHeaderLayer, trace::TraceLayer,
};
use tower_sessions::{SessionManagerLayer, cookie::SameSite};
mod api;
mod website;
mod admin;
const LOGIN_URL: &'static str = "/admin/login";
const ADMIN_OVERVIEW_URL: &'static str = "/admin";
#[derive(Debug)]
pub struct Config {
pub site_url: String,
pub max_age: usize,
pub session_ttl_seconds: u64,
pub login_token_name: String,
}
#[derive(Clone, axum::extract::FromRef)]
struct AppState {
struct WebState {
db: DatabaseConnection,
config: Arc<Config>,
scanner_config: ScannerConfig,
cache: Cache,
app_state: AppState,
templates: Arc<Tera>,
login_client: Client,
}
/// Start webserver
@@ -37,23 +46,72 @@ pub async fn start(
db: DatabaseConnection,
config: Config,
scanner_config: ScannerConfig,
cache: Cache,
app_state: AppState,
) -> Result<()> {
#[cfg(debug_assertions)]
let session_secure = false;
#[cfg(not(debug_assertions))]
let session_secure = true;
if !session_secure {
tracing::warn!("debug build, sessions are not secure!");
}
let session_store = tower_sessions::MemoryStore::default();
let session_service = ServiceBuilder::new()
.layer(HandleErrorLayer::new(|e: axum::BoxError| async move {
tracing::debug!(session_error=?e);
StatusCode::BAD_REQUEST
}))
.layer(
SessionManagerLayer::new(session_store)
.with_secure(session_secure)
.with_path("/admin".to_string())
.with_name("admin_login")
.with_same_site(SameSite::Strict)
.with_max_age(time::Duration::seconds(config.session_ttl_seconds as _)),
);
let user_agent = format!("nitter-status (+{}/about)",scanner_config.website_url);
let login_client = Client::builder()
.cookie_store(false)
.brotli(true)
.deflate(true)
.gzip(true)
.use_rustls_tls()
.user_agent(user_agent)
.connect_timeout(std::time::Duration::from_secs(3))
.timeout(std::time::Duration::from_secs(10))
.build().unwrap();
let config = Arc::new(config);
let tera = Tera::new("server/templates/*")?;
let state = AppState {
let mut tera = Tera::new("server/templates/*")?;
tera.autoescape_on(vec![".html.j2"]);
let state = WebState {
config: config.clone(),
db,
cache,
app_state,
scanner_config,
templates: Arc::new(tera),
login_client,
};
let router = Router::new()
.nest_service(
"/static",
ServeDir::new("server/static").append_index_html_on_directories(false),
)
.route("/api/v1/instances", get(api::instances))
.nest(ADMIN_OVERVIEW_URL, Router::new()
.route("/", get(admin::overview))
.route("/errors/:host", get(admin::errors_view))
// .route("/history/:host", get(admin::history_view))
// .route("/api/history", get(admin::history_json))
.route("/login", get(admin::login_view).post(admin::login))
.route("/logout", get(admin::logout))
// .layer(ServiceBuilder::new().layer(SetResponseHeaderLayer::overriding(header::CACHE_CONTROL, "must-revalidate")))
.layer(session_service)
)
// .route("/admin", get(admin::view))
.route("/about", get(website::about))
.route(
"/robots.txt",
@@ -98,13 +156,31 @@ pub enum ServerError {
MutexFailure,
#[error("Failed to perform templating: {0:?}")]
Templating(#[from] tera::Error),
#[error("Not logged in")]
NoLogin,
#[error("Internal Error during DB request: {0:?}")]
DBError(#[from] sea_orm::DbErr),
#[error("Internal Error session handling: {0:?}")]
SessionError(#[from] tower_sessions::session::SessionError),
#[error("Host '{0}' can't be found, logging out user!")]
HostNotFound(i32),
#[error("No permission to access this resource")]
MissingPermission,
}
impl axum::response::IntoResponse for ServerError {
fn into_response(self) -> axum::response::Response {
use ServerError::*;
let msg = match &self {
MutexFailure | Templating(_) => (
NoLogin => {
let mut resp = Redirect::temporary(LOGIN_URL).into_response();
// *resp.status_mut() = StatusCode::FOUND; // have to use a 301, [Redirect] 307 won't work for referrer
return resp;
},
MissingPermission =>
(StatusCode::FORBIDDEN,Cow::Borrowed("Missing permission to access this resource"))
,
MutexFailure | Templating(_) | DBError(_) | SessionError(_) | HostNotFound(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Cow::Borrowed("Internal Server Error"),
),
+5 -4
View File
@@ -6,18 +6,19 @@ use crate::Result;
use crate::ServerError;
use axum::response::IntoResponse;
use axum::{extract::State, response::Html};
use entities::state::AppState;
use entities::state::scanner::ScannerConfig;
use entities::state::Cache;
use hyper::http::HeaderValue;
pub async fn instances(
State(ref cache): State<Cache>,
State(ref app_state): State<AppState>,
State(ref template): State<Arc<tera::Tera>>,
State(ref config): State<Arc<crate::Config>>,
) -> Result<axum::response::Response> {
let mut context = tera::Context::new();
let mut res = {
let guard = cache.read().map_err(|_| ServerError::MutexFailure)?;
let guard = app_state.cache.read().map_err(|_| ServerError::MutexFailure)?;
context.insert("instances", &guard.hosts);
let time = guard.last_update.format("%Y.%m.%d %H:%M").to_string();
context.insert("last_updated", &time);
@@ -37,7 +38,7 @@ pub async fn instances(
}
pub async fn about(
State(ref cache): State<Cache>,
State(ref app_state): State<AppState>,
State(ref template): State<Arc<tera::Tera>>,
State(ref scanner_config): State<ScannerConfig>,
) -> Result<axum::response::Response> {
@@ -60,7 +61,7 @@ pub async fn about(
&scanner_config.ping_range.num_hours(),
);
{
let guard = cache.read().map_err(|_| ServerError::MutexFailure)?;
let guard = app_state.cache.read().map_err(|_| ServerError::MutexFailure)?;
context.insert("latest_commit", &guard.latest_commit);
}