Files
ngit-grasp/tests/common/setup_drop_relay.rs
T
DanConwayDev d36b1227cf feat(sync): keep GRASP-08 private services out of public sync
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.
2026-08-15 14:34:01 +00:00

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(());
}
}
}