Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1use axum::{http::HeaderValue, middleware as axum_middleware, Router};
2use minijinja::Environment;
3use sqlx::SqlitePool;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tower_cookies::{CookieManagerLayer, Key};
7use tower_http::services::ServeDir;
8use tower_http::set_header::SetResponseHeaderLayer;
9
10use crate::pdf::PdfRenderer;
11use crate::routes;
12use crate::{db, middleware, templates};
13
14#[derive(Clone)]
15pub struct AppState {
16 pub env: Arc<Environment<'static>>,
17 pub pool: SqlitePool,
18 pub cookie_key: Key,
19 pub config: Arc<Config>,
20 pub pdf_renderer: Arc<PdfRenderer>,
21}
22
23#[derive(Debug, Clone)]
24pub struct Config {
25 pub root: PathBuf,
26 pub data_dir: PathBuf,
27 pub password: String,
28 pub base_url: String,
29 pub alert_email: Option<String>,
30 pub discord_webhook_url: Option<String>,
31}
32
33impl AppState {
34 pub async fn from_env() -> anyhow::Result<Self> {
35 let root: PathBuf = std::env::var("STATUS_ROOT")
36 .map(PathBuf::from)
37 .unwrap_or_else(|_| PathBuf::from("."));
38 let data_dir = std::env::var("STATUS_DATA_DIR")
39 .map(PathBuf::from)
40 .unwrap_or_else(|_| root.join("data"));
41 std::fs::create_dir_all(&data_dir)?;
42
43 let password = std::env::var("STATUS_PASSWORD")
44 .ok()
45 .filter(|p| !p.is_empty())
46 .ok_or_else(|| {
47 anyhow::anyhow!(
48 "STATUS_PASSWORD is not set; refusing to start an internet-facing \
49 server with a guessable default"
50 )
51 })?;
52 let base_url = std::env::var("BASE_URL").unwrap_or_default();
53 let alert_email = std::env::var("ALERT_EMAIL").ok().filter(|s| !s.is_empty());
54 let discord_webhook_url = std::env::var("DISCORD_WEBHOOK_URL")
55 .ok()
56 .filter(|s| !s.is_empty());
57
58 let cookie_secret = std::env::var("STATUS_COOKIE_SECRET").unwrap_or_else(|_| {
59 // 64+ bytes derived from password if no secret provided. For a single-user
60 // self-hosted app this is fine; setting STATUS_COOKIE_SECRET is preferred.
61 use sha2::{Digest, Sha512};
62 let mut h = Sha512::new();
63 h.update(b"status-cookie:");
64 h.update(password.as_bytes());
65 let digest = h.finalize();
66 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, digest)
67 });
68 let cookie_key = Key::from(cookie_secret.as_bytes());
69
70 let pool = db::init(&data_dir).await?;
71
72 let templates_dir = root.join("templates");
73 let manifest_path = root.join("dist/.vite/manifest.json");
74 let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));
75
76 let pdf_renderer = Arc::new(PdfRenderer::new(root.clone()));
77
78 let config = Arc::new(Config {
79 root,
80 data_dir,
81 password,
82 base_url,
83 alert_email,
84 discord_webhook_url,
85 });
86
87 Ok(Self {
88 env,
89 pool,
90 cookie_key,
91 config,
92 pdf_renderer,
93 })
94 }
95}
96
97pub fn router(state: AppState) -> Router {
98 let dist_dir = state.config.root.join("dist");
99
100 let static_cache = SetResponseHeaderLayer::if_not_present(
101 axum::http::header::CACHE_CONTROL,
102 HeaderValue::from_static("public, max-age=31536000"),
103 );
104
105 Router::new()
106 .merge(routes::home::router())
107 .merge(routes::auth::router())
108 .merge(routes::seo::router())
109 .merge(routes::properties::router())
110 // routes::dashboard holds the UUID `/{property_id}` catch-all; merge
111 // last so named routes win the match.
112 .merge(routes::dashboard::router())
113 .nest_service(
114 "/static",
115 tower::ServiceBuilder::new()
116 .layer(static_cache)
117 .service(ServeDir::new(&dist_dir)),
118 )
119 .fallback(middleware::not_found)
120 .layer(CookieManagerLayer::new())
121 .layer(axum_middleware::from_fn(middleware::log_requests))
122 .with_state(state)
123}