diff --git a/Cargo.lock b/Cargo.lock index 9945cdff77c13f2a68a1ce8f98ff72004d263818..542707cc6ad71ccef512cef18e90500febe714bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3147,6 +3147,7 @@ dependencies = [ "base64", "getrandom 0.4.3", "sha1", + "tempfile", ] [[package]] diff --git a/crates/notebook/src/live.rs b/crates/notebook/src/live.rs index d1bc29fe1e3360c16e32c4d6f4ae4d16b60f9ad7..36b03a9bd8a82874e764bcb6e6bee771cf2591f4 100644 --- a/crates/notebook/src/live.rs +++ b/crates/notebook/src/live.rs @@ -7,7 +7,7 @@ //! and one writing. A connection whose frames arrive out of order is dropped and met again //! from scratch. -pub mod code; +pub use ::relay::code; mod relay; pub mod share; pub mod wire; diff --git a/crates/notebook/src/live/code.rs b/crates/notebook/src/live/code.rs deleted file mode 100644 index e9a95d0e9664d728ff09430409eb0b5dde7d3b9a..0000000000000000000000000000000000000000 --- a/crates/notebook/src/live/code.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Live Share's codes in Crockford's base32 (0-9 and A-Z without I, L, O and U), shown -//! `7KQ-4MZ-9XR`: two symbols naming the code's room, its number on a relay; six of secret -//! (30 bits), which SPAKE2 meets through; and a check symbol, so a mistyped code is refused -//! here before it spends one of the relay's few tries. Reading ignores case, hyphens and -//! spaces, and takes I and L for 1 and O for 0, as Crockford's decoding does. -//! -//! The check is the symbols' values weighted 1 to 8, summed modulo 31, the prime under 32, so -//! it stays one of the code's own symbols: it catches any symbol mistyped and any two -//! neighbours swapped, but for 0 and Z, whose values differ by 31. Crockford's own check -//! symbol, modulo 37, adds `*~$=U`, which read badly aloud. - -use std::io; - -const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; -/// The symbols naming a code's room, and how many rooms they name. -const NAMEPLATE: usize = 2; -pub const NAMEPLATES: u32 = 1 << (5 * NAMEPLATE); -/// The symbols of a code's secret. -pub const SECRET: usize = 6; - -/// A new secret, `SECRET` random symbols. -pub fn secret() -> io::Result { - let mut bytes = [0; 4]; - getrandom::fill(&mut bytes).map_err(|_| io::Error::other("System random source failed"))?; - let bits = u32::from_le_bytes(bytes); - Ok((0..SECRET) - .map(|at| symbol((bits >> (5 * at)) as u8)) - .collect()) -} - -fn symbol(value: u8) -> char { - char::from(ALPHABET[usize::from(value & 31)]) -} - -/// A symbol's value as typed, reading I and L as 1 and O as 0. -fn value(typed: char) -> Option { - let typed = match typed.to_ascii_uppercase() { - 'I' | 'L' => '1', - 'O' => '0', - typed => typed, - }; - ALPHABET - .iter() - .position(|symbol| char::from(*symbol) == typed) - .map(|at| at as u8) -} - -fn check(values: &[u8]) -> u8 { - let sum: u32 = (values.iter().enumerate()) - .map(|(at, value)| (at as u32 + 1) * u32::from(*value)) - .sum(); - (sum % 31) as u8 -} - -/// The code for room `nameplate` and `secret`, as shown: `7KQ-4MZ-9XR`. A secret that isn't -/// `SECRET` symbols has no code. -pub fn format(nameplate: u32, secret: &str) -> Option { - let secret: Vec = secret.chars().map(value).collect::>()?; - if nameplate >= NAMEPLATES || secret.len() != SECRET { - return None; - } - let mut values = vec![(nameplate >> 5) as u8, (nameplate & 31) as u8]; - values.extend(secret); - values.push(check(&values)); - let symbols: Vec = values.into_iter().map(symbol).collect(); - Some( - symbols - .chunks(3) - .map(|group| group.iter().collect::()) - .collect::>() - .join("-"), - ) -} - -/// A code as typed: its room's number and its secret, where it is a code whose check holds. -pub fn parse(typed: &str) -> Option<(u32, String)> { - let values: Vec = typed - .chars() - .filter(|c| *c != '-' && !c.is_whitespace()) - .map(value) - .collect::>()?; - let (check_value, values) = values.split_last()?; - if values.len() != NAMEPLATE + SECRET || check(values) != *check_value { - return None; - } - let nameplate = (u32::from(values[0]) << 5) | u32::from(values[1]); - let secret = values[NAMEPLATE..].iter().copied().map(symbol).collect(); - Some((nameplate, secret)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn codes_read_back_however_they_are_typed() { - let code = format(412, "4MZ9XR").unwrap(); - assert_eq!(code.len(), 11); - assert_eq!(parse(&code), Some((412, "4MZ9XR".into()))); - let typed = code - .to_lowercase() - .replace('-', " ") - .replace('1', "l") - .replace('0', "o"); - assert_eq!(parse(&typed), Some((412, "4MZ9XR".into()))); - assert_eq!(parse(&format!(" {code} ")), Some((412, "4MZ9XR".into()))); - assert!(format(NAMEPLATES, "4MZ9XR").is_none() && format(1, "4MZ9X").is_none()); - assert!(parse("412-violet-otter").is_none() && parse("").is_none()); - for _ in 0..100 { - let secret = secret().unwrap(); - assert_eq!(secret.len(), SECRET); - assert_eq!(parse(&format(7, &secret).unwrap()), Some((7, secret))); - } - } - - /// The check refuses any one symbol mistyped, and any two neighbours swapped, but for 0 - /// and Z. - #[test] - fn the_check_catches_a_symbol_mistyped_or_two_swapped() { - let code: Vec = format(999, "Q4MZ9X") - .unwrap() - .replace('-', "") - .chars() - .collect(); - for at in 0..code.len() { - for symbol in ALPHABET.iter().map(|byte| char::from(*byte)) { - let mut typed = code.clone(); - if typed[at] != symbol && !matches!((typed[at], symbol), ('0', 'Z') | ('Z', '0')) { - typed[at] = symbol; - assert!( - parse(&typed.iter().collect::()).is_none(), - "{typed:?}" - ); - } - } - } - for at in 0..code.len() - 1 { - let mut typed = code.clone(); - typed.swap(at, at + 1); - if typed != code { - assert!( - parse(&typed.iter().collect::()).is_none(), - "{typed:?}" - ); - } - } - } -} diff --git a/crates/relay/Cargo.toml b/crates/relay/Cargo.toml index c9aefd33faa8225c66dfb90faa0755627628f257..5c952c7234bc6a3674ad8b8ecbf9242f5d4f57aa 100644 --- a/crates/relay/Cargo.toml +++ b/crates/relay/Cargo.toml @@ -10,7 +10,15 @@ publish = false name = "snowbound-relay" path = "src/main.rs" +# snowbound.paperclover.net: the hosted web build, and a page for each Live Share code. +[[bin]] +name = "snowbound-site" +path = "src/site_main.rs" + [dependencies] base64 = { version = "0.23.1", default-features = false, features = ["std"] } getrandom = "0.4.3" sha1 = "0.11.0" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/relay/README.md b/crates/relay/README.md index b751f0843f2b55f89a9644c172a2707973edb63b..e708f15f835a21d3cad1d4eca0f4606d5fc08721 100644 --- a/crates/relay/README.md +++ b/crates/relay/README.md @@ -12,11 +12,11 @@ HTTP and WebSocket; a proxy in front of it terminates TLS. ## Build ```sh -python3 tools/release_relay.py # target/relay/snowbound-relay-linux-{x86_64,aarch64} +python3 tools/release_relay.py # target/relay/snowbound-{relay,site}-linux-{x86_64,aarch64} ``` It links with Rust's own lld against Rust's own musl, so it needs only `rustup`. The folder -it makes holds both executables, `SHA256SUMS`, this README and `snowbound-relay.service`. +it makes holds the executables, `SHA256SUMS`, this README and both systemd units. ## Deploy @@ -28,7 +28,7 @@ sudo install -m 755 /tmp/snowbound-relay /usr/local/bin/snowbound-relay sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now snowbound-relay -curl -s http://127.0.0.1:7650/health # {"rooms":0,"peers":0,"connections":1,"seconds":3} +curl -s http://127.0.0.1:23592/health # {"rooms":0,"peers":0,"connections":1,"seconds":3} ``` Then point a name at the server and put a TLS proxy in front. Caddy fetches its own @@ -36,7 +36,7 @@ certificate and passes WebSocket upgrades through as they are: ```text live.example.net { - reverse_proxy 127.0.0.1:7650 + reverse_proxy 127.0.0.1:23592 } ``` @@ -49,7 +49,7 @@ server { ssl_certificate /etc/letsencrypt/live/live.example.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/live.example.net/privkey.pem; location / { - proxy_pass http://127.0.0.1:7650; + proxy_pass http://127.0.0.1:23592; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; @@ -60,14 +60,50 @@ server { } ``` -The app then uses `wss://live.example.net` (for now, `SNOWBOUND_LIVE_RELAY=wss://live.example.net` -with a build that has the `live` feature). +The app uses `wss://relay.snowbound.paperclover.net` unless Options ▸ Sync & Storage ▸ Live +Share names another relay. `--trust-forwarded true`, as the unit sets it, counts each peer by the last `X-Forwarded-For` entry, the one the proxy added. Without a proxy, leave it off: a client could otherwise claim any address. Listening on a public address without TLS works but lets anyone on the path see room tags and nameplates. +## The site: snowbound.paperclover.net + +`snowbound-site` serves the hosted web build's folder, and for a path that is a Live Share +code (`/7KQ-4MZ-9XR`, read as loosely as the app reads one) a page that opens it with +`snowbound://join/`, and in the web build where `--web` names where (once the web build +joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's +module and JavaScript sit in `b//` and are cached for good; `index.html` and the +codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day. It +holds no state. + +`python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's +architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds' +folders for pages still running them) and the binary to `~/snowbound-web/snowbound-site`, +and restarts pm2's `snowbound-site` only when the binary changed. The first time: + +```sh +ssh vps +mkdir -p ~/snowbound-web/site +# after the first `release_web.py --deploy` has put the binary there: +pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \ + --listen 127.0.0.1:23593 --root "$HOME/snowbound-web/site" +pm2 save +``` + +Caddy in front: + +```text +snowbound.paperclover.net { + encode gzip + reverse_proxy 127.0.0.1:23593 +} +``` + +`snowbound-site.service` runs it under systemd instead (`--root /srv/snowbound/web`). +`snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_