| author | |
| committer | |
| log | fcec92e2decf2a994e4c6754a59ff7cfb4e25004 |
| tree | 6cb8a76bcdc73cb11e4ddb080745413ae12be712 |
| parent | 26d313ff895bef3a6121acee7d27aaa42b494384 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.57 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 | 86 | `snowbound://join/<code>`, and in the web build where `--web` names where (once the web build |
| 87 | 87 | joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's |
| 88 | 88 | module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the |
| 89 | codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day. It | |
| 90 | holds no state. | |
| 89 | codes' 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 | |
| 92 | text or JSON up to 64 KB, ten an hour from one address and 500 an hour in all, each kept as | |
| 93 | a file named for when it came (`2026-10-03T21-04-05Z-1a2b3c4d.txt`) in `--crashes`, by | |
| 94 | default `crashes` beside the root. Nothing else is kept, and nothing sent comes back. Behind | |
| 95 | a proxy, `--trust-forwarded true` counts senders by the address it adds, as the relay does. | |
| 91 | 96 | |
| 92 | 97 | `python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's |
| 93 | 98 | architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds' |
| ... | ... | @@ -99,7 +104,7 @@ ssh vps |
| 99 | 104 | mkdir -p ~/snowbound-web/site |
| 100 | 105 | # after the first `release_web.py --deploy` has put the binary there: |
| 101 | 106 | pm2 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 | |
| 103 | 108 | pm2 save |
| 104 | 109 | ``` |
| 105 | 110 | |
| ... | ... | @@ -112,7 +117,8 @@ snowbound.paperclover.net { |
| 112 | 117 | } |
| 113 | 118 | ``` |
| 114 | 119 | |
| 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 | |
| 121 | reports in `/var/lib/snowbound-site`). | |
| 116 | 122 | `snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`. |
| 117 | 123 | |
| 118 | 124 | ## Options |
crates/relay/deploy/snowbound-site.service+2-1| ... | ... | @@ -7,7 +7,8 @@ After=network-online.target |
| 7 | 7 | Wants=network-online.target |
| 8 | 8 | |
| 9 | 9 | [Service] |
| 10 | ExecStart=/usr/local/bin/snowbound-site --listen 127.0.0.1:23593 --root /srv/snowbound/web | |
| 10 | ExecStart=/usr/local/bin/snowbound-site --listen 127.0.0.1:23593 --root /srv/snowbound/web --crashes /var/lib/snowbound-site --trust-forwarded true | |
| 11 | StateDirectory=snowbound-site | |
| 11 | 12 | Restart=always |
| 12 | 13 | RestartSec=2 |
| 13 | 14 | DynamicUser=yes |
crates/relay/src/lib.rs+27-1| ... | ... | @@ -23,7 +23,33 @@ pub mod server; |
| 23 | 23 | pub mod site; |
| 24 | 24 | pub mod ws; |
| 25 | 25 | |
| 26 | use std::{fmt, str::FromStr}; | |
| 26 | use 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. | |
| 34 | pub 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 | } | |
| 27 | 53 | |
| 28 | 54 | /// The bytes before a binary message's payload: the slot it goes to or came from. |
| 29 | 55 | pub const SLOT: usize = 4; |
crates/relay/src/server.rs+4-35| ... | ... | @@ -6,7 +6,7 @@ use crate::{BROADCAST, GROUP, Notice, SLOT, Verdict, ws}; |
| 6 | 6 | use std::{ |
| 7 | 7 | collections::{BTreeMap, HashMap, VecDeque}, |
| 8 | 8 | io::{BufReader, Write}, |
| 9 | net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, TcpListener, TcpStream}, | |
| 9 | net::{IpAddr, Shutdown, TcpListener, TcpStream}, | |
| 10 | 10 | ops::RangeInclusive, |
| 11 | 11 | sync::{ |
| 12 | 12 | Arc, Condvar, Mutex, |
| ... | ... | @@ -209,7 +209,7 @@ impl Relay { |
| 209 | 209 | |
| 210 | 210 | /// Answers the request `head`: whether the connection serves another. |
| 211 | 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 | 213 | let target = head.split(' ').nth(1).unwrap_or_default(); |
| 214 | 214 | let (path, query) = target.split_once('?').unwrap_or((target, "")); |
| 215 | 215 | if let Some(id) = path.strip_prefix("/v1/poll/") { |
| ... | ... | @@ -377,27 +377,6 @@ impl Relay { |
| 377 | 377 | } |
| 378 | 378 | } |
| 379 | 379 | |
| 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 | 380 | fn health(&self) -> String { |
| 402 | 381 | let state = self.state.lock().unwrap(); |
| 403 | 382 | let peers: usize = state.rooms.values().map(|room| room.members.len()).sum(); |
| ... | ... | @@ -1082,25 +1061,15 @@ mod tests { |
| 1082 | 1061 | |
| 1083 | 1062 | #[test] |
| 1084 | 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 | 1064 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1096 | 1065 | let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); |
| 1097 | 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 | 1067 | assert_eq!( |
| 1099 | relay.address(&stream, head), | |
| 1068 | crate::peer(&stream, head, true), | |
| 1100 | 1069 | "2001:db8:1:2::".parse::<IpAddr>().unwrap() |
| 1101 | 1070 | ); |
| 1102 | 1071 | assert_eq!( |
| 1103 | relay.address(&stream, "GET / HTTP/1.1\r\n\r\n"), | |
| 1072 | crate::peer(&stream, head, false), | |
| 1104 | 1073 | "127.0.0.1".parse::<IpAddr>().unwrap() |
| 1105 | 1074 | ); |
| 1106 | 1075 | } |
crates/relay/src/site.rs+168-12| ... | ... | @@ -2,19 +2,28 @@ |
| 2 | 2 | //! and for a path that is a Live Share code (`/7KQ-4MZ-9XR`) a page that opens it in |
| 3 | 3 | //! Snowbound, or in the web build where `Site::web` says it joins. A build's module and |
| 4 | 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 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 | |
| 6 | 7 | //! front terminates TLS. |
| 7 | 8 | |
| 8 | 9 | use crate::{code, ws}; |
| 9 | 10 | use 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}, | |
| 12 | 14 | path::{Component, Path, PathBuf}, |
| 13 | sync::Arc, | |
| 15 | sync::{Arc, Mutex}, | |
| 14 | 16 | thread, |
| 15 | time::Duration, | |
| 17 | time::{Duration, Instant, SystemTime}, | |
| 16 | 18 | }; |
| 17 | 19 | |
| 20 | /// The largest crash report kept. | |
| 21 | pub const REPORT: usize = 64 << 10; | |
| 22 | /// Reports kept an hour from one address (an IPv6 /64), and from everyone. | |
| 23 | pub const PER_ADDRESS: usize = 10; | |
| 24 | pub const PER_HOUR: usize = 500; | |
| 25 | const HOUR: Duration = Duration::from_secs(60 * 60); | |
| 26 | ||
| 18 | 27 | /// What the site serves. |
| 19 | 28 | #[derive(Clone, Debug)] |
| 20 | 29 | pub struct Site { |
| ... | ... | @@ -23,32 +32,81 @@ pub struct Site { |
| 23 | 32 | /// Where Open in Web goes, the code appended (`https://snowbound.paperclover.net/?join=`); |
| 24 | 33 | /// none offers only Snowbound. |
| 25 | 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)] | |
| 43 | struct Received { | |
| 44 | by_address: HashMap<IpAddr, VecDeque<Instant>>, | |
| 45 | all: VecDeque<Instant>, | |
| 46 | } | |
| 47 | ||
| 48 | impl 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 | } |
| 27 | 70 | |
| 28 | 71 | /// Serves `site` on `listener` until it fails. |
| 29 | 72 | pub fn serve(listener: TcpListener, site: Site) -> io::Result<()> { |
| 30 | let site = Arc::new(site); | |
| 73 | let shared = Arc::new((site, Mutex::default())); | |
| 31 | 74 | for stream in listener.incoming() { |
| 32 | 75 | let Ok(stream) = stream else { continue }; |
| 33 | let site = Arc::clone(&site); | |
| 76 | let shared = Arc::clone(&shared); | |
| 34 | 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 | 82 | Ok(()) |
| 39 | 83 | } |
| 40 | 84 | |
| 41 | fn answer(site: &Site, stream: TcpStream) -> io::Result<()> { | |
| 85 | fn answer(site: &Site, received: &Mutex<Received>, stream: TcpStream) -> io::Result<()> { | |
| 42 | 86 | stream.set_read_timeout(Some(Duration::from_secs(10)))?; |
| 43 | 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 | 90 | let mut words = head.split(' '); |
| 46 | 91 | let (method, target) = ( |
| 47 | 92 | words.next().unwrap_or_default(), |
| 48 | 93 | words.next().unwrap_or("/"), |
| 49 | 94 | ); |
| 50 | 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 | 111 | "405 Method Not Allowed", |
| 54 | 112 | "text/plain", |
| ... | ... | @@ -70,7 +128,7 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> { |
| 70 | 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 | 132 | "no-cache" |
| 75 | 133 | } else if path.starts_with("/b/") { |
| 76 | 134 | "public, max-age=31536000, immutable" |
| ... | ... | @@ -91,6 +149,91 @@ fn answer(site: &Site, stream: TcpStream) -> io::Result<()> { |
| 91 | 149 | Ok(()) |
| 92 | 150 | } |
| 93 | 151 | |
| 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. | |
| 154 | fn 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. | |
| 210 | fn 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 | 237 | /// The file `path` names in `root`, `index.html` for a folder; none outside it. |
| 95 | 238 | fn file(root: &Path, path: &str) -> Option<PathBuf> { |
| 96 | 239 | let relative = Path::new(path.trim_start_matches('/')); |
| ... | ... | @@ -159,3 +302,16 @@ fn landing(shown: &str, site: &Site) -> String { |
| 159 | 302 | "# |
| 160 | 303 | ) |
| 161 | 304 | } |
| 305 | ||
| 306 | #[cfg(test)] | |
| 307 | mod 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 | 6 | const USAGE: &str = "\ |
| 7 | 7 | usage: snowbound-site [OPTION VALUE]... |
| 8 | 8 | |
| 9 | Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case: | |
| 10 | SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win. | |
| 9 | Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case with | |
| 10 | underscores: SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win. | |
| 11 | 11 | |
| 12 | 12 | --listen ADDRESS where to listen (127.0.0.1:23593) |
| 13 | 13 | --root FOLDER the web build's folder (web) |
| 14 | 14 | --web URL where Open in Web goes, the code appended, as |
| 15 | 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 | "; |
| 17 | 20 | |
| 18 | 21 | fn main() -> ExitCode { |
| ... | ... | @@ -20,9 +23,13 @@ fn main() -> ExitCode { |
| 20 | 23 | let mut site = Site { |
| 21 | 24 | root: PathBuf::from("web"), |
| 22 | 25 | web: None, |
| 26 | crashes: PathBuf::new(), | |
| 27 | trust_forwarded: false, | |
| 23 | 28 | }; |
| 29 | let mut crashes = None; | |
| 24 | 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 | 34 | let mut arguments = std::env::args().skip(1); |
| 28 | 35 | let mut from_flags = Vec::new(); |
| ... | ... | @@ -42,12 +49,21 @@ fn main() -> ExitCode { |
| 42 | 49 | "listen" => listen = value, |
| 43 | 50 | "root" => site.root = PathBuf::from(value), |
| 44 | 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 | 61 | eprintln!("--{option}: no such option\n\n{USAGE}"); |
| 47 | 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 | 67 | let listener = match TcpListener::bind(&listen) { |
| 52 | 68 | Ok(listener) => listener, |
| 53 | 69 | 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. | |
| 2 | 3 | |
| 3 | 4 | use std::{ |
| 4 | 5 | io::{BufRead, BufReader, Read, Write}, |
| ... | ... | @@ -36,11 +37,26 @@ impl Site { |
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 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 | 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(); | |
| 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() | |
| 44 | 60 | } |
| 45 | 61 | } |
| 46 | 62 | |
| ... | ... | @@ -105,3 +121,77 @@ fn a_code_gets_its_page_and_the_web_build_its_files() { |
| 105 | 121 | "{page}" |
| 106 | 122 | ); |
| 107 | 123 | } |
| 124 | ||
| 125 | #[test] | |
| 126 | fn 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 | } |