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
1mod alerts;
2mod app;
3mod checker;
4mod crawler;
5mod db;
6mod lighthouse;
7mod middleware;
8mod migrate;
9mod models;
10mod pdf;
11mod render;
12mod routes;
13mod scheduler;
14mod templates;
15
16pub use app::{AppState, Config};
17
18use std::net::SocketAddr;
19use std::path::PathBuf;
20
21#[tokio::main]
22async fn main() -> anyhow::Result<()> {
23 dotenvy::dotenv().ok();
24 tracing_subscriber::fmt()
25 .with_env_filter(
26 tracing_subscriber::EnvFilter::try_from_default_env()
27 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
28 )
29 .init();
30
31 // rustls 0.23 doesn't pick a CryptoProvider on its own when neither
32 // the `ring` nor the `aws-lc-rs` feature is enabled at the rustls
33 // crate level. The phased prober (src/checker.rs) builds rustls
34 // ClientConfigs directly, which would panic on first probe without
35 // this. Install once at startup; reqwest/lettre's TLS paths are
36 // unaffected because they ship their own provider via hyper-rustls.
37 let _ = rustls::crypto::ring::default_provider().install_default();
38
39 // Subcommand dispatch. Anything besides a known subcommand falls through to the server.
40 let mut argv = std::env::args().skip(1);
41 if let Some(first) = argv.next() {
42 match first.as_str() {
43 "migrate" => return run_migrate(argv.collect()).await,
44 "preview-email" => return run_preview_email(argv.collect()),
45 "--help" | "-h" => {
46 print_usage();
47 return Ok(());
48 }
49 other if !other.is_empty() => {
50 eprintln!("unknown subcommand: {other}");
51 print_usage();
52 std::process::exit(2);
53 }
54 _ => {}
55 }
56 }
57
58 let port: u16 = std::env::var("PORT")
59 .ok()
60 .and_then(|v| v.parse().ok())
61 .unwrap_or(8000);
62
63 let state = AppState::from_env().await?;
64
65 // Reset wedged crawl/lighthouse rows from a prior crash, then spawn the
66 // scheduler loop alongside the HTTP server.
67 scheduler::reset_states_on_boot(&state.pool).await?;
68 let scheduler_handle = scheduler::spawn(state.pool.clone(), state.config.clone());
69
70 let router = app::router(state);
71
72 let addr = SocketAddr::from(([0, 0, 0, 0], port));
73 let listener = tokio::net::TcpListener::bind(addr).await?;
74 tracing::info!("status listening on http://{addr}");
75 axum::serve(listener, router).await?;
76 drop(scheduler_handle);
77 Ok(())
78}
79
80fn print_usage() {
81 eprintln!(
82 "status: single-binary axum uptime monitor\n\
83 \n\
84 Usage:\n \
85 status run the HTTP server + scheduler\n \
86 status migrate <path> [--force] import a Django status SQLite\n \
87 status preview-email <down|recovery> render an alert HTML to stdout\n"
88 );
89}
90
91fn run_preview_email(args: Vec<String>) -> anyhow::Result<()> {
92 let kind = args
93 .first()
94 .map(String::as_str)
95 .ok_or_else(|| anyhow::anyhow!("usage: status preview-email <down|recovery>"))?;
96 let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
97 let html = alerts::render_preview_html(kind, &base_url)?;
98 print!("{html}");
99 Ok(())
100}
101
102async fn run_migrate(args: Vec<String>) -> anyhow::Result<()> {
103 let mut source: Option<PathBuf> = None;
104 let mut force = false;
105 for arg in args {
106 match arg.as_str() {
107 "--force" | "-f" => force = true,
108 "--help" | "-h" => {
109 eprintln!("Usage: status migrate <path-to-django-sqlite3> [--force]");
110 return Ok(());
111 }
112 v if v.starts_with("--") => anyhow::bail!("unknown migrate flag: {v}"),
113 v => {
114 if source.is_some() {
115 anyhow::bail!("migrate takes a single source path");
116 }
117 source = Some(PathBuf::from(v));
118 }
119 }
120 }
121 let source = source.ok_or_else(|| {
122 anyhow::anyhow!("usage: status migrate <path-to-django-sqlite3> [--force]")
123 })?;
124 migrate::run(source, force).await
125}