| author | |
| committer | |
| log | 0641110e7a154fd20595e7fd290ef572c8a36235 |
| tree | 09d6eeab9cf54d9281cbdff1fe9fec290171b022 |
| parent | f2e89c094937c86ace1a3078d7d48d6056a5d933 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
Once a burned code's owner left, the relay forgot the room, so the host's replacement code could draw the same number from the thousand on offer. The old code then led to the new one: a guest typing it was told its code was wrong rather than expired, and spent a try of the new code. A burned room now outlives its members by a minute, during which its number is neither claimed nor joined, and anyone typing the old code hears it expired. The relay test checks this by ending a polled owner, which answers only once it has left, so it needs no waiting.
Refs #150
Assisted-by: claude-opus-5.53 files changed, 85 insertions(+), 16 deletions(-)
crates/notebook/tests/live_share.rs+3-4| ... | @@ -424,11 +424,10 @@ fn wrong_codes_are_refused_and_counted() { | ... | @@ -424,11 +424,10 @@ fn wrong_codes_are_refused_and_counted() { |
| 424 | .is_some_and(|now| now != code && share::code(&now).is_some()) | 424 | .is_some_and(|now| now != code && share::code(&now).is_some()) |
| 425 | }); | 425 | }); |
| 426 | let fresh = host.code().unwrap(); | 426 | let fresh = host.code().unwrap(); |
| 427 | assert!(matches!( | 427 | assert_eq!(join(&code).unwrap_err(), Refusal::Expired); |
| 428 | join(&code).unwrap_err(), | 428 | let burned = number; |
| 429 | Refusal::Expired | Refusal::NoOne | ||
| 430 | )); | ||
| 431 | let (number, secret) = notebook::live::code::parse(&fresh).unwrap(); | 429 | let (number, secret) = notebook::live::code::parse(&fresh).unwrap(); |
| 430 | assert_ne!(number, burned); | ||
| 432 | let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap(); | 431 | let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap(); |
| 433 | assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); | 432 | assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); |
| 434 | // A third wrong code in a minute locks this network out, even from the right code. | 433 | // A third wrong code in a minute locks this network out, even from the right code. |
crates/relay/src/server.rs+26-12| ... | @@ -151,7 +151,9 @@ struct Room { | ... | @@ -151,7 +151,9 @@ struct Room { |
| 151 | joins: Bucket, | 151 | joins: Bucket, |
| 152 | bytes: Bucket, | 152 | bytes: Bucket, |
| 153 | failures: u32, | 153 | failures: u32, |
| 154 | burned: bool, | 154 | /// When wrong codes burned it, then when it emptied: its number stays out of use for a |
| 155 | /// minute after, so an old code never leads to its owner's next one. | ||
| 156 | burned: Option<Instant>, | ||
| 155 | } | 157 | } |
| 156 | 158 | ||
| 157 | struct Member { | 159 | struct Member { |
| ... | @@ -429,7 +431,7 @@ impl Relay { | ... | @@ -429,7 +431,7 @@ impl Relay { |
| 429 | let free = |number: u32| { | 431 | let free = |number: u32| { |
| 430 | rooms | 432 | rooms |
| 431 | .get(&code(number)) | 433 | .get(&code(number)) |
| 432 | .is_none_or(|room| room.owner.is_none() && !room.burned) | 434 | .is_none_or(|room| room.owner.is_none() && room.burned.is_none()) |
| 433 | }; | 435 | }; |
| 434 | let number = back | 436 | let number = back |
| 435 | .filter(|number| NAMEPLATES.contains(number) && free(*number)) | 437 | .filter(|number| NAMEPLATES.contains(number) && free(*number)) |
| ... | @@ -447,13 +449,13 @@ impl Relay { | ... | @@ -447,13 +449,13 @@ impl Relay { |
| 447 | (code(number), Some(number)) | 449 | (code(number), Some(number)) |
| 448 | } | 450 | } |
| 449 | Ask::Room(tag) if tag.starts_with("code-") => { | 451 | Ask::Room(tag) if tag.starts_with("code-") => { |
| 450 | let room = rooms | 452 | let room = rooms.get(&tag).ok_or(Refusal::NotFound)?; |
| 451 | .get(&tag) | 453 | if room.burned.is_some() { |
| 452 | .filter(|room| room.owner.is_some()) | ||
| 453 | .ok_or(Refusal::NotFound)?; | ||
| 454 | if room.burned { | ||
| 455 | return Err(Refusal::Gone); | 454 | return Err(Refusal::Gone); |
| 456 | } | 455 | } |
| 456 | if room.owner.is_none() { | ||
| 457 | return Err(Refusal::NotFound); | ||
| 458 | } | ||
| 457 | if client.failures.len() + client.pending >= config.failures_per_minute as usize { | 459 | if client.failures.len() + client.pending >= config.failures_per_minute as usize { |
| 458 | let wait = client.failures.front().map_or(config.pending, |first| { | 460 | let wait = client.failures.front().map_or(config.pending, |first| { |
| 459 | (*first + MINUTE).saturating_duration_since(now) | 461 | (*first + MINUTE).saturating_duration_since(now) |
| ... | @@ -474,7 +476,7 @@ impl Relay { | ... | @@ -474,7 +476,7 @@ impl Relay { |
| 474 | joins: Bucket::full(f64::from(config.room_joins_per_minute), now), | 476 | joins: Bucket::full(f64::from(config.room_joins_per_minute), now), |
| 475 | bytes: Bucket::full(config.room_bytes_per_second as f64, now), | 477 | bytes: Bucket::full(config.room_bytes_per_second as f64, now), |
| 476 | failures: 0, | 478 | failures: 0, |
| 477 | burned: false, | 479 | burned: None, |
| 478 | }); | 480 | }); |
| 479 | let per_minute = f64::from(config.room_joins_per_minute); | 481 | let per_minute = f64::from(config.room_joins_per_minute); |
| 480 | let admitted = room.members.len() < config.max_room_peers; | 482 | let admitted = room.members.len() < config.max_room_peers; |
| ... | @@ -649,7 +651,8 @@ impl Relay { | ... | @@ -649,7 +651,8 @@ impl Relay { |
| 649 | } | 651 | } |
| 650 | } | 652 | } |
| 651 | 653 | ||
| 652 | /// Fails the peers that waited too long to meet a code's owner, and forgets quiet addresses. | 654 | /// Fails the peers that waited too long to meet a code's owner, and forgets burned rooms |
| 655 | /// empty a minute and quiet addresses. | ||
| 653 | fn sweep(&self, now: Instant) { | 656 | fn sweep(&self, now: Instant) { |
| 654 | let config = &self.config; | 657 | let config = &self.config; |
| 655 | let mut state = self.state.lock().unwrap(); | 658 | let mut state = self.state.lock().unwrap(); |
| ... | @@ -676,6 +679,12 @@ impl Relay { | ... | @@ -676,6 +679,12 @@ impl Relay { |
| 676 | depart(&mut state, config, &poll.tag, poll.slot, now); | 679 | depart(&mut state, config, &poll.tag, poll.slot, now); |
| 677 | } | 680 | } |
| 678 | } | 681 | } |
| 682 | state.rooms.retain(|_, room| { | ||
| 683 | !room.members.is_empty() | ||
| 684 | || room | ||
| 685 | .burned | ||
| 686 | .is_some_and(|since| now.saturating_duration_since(since) < MINUTE) | ||
| 687 | }); | ||
| 679 | state.addresses.retain(|_, address| { | 688 | state.addresses.retain(|_, address| { |
| 680 | address.refresh(now); | 689 | address.refresh(now); |
| 681 | !address.quiet(config, now) | 690 | !address.quiet(config, now) |
| ... | @@ -714,8 +723,8 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant | ... | @@ -714,8 +723,8 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant |
| 714 | } | 723 | } |
| 715 | if member.pending.is_some() { | 724 | if member.pending.is_some() { |
| 716 | room.failures += 1; | 725 | room.failures += 1; |
| 717 | if room.failures >= config.burn_after && !room.burned { | 726 | if room.failures >= config.burn_after && room.burned.is_none() { |
| 718 | room.burned = true; | 727 | room.burned = Some(now); |
| 719 | eprintln!("{tag}: burned after {} wrong codes", room.failures); | 728 | eprintln!("{tag}: burned after {} wrong codes", room.failures); |
| 720 | if let Some(owner) = room.owner.and_then(|owner| room.members.get(&owner)) { | 729 | if let Some(owner) = room.owner.and_then(|owner| room.members.get(&owner)) { |
| 721 | owner.outbox.push(notice(Notice::Burned)); | 730 | owner.outbox.push(notice(Notice::Burned)); |
| ... | @@ -734,7 +743,12 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant | ... | @@ -734,7 +743,12 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant |
| 734 | } | 743 | } |
| 735 | } | 744 | } |
| 736 | if room.members.is_empty() { | 745 | if room.members.is_empty() { |
| 737 | rooms.remove(tag); | 746 | match &mut room.burned { |
| 747 | Some(since) => *since = now, | ||
| 748 | None => { | ||
| 749 | rooms.remove(tag); | ||
| 750 | } | ||
| 751 | } | ||
| 738 | } | 752 | } |
| 739 | } | 753 | } |
| 740 | 754 |
crates/relay/tests/relay.rs+56| ... | @@ -40,6 +40,27 @@ impl Relay { | ... | @@ -40,6 +40,27 @@ impl Relay { |
| 40 | response | 40 | response |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | /// Asks `method` on `path` with `body` on a connection of its own: the status line and | ||
| 44 | /// the answer. | ||
| 45 | fn ask(&self, method: &str, path: &str, body: &[u8]) -> (String, Vec<u8>) { | ||
| 46 | let mut stream = TcpStream::connect(self.address).unwrap(); | ||
| 47 | write!( | ||
| 48 | stream, | ||
| 49 | "{method} {path} HTTP/1.1\r\nHost: relay\r\nContent-Length: {}\r\n\r\n", | ||
| 50 | body.len() | ||
| 51 | ) | ||
| 52 | .unwrap(); | ||
| 53 | stream.write_all(body).unwrap(); | ||
| 54 | let head = ws::head(&mut stream).unwrap(); | ||
| 55 | let length = ws::header(&head, "Content-Length") | ||
| 56 | .unwrap() | ||
| 57 | .parse() | ||
| 58 | .unwrap(); | ||
| 59 | let mut answer = vec![0; length]; | ||
| 60 | stream.read_exact(&mut answer).unwrap(); | ||
| 61 | (head.lines().next().unwrap().to_owned(), answer) | ||
| 62 | } | ||
| 63 | |||
| 43 | fn join(&self, path: &str) -> Result<Peer, String> { | 64 | fn join(&self, path: &str) -> Result<Peer, String> { |
| 44 | self.join_from(path, None) | 65 | self.join_from(path, None) |
| 45 | } | 66 | } |
| ... | @@ -299,6 +320,41 @@ fn wrong_codes_burn_a_code() { | ... | @@ -299,6 +320,41 @@ fn wrong_codes_burn_a_code() { |
| 299 | assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone"); | 320 | assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone"); |
| 300 | } | 321 | } |
| 301 | 322 | ||
| 323 | /// A burned code's number stays out of use once its owner leaves, so the old code can't lead | ||
| 324 | /// to the owner's next one. | ||
| 325 | #[test] | ||
| 326 | fn a_burned_number_is_not_given_out_again() { | ||
| 327 | let relay = Relay::start(&["--burn-after", "1"]); | ||
| 328 | // A polled owner, whose leaving is answered once it has left. | ||
| 329 | let (_, session) = relay.ask("GET", "/v1/claim?poll=1", b""); | ||
| 330 | let session = String::from_utf8(session).unwrap(); | ||
| 331 | let session = format!("/v1/poll/{}", session.strip_prefix("session ").unwrap()); | ||
| 332 | let say = |opcode: u8, text: &str| { | ||
| 333 | let frame = ws::frame(opcode, text.as_bytes(), Some([1, 2, 3, 4])); | ||
| 334 | relay.ask("POST", &session, &frame).0 | ||
| 335 | }; | ||
| 336 | let (_, heard) = relay.ask("GET", &session, b""); | ||
| 337 | let Ok(Message::Text(nameplate)) = ws::Reader::new(&heard[..], 1 << 20, false).read() else { | ||
| 338 | panic!("heard {heard:?}"); | ||
| 339 | }; | ||
| 340 | let number: u32 = nameplate | ||
| 341 | .strip_prefix("nameplate ") | ||
| 342 | .unwrap() | ||
| 343 | .parse() | ||
| 344 | .unwrap(); | ||
| 345 | let path = format!("/v1/room/code-{number}"); | ||
| 346 | let mut guess = relay.join(&path).unwrap(); | ||
| 347 | assert_eq!(guess.text(), "welcome 2 1"); | ||
| 348 | assert_eq!(say(ws::TEXT, "failed 2"), "HTTP/1.1 200 OK"); | ||
| 349 | assert!(guess.closed(Duration::from_secs(5))); | ||
| 350 | assert_eq!(say(ws::CLOSE, ""), "HTTP/1.1 410 Gone"); | ||
| 351 | let mut next = relay | ||
| 352 | .join(&format!("/v1/claim?nameplate={number}")) | ||
| 353 | .unwrap(); | ||
| 354 | assert_ne!(next.text(), format!("nameplate {number}")); | ||
| 355 | assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone"); | ||
| 356 | } | ||
| 357 | |||
| 302 | /// An owner coming back asks for its code's number again, and gets it while no one else | 358 | /// An owner coming back asks for its code's number again, and gets it while no one else |
| 303 | /// has claimed it. | 359 | /// has claimed it. |
| 304 | #[test] | 360 | #[test] |