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
8686`snowbound://join/<code>`, and in the web build where `--web` names where (once the web build
8787joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's
8888module 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. It
90holds no state.
89codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day.
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
9297`python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's
9398architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds'
......@@ -99,7 +104,7 @@ ssh vps
99104mkdir -p ~/snowbound-web/site
100105# after the first `release_web.py --deploy` has put the binary there:
101106pm2 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
103108pm2 save
104109```
105110
......@@ -112,7 +117,8 @@ snowbound.paperclover.net {
112117}
113118```
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`).
116122`snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`.
117123
118124## Options
crates/relay/deploy/snowbound-site.service+2-1
......@@ -7,7 +7,8 @@ After=network-online.target
77Wants=network-online.target
88
99[Service]
10ExecStart=/usr/local/bin/snowbound-site --listen 127.0.0.1:23593 --root /srv/snowbound/web
10ExecStart=/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
1112Restart=always
1213RestartSec=2
1314DynamicUser=yes
crates/relay/src/lib.rs+27-1
......@@ -23,7 +23,33 @@ pub mod server;
2323pub mod site;
2424pub 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
2854/// The bytes before a binary message's payload: the slot it goes to or came from.
2955pub const SLOT: usize = 4;
crates/relay/src/server.rs+4-35
......@@ -6,7 +6,7 @@ use crate::{BROADCAST, GROUP, Notice, SLOT, Verdict, ws};
66use std::{
77 collections::{BTreeMap, HashMap, VecDeque},
88 io::{BufReader, Write},
9 net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, TcpListener, TcpStream},
9 net::{IpAddr, Shutdown, TcpListener, TcpStream},
1010 ops::RangeInclusive,
1111 sync::{
1212 Arc, Condvar, Mutex,
......@@ -209,7 +209,7 @@ impl Relay {
209209
210210 /// Answers the request `head`: whether the connection serves another.
211211 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);
213213 let target = head.split(' ').nth(1).unwrap_or_default();
214214 let (path, query) = target.split_once('?').unwrap_or((target, ""));
215215 if let Some(id) = path.strip_prefix("/v1/poll/") {
......@@ -377,27 +377,6 @@ impl Relay {
377377 }
378378 }
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
401380 fn health(&self) -> String {
402381 let state = self.state.lock().unwrap();
403382 let peers: usize = state.rooms.values().map(|room| room.members.len()).sum();
......@@ -1082,25 +1061,15 @@ mod tests {
10821061
10831062 #[test]
10841063 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 };
10951064 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
10961065 let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
10971066 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";
10981067 assert_eq!(
1099 relay.address(&stream, head),
1068 crate::peer(&stream, head, true),
11001069 "2001:db8:1:2::".parse::<IpAddr>().unwrap()
11011070 );
11021071 assert_eq!(
1103 relay.address(&stream, "GET / HTTP/1.1\r\n\r\n"),
1072 crate::peer(&stream, head, false),
11041073 "127.0.0.1".parse::<IpAddr>().unwrap()
11051074 );
11061075 }
crates/relay/src/site.rs+168-12
......@@ -2,19 +2,28 @@
22//! and for a path that is a Live Share code (`/7KQ-4MZ-9XR`) a page that opens it in
33//! Snowbound, or in the web build where `Site::web` says it joins. A build's module and
44//! 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 in
5//! `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
67//! front terminates TLS.
78
89use crate::{code, ws};
910use std::{
10 io::{self, BufReader, Write},
11 net::{TcpListener, TcpStream},
11 collections::{HashMap, VecDeque},
12 io::{self, BufReader, Read, Write},
13 net::{IpAddr, TcpListener, TcpStream},
1214 path::{Component, Path, PathBuf},
13 sync::Arc,
15 sync::{Arc, Mutex},
1416 thread,
15 time::Duration,
17 time::{Duration, Instant, SystemTime},
1618};
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
1827/// What the site serves.
1928#[derive(Clone, Debug)]
2029pub struct Site {
......@@ -23,32 +32,81 @@ pub struct Site {
2332 /// Where Open in Web goes, the code appended (`https://snowbound.paperclover.net/?join=`);
2433 /// none offers only Snowbound.
2534 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 }
2669}
2770
2871/// Serves `site` on `listener` until it fails.
2972pub fn serve(listener: TcpListener, site: Site) -> io::Result<()> {
30 let site = Arc::new(site);
73 let shared = Arc::new((site, Mutex::default()));
3174 for stream in listener.incoming() {
3275 let Ok(stream) = stream else { continue };
33 let site = Arc::clone(&site);
76 let shared = Arc::clone(&shared);
3477 thread::spawn(move || {
35 let _ = answer(&site, stream);
78 let (site, received) = &*shared;
79 let _ = answer(site, received, stream);
3680 });
3781 }
3882 Ok(())
3983}
4084
41fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {
85fn answer(site: &Site, received: &Mutex<Received>, stream: TcpStream) -> io::Result<()> {
4286 stream.set_read_timeout(Some(Duration::from_secs(10)))?;
4387 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)?;
4590 let mut words = head.split(' ');
4691 let (method, target) = (
4792 words.next().unwrap_or_default(),
4893 words.next().unwrap_or("/"),
4994 );
5095 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") {
52110 (
53111 "405 Method Not Allowed",
54112 "text/plain",
......@@ -70,7 +128,7 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {
70128 None => ("404 Not Found", "text/plain", b"Not found\n".to_vec()),
71129 }
72130 };
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") {
74132 "no-cache"
75133 } else if path.starts_with("/b/") {
76134 "public, max-age=31536000, immutable"
......@@ -91,6 +149,91 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> {
91149 Ok(())
92150}
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
94237/// The file `path` names in `root`, `index.html` for a folder; none outside it.
95238fn file(root: &Path, path: &str) -> Option<PathBuf> {
96239 let relative = Path::new(path.trim_start_matches('/'));
......@@ -159,3 +302,16 @@ fn landing(shown: &str, site: &Site) -> String {
159302"#
160303 )
161304}
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};
66const USAGE: &str = "\
77usage: snowbound-site [OPTION VALUE]...
88
9Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case:
10SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win.
9Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case with
10underscores: SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win.
1111
1212 --listen ADDRESS where to listen (127.0.0.1:23593)
1313 --root FOLDER the web build's folder (web)
1414 --web URL where Open in Web goes, the code appended, as
1515 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)
1619";
1720
1821fn main() -> ExitCode {
......@@ -20,9 +23,13 @@ fn main() -> ExitCode {
2023 let mut site = Site {
2124 root: PathBuf::from("web"),
2225 web: None,
26 crashes: PathBuf::new(),
27 trust_forwarded: false,
2328 };
29 let mut crashes = None;
2430 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))
2633 });
2734 let mut arguments = std::env::args().skip(1);
2835 let mut from_flags = Vec::new();
......@@ -42,12 +49,21 @@ fn main() -> ExitCode {
4249 "listen" => listen = value,
4350 "root" => site.root = PathBuf::from(value),
4451 "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 },
4560 _ => {
4661 eprintln!("--{option}: no such option\n\n{USAGE}");
4762 return ExitCode::from(2);
4863 }
4964 }
5065 }
66 site.crashes = crashes.unwrap_or_else(|| site.root.with_file_name("crashes"));
5167 let listener = match TcpListener::bind(&listen) {
5268 Ok(listener) => listener,
5369 Err(error) => {
crates/relay/tests/site.rs+95-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
34use std::{
45 io::{BufRead, BufReader, Read, Write},
......@@ -36,11 +37,26 @@ impl Site {
3637 }
3738
3839 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 {
3955 let mut stream = TcpStream::connect(self.address).unwrap();
40 write!(stream, "GET {path} HTTP/1.1\r\nHost: site\r\n\r\n").unwrap();
41 let mut response = String::new();
42 stream.read_to_string(&mut response).unwrap();
43 response
56 stream.write_all(request).unwrap();
57 let mut response = Vec::new();
58 let _ = stream.read_to_end(&mut response);
59 String::from_utf8_lossy(&response).into_owned()
4460 }
4561}
4662
......@@ -105,3 +121,77 @@ fn a_code_gets_its_page_and_the_web_build_its_files() {
105121 "{page}"
106122 );
107123}
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}