mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
A public mirror gains nothing from dialing a GRASP-08 private service: the service will never admit it, and even attempting the connection performs an AUTH exchange with a relay that only wants members. Worse, retry machinery would hammer it indefinitely. Approach: public instances fetch the NIP-11 document before the WebSocket dial (`preflight_limit_hints`). When it advertises GRASP-08, the worker returns a new `ConnectAttemptOutcome::PrivateService` without dialing. The actor parks the relay in `private_service_relays` (warn once, stable message tests grep for), retires all sync state without the re-registration path, and both `register_relay` and `schedule_connect_relay` refuse parked targets thereafter. Private instances treat GRASP-08 peers as ordinary sync targets and skip the probe. Session limit hints deliberately keep coming from the existing post-connect fetch: reusing the pre-dial probe for hints would remove the post-connect setup window whose stale-success handling (disconnect during NIP-11 setup) is separately guaranteed and tested. Correctness assumptions: the pre-dial NIP-11 probe is an outbound TCP connection, so `preflight_limit_hints` re-runs the resolved outbound target policy for event-directed URLs and skips the HTTP request entirely on rejection - `connect()` then fails with the same policy rejection through its own gate (tests/outbound_policy.rs stays green). The park set is in-memory by design: a service that stops being private becomes reachable again after a process restart at the latest. Deliberately excluded: private-instance behavior toward GRASP-08 peers (NIP-98 credentials on git fetches) lands separately. Test infrastructure: `wait_for_log_line` moved from outbound_policy.rs into the shared sync helpers; TestRelay gained a sync constructor with identity publication disabled so log assertions about the bootstrap connection are not confounded by user-index traffic. SetupDropRelay now answers pre-dial NIP-11 probes directly and only runs its drop-during-setup choreography for a fetch that arrives during a live WebSocket session; the naughty-list scheduling test accounts for the probe as a second accepted connection on the first attempt. Validation: new tests/sync/outbound_auth.rs proves the park warning appears exactly once and that, across a 2s observation window, the private bootstrap relay is never connected to and no NIP-42 authentication occurs. cargo test --test sync -- sync::outbound_auth sync::stale_connect_result sync::naughty_list_scheduling and --test outbound_policy pass.
150 lines
5.2 KiB
Rust
150 lines
5.2 KiB
Rust
//! Relay fixture that disconnects the WebSocket during its NIP-11 setup fetch.
|
|
//!
|
|
//! Public syncing instances also probe NIP-11 *before* dialing (to detect
|
|
//! GRASP-08 private services); that pre-dial probe is answered immediately.
|
|
//! Only a NIP-11 request arriving while a WebSocket session is live triggers
|
|
//! the drop-during-setup choreography under test.
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use tokio::sync::{oneshot, watch};
|
|
|
|
/// Endpoint for reproducing a stale successful connection result.
|
|
pub struct SetupDropRelay {
|
|
url: String,
|
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
|
handle: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
impl SetupDropRelay {
|
|
pub async fn start() -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("SetupDropRelay failed to bind");
|
|
let port = listener.local_addr().expect("setup-drop local_addr").port();
|
|
let (nip11_tx, nip11_rx) = watch::channel(false);
|
|
let (dropped_tx, dropped_rx) = watch::channel(false);
|
|
let nip11_tx = Arc::new(nip11_tx);
|
|
let dropped_tx = Arc::new(dropped_tx);
|
|
let active_websockets = Arc::new(AtomicUsize::new(0));
|
|
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
accepted = listener.accept() => {
|
|
let Ok((stream, _)) = accepted else { break };
|
|
let nip11_tx = nip11_tx.clone();
|
|
let nip11_rx = nip11_rx.clone();
|
|
let dropped_tx = dropped_tx.clone();
|
|
let dropped_rx = dropped_rx.clone();
|
|
let active_websockets = active_websockets.clone();
|
|
tokio::spawn(async move {
|
|
handle_connection(
|
|
stream,
|
|
nip11_tx,
|
|
nip11_rx,
|
|
dropped_tx,
|
|
dropped_rx,
|
|
active_websockets,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
_ = &mut shutdown_rx => break,
|
|
}
|
|
}
|
|
});
|
|
|
|
Self {
|
|
url: format!("ws://127.0.0.1:{port}"),
|
|
shutdown_tx: Some(shutdown_tx),
|
|
handle: Some(handle),
|
|
}
|
|
}
|
|
|
|
pub fn url(&self) -> &str {
|
|
&self.url
|
|
}
|
|
|
|
pub async fn stop(mut self) {
|
|
if let Some(tx) = self.shutdown_tx.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
if let Some(handle) = self.handle.take() {
|
|
let _ = handle.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_connection(
|
|
mut stream: TcpStream,
|
|
nip11_tx: Arc<watch::Sender<bool>>,
|
|
mut nip11_rx: watch::Receiver<bool>,
|
|
dropped_tx: Arc<watch::Sender<bool>>,
|
|
mut dropped_rx: watch::Receiver<bool>,
|
|
active_websockets: Arc<AtomicUsize>,
|
|
) {
|
|
let mut header = [0_u8; 4096];
|
|
let header_len = loop {
|
|
let Ok(length) = stream.peek(&mut header).await else {
|
|
return;
|
|
};
|
|
if length == 0 {
|
|
return;
|
|
}
|
|
if header[..length]
|
|
.windows(4)
|
|
.any(|bytes| bytes == b"\r\n\r\n")
|
|
{
|
|
break length;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
};
|
|
let request = String::from_utf8_lossy(&header[..header_len]).to_ascii_lowercase();
|
|
|
|
if request.contains("upgrade: websocket") {
|
|
let Ok(_websocket) = tokio_tungstenite::accept_async(stream).await else {
|
|
return;
|
|
};
|
|
active_websockets.fetch_add(1, Ordering::SeqCst);
|
|
while !*nip11_rx.borrow() && nip11_rx.changed().await.is_ok() {}
|
|
// Dropping the live socket is the behavior under test. Give the SDK a
|
|
// bounded propagation window before allowing the HTTP setup to finish.
|
|
drop(_websocket);
|
|
active_websockets.fetch_sub(1, Ordering::SeqCst);
|
|
let _ = dropped_tx.send(true);
|
|
} else {
|
|
let mut request_bytes = vec![0_u8; header_len];
|
|
if stream.read_exact(&mut request_bytes).await.is_err() {
|
|
return;
|
|
}
|
|
// Pre-dial NIP-11 probes (no live WebSocket) are answered without
|
|
// choreography; only a fetch during a live session drops it.
|
|
if active_websockets.load(Ordering::SeqCst) > 0 {
|
|
let _ = nip11_tx.send(true);
|
|
while !*dropped_rx.borrow() && dropped_rx.changed().await.is_ok() {}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
let body = r#"{"limitation":{"max_subscriptions":20}}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
}
|
|
}
|
|
|
|
impl Drop for SetupDropRelay {
|
|
fn drop(&mut self) {
|
|
if let Some(tx) = self.shutdown_tx.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
}
|