authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 15:27:52-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 15:45:52-07:00
logfcec92e2decf2a994e4c6754a59ff7cfb4e25004
tree6cb8a76bcdc73cb11e4ddb080745413ae12be712
parent26d313ff895bef3a6121acee7d27aaa42b494384
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the site keeps the crash reports Snowbound sends to POST /crash

Plain text or JSON up to 64 KB, ten an hour from an address and 500 an hour in all, each a timestamped file in --crashes (crashes beside the root by default), nothing echoed back. --trust-forwarded counts senders as the relay does, whose address rule both now share. Assisted-by: claude-opus-5.5

7 files changed, 325 insertions(+), 61 deletions(-)

crates/relay/README.md+10-4
...@@ -86,8 +86,13 @@ code (`/7KQ-4MZ-9XR`, read as loosely as the app reads one) a page that opens it...@@ -86,8 +86,13 @@ code (`/7KQ-4MZ-9XR`, read as loosely as the app reads one) a page that opens it
86`snowbound://join/<code>`, and in the web build where `--web` names where (once the web build86`snowbound://join/<code>`, and in the web build where `--web` names where (once the web build
87joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's87joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's
88module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the88module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the
89codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day. It89codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day.
90holds no state.90
91`POST /crash` takes a crash report the app sends when its user chooses Send Report: plain
92text or JSON up to 64 KB, ten an hour from one address and 500 an hour in all, each kept as
93a file named for when it came (`2026-10-03T21-04-05Z-1a2b3c4d.txt`) in `--crashes`, by
94default `crashes` beside the root. Nothing else is kept, and nothing sent comes back. Behind
95a proxy, `--trust-forwarded true` counts senders by the address it adds, as the relay does.
9196
92`python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's97`python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's
93architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds'98architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds'
...@@ -99,7 +104,7 @@ ssh vps...@@ -99,7 +104,7 @@ ssh vps
99mkdir -p ~/snowbound-web/site104mkdir -p ~/snowbound-web/site
100# after the first `release_web.py --deploy` has put the binary there:105# after the first `release_web.py --deploy` has put the binary there:
101pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \106pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \
102 --listen 127.0.0.1:23593 --root "$HOME/snowbound-web/site"107 --listen 127.0.0.1:23593 --root "$HOME/snowbound-web/site" --trust-forwarded true
103pm2 save108pm2 save
104```109```
105110
...@@ -112,7 +117,8 @@ snowbound.paperclover.net {...@@ -112,7 +117,8 @@ snowbound.paperclover.net {
112}117}
113```118```
114119
115`snowbound-site.service` runs it under systemd instead (`--root /srv/snowbound/web`).120`snowbound-site.service` runs it under systemd instead (`--root /srv/snowbound/web`, the
121reports in `/var/lib/snowbound-site`).
116`snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`.122`snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`.
117123
118## Options124## Options
crates/relay/deploy/snowbound-site.service+2-1
...@@ -7,7 +7,8 @@ After=network-online.target...@@ -7,7 +7,8 @@ After=network-online.target
7Wants=network-online.target7Wants=network-online.target
88
9[Service]9[Service]
10ExecStart=/usr/local/bin/snowbound-site --listen 127.0.0.1:23593 --root /srv/snowbound/web10ExecStart=/usr/local/bin/snowbound-site --listen 127.0.0.1:23593 --root /srv/snowbound/web --crashes /var/lib/snowbound-site --trust-forwarded true
11StateDirectory=snowbound-site
11Restart=always12Restart=always
12RestartSec=213RestartSec=2
13DynamicUser=yes14DynamicUser=yes
crates/relay/src/lib.rs+27-1
...@@ -23,7 +23,33 @@ pub mod server;...@@ -23,7 +23,33 @@ pub mod server;
23pub mod site;23pub mod site;
24pub mod ws;24pub mod ws;
2525
26use std::{fmt, str::FromStr};26use std::{
27 fmt,
28 net::{IpAddr, Ipv4Addr, Ipv6Addr, TcpStream},
29 str::FromStr,
30};
31
32/// Where a request counts against limits: the address it connected from, or with
33/// `trust_forwarded` the one its proxy says, an IPv6 address by its /64.
34pub fn peer(stream: &TcpStream, head: &str, trust_forwarded: bool) -> IpAddr {
35 let forwarded = ws::header(head, "X-Forwarded-For")
36 .filter(|_| trust_forwarded)
37 .and_then(|value| value.rsplit(',').next()?.trim().parse().ok());
38 let ip = forwarded
39 .or_else(|| stream.peer_addr().ok().map(|address| address.ip()))
40 .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
41 match ip {
42 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
43 Some(v4) => IpAddr::V4(v4),
44 // One subscriber is usually given a whole /64.
45 None => {
46 let [a, b, c, d, ..] = v6.segments();
47 IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0))
48 }
49 },
50 v4 => v4,
51 }
52}
2753
28/// The bytes before a binary message's payload: the slot it goes to or came from.54/// The bytes before a binary message's payload: the slot it goes to or came from.
29pub const SLOT: usize = 4;55pub const SLOT: usize = 4;
crates/relay/src/server.rs+4-35
...@@ -6,7 +6,7 @@ use crate::{BROADCAST, GROUP, Notice, SLOT, Verdict, ws};...@@ -6,7 +6,7 @@ use crate::{BROADCAST, GROUP, Notice, SLOT, Verdict, ws};
6use std::{6use std::{
7 collections::{BTreeMap, HashMap, VecDeque},7 collections::{BTreeMap, HashMap, VecDeque},
8 io::{BufReader, Write},8 io::{BufReader, Write},
9 net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, TcpListener, TcpStream},9 net::{IpAddr, Shutdown, TcpListener, TcpStream},
10 ops::RangeInclusive,10 ops::RangeInclusive,
11 sync::{11 sync::{
12 Arc, Condvar, Mutex,12 Arc, Condvar, Mutex,
...@@ -209,7 +209,7 @@ impl Relay {...@@ -209,7 +209,7 @@ impl Relay {
209209
210 /// Answers the request `head`: whether the connection serves another.210 /// Answers the request `head`: whether the connection serves another.
211 fn request(&self, stream: &TcpStream, reader: &mut BufReader<TcpStream>, head: &str) -> bool {211 fn request(&self, stream: &TcpStream, reader: &mut BufReader<TcpStream>, head: &str) -> bool {
212 let address = self.address(stream, head);212 let address = crate::peer(stream, head, self.config.trust_forwarded);
213 let target = head.split(' ').nth(1).unwrap_or_default();213 let target = head.split(' ').nth(1).unwrap_or_default();
214 let (path, query) = target.split_once('?').unwrap_or((target, ""));214 let (path, query) = target.split_once('?').unwrap_or((target, ""));
215 if let Some(id) = path.strip_prefix("/v1/poll/") {215 if let Some(id) = path.strip_prefix("/v1/poll/") {
...@@ -377,27 +377,6 @@ impl Relay {...@@ -377,27 +377,6 @@ impl Relay {
377 }377 }
378 }378 }
379379
380 /// Where a peer counts: the address it connected from, or its proxy says it did.
381 fn address(&self, stream: &TcpStream, head: &str) -> IpAddr {
382 let forwarded = ws::header(head, "X-Forwarded-For")
383 .filter(|_| self.config.trust_forwarded)
384 .and_then(|value| value.rsplit(',').next()?.trim().parse().ok());
385 let ip = forwarded
386 .or_else(|| stream.peer_addr().ok().map(|address| address.ip()))
387 .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
388 match ip {
389 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
390 Some(v4) => IpAddr::V4(v4),
391 // One subscriber is usually given a whole /64.
392 None => {
393 let [a, b, c, d, ..] = v6.segments();
394 IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0))
395 }
396 },
397 v4 => v4,
398 }
399 }
400
401 fn health(&self) -> String {380 fn health(&self) -> String {
402 let state = self.state.lock().unwrap();381 let state = self.state.lock().unwrap();
403 let peers: usize = state.rooms.values().map(|room| room.members.len()).sum();382 let peers: usize = state.rooms.values().map(|room| room.members.len()).sum();
...@@ -1082,25 +1061,15 @@ mod tests {...@@ -1082,25 +1061,15 @@ mod tests {
10821061
1083 #[test]1062 #[test]
1084 fn ipv6_addresses_count_by_their_64() {1063 fn ipv6_addresses_count_by_their_64() {
1085 let relay = Relay {
1086 config: Config {
1087 trust_forwarded: true,
1088 ..Config::default()
1089 },
1090 state: Mutex::default(),
1091 connections: AtomicUsize::new(0),
1092 relayed: Default::default(),
1093 started: Instant::now(),
1094 };
1095 let listener = TcpListener::bind("127.0.0.1:0").unwrap();1064 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1096 let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap();1065 let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
1097 let head = "GET / HTTP/1.1\r\nX-Forwarded-For: 1.2.3.4, 2001:db8:1:2:3:4:5:6\r\n\r\n";1066 let head = "GET / HTTP/1.1\r\nX-Forwarded-For: 1.2.3.4, 2001:db8:1:2:3:4:5:6\r\n\r\n";
1098 assert_eq!(1067 assert_eq!(
1099 relay.address(&stream, head),1068 crate::peer(&stream, head, true),
1100 "2001:db8:1:2::".parse::<IpAddr>().unwrap()1069 "2001:db8:1:2::".parse::<IpAddr>().unwrap()
1101 );1070 );
1102 assert_eq!(1071 assert_eq!(
1103 relay.address(&stream, "GET / HTTP/1.1\r\n\r\n"),1072 crate::peer(&stream, head, false),
1104 "127.0.0.1".parse::<IpAddr>().unwrap()1073 "127.0.0.1".parse::<IpAddr>().unwrap()
1105 );1074 );
1106 }1075 }
crates/relay/src/site.rs+168-12
...@@ -2,19 +2,28 @@...@@ -2,19 +2,28 @@
2//! and for a path that is a Live Share code (`/7KQ-4MZ-9XR`) a page that opens it in2//! and for a path that is a Live Share code (`/7KQ-4MZ-9XR`) a page that opens it in
3//! Snowbound, or in the web build where `Site::web` says it joins. A build's module and3//! Snowbound, or in the web build where `Site::web` says it joins. A build's module and
4//! JavaScript sit in `b/<hash>/`, named by their contents, so they are kept for good;4//! JavaScript sit in `b/<hash>/`, named by their contents, so they are kept for good;
5//! `index.html`, which names them, is checked on every load. GET and HEAD only; a proxy in5//! `index.html`, which names them, is checked on every load. `POST /crash` keeps a crash
6//! report Snowbound sends as a file of its own; everything else is GET and HEAD. A proxy in
6//! front terminates TLS.7//! front terminates TLS.
78
8use crate::{code, ws};9use crate::{code, ws};
9use std::{10use std::{
10 io::{self, BufReader, Write},11 collections::{HashMap, VecDeque},
11 net::{TcpListener, TcpStream},12 io::{self, BufReader, Read, Write},
13 net::{IpAddr, TcpListener, TcpStream},
12 path::{Component, Path, PathBuf},14 path::{Component, Path, PathBuf},
13 sync::Arc,15 sync::{Arc, Mutex},
14 thread,16 thread,
15 time::Duration,17 time::{Duration, Instant, SystemTime},
16};18};
1719
20/// The largest crash report kept.
21pub const REPORT: usize = 64 << 10;
22/// Reports kept an hour from one address (an IPv6 /64), and from everyone.
23pub const PER_ADDRESS: usize = 10;
24pub const PER_HOUR: usize = 500;
25const HOUR: Duration = Duration::from_secs(60 * 60);
26
18/// What the site serves.27/// What the site serves.
19#[derive(Clone, Debug)]28#[derive(Clone, Debug)]
20pub struct Site {29pub struct Site {
...@@ -23,32 +32,81 @@ pub struct Site {...@@ -23,32 +32,81 @@ pub struct Site {
23 /// Where Open in Web goes, the code appended (`https://snowbound.paperclover.net/?join=`);32 /// Where Open in Web goes, the code appended (`https://snowbound.paperclover.net/?join=`);
24 /// none offers only Snowbound.33 /// none offers only Snowbound.
25 pub web: Option<String>,34 pub web: Option<String>,
35 /// Where each crash report is kept, a file apiece.
36 pub crashes: PathBuf,
37 /// Counts senders by the address the proxy in front gives in `X-Forwarded-For`.
38 pub trust_forwarded: bool,
39}
40
41/// When the reports of the last hour came, by sender and in all.
42#[derive(Default)]
43struct Received {
44 by_address: HashMap<IpAddr, VecDeque<Instant>>,
45 all: VecDeque<Instant>,
46}
47
48impl Received {
49 /// Counts a report from `address` at `now`, unless it is one too many.
50 fn admit(&mut self, address: IpAddr, now: Instant) -> bool {
51 let recent = |times: &mut VecDeque<Instant>| {
52 while times.front().is_some_and(|&time| now - time >= HOUR) {
53 times.pop_front();
54 }
55 };
56 recent(&mut self.all);
57 self.by_address.retain(|_, times| {
58 recent(times);
59 !times.is_empty()
60 });
61 let sent = self.by_address.entry(address).or_default();
62 if sent.len() >= PER_ADDRESS || self.all.len() >= PER_HOUR {
63 return false;
64 }
65 sent.push_back(now);
66 self.all.push_back(now);
67 true
68 }
26}69}
2770
28/// Serves `site` on `listener` until it fails.71/// Serves `site` on `listener` until it fails.
29pub fn serve(listener: TcpListener, site: Site) -> io::Result<()> {72pub fn serve(listener: TcpListener, site: Site) -> io::Result<()> {
30 let site = Arc::new(site);73 let shared = Arc::new((site, Mutex::default()));
31 for stream in listener.incoming() {74 for stream in listener.incoming() {
32 let Ok(stream) = stream else { continue };75 let Ok(stream) = stream else { continue };
33 let site = Arc::clone(&site);76 let shared = Arc::clone(&shared);
34 thread::spawn(move || {77 thread::spawn(move || {
35 let _ = answer(&site, stream);78 let (site, received) = &*shared;
79 let _ = answer(site, received, stream);
36 });80 });
37 }81 }
38 Ok(())82 Ok(())
39}83}
4084
41fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {85fn answer(site: &Site, received: &Mutex<Received>, stream: TcpStream) -> io::Result<()> {
42 stream.set_read_timeout(Some(Duration::from_secs(10)))?;86 stream.set_read_timeout(Some(Duration::from_secs(10)))?;
43 stream.set_write_timeout(Some(Duration::from_secs(30)))?;87 stream.set_write_timeout(Some(Duration::from_secs(30)))?;
44 let head = ws::head(&mut BufReader::new(&stream))?;88 let mut reader = BufReader::new(&stream);
89 let head = ws::head(&mut reader)?;
45 let mut words = head.split(' ');90 let mut words = head.split(' ');
46 let (method, target) = (91 let (method, target) = (
47 words.next().unwrap_or_default(),92 words.next().unwrap_or_default(),
48 words.next().unwrap_or("/"),93 words.next().unwrap_or("/"),
49 );94 );
50 let path = target.split(['?', '#']).next().unwrap_or("/");95 let path = target.split(['?', '#']).next().unwrap_or("/");
51 let (status, kind, body) = if !matches!(method, "GET" | "HEAD") {96 let (status, kind, body) = if (method, path) == ("POST", "/crash") {
97 let address = crate::peer(&stream, &head, site.trust_forwarded);
98 let admit = || {
99 received
100 .lock()
101 .is_ok_and(|mut received| received.admit(address, Instant::now()))
102 };
103 let status = keep_crash(&site.crashes, &head, &mut reader, admit);
104 (
105 status,
106 "text/plain",
107 format!("{}\n", &status[4..]).into_bytes(),
108 )
109 } else if !matches!(method, "GET" | "HEAD") {
52 (110 (
53 "405 Method Not Allowed",111 "405 Method Not Allowed",
54 "text/plain",112 "text/plain",
...@@ -70,7 +128,7 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {...@@ -70,7 +128,7 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {
70 None => ("404 Not Found", "text/plain", b"Not found\n".to_vec()),128 None => ("404 Not Found", "text/plain", b"Not found\n".to_vec()),
71 }129 }
72 };130 };
73 let cache = if !status.starts_with("200") || kind.starts_with("text/html") {131 let cache = if method == "POST" || !status.starts_with("200") || kind.starts_with("text/html") {
74 "no-cache"132 "no-cache"
75 } else if path.starts_with("/b/") {133 } else if path.starts_with("/b/") {
76 "public, max-age=31536000, immutable"134 "public, max-age=31536000, immutable"
...@@ -91,6 +149,91 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {...@@ -91,6 +149,91 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {
91 Ok(())149 Ok(())
92}150}
93151
152/// Keeps the report a `POST /crash` with `head` brings in `reader` in `folder`, where it is
153/// small enough, plain text or JSON, and `admit` lets it in; answers the status.
154fn keep_crash(
155 folder: &Path,
156 head: &str,
157 reader: &mut impl Read,
158 admit: impl FnOnce() -> bool,
159) -> &'static str {
160 let kind = ws::header(head, "Content-Type").unwrap_or_default();
161 let kind = kind.split(';').next().unwrap_or_default().trim();
162 let extension = if kind.eq_ignore_ascii_case("text/plain") {
163 "txt"
164 } else if kind.eq_ignore_ascii_case("application/json") {
165 "json"
166 } else {
167 return "415 Unsupported Media Type";
168 };
169 if ws::header(head, "Transfer-Encoding").is_some() {
170 return "411 Length Required";
171 }
172 let Some(length) = ws::header(head, "Content-Length").and_then(|value| value.parse().ok())
173 else {
174 return "411 Length Required";
175 };
176 if length > REPORT {
177 return "413 Content Too Large";
178 }
179 if !admit() {
180 return "429 Too Many Requests";
181 }
182 let mut report = vec![0; length];
183 if reader.read_exact(&mut report).is_err() || std::str::from_utf8(&report).is_err() {
184 return "400 Bad Request";
185 }
186 let mut tag = [0; 4];
187 let _ = getrandom::fill(&mut tag);
188 let tag: String = tag.iter().map(|byte| format!("{byte:02x}")).collect();
189 let file = folder.join(format!(
190 "{}-{tag}.{extension}",
191 timestamp(SystemTime::now())
192 ));
193 let kept = std::fs::create_dir_all(folder).and_then(|()| {
194 std::fs::OpenOptions::new()
195 .write(true)
196 .create_new(true)
197 .open(&file)?
198 .write_all(&report)
199 });
200 match kept {
201 Ok(()) => "200 OK",
202 Err(error) => {
203 eprintln!("Cannot keep a crash report in {}: {error}", file.display());
204 "500 Internal Server Error"
205 }
206 }
207}
208
209/// `time` in UTC as `2026-10-03T21-04-05Z`, which sorts as it reads and names a file anywhere.
210fn timestamp(time: SystemTime) -> String {
211 let seconds = time
212 .duration_since(SystemTime::UNIX_EPOCH)
213 .map_or(0, |since| since.as_secs());
214 let (days, of_day) = (seconds / 86_400, seconds % 86_400);
215 // Howard Hinnant's civil_from_days, from 1970-01-01.
216 let shifted = days + 719_468;
217 let era = shifted / 146_097;
218 let of_era = shifted % 146_097;
219 let year_of_era = (of_era - of_era / 1_460 + of_era / 36_524 - of_era / 146_096) / 365;
220 let of_year = of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
221 let month_index = (5 * of_year + 2) / 153;
222 let day = of_year - (153 * month_index + 2) / 5 + 1;
223 let month = if month_index < 10 {
224 month_index + 3
225 } else {
226 month_index - 9
227 };
228 let year = year_of_era + era * 400 + u64::from(month <= 2);
229 format!(
230 "{year:04}-{month:02}-{day:02}T{:02}-{:02}-{:02}Z",
231 of_day / 3_600,
232 of_day / 60 % 60,
233 of_day % 60
234 )
235}
236
94/// The file `path` names in `root`, `index.html` for a folder; none outside it.237/// The file `path` names in `root`, `index.html` for a folder; none outside it.
95fn file(root: &Path, path: &str) -> Option<PathBuf> {238fn file(root: &Path, path: &str) -> Option<PathBuf> {
96 let relative = Path::new(path.trim_start_matches('/'));239 let relative = Path::new(path.trim_start_matches('/'));
...@@ -159,3 +302,16 @@ fn landing(shown: &str, site: &Site) -> String {...@@ -159,3 +302,16 @@ fn landing(shown: &str, site: &Site) -> String {
159"#302"#
160 )303 )
161}304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn timestamps_are_utc_dates_and_times() {
312 let at = |seconds| timestamp(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds));
313 assert_eq!(at(0), "1970-01-01T00-00-00Z");
314 assert_eq!(at(951_782_400), "2000-02-29T00-00-00Z");
315 assert_eq!(at(1_791_072_245), "2026-10-04T00-04-05Z");
316 }
317}
crates/relay/src/site_main.rs+19-3
...@@ -6,13 +6,16 @@ use std::{net::TcpListener, path::PathBuf, process::ExitCode};...@@ -6,13 +6,16 @@ use std::{net::TcpListener, path::PathBuf, process::ExitCode};
6const USAGE: &str = "\6const USAGE: &str = "\
7usage: snowbound-site [OPTION VALUE]...7usage: snowbound-site [OPTION VALUE]...
88
9Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case:9Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case with
10SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win.10underscores: SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win.
1111
12 --listen ADDRESS where to listen (127.0.0.1:23593)12 --listen ADDRESS where to listen (127.0.0.1:23593)
13 --root FOLDER the web build's folder (web)13 --root FOLDER the web build's folder (web)
14 --web URL where Open in Web goes, the code appended, as14 --web URL where Open in Web goes, the code appended, as
15 https://snowbound.paperclover.net/?join= (none: no Open in Web)15 https://snowbound.paperclover.net/?join= (none: no Open in Web)
16 --crashes FOLDER where crash reports are kept (crashes, beside the root)
17 --trust-forwarded true|false
18 count senders by X-Forwarded-For, behind a proxy (false)
16";19";
1720
18fn main() -> ExitCode {21fn main() -> ExitCode {
...@@ -20,9 +23,13 @@ fn main() -> ExitCode {...@@ -20,9 +23,13 @@ fn main() -> ExitCode {
20 let mut site = Site {23 let mut site = Site {
21 root: PathBuf::from("web"),24 root: PathBuf::from("web"),
22 web: None,25 web: None,
26 crashes: PathBuf::new(),
27 trust_forwarded: false,
23 };28 };
29 let mut crashes = None;
24 let from_environment = std::env::vars().filter_map(|(key, value)| {30 let from_environment = std::env::vars().filter_map(|(key, value)| {
25 Some((key.strip_prefix("SNOWBOUND_SITE_")?.to_lowercase(), value))31 let option = key.strip_prefix("SNOWBOUND_SITE_")?;
32 Some((option.to_lowercase().replace('_', "-"), value))
26 });33 });
27 let mut arguments = std::env::args().skip(1);34 let mut arguments = std::env::args().skip(1);
28 let mut from_flags = Vec::new();35 let mut from_flags = Vec::new();
...@@ -42,12 +49,21 @@ fn main() -> ExitCode {...@@ -42,12 +49,21 @@ fn main() -> ExitCode {
42 "listen" => listen = value,49 "listen" => listen = value,
43 "root" => site.root = PathBuf::from(value),50 "root" => site.root = PathBuf::from(value),
44 "web" => site.web = Some(value).filter(|web| !web.is_empty()),51 "web" => site.web = Some(value).filter(|web| !web.is_empty()),
52 "crashes" => crashes = Some(PathBuf::from(value)),
53 "trust-forwarded" => match value.parse() {
54 Ok(trust) => site.trust_forwarded = trust,
55 Err(_) => {
56 eprintln!("--trust-forwarded {value}: true or false\n\n{USAGE}");
57 return ExitCode::from(2);
58 }
59 },
45 _ => {60 _ => {
46 eprintln!("--{option}: no such option\n\n{USAGE}");61 eprintln!("--{option}: no such option\n\n{USAGE}");
47 return ExitCode::from(2);62 return ExitCode::from(2);
48 }63 }
49 }64 }
50 }65 }
66 site.crashes = crashes.unwrap_or_else(|| site.root.with_file_name("crashes"));
51 let listener = match TcpListener::bind(&listen) {67 let listener = match TcpListener::bind(&listen) {
52 Ok(listener) => listener,68 Ok(listener) => listener,
53 Err(error) => {69 Err(error) => {
crates/relay/tests/site.rs+95-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1//! The site as shipped: a code's page, the web build's files, nothing outside them.1//! The site as shipped: a code's page, the web build's files, nothing outside them, and crash
2//! reports kept.
23
3use std::{4use std::{
4 io::{BufRead, BufReader, Read, Write},5 io::{BufRead, BufReader, Read, Write},
...@@ -36,11 +37,26 @@ impl Site {...@@ -36,11 +37,26 @@ impl Site {
36 }37 }
3738
38 fn get(&self, path: &str) -> String {39 fn get(&self, path: &str) -> String {
40 self.send(format!("GET {path} HTTP/1.1\r\nHost: site\r\n\r\n").as_bytes())
41 }
42
43 /// The status line answering a crash report of `body` as `kind`, with `extra` headers.
44 fn report(&self, kind: &str, body: &str, extra: &str) -> String {
45 let request = format!(
46 "POST /crash HTTP/1.1\r\nHost: site\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n{extra}\r\n{body}",
47 body.len()
48 );
49 let response = self.send(request.as_bytes());
50 assert!(!response.contains(body), "{response}");
51 response.lines().next().unwrap().to_owned()
52 }
53
54 fn send(&self, request: &[u8]) -> String {
39 let mut stream = TcpStream::connect(self.address).unwrap();55 let mut stream = TcpStream::connect(self.address).unwrap();
40 write!(stream, "GET {path} HTTP/1.1\r\nHost: site\r\n\r\n").unwrap();56 stream.write_all(request).unwrap();
41 let mut response = String::new();57 let mut response = Vec::new();
42 stream.read_to_string(&mut response).unwrap();58 let _ = stream.read_to_end(&mut response);
43 response59 String::from_utf8_lossy(&response).into_owned()
44 }60 }
45}61}
4662
...@@ -105,3 +121,77 @@ fn a_code_gets_its_page_and_the_web_build_its_files() {...@@ -105,3 +121,77 @@ fn a_code_gets_its_page_and_the_web_build_its_files() {
105 "{page}"121 "{page}"
106 );122 );
107}123}
124
125#[test]
126fn crash_reports_are_kept_small_and_few_and_never_echoed() {
127 let folder = tempfile::tempdir().unwrap();
128 let root = folder.path().join("web");
129 std::fs::create_dir_all(&root).unwrap();
130 let site = Site::start(&root, &["--trust-forwarded", "true"]);
131 let report = "Snowbound 2026-10-03-r46\npanicked at crates/canvas/src/view.rs:1:1";
132 assert_eq!(
133 site.report("text/plain; charset=utf-8", report, ""),
134 "HTTP/1.1 200 OK"
135 );
136 // Kept beside the root, as sent, under a name that sorts by when it came.
137 let crashes = folder.path().join("crashes");
138 let kept: Vec<_> = std::fs::read_dir(&crashes)
139 .unwrap()
140 .map(|entry| entry.unwrap().path())
141 .collect();
142 assert_eq!(kept.len(), 1);
143 assert_eq!(std::fs::read_to_string(&kept[0]).unwrap(), report);
144 let name = kept[0].file_name().unwrap().to_str().unwrap();
145 assert!(name.starts_with("20") && name.ends_with(".txt"), "{name}");
146
147 assert_eq!(
148 site.report("application/json", r#"{"panic":"x"}"#, ""),
149 "HTTP/1.1 200 OK"
150 );
151 assert_eq!(
152 site.report("text/html", "<p>no</p>", ""),
153 "HTTP/1.1 415 Unsupported Media Type"
154 );
155 // Refused from its head alone, before any of it is read.
156 let large = site.send(
157 format!(
158 "POST /crash HTTP/1.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n",
159 relay::site::REPORT + 1
160 )
161 .as_bytes(),
162 );
163 assert!(large.starts_with("HTTP/1.1 413"), "{large}");
164 let chunked = site.send(
165 b"POST /crash HTTP/1.1\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n",
166 );
167 assert!(chunked.starts_with("HTTP/1.1 411"), "{chunked}");
168 assert_eq!(site.report("text/plain", "\u{fffd}", ""), "HTTP/1.1 200 OK");
169 let invalid = site.send(
170 b"POST /crash HTTP/1.1\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\n\xff\xfe",
171 );
172 assert!(invalid.starts_with("HTTP/1.1 400"), "{invalid}");
173
174 // One sender is held to a few an hour; another, by the proxy's word, still gets in.
175 let sent = (0..relay::site::PER_ADDRESS)
176 .map(|_| site.report("text/plain", "again", ""))
177 .filter(|status| status == "HTTP/1.1 200 OK")
178 .count();
179 assert_eq!(sent, relay::site::PER_ADDRESS - 4);
180 assert_eq!(
181 site.report("text/plain", "again", ""),
182 "HTTP/1.1 429 Too Many Requests"
183 );
184 assert_eq!(
185 site.report(
186 "text/plain",
187 "elsewhere",
188 "X-Forwarded-For: 203.0.113.9\r\n"
189 ),
190 "HTTP/1.1 200 OK"
191 );
192 assert_eq!(
193 std::fs::read_dir(&crashes).unwrap().count(),
194 relay::site::PER_ADDRESS
195 );
196 assert!(site.get("/crash").starts_with("HTTP/1.1 404"));
197}