| 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,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 build | 86 | `snowbound://join/<code>`, and in the web build where `--web` names where (once the web build |
| 87 | joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's | 87 | joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's |
| 88 | module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the | 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 | 89 | codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day. |
| 90 | holds no state. | 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 | `python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's | 97 | `python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's |
| 93 | architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds' | 98 | architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds' |
| ... | @@ -99,7 +104,7 @@ ssh vps | ... | @@ -99,7 +104,7 @@ ssh vps |
| 99 | mkdir -p ~/snowbound-web/site | 104 | mkdir -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: |
| 101 | pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \ | 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 | pm2 save | 108 | pm2 save |
| 104 | ``` | 109 | ``` |
| 105 | 110 | ||
| ... | @@ -112,7 +117,8 @@ snowbound.paperclover.net { | ... | @@ -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 | `snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`. | 122 | `snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`. |
| 117 | 123 | ||
| 118 | ## Options | 124 | ## 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 |
| 7 | Wants=network-online.target | 7 | Wants=network-online.target |
| 8 | 8 | ||
| 9 | [Service] | 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 | Restart=always | 12 | Restart=always |
| 12 | RestartSec=2 | 13 | RestartSec=2 |
| 13 | DynamicUser=yes | 14 | DynamicUser=yes |
crates/relay/src/lib.rs+27-1| ... | @@ -23,7 +23,33 @@ pub mod server; | ... | @@ -23,7 +23,33 @@ pub mod server; |
| 23 | pub mod site; | 23 | pub mod site; |
| 24 | pub mod ws; | 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 | /// 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. |
| 29 | pub const SLOT: usize = 4; | 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,7 +6,7 @@ use crate::{BROADCAST, GROUP, Notice, SLOT, Verdict, ws}; |
| 6 | use std::{ | 6 | use 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 { |
| 209 | 209 | ||
| 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 | } |
| 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 | 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 { |
| 1082 | 1061 | ||
| 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 in | 2 | //! 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 and | 3 | //! 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 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 | //! front terminates TLS. | 7 | //! front terminates TLS. |
| 7 | 8 | ||
| 8 | use crate::{code, ws}; | 9 | use crate::{code, ws}; |
| 9 | use std::{ | 10 | use 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 | }; |
| 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 | /// What the site serves. | 27 | /// What the site serves. |
| 19 | #[derive(Clone, Debug)] | 28 | #[derive(Clone, Debug)] |
| 20 | pub struct Site { | 29 | pub 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)] | ||
| 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 | /// Serves `site` on `listener` until it fails. | 71 | /// Serves `site` on `listener` until it fails. |
| 29 | pub fn serve(listener: TcpListener, site: Site) -> io::Result<()> { | 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 | 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 | } |
| 40 | 84 | ||
| 41 | fn answer(site: &Site, stream: TcpStream) -> io::Result<()> { | 85 | fn 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 | } |
| 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 | /// 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. |
| 95 | fn file(root: &Path, path: &str) -> Option<PathBuf> { | 238 | fn 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)] | ||
| 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,13 +6,16 @@ use std::{net::TcpListener, path::PathBuf, process::ExitCode}; |
| 6 | const USAGE: &str = "\ | 6 | const USAGE: &str = "\ |
| 7 | usage: snowbound-site [OPTION VALUE]... | 7 | usage: snowbound-site [OPTION VALUE]... |
| 8 | 8 | ||
| 9 | Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case: | 9 | Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case with |
| 10 | SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win. | 10 | underscores: SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win. |
| 11 | 11 | ||
| 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, as | 14 | --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 | "; |
| 17 | 20 | ||
| 18 | fn main() -> ExitCode { | 21 | fn 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. | ||
| 2 | 3 | ||
| 3 | use std::{ | 4 | use 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 | } |
| 37 | 38 | ||
| 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 | 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,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] | ||
| 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 | } |