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 reqwest::Client;
2use std::collections::HashMap;
3use std::time::{Duration, Instant};
4use url::Url;
5
6pub const PAGE_CAP: usize = 500;
7pub const CONCURRENCY: usize = 4;
8pub const EXTERNAL_LINK_CAP: usize = 500;
9/// Per-page HTML body cap. Real HTML pages are well under this; anything
10/// larger is a mislabeled download and buffering it whole (times 4-way
11/// concurrency) could exhaust container memory.
12const MAX_BODY_BYTES: usize = 5 * 1024 * 1024;
13const REQUEST_TIMEOUT_SECS: u64 = 15;
14const EXTERNAL_LINK_TIMEOUT_SECS: u64 = 8;
15pub const CRAWL_DEADLINE_SECS: u64 = 540;
16const USER_AGENT: &str = "status (+https://status.bythewood.me)";
17
18#[derive(Debug, Clone)]
19pub struct FetchResult {
20 pub url: String,
21 pub requested_url: String,
22 pub status: u16,
23 pub headers: HashMap<String, String>,
24 pub body: Vec<u8>,
25 pub content_type: String,
26 pub elapsed_ms: i64,
27 pub redirect_chain: Vec<(u16, String)>,
28 pub error: String,
29}
30
31pub fn make_client() -> Client {
32 Client::builder()
33 .user_agent(USER_AGENT)
34 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
35 .redirect(reqwest::redirect::Policy::limited(10))
36 .build()
37 .expect("client builds")
38}
39
40// Companion client used only to probe the server's `Content-Encoding` header.
41// The main client has reqwest's `gzip` and `brotli` features on, which
42// auto-decompress responses *and* strip `Content-Encoding` from the headers,
43// so we can't tell from a normal fetch whether the server compressed the
44// response. This client disables auto-decompression so the header survives.
45pub fn make_probe_client() -> Client {
46 Client::builder()
47 .user_agent(USER_AGENT)
48 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
49 .redirect(reqwest::redirect::Policy::limited(10))
50 .gzip(false)
51 .brotli(false)
52 .build()
53 .expect("probe client builds")
54}
55
56/// Probe `url` with a non-decompressing client and return the server's
57/// `Content-Encoding` (lowercased). Returns `None` if the server didn't
58/// compress, the encoding was `identity`, or the request failed.
59pub async fn probe_compression(client: &Client, url: &str) -> Option<String> {
60 let resp = client
61 .get(url)
62 .header("Accept-Encoding", "gzip, br, zstd, deflate")
63 .send()
64 .await
65 .ok()?;
66 let enc = resp
67 .headers()
68 .get(reqwest::header::CONTENT_ENCODING)
69 .and_then(|v| v.to_str().ok())?
70 .trim()
71 .to_lowercase();
72 if enc.is_empty() || enc == "identity" {
73 None
74 } else {
75 Some(enc)
76 }
77}
78
79pub async fn fetch(client: &Client, url: &str) -> FetchResult {
80 let started = Instant::now();
81 match client.get(url).send().await {
82 Ok(mut resp) => {
83 let final_url = resp.url().to_string();
84 let status = resp.status().as_u16();
85 let mut headers = HashMap::new();
86 for (k, v) in resp.headers().iter() {
87 if let Ok(s) = v.to_str() {
88 headers.insert(k.as_str().to_string(), s.to_string());
89 }
90 }
91 let content_type = headers
92 .iter()
93 .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
94 .map(|(_, v)| v.to_lowercase())
95 .unwrap_or_default();
96 let body = if content_type.contains("text/html") {
97 let mut buf: Vec<u8> = Vec::new();
98 loop {
99 match resp.chunk().await {
100 Ok(Some(chunk)) => {
101 let room = MAX_BODY_BYTES - buf.len();
102 if chunk.len() >= room {
103 buf.extend_from_slice(&chunk[..room]);
104 tracing::warn!(
105 "[crawler] body cap {MAX_BODY_BYTES} hit for {url}"
106 );
107 break;
108 }
109 buf.extend_from_slice(&chunk);
110 }
111 Ok(None) => break,
112 Err(_) => break,
113 }
114 }
115 buf
116 } else {
117 Vec::new()
118 };
119 let elapsed_ms = started.elapsed().as_millis() as i64;
120 // Reqwest doesn't expose the redirect chain, so we approximate
121 // with [final_status, final_url]. Mid-chain hops are lost.
122 let redirect_chain = vec![(status, final_url.clone())];
123 FetchResult {
124 url: final_url,
125 requested_url: url.to_string(),
126 status,
127 headers,
128 body,
129 content_type,
130 elapsed_ms,
131 redirect_chain,
132 error: String::new(),
133 }
134 }
135 Err(e) => FetchResult {
136 url: url.to_string(),
137 requested_url: url.to_string(),
138 status: 0,
139 headers: HashMap::new(),
140 body: Vec::new(),
141 content_type: String::new(),
142 elapsed_ms: started.elapsed().as_millis() as i64,
143 redirect_chain: Vec::new(),
144 error: e.to_string(),
145 },
146 }
147}
148
149pub async fn head_status(client: &Client, url: &str) -> u16 {
150 let timeout = Duration::from_secs(EXTERNAL_LINK_TIMEOUT_SECS);
151 match client.head(url).timeout(timeout).send().await {
152 Ok(r) => {
153 let s = r.status().as_u16();
154 if matches!(s, 403 | 405 | 501) {
155 client
156 .get(url)
157 .timeout(timeout)
158 .send()
159 .await
160 .map(|r| r.status().as_u16())
161 .unwrap_or(0)
162 } else {
163 s
164 }
165 }
166 Err(_) => 0,
167 }
168}
169
170/// Robots.txt evaluator. Uses the `robotstxt` crate. Treats parse errors
171/// or missing files as "allow everything" so a broken robots.txt doesn't
172/// tank the crawl.
173pub struct Robots {
174 text: Option<String>,
175}
176
177impl Robots {
178 pub fn allowed(&self, url: &str) -> bool {
179 let Some(text) = &self.text else { return true };
180 let mut matcher = robotstxt::DefaultMatcher::default();
181 matcher.one_agent_allowed_by_robots(text, "*", url)
182 }
183 fn empty() -> Self {
184 Self { text: None }
185 }
186}
187
188pub async fn load_robots(client: &Client, base_origin: &str) -> (Robots, String, Option<String>) {
189 let robots_url = format!("{base_origin}/robots.txt");
190 let mut robots = Robots::empty();
191 let mut raw: Option<String> = None;
192 if let Ok(r) = client.get(&robots_url).send().await {
193 if r.status().as_u16() == 200 {
194 if let Ok(text) = r.text().await {
195 robots.text = Some(text.clone());
196 raw = Some(text);
197 }
198 }
199 }
200 (robots, robots_url, raw)
201}
202
203pub async fn load_sitemap(
204 client: &Client,
205 base_origin: &str,
206 robots_text: Option<&str>,
207) -> Vec<String> {
208 let mut candidates: Vec<String> = Vec::new();
209 if let Some(text) = robots_text {
210 for line in text.lines() {
211 let line = line.trim();
212 if let Some(rest) = line.to_lowercase().strip_prefix("sitemap:") {
213 // Reuse the original to preserve casing of the URL.
214 let original_after = &line[line.len() - rest.len()..];
215 candidates.push(original_after.trim().to_string());
216 }
217 }
218 }
219 if candidates.is_empty() {
220 candidates.push(format!("{base_origin}/sitemap.xml"));
221 }
222
223 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
224 let mut urls: Vec<String> = Vec::new();
225 let mut to_fetch: Vec<String> = candidates;
226 while let Some(smurl) = to_fetch.pop() {
227 if seen.len() >= 20 {
228 break;
229 }
230 if seen.contains(&smurl) {
231 continue;
232 }
233 seen.insert(smurl.clone());
234 let r = match client.get(&smurl).send().await {
235 Ok(r) => r,
236 Err(_) => continue,
237 };
238 if r.status().as_u16() != 200 {
239 continue;
240 }
241 let body = match r.bytes().await {
242 Ok(b) => b,
243 Err(_) => continue,
244 };
245 for loc in parse_sitemap_xml(&body) {
246 let lower = loc.to_lowercase();
247 if lower.ends_with(".xml") || lower.contains("sitemap") {
248 to_fetch.push(loc);
249 } else {
250 urls.push(loc);
251 }
252 }
253 }
254 urls
255}
256
257/// Pull `<loc>` text from a sitemap XML body. Lightweight regex-based
258/// extraction; sitemaps are well-formed enough that we don't need a full
259/// XML parser, and adding one just for this would be overkill.
260fn parse_sitemap_xml(body: &[u8]) -> Vec<String> {
261 let s = String::from_utf8_lossy(body);
262 let re = regex::Regex::new(r"(?is)<loc>\s*([^<]+?)\s*</loc>").expect("regex");
263 re.captures_iter(&s)
264 .map(|c| c[1].trim().to_string())
265 .collect()
266}
267
268pub fn same_site(url: &str, host: &str) -> bool {
269 let Ok(u) = Url::parse(url) else { return false };
270 let h_lower = host.to_lowercase();
271 let Some(u_host) = u.host_str() else { return false };
272 let u_lower = u_host.to_lowercase();
273 if u_lower == h_lower {
274 return true;
275 }
276 if u_lower == format!("www.{h_lower}") || h_lower == format!("www.{u_lower}") {
277 return true;
278 }
279 false
280}
281
282/// Hosts that aggressively block non-browser HTTP clients, returning 403/404
283/// to anything that looks like a crawler regardless of the actual link state.
284/// Treating their responses as broken-link signals produces only false
285/// positives, so we skip them entirely from the external HEAD probe and the
286/// broken-external-links check.
287pub fn is_crawler_hostile(url: &str) -> bool {
288 let Ok(u) = Url::parse(url) else { return false };
289 let Some(host) = u.host_str() else { return false };
290 let host = host.to_lowercase();
291 matches!(
292 host.as_str(),
293 "linkedin.com" | "www.linkedin.com"
294 ) || host.ends_with(".linkedin.com")
295}