diff --git a/locales/en.json b/locales/en.json index c3712f165..b6be7dcf8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2462,6 +2462,8 @@ "s_0740d167": "Keyboard & mouse", "s_0649e577": "Kraft", "s_9a7f4d8e": "L4S", + "s_network_test": "Network test", + "s_network_test_hint": "Measure this zone's UDP payload reachability before streaming · selected zones only", "s_4ddabc2b": "LB", "s_e75f32f3": "LOU", "s_b2fab4ce": "Launching the native media runtime…", diff --git a/native/opennow-core/Cargo.lock b/native/opennow-core/Cargo.lock index dd4e81d48..f541053d9 100644 --- a/native/opennow-core/Cargo.lock +++ b/native/opennow-core/Cargo.lock @@ -1395,6 +1395,7 @@ dependencies = [ "ed25519-dalek", "fs2", "hkdf", + "hmac", "httpdate", "keyring", "libc", diff --git a/native/opennow-core/Cargo.toml b/native/opennow-core/Cargo.toml index a02fa1f2d..a11822437 100644 --- a/native/opennow-core/Cargo.toml +++ b/native/opennow-core/Cargo.toml @@ -23,6 +23,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +hmac = "0.12" scrypt = "0.11" semver = "1" subtle = "2" diff --git a/native/opennow-core/src/cloudmatch.rs b/native/opennow-core/src/cloudmatch.rs index f467be179..c22635e12 100644 --- a/native/opennow-core/src/cloudmatch.rs +++ b/native/opennow-core/src/cloudmatch.rs @@ -6,7 +6,7 @@ use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_ use serde_json::{Value, json}; use std::collections::HashMap; use std::io::Read; -use std::net::IpAddr; +use std::net::{IpAddr, UdpSocket}; use std::path::PathBuf; use std::sync::{Mutex, MutexGuard}; use std::thread; @@ -19,6 +19,8 @@ const DEFAULT_STREAMING_BASE: &str = "https://prod.cloudmatchbeta.nvidiagrid.net const DEFAULT_STUN_SERVER: &str = "stun:s1.stun.gamestream.nvidia.com:19308"; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(12); const DISCOVERY_REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +const NETWORK_TEST_REQUEST_TIMEOUT: Duration = Duration::from_secs(8); +const MAXIMUM_NETWORK_TEST_RESPONSE_BYTES: u64 = 4 * 1024 * 1024; const MAX_DISCOVERY_REGIONS: usize = 32; const DISCOVERY_CONCURRENCY: usize = 4; const MAX_CLEANUP_RECORD_BYTES: usize = 16 * 1024; @@ -221,7 +223,30 @@ impl CloudMatchService { .lock() .expect("CloudMatch conflict state poisoned") = None; let token = session_token(auth); - let body = build_create_body(&app_id, params, settings, device_id); + let mut session_params = params.clone(); + let network_test = if requests_network_test(params, settings) { + acquire_network_test_session(&client, &base, token, device_id, params, settings) + } else { + json!({"status":"not_requested"}) + }; + match network_test["status"].as_str() { + Some("measured") => eprintln!( + "Network test measured path datagram {} bytes in {} probes", + network_test["measuredDatagramBytes"], network_test["probes"] + ), + Some("unmeasured") => eprintln!( + "Network test confirmed no probe datagram in {} probes", + network_test["probes"] + ), + Some("unavailable") => eprintln!( + "Network test session unavailable: {}", + network_test["error"].as_str().unwrap_or_default() + ), + _ => {} + } + crate::requests::check()?; + session_params["networkTestSessionId"] = json!(network_test["sessionId"].as_str()); + let body = build_create_body(&app_id, &session_params, settings, device_id); let mut url = base .join("v2/session") .map_err(|_| invalid("Invalid CloudMatch session URL"))?; @@ -248,6 +273,10 @@ impl CloudMatchService { .or_else(|| base.host_str().map(ToOwned::to_owned)) .unwrap_or_default(); let mut info = session_info(&payload, &base, &zone, &app_id, device_id)?; + if let Some(session_id) = network_test["sessionId"].as_str() { + info["networkTestSessionId"] = json!(session_id); + } + info["networkTest"] = network_test; *self .fresh .lock() @@ -1375,7 +1404,7 @@ fn build_create_body(app_id: &str, params: &Value, settings: &Value, device_id: "internalTitle":params["title"].as_str(), "availableSupportedControllers":[2], "preferredController":2, - "networkTestSessionId":null, + "networkTestSessionId":params["networkTestSessionId"].as_str(), "parentSessionId":null, "clientIdentification":"GFN-PC", "deviceHashId":device_id, @@ -2234,6 +2263,173 @@ fn validate_delete_response(context: &str, response: Response) -> Result<(), Ser Ok(()) } +fn requests_network_test(params: &Value, settings: &Value) -> bool { + params["networkTest"] + .as_bool() + .or_else(|| settings["networkTest"].as_bool()) + .unwrap_or(false) +} + +fn network_test_key_unavailable() -> ServiceError { + ServiceError { + code: "network-test-key-unavailable", + message: "The session did not provision a network test HMAC key; refusing to probe without verified key material".to_owned(), + } +} + +fn acquire_network_test_session( + client: &Client, + base: &Url, + token: &str, + device_id: &str, + params: &Value, + settings: &Value, +) -> Value { + match try_network_test_session(client, base, token, device_id, params, settings) { + Ok(value) => value, + Err(error) => json!({ + "status":"unavailable", + "code":error.code, + "error":error.message, + }), + } +} + +fn network_test_display_profile( + settings: &Value, + params: &Value, +) -> crate::network_test::DisplayProfile { + let (width, height) = parse_resolution(&setting_string(settings, "resolution", "1920x1080")); + crate::network_test::DisplayProfile { + width: u32::try_from(width).unwrap_or(1920), + height: u32::try_from(height).unwrap_or(1080), + fps: u32::try_from(crate::frame_rate::request_frame_rate( + settings, params, width, height, + )) + .unwrap_or(60), + } +} + +fn try_network_test_session( + client: &Client, + base: &Url, + token: &str, + device_id: &str, + params: &Value, + settings: &Value, +) -> Result { + let profile = network_test_display_profile(settings, params); + let url = crate::network_test::nettest_url(base)?; + let body = crate::network_test::allocation_body("GFN-PC", profile); + let mut headers = cloudmatch_headers(token, device_id)?; + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + crate::requests::check()?; + let response = client + .post(url) + .headers(headers) + .timeout(NETWORK_TEST_REQUEST_TIMEOUT) + .json(&body) + .send() + .map_err(|error| network("Network test session failed", error))?; + let status = response.status(); + if response.content_length().unwrap_or(0) > MAXIMUM_NETWORK_TEST_RESPONSE_BYTES { + return Err(ServiceError { + code: "network-test-rejected", + message: "Network test session response exceeded the size limit".to_owned(), + }); + } + let mut response = response; + let mut body_bytes = Vec::new(); + response + .by_ref() + .take(MAXIMUM_NETWORK_TEST_RESPONSE_BYTES + 1) + .read_to_end(&mut body_bytes) + .map_err(|error| network("Network test session failed", error))?; + if body_bytes.len() as u64 > MAXIMUM_NETWORK_TEST_RESPONSE_BYTES { + return Err(ServiceError { + code: "network-test-rejected", + message: "Network test session response exceeded the size limit".to_owned(), + }); + } + let payload = serde_json::from_slice::(&body_bytes); + if !status.is_success() { + let detail = payload + .ok() + .and_then(|payload| { + payload["requestStatus"]["statusDescription"] + .as_str() + .map(ToOwned::to_owned) + }) + .unwrap_or_default(); + return Err(ServiceError { + code: if matches!(status.as_u16(), 401 | 403) { + "network-test-unauthorized" + } else { + "network-test-rejected" + }, + message: format!( + "Network test session returned HTTP {} {detail}", + status.as_u16() + ), + }); + } + let payload = payload.map_err(|_| invalid("Network test session returned invalid JSON"))?; + let session = crate::network_test::parse_allocation(&payload)?; + let Some(key) = session.hmac_key.as_deref() else { + return Err(network_test_key_unavailable()); + }; + crate::requests::check()?; + let outcome = probe_network_test_path(&session, key)?; + crate::requests::check()?; + let Some(measured_datagram_bytes) = outcome.measured_datagram_bytes else { + return Ok(json!({ + "status":"unmeasured", + "probes":outcome.probes, + "error":"No probe datagram was confirmed on the measured path", + })); + }; + Ok(json!({ + "status":"measured", + "sessionId":session.session_id, + "serverId":session.server_id, + "zone":base.host_str().unwrap_or_default(), + "address":session.address, + "port":session.port, + "secure":session.secure, + "measuredDatagramBytes":measured_datagram_bytes, + "probes":outcome.probes, + "thresholds":{ + "bandwidthRecommendedMbps":session.thresholds.bandwidth_recommended_mbps, + "bandwidthLimitMbps":session.thresholds.bandwidth_limit_mbps, + "latencyRecommendedMs":session.thresholds.latency_recommended_ms, + "latencyLimitMs":session.thresholds.latency_limit_ms, + "packetLossRecommendedPct":session.thresholds.packet_loss_recommended_pct, + "packetLossLimitPct":session.thresholds.packet_loss_limit_pct, + }, + })) +} + +fn probe_network_test_path( + session: &crate::network_test::NetworkTestSession, + key: &[u8], +) -> Result { + let peer = std::net::SocketAddr::new(session.address, session.port); + let socket = UdpSocket::bind(if peer.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }) + .map_err(|_| invalid("Network test probe could not bind a UDP socket"))?; + crate::network_test::probe_mtu( + &socket, + peer, + key, + session.session_id.as_bytes(), + crate::network_test::PROBE_FLOOR_BYTES, + crate::network_test::PROBE_CEILING_BYTES, + ) +} + fn session_token(auth: &AuthSession) -> &str { auth.tokens .id_token @@ -4265,6 +4461,39 @@ mod tests { ); } + #[test] + fn the_network_test_profile_matches_the_session_profile() { + let hardware = json!({"protocolVersion":7, "videoBackends":[{"backend":"vaapi", + "available":true, "codecs":[{"codec":"h265", "available":true, + "colorQualities":["8bit_420"]}]}]}); + let software = json!({"protocolVersion":7, "videoBackends":[{"backend":"software", + "available":true, "codecs":[{"codec":"h265", "available":true, + "colorQualities":["8bit_420"]}]}]}); + let settings = json!({"resolution":"1920x1080", "fps":360, "codec":"h265"}); + for (capabilities, entitled, expected) in [ + (&hardware, 360_i64, 360_i64), + (&software, 360, 240), + (&json!({}), 360, 240), + (&hardware, 0, 240), + (&hardware, 120, 120), + ] { + let params = json!({"runtimeCapabilities":capabilities, "maxEntitledFps":entitled}); + let session = build_create_body("12345", ¶ms, &settings, "device-id"); + let session_fps = + session["sessionRequestData"]["clientRequestMonitorSettings"][0]["framesPerSecond"] + .clone(); + let profile = network_test_display_profile(&settings, ¶ms); + let allocation = crate::network_test::allocation_body("GFN-PC", profile); + assert_eq!(profile.width, 1920); + assert_eq!(profile.height, 1080); + assert_eq!( + allocation["netTestRequestData"]["netTestProfile"]["framesPerSecond"], session_fps, + "the allocation profile must match the session profile for {capabilities}" + ); + assert_eq!(session_fps, json!(expected), "{capabilities}"); + } + } + #[test] fn manual_av1_uses_native_nvst_even_with_a_legacy_transport_value() { let body = build_create_body( @@ -4765,4 +4994,590 @@ mod tests { assert!(trusted_learned_server_base("203.0.113.20").is_ok()); assert!(trusted_learned_server_base("2001:db8::20").is_ok()); } + + fn network_test_udp_server( + cap: u32, + key: &[u8], + session_id: &str, + ) -> (std::net::SocketAddr, thread::JoinHandle) { + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let address = socket.local_addr().unwrap(); + let key = key.to_vec(); + let session_id = session_id.as_bytes().to_vec(); + let worker = thread::spawn(move || { + let mut served = 0_usize; + let mut buffer = vec![0_u8; 4096]; + while let Ok((length, peer)) = socket.recv_from(&mut buffer) { + let Ok(request) = + crate::network_test::NetworkTestMessage::decode(&buffer[..length]) + else { + continue; + }; + let Some(size) = request.payload_size() else { + continue; + }; + if !request.verify(&key).unwrap_or(false) || size > cap { + continue; + } + let mut reply = crate::network_test::NetworkTestMessage::default(); + reply.set_message_type(crate::network_test::MESSAGE_TYPE_MTU_RESPONSE); + reply.set_session_id(session_id.clone()); + reply.set_payload_size(size); + let datagram = reply.encode_response(size as usize); + let _ = socket.send_to(&datagram, peer); + served += 1; + } + served + }); + (address, worker) + } + + #[test] + fn measured_network_test_session_reaches_allocation_and_the_session_context() { + use std::io::{BufRead, BufReader, Read, Write}; + use std::sync::{Arc, Mutex}; + + let key: [u8; 32] = [0x7e; 32]; + let (udp_address, udp_server) = network_test_udp_server(1_200, &key, "nt-1"); + + let allocation = json!({ + "requestStatus":{"requestId":"req-1","serverId":"zone-1","statusCode":0}, + "netTestSession":{ + "sessionId":"nt-1", + "serverId":"zone-1", + "hmacKey":"~".repeat(32), + "connectionInfo":[{ + "ip":udp_address.ip().to_string(), + "port":udp_address.port(), + "appLevelProtocol":5 + }], + "netTestThresholds":{ + "recommendedBandwidthMBPS":50.0, + "requiredBandwidthMBPS":25.0, + "recommendedLatencyMS":40.0, + "requiredLatencyMS":80.0, + "recommendedPacketLossPct":1.0, + "requiredPacketLossPct":3.0 + } + } + }); + let create_reply = json!({ + "requestStatus":{"statusCode":1}, + "session":{ + "sessionId":"seat-1", + "status":2, + "connectionInfo":[{ + "ip":"127.0.0.1","port":49_100,"usage":14,"resourcePath":"/nvst/" + }] + } + }); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let server_records = Arc::clone(&recorded); + let server = thread::spawn(move || { + for (status, body) in [ + (200_u16, allocation.to_string()), + (200, create_reply.to_string()), + (200, String::new()), + ] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut reader = BufReader::new(&stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let mut length = 0_usize; + let mut line = String::new(); + loop { + line.clear(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + } + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload).unwrap(); + server_records.lock().unwrap().push(( + request_line.trim().to_owned(), + String::from_utf8_lossy(&payload).into_owned(), + )); + write!( + stream, + "HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + }); + + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let service = CloudMatchService::new(client.clone()); + let created = service + .create_at( + &json!({"appId":"123", "networkTest":true}), + &json!({}), + &conflict_auth(), + "device", + || Ok((client, base)), + ) + .unwrap(); + + let info = &created["session"]; + assert_eq!(info["sessionId"], "seat-1"); + assert_eq!(info["networkTest"]["sessionId"], "nt-1"); + assert_eq!(info["networkTest"]["status"], "measured"); + assert_eq!(info["networkTestSessionId"], "nt-1"); + assert_eq!(info["networkTest"]["zone"], "127.0.0.1"); + let measured = info["networkTest"]["measuredDatagramBytes"] + .as_u64() + .expect("measured datagram size"); + assert!(measured <= 1_200, "measured {measured}"); + assert!(measured + 32 >= 1_200, "measured {measured}"); + assert!(info["networkTest"]["probes"].as_u64().unwrap_or_default() > 0); + + server.join().unwrap(); + let received = recorded.lock().unwrap().clone(); + assert_eq!(received.len(), 3); + assert!(received[0].0.starts_with("POST /v2/nettestsession")); + assert!(received[0].1.contains("\"clientPlatformName\"")); + assert!(received[1].0.starts_with("POST /v2/session")); + assert!( + received[1].1.contains("\"networkTestSessionId\":\"nt-1\""), + "allocation body carries the measured session: {}", + received[1].1 + ); + assert!(received[2].0.starts_with("PUT /v2/session/seat-1")); + + assert!( + udp_server.join().unwrap() > 0, + "the probe never reached the authenticated server" + ); + } + + #[test] + fn a_session_without_a_response_key_refuses_to_probe() { + let (base, server) = session_server( + vec![ + ( + 200, + json!({"netTestSession":{ + "sessionId":"nt-nokey", + "connectionInfo":[{ + "ip":"127.0.0.1","port":49_100,"appLevelProtocol":5 + }], + "netTestThresholds":{ + "recommendedBandwidthMBPS":50.0,"requiredBandwidthMBPS":25.0, + "recommendedLatencyMS":40.0,"requiredLatencyMS":80.0, + "recommendedPacketLossPct":1.0,"requiredPacketLossPct":3.0 + } + }}), + ), + ( + 200, + json!({"requestStatus":{"statusCode":1},"session":{"sessionId":"B","status":2}}), + ), + (200, json!({})), + ], + |_| {}, + ); + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let service = CloudMatchService::new(client.clone()); + let created = service + .create_at( + &json!({"appId":"123", "networkTest":true}), + &json!({}), + &conflict_auth(), + "device", + || Ok((client, base)), + ) + .unwrap(); + assert_eq!(created["session"]["networkTest"]["status"], "unavailable"); + assert_eq!( + created["session"]["networkTest"]["code"], + "network-test-key-unavailable" + ); + assert!( + created["session"]["networkTestSessionId"].is_null(), + "no session is advertised without a verified measurement" + ); + let received = server.join().unwrap(); + assert_eq!(received.len(), 3); + assert!( + received[0].starts_with("POST /v2/nettestsession"), + "{}", + received[0] + ); + assert!(received[1].starts_with("POST /v2/session")); + assert!(received[2].starts_with("PUT /v2/session/B")); + } + + #[test] + fn a_stalled_network_test_allocation_times_out() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + let worker = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + thread::sleep(Duration::from_secs(12)); + drop(stream); + }); + let client = Client::builder().no_proxy().build().unwrap(); + let error = + try_network_test_session(&client, &base, "token", "device", &json!({}), &json!({})) + .unwrap_err(); + assert_eq!(error.code, "network_error"); + worker.join().unwrap(); + } + + #[test] + fn a_cancelled_request_never_reaches_the_network_test_allocation() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = Client::builder().no_proxy().build().unwrap(); + let requests = std::sync::Arc::new(crate::requests::Requests::default()); + let permit = requests.admit("nettest", "session.create").unwrap(); + requests.cancel("nettest"); + let result = crate::requests::scope(permit.token.clone(), || { + try_network_test_session(&client, &base, "token", "device", &json!({}), &json!({})) + }); + assert_eq!(result.unwrap_err().code, "cancelled"); + assert!( + matches!(listener.accept(), Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock), + "a cancelled request must not open a connection" + ); + } + + #[test] + fn the_network_test_setting_enables_the_probe() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + let key: [u8; 32] = [0x7e; 32]; + let (udp_address, udp_server) = network_test_udp_server(1_200, &key, "nt-1"); + let allocation = json!({ + "netTestSession":{ + "sessionId":"nt-1", + "serverId":"zone-1", + "hmacKey":"~".repeat(32), + "connectionInfo":[{ + "ip":udp_address.ip().to_string(), + "port":udp_address.port(), + "appLevelProtocol":5 + }], + "netTestThresholds":{ + "recommendedBandwidthMBPS":50.0,"requiredBandwidthMBPS":25.0, + "recommendedLatencyMS":40.0,"requiredLatencyMS":80.0, + "recommendedPacketLossPct":1.0,"requiredPacketLossPct":3.0 + } + } + }); + let create_reply = json!({ + "requestStatus":{"statusCode":1}, + "session":{"sessionId":"seat-1","status":2} + }); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let server_records = Arc::clone(&recorded); + let server = thread::spawn(move || { + for (status, body) in [ + (200_u16, allocation.to_string()), + (200, create_reply.to_string()), + (200, String::new()), + ] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut reader = std::io::BufReader::new(&stream); + let mut request_line = String::new(); + std::io::BufRead::read_line(&mut reader, &mut request_line).unwrap(); + let mut length = 0_usize; + let mut line = String::new(); + loop { + line.clear(); + assert!(std::io::BufRead::read_line(&mut reader, &mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + } + server_records + .lock() + .unwrap() + .push(request_line.trim().to_owned()); + let mut payload = vec![0_u8; length]; + std::io::Read::read_exact(&mut reader, &mut payload).unwrap(); + write!( + stream, + "HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + }); + + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let service = CloudMatchService::new(client.clone()); + let created = service + .create_at( + &json!({"appId":"123"}), + &json!({"networkTest":true}), + &conflict_auth(), + "device", + || Ok((client, base)), + ) + .unwrap(); + assert_eq!(created["session"]["networkTest"]["status"], "measured"); + assert_eq!(created["session"]["networkTestSessionId"], "nt-1"); + server.join().unwrap(); + let received = recorded.lock().unwrap().clone(); + assert_eq!(received.len(), 3); + assert!( + received[0].starts_with("POST /v2/nettestsession"), + "{}", + received[0] + ); + assert!(udp_server.join().unwrap() > 0, "the setting never probed"); + } + + #[test] + fn a_zone_without_network_test_keeps_the_previous_allocation_body() { + let (base, server) = session_server( + vec![ + ( + 200, + json!({"requestStatus":{"statusCode":1},"session":{"sessionId":"A","status":1}}), + ), + (200, json!({})), + ], + |_| {}, + ); + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let service = CloudMatchService::new(client.clone()); + let created = service + .create_at( + &json!({"appId":"123"}), + &json!({}), + &conflict_auth(), + "device", + || Ok((client, base)), + ) + .unwrap(); + assert_eq!(created["session"]["networkTest"]["status"], "not_requested"); + assert!(created["session"]["networkTestSessionId"].is_null()); + server.join().unwrap(); + } + + #[test] + fn an_oversized_chunked_allocation_response_is_rejected() { + use std::io::Write; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0_u8; 4096]; + let _ = std::io::Read::read(&mut stream, &mut request); + let _ = stream.write_all( + b"HTTP/1.1 200 Fixture\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", + ); + let chunk = vec![b'x'; 64 * 1024]; + let mut written = 0_usize; + while written <= MAXIMUM_NETWORK_TEST_RESPONSE_BYTES as usize { + let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes()); + let _ = stream.write_all(&chunk); + let _ = stream.write_all(b"\r\n"); + written += chunk.len(); + } + }); + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let error = + try_network_test_session(&client, &base, "token", "device", &json!({}), &json!({})) + .unwrap_err(); + assert_eq!(error.code, "network-test-rejected", "{}", error.message); + assert!(error.message.contains("size limit"), "{}", error.message); + worker.join().unwrap(); + } + + #[test] + fn the_persisted_network_test_setting_reaches_session_creation() { + let directory = tempfile::tempdir().unwrap(); + let mut store = + crate::settings::SettingsStore::load(Some(directory.path().to_path_buf())).unwrap(); + assert_eq!(store.all()["networkTest"], false, "the setting ships off"); + + let key: [u8; 32] = [0x7e; 32]; + let (udp_address, udp_server) = network_test_udp_server(1_200, &key, "nt-1"); + let allocation = json!({ + "netTestSession":{ + "sessionId":"nt-1", + "serverId":"zone-1", + "hmacKey":"~".repeat(32), + "connectionInfo":[{ + "ip":udp_address.ip().to_string(), + "port":udp_address.port(), + "appLevelProtocol":5 + }], + "netTestThresholds":{ + "recommendedBandwidthMBPS":50.0,"requiredBandwidthMBPS":25.0, + "recommendedLatencyMS":40.0,"requiredLatencyMS":80.0, + "recommendedPacketLossPct":1.0,"requiredPacketLossPct":3.0 + } + } + }); + let (base, server) = session_server( + vec![ + ( + 200, + json!({"requestStatus":{"statusCode":1},"session":{"sessionId":"A","status":1}}), + ), + (200, json!({})), + (200, json!({})), + (200, allocation), + ( + 200, + json!({"requestStatus":{"statusCode":1},"session":{"sessionId":"B","status":2}}), + ), + (200, json!({})), + ], + |_| {}, + ); + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let service = CloudMatchService::new(client.clone()); + + let created = service + .create_at( + &json!({"appId":"123"}), + &store.all(), + &conflict_auth(), + "device", + || Ok((client.clone(), base.clone())), + ) + .unwrap(); + assert_eq!(created["session"]["networkTest"]["status"], "not_requested"); + service.finish_create("A", false).unwrap(); + + store.set("networkTest", json!(true)).unwrap(); + let restored = + crate::settings::SettingsStore::load(Some(directory.path().to_path_buf())).unwrap(); + assert_eq!(restored.all()["networkTest"], true, "the setting persists"); + + let created = service + .create_at( + &json!({"appId":"123"}), + &restored.all(), + &conflict_auth(), + "device", + || Ok((client, base)), + ) + .unwrap(); + assert_eq!(created["session"]["networkTest"]["status"], "measured"); + assert_eq!(created["session"]["networkTestSessionId"], "nt-1"); + + let received = server.join().unwrap(); + assert_eq!(received.len(), 6, "{received:?}"); + assert!(received[0].starts_with("POST /v2/session"), "{received:?}"); + let probe = received + .iter() + .position(|line| line.starts_with("POST /v2/nettestsession")) + .expect("the opt-in probe runs"); + assert_eq!( + probe, 3, + "the default-off create must not probe: {received:?}" + ); + assert!(udp_server.join().unwrap() > 0); + } + + #[test] + fn a_path_without_a_confirmed_datagram_is_reported_unmeasured() { + let silent = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let silent_address = silent.local_addr().unwrap(); + let (base, server) = session_server( + vec![ + ( + 200, + json!({"netTestSession":{ + "sessionId":"nt-silent", + "serverId":"zone-1", + "hmacKey":"~".repeat(32), + "connectionInfo":[{ + "ip":silent_address.ip().to_string(), + "port":silent_address.port(), + "appLevelProtocol":5 + }], + "netTestThresholds":{ + "recommendedBandwidthMBPS":50.0,"requiredBandwidthMBPS":25.0, + "recommendedLatencyMS":40.0,"requiredLatencyMS":80.0, + "recommendedPacketLossPct":1.0,"requiredPacketLossPct":3.0 + } + }}), + ), + ( + 200, + json!({"requestStatus":{"statusCode":1},"session":{"sessionId":"A","status":2}}), + ), + (200, json!({})), + ], + |_| {}, + ); + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let service = CloudMatchService::new(client.clone()); + let created = service + .create_at( + &json!({"appId":"123", "networkTest":true}), + &json!({}), + &conflict_auth(), + "device", + || Ok((client, base)), + ) + .unwrap(); + let measured = &created["session"]["networkTest"]; + assert_eq!(measured["status"], "unmeasured"); + assert!(measured["measuredDatagramBytes"].is_null()); + assert!(measured["probes"].as_u64().unwrap_or_default() > 0); + assert!( + created["session"]["networkTestSessionId"].is_null(), + "an unconfirmed path must not advertise an unmeasured session" + ); + assert_eq!(created["session"]["sessionId"], "A"); + server.join().unwrap(); + } } diff --git a/native/opennow-core/src/main.rs b/native/opennow-core/src/main.rs index 9c2f6c53a..d9988abaf 100644 --- a/native/opennow-core/src/main.rs +++ b/native/opennow-core/src/main.rs @@ -15,6 +15,7 @@ mod gfn; mod language; mod media; mod network; +mod network_test; mod persistent_storage; mod proxy; mod push_registry; diff --git a/native/opennow-core/src/network_test.rs b/native/opennow-core/src/network_test.rs new file mode 100644 index 000000000..d199de7d9 --- /dev/null +++ b/native/opennow-core/src/network_test.rs @@ -0,0 +1,1398 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; +#[cfg(test)] +use subtle::ConstantTimeEq; + +use crate::gfn::ServiceError; + +pub const MESSAGE_TYPE_MTU_PROBE: u32 = 7; +pub const MESSAGE_TYPE_MTU_RESPONSE: u32 = 8; +pub const MAC_LEN: usize = 32; + +pub const REPLY_PREFIX_THRESHOLD: usize = 484; + +const MAX_MESSAGE_BYTES: usize = 64 * 1024; +const MAX_FIELDS: usize = 64; +const MAX_STRING_BYTES: usize = 16 * 1024; +const MAX_SESSION_KEY_BYTES: usize = 4096; +const MAX_SESSION_ID_BYTES: usize = 256; + +#[derive(Debug, Clone, PartialEq)] +pub enum WireValue { + Varint(u64), + Fixed64(u64), + Fixed32(u32), + Bytes(Vec), +} + +pub const TYPE_FIELD: u32 = 1; +pub const SESSION_ID_FIELD: u32 = 2; +pub const MAC_FIELD: u32 = 10; +pub const SEQUENCE_FIELD: u32 = 12; +pub const PAYLOAD_SIZE_FIELD: u32 = 13; +pub const KNOWN_FIELD_MAX: u32 = 18; + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct NetworkTestMessage { + known: [Option; (KNOWN_FIELD_MAX + 1) as usize], + unknown: Vec<(u32, WireValue)>, +} + +impl NetworkTestMessage { + pub fn mtu_probe(payload_size: u32, session_id: &[u8], sequence: u32) -> Self { + let mut message = Self::default(); + message.set_message_type(MESSAGE_TYPE_MTU_PROBE); + message.set_session_id(session_id.to_vec()); + message.set_sequence(sequence); + message.set_payload_size(payload_size); + message + } + + pub fn message_type(&self) -> u32 { + match self.known[TYPE_FIELD as usize] { + Some(WireValue::Varint(raw)) => raw as u32, + _ => 0, + } + } + + pub fn set_message_type(&mut self, value: u32) { + self.known[TYPE_FIELD as usize] = Some(WireValue::Varint(u64::from(value))); + } + + pub fn payload_size(&self) -> Option { + match self.known[PAYLOAD_SIZE_FIELD as usize] { + Some(WireValue::Varint(raw)) => u32::try_from(raw).ok(), + _ => None, + } + } + + pub fn set_payload_size(&mut self, value: u32) { + self.known[PAYLOAD_SIZE_FIELD as usize] = Some(WireValue::Varint(u64::from(value))); + } + + pub fn session_id(&self) -> &[u8] { + match &self.known[SESSION_ID_FIELD as usize] { + Some(WireValue::Bytes(raw)) => raw, + _ => &[], + } + } + + pub fn set_session_id(&mut self, value: Vec) { + self.known[SESSION_ID_FIELD as usize] = Some(WireValue::Bytes(value)); + } + + #[cfg(test)] + pub fn sequence(&self) -> Option { + match self.known[SEQUENCE_FIELD as usize] { + Some(WireValue::Varint(raw)) => u32::try_from(raw).ok(), + _ => None, + } + } + + pub fn set_sequence(&mut self, value: u32) { + self.known[SEQUENCE_FIELD as usize] = Some(WireValue::Varint(u64::from(value))); + } + + #[cfg(test)] + pub fn mac(&self) -> &[u8] { + match &self.known[MAC_FIELD as usize] { + Some(WireValue::Bytes(raw)) => raw, + _ => &[], + } + } + + pub fn set_mac(&mut self, value: Vec) { + self.known[MAC_FIELD as usize] = Some(WireValue::Bytes(value)); + } + + #[cfg(test)] + pub fn unknown(&self) -> &[(u32, WireValue)] { + &self.unknown + } + + #[cfg(test)] + pub fn push_unknown(&mut self, number: u32, value: WireValue) { + self.unknown.push((number, value)); + } + + pub fn encode(&self) -> Vec { + let mut body = Vec::new(); + self.encode_body(&mut body, true); + body + } + + #[cfg(test)] + pub fn encode_response(&self, datagram_bytes: usize) -> Vec { + let body = self.encode(); + let prefix = u32::try_from(body.len()).unwrap_or(u32::MAX).to_le_bytes(); + let mut datagram = Vec::with_capacity(datagram_bytes.max(body.len() + 4)); + datagram.extend_from_slice(&prefix); + datagram.extend_from_slice(&body); + datagram.resize(datagram_bytes.max(datagram.len()), 0x5a); + datagram + } + + fn encode_body(&self, out: &mut Vec, include_mac: bool) { + for number in 1..=KNOWN_FIELD_MAX { + let Some(value) = &self.known[number as usize] else { + continue; + }; + if number == MAC_FIELD && !include_mac { + continue; + } + write_tag(out, number, wire_type(value)); + write_value(out, value); + } + for (number, value) in &self.unknown { + write_tag(out, *number, wire_type(value)); + write_value(out, value); + } + } + + pub fn authenticated(&self, key: &[u8]) -> Result, ServiceError> { + let mut body = Vec::new(); + self.encode_body(&mut body, false); + Ok(compute_mac(key, &body)?.to_vec()) + } + + pub fn seal(&mut self, key: &[u8]) -> Result<(), ServiceError> { + let mac = self.authenticated(key)?; + self.set_mac(mac); + Ok(()) + } + + #[cfg(test)] + pub fn verify(&self, key: &[u8]) -> Result { + let mac = self.mac(); + if mac.len() != MAC_LEN { + return Ok(false); + } + let expected = self.authenticated(key)?; + Ok(bool::from(expected.as_slice().ct_eq(mac))) + } + + pub fn decode_reply(datagram: &[u8]) -> Result { + if datagram.len() >= REPLY_PREFIX_THRESHOLD { + Self::decode_response(datagram) + } else { + Self::decode(datagram) + } + } + + pub fn decode_response(datagram: &[u8]) -> Result { + if datagram.len() > MAX_MESSAGE_BYTES { + return Err(invalid("network test message is too large")); + } + let prefix = datagram + .get(..4) + .ok_or_else(|| invalid("network test message is truncated"))?; + let body_len = usize::try_from(u32::from_le_bytes(prefix.try_into().expect("four bytes"))) + .map_err(|_| invalid("network test length"))?; + let end = 4_usize + .checked_add(body_len) + .filter(|end| *end <= datagram.len()) + .ok_or_else(|| invalid("network test message is truncated"))?; + Self::decode(&datagram[4..end]) + } + + pub fn decode(body: &[u8]) -> Result { + if body.len() > MAX_MESSAGE_BYTES { + return Err(invalid("network test message is too large")); + } + let end = body.len(); + let mut offset = 0_usize; + let mut message = Self::default(); + let mut fields = 0_usize; + while offset < end { + fields += 1; + if fields > MAX_FIELDS { + return Err(invalid("network test message has too many fields")); + } + let (tag, next) = read_varint(body.get(offset..).unwrap_or_default())?; + offset += next; + let number = + u32::try_from(tag >> 3).map_err(|_| invalid("network test field number"))?; + if number == 0 { + return Err(invalid("network test field number is zero")); + } + let value = decode_value(body, &mut offset, tag & 0x7)?; + match known_wire_type(number) { + Some(expected) if expected == tag & 0x7 => { + message.known[number as usize] = Some(value); + } + _ => message.unknown.push((number, value)), + } + } + Ok(message) + } +} + +fn known_wire_type(number: u32) -> Option { + match number { + 1 | 6 | 8 | 9 | 12 | 13 | 14 | 16 | 17 | 18 => Some(0), + 2 | 3 | 5 | 7 | 10 => Some(2), + 11 | 15 => Some(5), + _ => None, + } +} + +fn decode_value(body: &[u8], offset: &mut usize, wire: u64) -> Result { + Ok(match wire { + 0 => { + let (value, next) = read_varint(body.get(*offset..).unwrap_or_default())?; + *offset += next; + WireValue::Varint(value) + } + 1 => { + let raw = take(body, *offset, 8)?; + *offset += 8; + WireValue::Fixed64(u64::from_le_bytes(raw.try_into().expect("eight bytes"))) + } + 2 => { + let (len, next) = read_varint(body.get(*offset..).unwrap_or_default())?; + *offset += next; + let len = usize::try_from(len).map_err(|_| invalid("network test length"))?; + if len > MAX_STRING_BYTES { + return Err(invalid("network test field is too large")); + } + let raw = take(body, *offset, len)?; + *offset += len; + WireValue::Bytes(raw.to_vec()) + } + 5 => { + let raw = take(body, *offset, 4)?; + *offset += 4; + WireValue::Fixed32(u32::from_le_bytes(raw.try_into().expect("four bytes"))) + } + _ => return Err(invalid("network test field type is unsupported")), + }) +} + +fn wire_type(value: &WireValue) -> u64 { + match value { + WireValue::Varint(_) => 0, + WireValue::Fixed64(_) => 1, + WireValue::Bytes(_) => 2, + WireValue::Fixed32(_) => 5, + } +} + +fn write_tag(out: &mut Vec, number: u32, wire: u64) { + write_varint(out, (u64::from(number) << 3) | wire); +} + +fn write_value(out: &mut Vec, value: &WireValue) { + match value { + WireValue::Varint(raw) => write_varint(out, *raw), + WireValue::Fixed64(raw) => out.extend_from_slice(&raw.to_le_bytes()), + WireValue::Fixed32(raw) => out.extend_from_slice(&raw.to_le_bytes()), + WireValue::Bytes(raw) => { + write_varint(out, raw.len() as u64); + out.extend_from_slice(raw); + } + } +} + +fn write_varint(out: &mut Vec, mut value: u64) { + while value >= 0x80 { + out.push((value as u8) | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +fn read_varint(input: &[u8]) -> Result<(u64, usize), ServiceError> { + let mut value = 0_u64; + let mut shift = 0_u32; + for (index, byte) in input.iter().enumerate().take(10) { + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok((value, index + 1)); + } + shift += 7; + } + Err(invalid("network test varint is malformed")) +} + +fn take(input: &[u8], offset: usize, len: usize) -> Result<&[u8], ServiceError> { + input + .get(offset..offset + len) + .ok_or_else(|| invalid("network test message is truncated")) +} + +fn compute_mac(key: &[u8], body: &[u8]) -> Result<[u8; MAC_LEN], ServiceError> { + if key.is_empty() || key.len() > 4096 { + return Err(invalid("network test key length is invalid")); + } + let mut mac = as Mac>::new_from_slice(key) + .map_err(|_| invalid("network test key is invalid"))?; + mac.update(body); + let mut tag = [0_u8; MAC_LEN]; + tag.copy_from_slice(&mac.finalize().into_bytes()); + Ok(tag) +} + +fn invalid(message: &str) -> ServiceError { + ServiceError { + code: "network-test-invalid", + message: message.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [0x5a; 32]; + + #[test] + fn mtu_probe_encodes_the_verified_field_numbers() { + let message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + let encoded = message.encode(); + assert_eq!( + encoded, + vec![ + 0x08, 0x07, 0x12, 0x04, b'n', b't', b'-', b'1', 0x60, 0x01, 0x68, 0x80, 0x0a, + ], + "bare protobuf: type 7, session id, sequence and payload size" + ); + assert_eq!(message.session_id(), b"nt-1"); + assert_eq!(message.sequence(), Some(1)); + } + + #[test] + fn mac_is_field_ten_and_covers_the_body_without_it() { + let mut message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + let unsealed_body = message.encode(); + message.seal(&KEY).unwrap(); + let body = message.encode(); + + assert_eq!(&body[..2], &[0x08, 0x07], "field 1 carries the type"); + assert_eq!(message.mac().len(), MAC_LEN); + let expected = compute_mac(&KEY, &unsealed_body).unwrap(); + assert_eq!(message.mac(), expected.as_slice()); + + let mac_at = body + .windows(2) + .position(|pair| pair == [0x52, 0x20]) + .expect("field 10 tag"); + let mut without_mac = body.clone(); + without_mac.drain(mac_at..mac_at + 2 + MAC_LEN); + assert_eq!( + without_mac, unsealed_body, + "sealing only inserts field 10 and leaves the covered body intact" + ); + assert_eq!( + &body[mac_at + 2..mac_at + 2 + MAC_LEN], + expected.as_slice(), + "mac covers the body without itself" + ); + } + + #[test] + fn response_framing_is_a_length_prefix_then_body_then_padding() { + let mut message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + message.seal(&KEY).unwrap(); + let body = message.encode(); + let datagram = message.encode_response(1_300); + assert_eq!( + datagram.len(), + 1_300, + "datagram is exactly the promised size" + ); + assert_eq!( + u32::from_le_bytes(datagram[..4].try_into().unwrap()) as usize, + body.len(), + "prefix is the little-endian protobuf body length" + ); + assert_eq!(&datagram[4..4 + body.len()], body.as_slice()); + + let decoded = NetworkTestMessage::decode_response(&datagram).unwrap(); + assert_eq!(decoded, message); + assert!(decoded.verify(&KEY).unwrap()); + assert_eq!(decoded.payload_size(), Some(1_280)); + } + + #[test] + fn a_sealed_message_verifies_and_rejects_tampering() { + let mut message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + message.seal(&KEY).unwrap(); + assert!(message.verify(&KEY).unwrap()); + + let mut changed = message.clone(); + changed.set_payload_size(1_279); + assert!(!changed.verify(&KEY).unwrap()); + + let mut rekeyed = message.clone(); + assert!(!rekeyed.verify(&[0x11; 32]).unwrap()); + + let mut tampered = rekeyed.mac().to_vec(); + tampered[0] ^= 0x01; + rekeyed.set_mac(tampered); + assert!(!rekeyed.verify(&KEY).unwrap()); + } + + #[test] + fn decoding_round_trips_unknown_fields() { + let mut message = NetworkTestMessage::mtu_probe(1_500, b"nt-1", 1); + message.push_unknown(4, WireValue::Bytes(b"session".to_vec())); + message.push_unknown(19, WireValue::Fixed32(1.5_f32.to_bits())); + message.push_unknown(20, WireValue::Varint(9)); + message.seal(&KEY).unwrap(); + + let decoded = NetworkTestMessage::decode(&message.encode()).unwrap(); + assert_eq!(decoded, message); + assert!(decoded.verify(&KEY).unwrap()); + } + + #[test] + fn decode_rejects_malformed_and_oversized_input() { + assert!(NetworkTestMessage::decode(&[0x7f, 0x08, 0x07]).is_err()); + let oversized = vec![0xff; MAX_MESSAGE_BYTES + 1]; + assert!(NetworkTestMessage::decode(&oversized).is_err()); + + assert!(NetworkTestMessage::decode_response(&[0x05, 0x00]).is_err()); + assert!( + NetworkTestMessage::decode_response(&[0xff, 0xff, 0xff, 0xff, 0x08]).is_err(), + "declared body length must fit in the datagram" + ); + let truncated = { + let mut message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + message.seal(&KEY).unwrap(); + let mut datagram = message.encode_response(1_300); + datagram.truncate(datagram.len() - 30); + datagram + }; + assert!( + NetworkTestMessage::decode_response(&truncated).is_ok(), + "padding is outside the prefixed body" + ); + } + + #[test] + fn sealing_fails_closed_without_usable_key_material() { + let mut message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + assert!(message.seal(&[]).is_err()); + assert!(message.authenticated(&[]).is_err()); + assert_eq!(message.mac(), &[] as &[u8]); + assert!(!message.verify(&[]).unwrap()); + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NetworkTestThresholds { + pub bandwidth_recommended_mbps: f64, + pub bandwidth_limit_mbps: f64, + pub latency_recommended_ms: f64, + pub latency_limit_ms: f64, + pub packet_loss_recommended_pct: f64, + pub packet_loss_limit_pct: f64, +} + +#[derive(Clone, PartialEq)] +pub struct NetworkTestSession { + pub session_id: String, + pub server_id: String, + pub address: std::net::IpAddr, + pub port: u16, + pub secure: bool, + pub hmac_key: Option>, + pub thresholds: NetworkTestThresholds, +} + +impl std::fmt::Debug for NetworkTestSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NetworkTestSession") + .field("session_id", &self.session_id) + .field("server_id", &self.server_id) + .field("address", &self.address) + .field("port", &self.port) + .field("secure", &self.secure) + .field("hmac_key", &self.hmac_key.as_ref().map(|_| "[redacted]")) + .field("thresholds", &self.thresholds) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProbeOutcome { + pub measured_datagram_bytes: Option, + pub probes: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DisplayProfile { + pub width: u32, + pub height: u32, + pub fps: u32, +} + +pub fn allocation_body(platform: &str, profile: DisplayProfile) -> serde_json::Value { + let DisplayProfile { width, height, fps } = profile; + serde_json::json!({ + "netTestRequestData": { + "clientPlatformName": platform, + "netTestProfile": { + "widthInPixels": width, + "heightInPixels": height, + "framesPerSecond": fps, + }, + } + }) +} + +pub fn parse_allocation(response: &serde_json::Value) -> Result { + let session = response + .get("netTestSession") + .ok_or_else(|| invalid("network test response has no session"))?; + let session_id = session + .get("sessionId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= MAX_SESSION_ID_BYTES) + .ok_or_else(|| invalid("network test session has no id"))? + .to_owned(); + let server_id = session + .get("serverId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + let connection = session + .get("connectionInfo") + .and_then(serde_json::Value::as_array) + .and_then(|entries| entries.first()) + .ok_or_else(|| invalid("network test session has no connection info"))?; + let address = connection + .get("ip") + .and_then(serde_json::Value::as_str) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| invalid("network test session has no literal address"))?; + let port = connection + .get("port") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| *value != 0) + .ok_or_else(|| invalid("network test session has no port"))?; + let secure = connection + .get("appLevelProtocol") + .and_then(serde_json::Value::as_u64) + == Some(5); + let hmac_key = match session.get("hmacKey") { + None | Some(serde_json::Value::Null) => None, + Some(value) => Some(parse_session_key(value)?), + }; + let thresholds = session + .get("netTestThresholds") + .ok_or_else(|| invalid("network test session has no thresholds"))?; + let number = |key: &str| { + thresholds + .get(key) + .and_then(serde_json::Value::as_f64) + .unwrap_or_default() + }; + Ok(NetworkTestSession { + session_id, + server_id, + address, + port, + secure, + hmac_key, + thresholds: NetworkTestThresholds { + bandwidth_recommended_mbps: number("recommendedBandwidthMBPS"), + bandwidth_limit_mbps: number("requiredBandwidthMBPS"), + latency_recommended_ms: number("recommendedLatencyMS"), + latency_limit_ms: number("requiredLatencyMS"), + packet_loss_recommended_pct: number("recommendedPacketLossPct"), + packet_loss_limit_pct: number("requiredPacketLossPct"), + }, + }) +} + +fn parse_session_key(value: &serde_json::Value) -> Result, ServiceError> { + let raw = value + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| invalid("network test session key is not a string"))?; + let bytes = raw.as_bytes(); + if bytes.len() > MAX_SESSION_KEY_BYTES { + return Err(invalid("network test session key is too large")); + } + if bytes.is_empty() || bytes.contains(&0) { + return Err(invalid("network test session key is not usable")); + } + Ok(bytes.to_vec()) +} + +pub fn nettest_url(base: &url::Url) -> Result { + let host = base.host_str().unwrap_or_default(); + if host.is_empty() { + return Err(invalid("network test zone has no host")); + } + let loopback = matches!(host, "127.0.0.1" | "::1" | "localhost"); + if base.scheme() != "https" && !(loopback && base.scheme() == "http") { + return Err(invalid("network test zone must use https")); + } + base.join("v2/nettestsession") + .map_err(|_| invalid("invalid network test session URL")) +} + +pub const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_millis(400); +pub const PROBE_FLOOR_BYTES: u32 = 548; +pub const PROBE_CEILING_BYTES: u32 = 1_472; +const PROBE_ATTEMPT_WAIT: std::time::Duration = std::time::Duration::from_millis(40); +const MAX_PROBES: usize = 12; +const PROBE_STEP: u32 = 16; + +fn reply_is_accepted(reply: &NetworkTestMessage, session_id: &[u8], size: u32) -> bool { + reply.message_type() == MESSAGE_TYPE_MTU_RESPONSE + && reply.session_id() == session_id + && reply.payload_size() == Some(size) +} + +fn confirm_datagram( + socket: &std::net::UdpSocket, + peer: std::net::SocketAddr, + key: &[u8], + session_id: &[u8], + sequence: u32, + size: u32, + deadline: std::time::Instant, +) -> bool { + let mut message = NetworkTestMessage::mtu_probe(size, session_id, sequence); + if message.seal(key).is_err() { + return false; + } + if socket.send_to(&message.encode(), peer).is_err() { + return false; + } + let mut buffer = vec![0_u8; MAX_MESSAGE_BYTES]; + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let wait = remaining + .min(PROBE_ATTEMPT_WAIT) + .max(std::time::Duration::from_millis(1)); + if socket.set_read_timeout(Some(wait)).is_err() { + return false; + } + let Ok((length, source)) = socket.recv_from(&mut buffer) else { + return false; + }; + if source != peer || length < size as usize { + continue; + } + let Ok(reply) = NetworkTestMessage::decode_reply(&buffer[..length]) else { + continue; + }; + if reply_is_accepted(&reply, session_id, size) { + return true; + } + } + false +} + +pub fn probe_mtu( + socket: &std::net::UdpSocket, + peer: std::net::SocketAddr, + key: &[u8], + session_id: &[u8], + floor: u32, + ceiling: u32, +) -> Result { + if ceiling <= floor { + return Err(invalid("network test probe range is empty")); + } + let deadline = std::time::Instant::now() + PROBE_BUDGET; + let mut probes = 0_usize; + let confirmed = |size: u32, probes: &mut usize| -> bool { + if *probes >= MAX_PROBES || std::time::Instant::now() >= deadline { + return false; + } + *probes += 1; + confirm_datagram( + socket, + peer, + key, + session_id, + u32::try_from(*probes).unwrap_or(u32::MAX), + size, + deadline, + ) + }; + if confirmed(ceiling, &mut probes) { + return Ok(ProbeOutcome { + measured_datagram_bytes: Some(ceiling), + probes, + }); + } + if !confirmed(floor, &mut probes) { + return Ok(ProbeOutcome { + measured_datagram_bytes: None, + probes, + }); + } + let mut low = floor; + let mut high = ceiling; + while high - low > PROBE_STEP && probes < MAX_PROBES { + let middle = low + (high - low) / 2; + if confirmed(middle, &mut probes) { + low = middle; + } else { + high = middle; + } + } + Ok(ProbeOutcome { + measured_datagram_bytes: Some(low), + probes, + }) +} + +#[cfg(test)] +mod probe_tests { + use super::*; + use std::net::{SocketAddr, UdpSocket}; + use std::time::Duration; + + const KEY: [u8; 32] = [0x33; 32]; + const SESSION: &[u8] = b"nt-1"; + + fn vendor_reply_body(session: &[u8], size: u32, message_type: u32) -> Vec { + let mut body = Vec::new(); + assert!(message_type < 0x80 && session.len() < 0x80); + body.push(0x08); + body.push(message_type as u8); + body.push(0x12); + body.push(session.len() as u8); + body.extend_from_slice(session); + body.push(0x68); + let mut value = size; + while value >= 0x80 { + body.push((value as u8) | 0x80); + value >>= 7; + } + body.push(value as u8); + body + } + + fn vendor_datagram(session: &[u8], size: u32, message_type: u32) -> Vec { + let body = vendor_reply_body(session, size, message_type); + let mut datagram = Vec::with_capacity(size as usize); + datagram.extend_from_slice(&(body.len() as u32).to_le_bytes()); + datagram.extend_from_slice(&body); + datagram.resize(size as usize, 0x5a); + datagram + } + + fn vendor_server(cap: u32, respond: F) -> (SocketAddr, std::thread::JoinHandle) + where + F: Fn(u32) -> Vec> + Send + 'static, + { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(250))) + .unwrap(); + let address = socket.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let mut served = 0_usize; + let mut buffer = vec![0_u8; 4096]; + while let Ok((length, peer)) = socket.recv_from(&mut buffer) { + let Ok(request) = NetworkTestMessage::decode(&buffer[..length]) else { + continue; + }; + let Some(size) = request.payload_size() else { + continue; + }; + if size > cap { + continue; + } + for datagram in respond(size) { + let _ = socket.send_to(&datagram, peer); + } + served += 1; + } + served + }); + (address, handle) + } + + fn foreign_server(respond: F) -> (SocketAddr, std::thread::JoinHandle) + where + F: Fn(u32) -> Vec + Send + 'static, + { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + let sender = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(250))) + .unwrap(); + let address = socket.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let mut served = 0_usize; + let mut buffer = vec![0_u8; 4096]; + while let Ok((length, peer)) = socket.recv_from(&mut buffer) { + let Ok(request) = NetworkTestMessage::decode(&buffer[..length]) else { + continue; + }; + let Some(size) = request.payload_size() else { + continue; + }; + let datagram = respond(size); + let deadline = std::time::Instant::now() + Duration::from_millis(150); + while std::time::Instant::now() < deadline { + let _ = sender.send_to(&datagram, peer); + std::thread::sleep(Duration::from_millis(5)); + } + served += 1; + } + served + }); + (address, handle) + } + + fn client_socket() -> UdpSocket { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(10))) + .unwrap(); + socket + } + + #[test] + fn a_path_that_carries_the_ceiling_needs_no_adjustment() { + let (peer, server) = + vendor_server(u32::MAX, |size| vec![vendor_datagram(SESSION, size, 8)]); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, Some(1_340)); + drop(socket); + let _ = server.join(); + } + + #[test] + fn a_narrower_path_reports_the_largest_confirmed_size() { + let (peer, server) = vendor_server(1_280, |size| vec![vendor_datagram(SESSION, size, 8)]); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + let measured = outcome.measured_datagram_bytes.expect("measured"); + assert!(measured <= 1_280, "measured {measured}"); + assert!(measured + PROBE_STEP * 2 >= 1_280, "measured {measured}"); + assert!(outcome.probes <= MAX_PROBES); + drop(socket); + let _ = server.join(); + } + + #[test] + fn a_silent_path_yields_no_measurement() { + let (peer, server) = vendor_server(1_280, |_| Vec::new()); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, None); + drop(socket); + let _ = server.join(); + } + + #[test] + fn replies_with_a_foreign_type_are_ignored() { + let (peer, server) = + vendor_server(u32::MAX, |size| vec![vendor_datagram(SESSION, size, 7)]); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, None); + drop(socket); + let _ = server.join(); + } + + #[test] + fn replies_for_another_session_are_ignored() { + let (peer, server) = + vendor_server(u32::MAX, |size| vec![vendor_datagram(b"other", size, 8)]); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, None); + drop(socket); + let _ = server.join(); + } + + #[test] + fn stale_replies_for_another_size_are_ignored() { + let (peer, server) = vendor_server(u32::MAX, |size| { + vec![ + vendor_datagram(SESSION, size + 64, 8), + vendor_datagram(SESSION, size, 8), + ] + }); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, Some(1_340)); + drop(socket); + let _ = server.join(); + } + + #[test] + fn duplicate_replies_do_not_change_the_measurement() { + let (peer, server) = vendor_server(u32::MAX, |size| { + let datagram = vendor_datagram(SESSION, size, 8); + vec![datagram.clone(), datagram] + }); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, Some(1_340)); + drop(socket); + let _ = server.join(); + } + + #[test] + fn replies_from_a_foreign_source_are_ignored() { + let (peer, server) = foreign_server(|size| vendor_datagram(SESSION, size, 8)); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, None); + drop(socket); + let _ = server.join(); + } + + #[test] + fn truncated_and_malformed_replies_are_ignored() { + let (peer, server) = vendor_server(u32::MAX, |size| { + let mut overclaimed = vec![0_u8; size as usize]; + overclaimed[..4].copy_from_slice(&(size + 1_000).to_le_bytes()); + overclaimed[4] = 0x08; + overclaimed[5] = 0x08; + vec![overclaimed, vec![0xff; size as usize]] + }); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, None); + drop(socket); + let _ = server.join(); + } + + #[test] + fn short_replies_are_ignored() { + let (peer, server) = vendor_server(u32::MAX, |size| { + let datagram = vendor_datagram(SESSION, size, 8); + vec![datagram[..datagram.len() - 16].to_vec()] + }); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, None); + drop(socket); + let _ = server.join(); + } + + #[test] + fn a_floor_below_usefulness_yields_no_measurement() { + let (peer, server) = vendor_server(100, |size| vec![vendor_datagram(SESSION, size, 8)]); + let socket = client_socket(); + let outcome = probe_mtu(&socket, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + assert_eq!(outcome.measured_datagram_bytes, None); + drop(socket); + let _ = server.join(); + } + + #[test] + fn an_empty_probe_range_is_rejected() { + let socket = client_socket(); + let peer: SocketAddr = "127.0.0.1:1".parse().unwrap(); + assert!(probe_mtu(&socket, peer, &KEY, SESSION, 1_340, PROBE_FLOOR_BYTES).is_err()); + } + + #[test] + fn unsigned_replies_are_accepted_by_the_correlation_rules() { + let datagram = vendor_datagram(SESSION, 1_280, 8); + assert_eq!( + datagram.len(), + 1_280, + "vendor datagram is the requested size" + ); + let reply = NetworkTestMessage::decode_reply(&datagram).unwrap(); + assert!(reply_is_accepted(&reply, SESSION, 1_280)); + assert!(!reply_is_accepted(&reply, b"other", 1_280), "session"); + assert!(!reply_is_accepted(&reply, SESSION, 1_279), "size"); + assert!(reply.mac().is_empty(), "vendor replies are unsigned"); + } + + #[test] + fn bare_replies_below_the_prefix_threshold_are_parsed() { + let body = vendor_reply_body(SESSION, 300, 8); + assert!(body.len() < REPLY_PREFIX_THRESHOLD); + let reply = NetworkTestMessage::decode_reply(&body).unwrap(); + assert!(reply_is_accepted(&reply, SESSION, 300)); + } + + #[test] + fn a_hostile_flood_cannot_stretch_the_probe_deadline() { + use std::time::Instant; + + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + let peer = socket.local_addr().unwrap(); + let client = client_socket(); + let client_address = client.local_addr().unwrap(); + let started = Instant::now(); + let flood_until = started + PROBE_BUDGET - Duration::from_millis(10); + let server = std::thread::spawn(move || { + let mut sent = 0_usize; + while Instant::now() < flood_until { + let _ = socket.send_to(&vendor_datagram(b"other", 1_340, 8), client_address); + sent += 1; + std::thread::sleep(Duration::from_millis(1)); + } + sent + }); + let outcome = probe_mtu(&client, peer, &KEY, SESSION, PROBE_FLOOR_BYTES, 1_340).unwrap(); + let elapsed = Instant::now() - started; + assert_eq!(outcome.measured_datagram_bytes, None); + assert!( + elapsed < PROBE_BUDGET + PROBE_ATTEMPT_WAIT / 2, + "the hostile flood stretched the probe to {elapsed:?}" + ); + let sent = server.join().unwrap(); + assert!(sent > 20, "the flood never reached the client: {sent}"); + } +} +#[cfg(test)] +mod allocation_tests { + use super::*; + + fn response(app_level_protocol: u64) -> serde_json::Value { + serde_json::json!({ + "requestStatus": {"requestId": "req-1", "serverId": "zone-1", "statusCode": "200"}, + "netTestSession": { + "sessionId": "nt-42", + "serverId": "srv-9", + "connectionInfo": [{ + "ip": "192.0.2.44", + "port": 49100, + "appLevelProtocol": app_level_protocol, + }], + "netTestThresholds": { + "recommendedBandwidthMBPS": 50.0, + "requiredBandwidthMBPS": 25.0, + "recommendedLatencyMS": 40.0, + "requiredLatencyMS": 80.0, + "recommendedPacketLossPct": 1.0, + "requiredPacketLossPct": 3.0, + } + } + }) + } + + #[test] + fn debug_output_never_contains_the_session_key() { + let mut payload = response(5); + payload["netTestSession"]["hmacKey"] = serde_json::json!("super-secret-key-material"); + let session = parse_allocation(&payload).unwrap(); + assert_eq!( + session.hmac_key.as_deref(), + Some("super-secret-key-material".as_bytes()) + ); + let debug = format!("{session:?}"); + assert!( + !debug.contains("super-secret-key-material"), + "debug leaked the key: {debug}" + ); + assert!(debug.contains("[redacted]")); + } + + #[test] + fn non_literal_addresses_and_oversized_ids_are_rejected() { + let mut hostname = response(5); + hostname["netTestSession"]["connectionInfo"][0]["ip"] = + serde_json::json!("net-test.example.com"); + assert!( + parse_allocation(&hostname).is_err(), + "hostnames must not reach a resolver" + ); + + let mut long_id = response(5); + long_id["netTestSession"]["sessionId"] = serde_json::json!("n".repeat(257)); + assert!(parse_allocation(&long_id).is_err()); + } + + #[test] + fn unusable_session_keys_are_rejected() { + let mut nul_key = String::from("nul"); + nul_key.push('\0'); + nul_key.push_str("byte"); + for key in [ + serde_json::json!(""), + serde_json::json!(7), + serde_json::json!(nul_key), + ] { + let mut payload = response(5); + payload["netTestSession"]["hmacKey"] = key.clone(); + assert!(parse_allocation(&payload).is_err(), "accepted {key:?}"); + } + let mut payload = response(5); + payload["netTestSession"]["hmacKey"] = serde_json::Value::Null; + assert_eq!(parse_allocation(&payload).unwrap().hmac_key, None); + } + + #[test] + fn allocation_body_matches_the_recovered_schema() { + let body = allocation_body( + "GFN-PC", + DisplayProfile { + width: 1920, + height: 1080, + fps: 60, + }, + ); + assert_eq!(body["netTestRequestData"]["clientPlatformName"], "GFN-PC"); + assert_eq!( + body["netTestRequestData"]["netTestProfile"]["widthInPixels"], + 1920 + ); + assert_eq!( + body["netTestRequestData"]["netTestProfile"]["heightInPixels"], + 1080 + ); + assert_eq!( + body["netTestRequestData"]["netTestProfile"]["framesPerSecond"], + 60 + ); + } + + #[test] + fn allocation_response_is_parsed_with_thresholds() { + let session = parse_allocation(&response(5)).unwrap(); + assert_eq!(session.session_id, "nt-42"); + assert_eq!(session.server_id, "srv-9"); + assert_eq!( + session.address, + "192.0.2.44".parse::().unwrap() + ); + assert_eq!(session.port, 49_100); + assert!(session.secure); + assert_eq!(session.thresholds.bandwidth_limit_mbps, 25.0); + assert_eq!(session.thresholds.latency_limit_ms, 80.0); + assert_eq!(session.thresholds.packet_loss_limit_pct, 3.0); + assert!(!parse_allocation(&response(4)).unwrap().secure); + } + + #[test] + fn malformed_allocation_responses_are_rejected() { + assert!(parse_allocation(&serde_json::json!({})).is_err()); + let mut missing_port = response(5); + missing_port["netTestSession"]["connectionInfo"][0] + .as_object_mut() + .unwrap() + .remove("port"); + assert!(parse_allocation(&missing_port).is_err()); + let mut empty_session = response(5); + empty_session["netTestSession"]["sessionId"] = serde_json::json!(""); + assert!(parse_allocation(&empty_session).is_err()); + } + + #[test] + fn allocation_requires_https_and_a_token() { + assert!(nettest_url(&url::Url::parse("http://example.test").unwrap()).is_err()); + assert!(nettest_url(&url::Url::parse("http://127.0.0.1:1").unwrap()).is_ok()); + assert!(nettest_url(&url::Url::parse("https://example.test").unwrap()).is_ok()); + assert_eq!( + nettest_url(&url::Url::parse("https://example.test").unwrap()) + .unwrap() + .as_str(), + "https://example.test/v2/nettestsession" + ); + } +} + +#[cfg(test)] +mod canonical_tests { + use super::*; + + const KEY: [u8; 32] = [0x77; 32]; + + #[test] + fn known_fields_encode_ascending_and_unknown_fields_follow() { + let mut message = NetworkTestMessage::default(); + message.set_message_type(7); + message.set_payload_size(1_280); + message.push_unknown(19, WireValue::Varint(3)); + message.push_unknown(4, WireValue::Bytes(b"b".to_vec())); + message.push_unknown(4, WireValue::Varint(1)); + let encoded = message.encode(); + assert_eq!( + decode_field_numbers(&encoded), + vec![1, 13, 19, 4, 4], + "known fields ascending, then unknown in received order" + ); + } + + #[test] + fn duplicate_scalar_fields_take_the_last_value() { + let mut body = Vec::new(); + write_varint(&mut body, 0x08); + write_varint(&mut body, 1); + write_varint(&mut body, 0x08); + write_varint(&mut body, 7); + let decoded = NetworkTestMessage::decode(&body).unwrap(); + assert_eq!(decoded.message_type(), 7); + let numbers = decode_field_numbers(&decoded.encode()); + assert_eq!(numbers, vec![1], "re-encoding collapses the duplicate"); + } + + #[test] + fn unknown_fields_are_authenticated_and_survive_re_encoding() { + let mut sender = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + sender.push_unknown(4, WireValue::Varint(11)); + sender.seal(&KEY).unwrap(); + + let received = NetworkTestMessage::decode(&sender.encode()).unwrap(); + assert!(received.verify(&KEY).unwrap()); + assert_eq!(received.unknown(), &[(4, WireValue::Varint(11))]); + assert_eq!(received.encode(), sender.encode()); + } + + #[test] + fn non_canonical_input_order_still_authenticates() { + let mut sender = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + sender.push_unknown(19, WireValue::Varint(5)); + sender.seal(&KEY).unwrap(); + let canonical = sender.encode(); + + let mut reordered = Vec::new(); + write_varint(&mut reordered, 19 << 3); + write_varint(&mut reordered, 5); + reordered.extend_from_slice(&canonical[..canonical.len() - 3]); + + let received = NetworkTestMessage::decode(&reordered).unwrap(); + assert!( + received.verify(&KEY).unwrap(), + "official order-independent parse" + ); + assert_eq!(received.encode(), canonical); + } + + fn decode_field_numbers(body: &[u8]) -> Vec { + let mut numbers = Vec::new(); + let mut offset = 0_usize; + let end = body.len(); + while offset < end { + let (tag, next) = read_varint(&body[offset..]).unwrap(); + offset += next; + numbers.push((tag >> 3) as u32); + offset += match tag & 7 { + 0 => read_varint(&body[offset..]).unwrap().1, + 1 => 8, + 2 => { + let (len, next) = read_varint(&body[offset..]).unwrap(); + next + len as usize + } + 5 => 4, + other => panic!("unexpected wire type {other}"), + }; + } + numbers + } +} + +#[cfg(test)] +mod golden_tests { + use super::*; + + const KEY: [u8; 32] = [0x33; 32]; + const PROBE_BODY: &[u8] = &[ + 0x08, 0x07, 0x12, 0x04, b'n', b't', b'-', b'1', 0x60, 0x01, 0x68, 0x80, 0x0a, + ]; + const PROBE_HMAC: &str = "e891c98a2b313e55ebb343cf4f5aa46db2054fb419e1c0c4f51c23f1ffdfe987"; + const PROBE_SEALED: &str = "080712046e742d315220e891c98a2b313e55ebb343cf4f5aa46db2054fb419e1c0c4f51c23f1ffdfe987600168800a"; + const UNKNOWN_HMAC: &str = "770f593149064511de33d7dc0aaeebd71510eabcd591cfd5fd3b64a159c9f84d"; + + fn hex(text: &str) -> Vec { + (0..text.len() / 2) + .map(|index| u8::from_str_radix(&text[index * 2..index * 2 + 2], 16).unwrap()) + .collect() + } + + fn expected_body() -> Vec { + PROBE_BODY.to_vec() + } + + #[test] + fn golden_probe_matches_an_independent_protobuf_encoder() { + let message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + assert_eq!(message.encode(), expected_body()); + assert_eq!(message.authenticated(&KEY).unwrap(), hex(PROBE_HMAC)); + } + + #[test] + fn golden_sealed_bytes_match_the_independent_encoder() { + let mut message = NetworkTestMessage::mtu_probe(1_280, b"nt-1", 1); + message.seal(&KEY).unwrap(); + assert_eq!(message.encode(), hex(PROBE_SEALED)); + assert!(message.verify(&KEY).unwrap()); + assert_eq!( + NetworkTestMessage::decode(&message.encode()).unwrap(), + message, + "bare request body round-trips" + ); + } + + #[test] + fn golden_unknown_duplicates_authenticate_like_the_independent_encoder() { + let body = hex("080768800a200b200c"); + assert_eq!(body.len(), 9); + let received = NetworkTestMessage::decode(&body).unwrap(); + assert_eq!(received.unknown().len(), 2, "both duplicates preserved"); + assert_eq!( + received.unknown(), + &[(4, WireValue::Varint(11)), (4, WireValue::Varint(12))] + ); + assert_eq!(received.encode(), body, "unknown re-encodes in order"); + assert_eq!(received.authenticated(&KEY).unwrap(), hex(UNKNOWN_HMAC)); + assert!(!received.verify(&KEY).unwrap(), "no mac field present"); + } + + #[test] + fn golden_duplicate_known_field_takes_the_last_value() { + let body = hex("080768800a68800b"); + let decoded = NetworkTestMessage::decode(&body).unwrap(); + assert_eq!(decoded.payload_size(), Some(1_408)); + assert_eq!( + decoded.encode(), + hex("080768800b"), + "re-encoding collapses the duplicate" + ); + } + + #[test] + fn golden_unknown_before_known_moves_after_known() { + let body = hex("200b080768800a"); + let decoded = NetworkTestMessage::decode(&body).unwrap(); + assert_eq!(decoded.encode(), expected_body_with_unknown()); + } + + fn expected_body_with_unknown() -> Vec { + hex("080768800a200b") + } +} + +#[cfg(test)] +mod wire_semantics_tests { + use super::*; + + #[test] + fn wrong_wire_type_on_a_known_field_is_preserved_as_unknown() { + let body = hex_bytes("6a02aabb"); + let decoded = NetworkTestMessage::decode(&body).unwrap(); + assert_eq!(decoded.payload_size(), None); + assert_eq!( + decoded.unknown(), + &[(13, WireValue::Bytes(vec![0xAA, 0xBB]))] + ); + assert_eq!(decoded.encode(), body); + } + + #[test] + fn wrong_wire_type_on_the_type_field_is_preserved_as_unknown() { + let body = hex_bytes("0a020700"); + let decoded = NetworkTestMessage::decode(&body).unwrap(); + assert_eq!(decoded.message_type(), 0); + assert_eq!(decoded.encode(), body); + } + + fn hex_bytes(text: &str) -> Vec { + (0..text.len() / 2) + .map(|index| u8::from_str_radix(&text[index * 2..index * 2 + 2], 16).unwrap()) + .collect() + } +} diff --git a/native/opennow-core/src/settings.rs b/native/opennow-core/src/settings.rs index 1766364f4..fa4495e0c 100644 --- a/native/opennow-core/src/settings.rs +++ b/native/opennow-core/src/settings.rs @@ -903,7 +903,7 @@ fn defaults() -> Map { "codec":"auto", "fallbackCodec":"auto", "decoderPreference":"auto", "encoderPreference":"auto", "colorQuality":"8bit_420", "enableHdr":false, "region":"", "regionProviderIdpId":"", "providerRegions":{}, "suppressTenBitWarning":false, - "sessionProxyEnabled":false, "sessionProxyUrl":"", "clipboardPaste":false, + "sessionProxyEnabled":false, "sessionProxyUrl":"", "clipboardPaste":false, "networkTest":false, "enableGyroscopeControls":false, "steamControllerCompatibilityMode":false, "nativeCursorOverlay":true, "mouseSensitivity":1, "mouseAcceleration":1, "shortcutToggleStats":"Ctrl+N", "shortcutTogglePointerLock":"F8", diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs index 3acc13c85..8902f6802 100644 --- a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs @@ -648,10 +648,15 @@ pub fn prepare_owned_nvst( format!("Could not select the local route to the NVST media peer: {error}"), ) })?; - let video_packet_size = nvst_video_packet_size(video_peer_ip.parse().map_err(|_| { + let video_peer_ip_parsed = video_peer_ip.parse().map_err(|_| { NvstRtspError::new("invalid-media-peer", "NVST video peer is not an IP address") - })?) - .map_err(|error| NvstRtspError::new("nvst-video-mtu-invalid", error.to_string()))?; + })?; + let video_packet_size = nvst_video_packet_size(video_peer_ip_parsed) + .map_err(|error| NvstRtspError::new("nvst-video-mtu-invalid", error.to_string()))?; + let video_packet_size = measured_path_packet_size(context, video_peer_ip_parsed) + .map_or(video_packet_size, |measured| { + video_packet_size.min(measured) + }); opennow_streamer_protocol::log::log_line( "INFO", "transport", @@ -1031,6 +1036,16 @@ fn negotiated_fps(context: &SessionContext) -> u64 { .clamp(30, u64::from(super::MAX_STREAM_FPS)) } +fn measured_path_packet_size(context: &SessionContext, peer: IpAddr) -> Option { + let datagram = context + .session + .extra + .get("networkTest")? + .get("measuredDatagramBytes")? + .as_u64()?; + opennow_streamer_transport::measured_video_packet_size(usize::try_from(datagram).ok()?, peer) +} + fn negotiated_codec(context: &SessionContext) -> String { context .session @@ -1605,6 +1620,60 @@ mod tests { } } + #[test] + fn announce_uses_the_measured_authenticated_path_when_it_is_tighter() { + let mut context = context(); + context.session.extra.insert( + "networkTest".to_owned(), + json!({"sessionId":"nt-1", "measuredDatagramBytes":1_200}), + ); + let peer: IpAddr = "192.0.2.1".parse().unwrap(); + let packet_size = measured_path_packet_size(&context, peer).expect("measured packet size"); + assert_eq!(packet_size, 1_168); + assert!(packet_size < nvst_video_packet_size(peer).unwrap()); + + let sdp = build_announce( + &context, + AnnounceParams { + key: &"01".repeat(32), + key_id: 7, + port: 49006, + address: "192.0.2.10", + ufrag: "abcd", + password: "abcdefghijklmnopqrstuv", + fingerprint: "AA:BB", + video_port: 5004, + video_packet_size: packet_size, + rtcp_on_sctp: true, + microphone_available: false, + }, + ); + assert_eq!( + sdp_attribute(&sdp, "video[0].packetSize"), + Some(packet_size.to_string()) + ); + } + + #[test] + fn announce_ignores_an_absent_or_unusable_measurement() { + let base = context(); + let peer: IpAddr = "192.0.2.1".parse().unwrap(); + assert_eq!(measured_path_packet_size(&base, peer), None); + + for measured in [0_u64, 1, 12] { + let mut context = context(); + context.session.extra.insert( + "networkTest".to_owned(), + json!({"measuredDatagramBytes":measured}), + ); + assert_eq!( + measured_path_packet_size(&context, peer), + None, + "{measured}" + ); + } + } + #[test] fn announce_uses_the_negotiated_dynamic_quality_policy_not_the_saved_preference() { for (profile, policy, adjust) in [ diff --git a/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs index dc080358a..8171a5d9b 100644 --- a/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs @@ -23,9 +23,9 @@ pub use nvst::{ NvstReceiveEvent, NvstReceiverState, NvstRecovery, NvstSrtpProfile, NvstUdpReceiverControl, NvstUdpReceiverError, NvstUdpReceiverSession, NvstUnsupportedFeature, NvstVideoCodec, NvstVideoConfig, NvstVideoReceiver, ReservedNvstBundle, SharedNvstFeedback, - advertised_nvst_ipv4, nvst_video_packet_size, parse_nvst_video_handoff, - reserve_nvst_mjolnir_udp_socket, reserve_nvst_udp_socket, spawn_nvst_mjolnir_receiver, - spawn_nvst_udp_receiver, spawn_nvst_udp_receiver_with_socket, + advertised_nvst_ipv4, measured_video_packet_size, nvst_video_packet_size, + parse_nvst_video_handoff, reserve_nvst_mjolnir_udp_socket, reserve_nvst_udp_socket, + spawn_nvst_mjolnir_receiver, spawn_nvst_udp_receiver, spawn_nvst_udp_receiver_with_socket, }; static INSTALL_CRYPTO: Once = Once::new(); diff --git a/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs b/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs index b4302a6bb..8fb7aaad5 100644 --- a/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs +++ b/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs @@ -191,6 +191,7 @@ const NVST_FEC_RTP_HEADER_ALLOWANCE: usize = 16; const DEFAULT_NVST_VIDEO_PACKET_SIZE: usize = 1_280; const MIN_NVST_VIDEO_PACKET_SIZE: usize = 256; const MAX_NVST_VIDEO_PACKET_SIZE: usize = 65_519; +const UDP_HEADER_BYTES: usize = 8; // Match the official client's bounded NACK/dejitter envelope: it keeps up to // 1,024 RTP packets available for late or retransmitted packets and permits a // 2,048-entry NACK queue. A 32-packet window is only a few milliseconds at @@ -4622,18 +4623,25 @@ pub fn nvst_video_packet_size(peer: IpAddr) -> std::io::Result { Ok(packet_size) } +fn wire_overhead_bytes(peer: IpAddr) -> usize { + let ip_header_bytes = if peer.is_ipv4() { 20 } else { 40 }; + ip_header_bytes + UDP_HEADER_BYTES + NVST_FEC_RTP_HEADER_ALLOWANCE + SRTP_AEAD_AES_GCM_TAG_LEN +} + +pub fn measured_video_packet_size(datagram_size: usize, peer: IpAddr) -> Option { + let ip_header_bytes = if peer.is_ipv4() { 20 } else { 40 }; + let route_mtu = datagram_size + .checked_add(UDP_HEADER_BYTES)? + .checked_add(ip_header_bytes)?; + video_packet_size_for_vpn_mtu(peer, Some(route_mtu)).ok() +} + fn video_packet_size_for_vpn_mtu(peer: IpAddr, route_mtu: Option) -> std::io::Result { let Some(route_mtu) = route_mtu else { return Ok(DEFAULT_NVST_VIDEO_PACKET_SIZE); }; - let ip_header_bytes = if peer.is_ipv4() { 20 } else { 40 }; - let udp_header_bytes = 8; - let overhead = ip_header_bytes - + udp_header_bytes - + NVST_FEC_RTP_HEADER_ALLOWANCE - + SRTP_AEAD_AES_GCM_TAG_LEN; let packet_size = route_mtu - .saturating_sub(overhead) + .saturating_sub(wire_overhead_bytes(peer)) .min(DEFAULT_NVST_VIDEO_PACKET_SIZE) / 16 * 16; @@ -8103,6 +8111,45 @@ mod tests { } } + #[test] + fn measured_datagram_sizes_map_to_wire_budget_packet_sizes() { + for (address, datagram_size, expected) in [ + ("192.0.2.1", 1_340_usize, Some(1_280)), + ("192.0.2.1", 1_300, Some(1_264)), + ("192.0.2.1", 1_216, Some(1_184)), + ("2001:db8::1", 1_340, Some(1_280)), + ("2001:db8::1", 1_300, Some(1_264)), + ("192.0.2.1", 300, Some(256)), + ("192.0.2.1", 65_535, Some(1_280)), + ("192.0.2.1", 0, None), + ("2001:db8::1", 0, None), + ] { + let peer: IpAddr = address.parse().unwrap(); + assert_eq!( + measured_video_packet_size(datagram_size, peer), + expected, + "{address} {datagram_size}" + ); + } + } + + #[test] + fn measured_datagrams_never_exceed_the_route_derived_packet_size() { + for peer in ["192.0.2.1", "2001:db8::1"] { + let peer: IpAddr = peer.parse().unwrap(); + let route_derived = video_packet_size_for_vpn_mtu(peer, Some(1_500)).unwrap(); + for datagram_size in [320_usize, 700, 1_000, 1_216, 1_300, 1_340, 1_400] { + let measured = measured_video_packet_size(datagram_size, peer); + assert_eq!( + measured, + Some(((datagram_size - 32) / 16 * 16).min(1_280)), + "{peer} {datagram_size}" + ); + assert!(measured.unwrap() <= route_derived); + } + } + } + #[test] fn tunnel_packet_size_is_preserved_by_the_handoff_and_fec_receiver() { let mut handoff = legacy_handoff(); diff --git a/opennow-qt/cmake/Tests.cmake b/opennow-qt/cmake/Tests.cmake index ffac9764b..4d57099dd 100644 --- a/opennow-qt/cmake/Tests.cmake +++ b/opennow-qt/cmake/Tests.cmake @@ -393,7 +393,7 @@ if(BUILD_TESTING) set_tests_properties(qml-push-invalidation-${width} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) endforeach() qt_add_resources(opennow-qt "store-paging-acceptance" - PREFIX "/acceptance" BASE tests FILES tests/RegionPingAcceptance.qml tests/RegionChoicesAcceptance.qml tests/StorePagingAcceptance.qml tests/BackendAvailabilityAcceptance.qml tests/StreamRecoveryAcceptance.qml tests/IdleModeAcceptance.qml tests/FrameGenerationAcceptance.qml tests/AudioOutputAcceptance.qml tests/CollectionsAcceptance.qml tests/SteamBigPictureAcceptance.qml tests/PersistentInGameSettingsAcceptance.qml tests/SaveBandwidthAcceptance.qml tests/StoreLaunchAcceptance.qml tests/ControllerMetadataAcceptance.qml tests/MicrophoneAcceptance.qml tests/RecordingAcceptance.qml) + PREFIX "/acceptance" BASE tests FILES tests/RegionPingAcceptance.qml tests/RegionChoicesAcceptance.qml tests/StorePagingAcceptance.qml tests/BackendAvailabilityAcceptance.qml tests/StreamRecoveryAcceptance.qml tests/IdleModeAcceptance.qml tests/FrameGenerationAcceptance.qml tests/AudioOutputAcceptance.qml tests/CollectionsAcceptance.qml tests/SteamBigPictureAcceptance.qml tests/PersistentInGameSettingsAcceptance.qml tests/NetworkTestAcceptance.qml tests/SaveBandwidthAcceptance.qml tests/StoreLaunchAcceptance.qml tests/ControllerMetadataAcceptance.qml tests/MicrophoneAcceptance.qml tests/RecordingAcceptance.qml) add_test(NAME qml-recording COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop --route settings --smoke-recording --reduced-motion) @@ -526,6 +526,10 @@ if(BUILD_TESTING) COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop --route settings-streaming --smoke-persistent-in-game-settings --reduced-motion) set_tests_properties(qml-persistent-in-game-settings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 10) + add_test(NAME qml-network-test + COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop + --route settings-network --smoke-network-test --reduced-motion) + set_tests_properties(qml-network-test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 10) add_test(NAME qml-save-bandwidth COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop --route settings-streaming --smoke-save-bandwidth --reduced-motion) diff --git a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsNetworkPage.qml b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsNetworkPage.qml index 6bc931ab8..7ab17c682 100644 --- a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsNetworkPage.qml +++ b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsNetworkPage.qml @@ -77,9 +77,16 @@ Column { DesktopSettingsSection { text: qsTr("TRANSPORT") } DesktopSettingsRow { width: parent.width; paperStyle: true; glyph: "bolt"; title: qsTr("L4S") - description: qsTr("Request scalable low-latency transport for the next session"); showDivider: false + description: qsTr("Request scalable low-latency transport for the next session") DesktopSettingsToggle { checked: page.settingsScreen.boolSetting("enableL4S",false); onValueChangedByUser: value => page.settingsScreen.setSetting("enableL4S",value) } } + DesktopSettingsRow { + objectName: "renewNetworkTest" + width: parent.width; paperStyle: true; glyph: "speed"; title: qsTr("Network test") + showDivider: false + description: qsTr("Measure this zone's UDP payload reachability before streaming · selected zones only") + DesktopSettingsToggle { objectName: "renewNetworkTestToggle"; checked: page.settingsScreen.boolSetting("networkTest",false); onValueChangedByUser: value => page.settingsScreen.setSetting("networkTest",value) } + } } } } diff --git a/opennow-qt/qml/screens/SettingsScreen.qml b/opennow-qt/qml/screens/SettingsScreen.qml index 90a3602c8..14e1d546e 100644 --- a/opennow-qt/qml/screens/SettingsScreen.qml +++ b/opennow-qt/qml/screens/SettingsScreen.qml @@ -412,6 +412,7 @@ FocusScope { {t:"Proxy address", d:"HTTP(S), SOCKS4 or SOCKS5; credentials stay in the protected local settings file", v:root.proxyDisplay(settings.sessionProxyUrl), action:"proxy-url"}, toggle("Session proxy", "Use the configured community session proxy", "sessionProxyEnabled"), toggle("L4S", "Request low-latency scalable throughput when available", "enableL4S"), + toggle("Network test", "Measure this zone's UDP payload reachability before streaming · selected zones only", "networkTest"), toggle("Steam Deck identity", "Unlock Deck resolutions and 90 FPS · refreshes entitlements", "identifyAsSteamDeck"), {t:"Refresh regions", d:ShellStore.regionsVpcId ? qsTr("Service region %1").arg(ShellStore.regionsVpcId) : "Query the authenticated NVIDIA region service", v:ShellStore.regionsRequestId === "" ? "Run" : "Running…", action:"refresh-regions"} ] diff --git a/opennow-qt/src/acceptance/SmokeAcceptance.cpp b/opennow-qt/src/acceptance/SmokeAcceptance.cpp index 5b699258c..57747c2aa 100644 --- a/opennow-qt/src/acceptance/SmokeAcceptance.cpp +++ b/opennow-qt/src/acceptance/SmokeAcceptance.cpp @@ -238,6 +238,7 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-persistent-in-game-settings"_s) || m_arguments.contains(u"--smoke-save-bandwidth"_s) || m_arguments.contains(u"--smoke-store-launch"_s) + || m_arguments.contains(u"--smoke-network-test"_s) || m_arguments.contains(u"--smoke-idle-mode"_s) || m_arguments.contains(u"--smoke-queue-drops"_s) || m_arguments.contains(u"--smoke-color-format"_s) @@ -274,6 +275,8 @@ int AcceptanceSession::startSmokeWorkload() ? u"qrc:/acceptance/SaveBandwidthAcceptance.qml"_s : m_arguments.contains(u"--smoke-store-launch"_s) ? u"qrc:/acceptance/StoreLaunchAcceptance.qml"_s + : m_arguments.contains(u"--smoke-network-test"_s) + ? u"qrc:/acceptance/NetworkTestAcceptance.qml"_s : m_arguments.contains(u"--smoke-idle-mode"_s) ? u"qrc:/acceptance/IdleModeAcceptance.qml"_s : m_arguments.contains(u"--smoke-stream-recovery"_s) @@ -301,7 +304,8 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-steam-big-picture"_s) || m_arguments.contains(u"--smoke-persistent-in-game-settings"_s) || m_arguments.contains(u"--smoke-save-bandwidth"_s) - || m_arguments.contains(u"--smoke-store-launch"_s)) { + || m_arguments.contains(u"--smoke-store-launch"_s) + || m_arguments.contains(u"--smoke-network-test"_s)) { auto *client = fixture->property("client").value(); if (!client) return EXIT_FAILURE; m_engine.rootContext()->setContextProperty(u"CoreClient"_s, client); @@ -380,7 +384,12 @@ int AcceptanceSession::startSmokeWorkload() m_application.exit(saved && !m_qmlWarningOccurred ? EXIT_SUCCESS : EXIT_FAILURE); }); } else { - m_application.exit(ok ? EXIT_SUCCESS : EXIT_FAILURE); + const auto shot = m_arguments.indexOf(u"--screenshot"_s); + const bool saved = shot < 0 + || (shot + 1 < m_arguments.size() + && QFileInfo(m_arguments.at(shot + 1)).isAbsolute() + && window->grabWindow().save(m_arguments.at(shot + 1))); + m_application.exit(ok && saved ? EXIT_SUCCESS : EXIT_FAILURE); } }); } else if (m_smokeTest && (m_arguments.contains(u"--smoke-region-ping"_s) || m_arguments.contains(u"--smoke-store-paging"_s))) { diff --git a/opennow-qt/tests/NetworkTestAcceptance.qml b/opennow-qt/tests/NetworkTestAcceptance.qml new file mode 100644 index 000000000..08e448346 --- /dev/null +++ b/opennow-qt/tests/NetworkTestAcceptance.qml @@ -0,0 +1,63 @@ +import QtQuick +import OpenNOW + +QtObject { + property QtObject client: QtObject { + property string state: "stopped" + property string lastError: "" + property var calls: [] + signal responseReceived(string requestId, var result) + signal requestFailed(string requestId, string code, string message) + signal eventReceived(string name, var payload) + function markUiReady() {} + function logShellDiagnostic(message) {} + function request(method, params, timeout) { + const id = "fixture-" + (calls.length + 1) + calls = calls.concat([{id:id, method:method, params:params}]) + return id + } + function cancel(id) { return true } + } + property Component consoleSettings: Component { SettingsScreen { visible: false; selectedSection: 4 } } + function check(ok, message) { if (!ok) throw new Error("Network test: " + message) } + function find(item, name) { + if (item.objectName === name) return item + for (const child of item.children || []) { + const found = find(child, name) + if (found) return found + } + return null + } + function lastWrite() { return client.calls[client.calls.length - 1] } + function run(parent) { + client.state = "ready" + ShellStore.settings = ({}) + const desktop = find(parent, "desktopSettingsScreen") + check(desktop !== null, "the desktop settings screen must be present") + desktop.advancedOpen = true + const toggle = find(desktop, "renewNetworkTestToggle") + check(toggle !== null, "the network page must expose the network test opt-in") + check(!toggle.checked, "the network test must ship off") + + toggle.clicked() + let write = lastWrite() + check(write.method === "settings.set" && write.params.key === "networkTest" + && write.params.value === true, "the desktop toggle persists the opt-in") + client.eventReceived("settings.changed", {key:"networkTest", value:true}) + check(toggle.checked && ShellStore.settings.networkTest === true, + "the desktop toggle reflects the saved value") + + const consolePage = consoleSettings.createObject(parent) + const row = consolePage.settingsModel().find(item => item.key === "networkTest") + check(row !== undefined && row.toggle === true && row.v === "On", + "the console exposes the same opt-in") + consolePage.activate(row) + write = lastWrite() + check(write.method === "settings.set" && write.params.key === "networkTest" + && write.params.value === false, "the console writes the same opt-in") + client.eventReceived("settings.changed", {key:"networkTest", value:false}) + check(!toggle.checked, "the desktop toggle reflects console changes") + consolePage.destroy() + return true + } +}