feat(qconnect): add transactional LAN delegation
cx
This commit is contained in:
19 files changed
+4461
-297
No files matched your search
@@ -9,14 +9,19 @@ description = "Official Qobuz Connect LAN receiver wire and discovery"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tiny_http = "0.12"
|
||||
mdns-sd = "0.21"
|
||||
if-addrs = "0.15"
|
||||
socket2 = "0.6"
|
||||
url = "2"
|
||||
zeroize = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
qconnect-core = { path = "../qconnect-core" }
|
||||
qconnect-protocol = { path = "../qconnect-protocol" }
|
||||
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
|
||||
reqwest = { workspace = true }
|
||||
rustls = { version = "0.23", features = ["aws_lc_rs"] }
|
||||
tokio = { workspace = true }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Development-only official-client handoff probe.
|
||||
//!
|
||||
//! This binary advertises one ephemeral LAN renderer, accepts exactly the
|
||||
//! official handoff shape, and validates the two delegated contexts without
|
||||
//! printing token, endpoint, account or session values. It never starts the
|
||||
//! player and never persists the handoff.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::error::Error;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use qbz_qobuz::{endpoints, QobuzClient};
|
||||
use qconnect_core::QueueVersion;
|
||||
use qconnect_lan::{
|
||||
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanProjection,
|
||||
LanService, LanServiceConfig, MaxAudioQuality,
|
||||
};
|
||||
use qconnect_protocol::{
|
||||
build_qconnect_renderer_outbound_envelope, RendererBufferState, RendererCommandType,
|
||||
RendererReport, RendererReportType,
|
||||
};
|
||||
use qconnect_transport_ws::{NativeWsTransport, TransportEvent, WsTransport, WsTransportConfig};
|
||||
use reqwest::redirect::Policy;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
const WAIT_FOR_HANDOFF: Duration = Duration::from_secs(10 * 60);
|
||||
const CLOUD_VALIDATION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let client = QobuzClient::new()?;
|
||||
client.init().await?;
|
||||
login_from_saved_oauth(&client).await?;
|
||||
let app_id = client.app_id().await?;
|
||||
let (qws_endpoint, _expires_at, mut owner_qws_jwt) =
|
||||
client.create_qconnect_token().await?.into_parts();
|
||||
owner_qws_jwt.zeroize();
|
||||
let qws_endpoint = qws_endpoint.ok_or("owner QWS token omitted its endpoint")?;
|
||||
let endpoint_policy =
|
||||
EndpointPolicy::from_trusted_endpoints(endpoints::BASE_URL, &qws_endpoint)
|
||||
.map_err(|_| "owner token supplied an invalid trusted endpoint")?;
|
||||
|
||||
let device_uuid = Uuid::new_v4().to_string();
|
||||
let projection = LanProjection::new(
|
||||
DisplayInfo {
|
||||
friendly_name: "QBZ protocol probe".to_string(),
|
||||
serial_number: device_uuid.clone(),
|
||||
brand_display_name: "QBZ".to_string(),
|
||||
model_display_name: "Development harness".to_string(),
|
||||
max_audio_quality: MaxAudioQuality::UpToHires192,
|
||||
device_type: DeviceType::Computer,
|
||||
software_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
ConnectInfo {
|
||||
app_id: app_id.clone(),
|
||||
current_session_id: None,
|
||||
},
|
||||
);
|
||||
let (observations_tx, observations_rx) = mpsc::sync_channel(32);
|
||||
let mut config = LanServiceConfig::new(projection, endpoint_policy, device_uuid.clone());
|
||||
config.request_observer = Some(observations_tx);
|
||||
let (mut lan, inbox) = LanService::start(config)?;
|
||||
|
||||
eprintln!("[probe] advertised 'QBZ protocol probe' for ten minutes");
|
||||
eprintln!("[probe] select it from an official Qobuz client on this LAN");
|
||||
let observation_task = tokio::task::spawn_blocking(move || {
|
||||
while let Ok(observation) = observations_rx.recv() {
|
||||
eprintln!("[probe] LAN {observation:?}");
|
||||
}
|
||||
});
|
||||
let wait = tokio::task::spawn_blocking(move || inbox.take_timeout(WAIT_FOR_HANDOFF));
|
||||
let candidate = tokio::select! {
|
||||
result = wait => result.map_err(|_| "handoff waiter failed")?,
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
lan.shutdown();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
.ok_or("no official handoff arrived before the deadline")?;
|
||||
lan.shutdown();
|
||||
let _ = observation_task.await;
|
||||
|
||||
eprintln!("[probe] exact official handoff admitted; no secret was logged");
|
||||
print_jwt_shape("jwt_api", candidate.api_token().jwt());
|
||||
print_jwt_shape("jwt_qconnect", candidate.qconnect_token().jwt());
|
||||
|
||||
let api = probe_api_auth(&candidate, &app_id).await?;
|
||||
eprintln!(
|
||||
"[probe] delegated API user/get: baseline={}, x-user-auth-token={}, bearer={}",
|
||||
api.baseline.label(),
|
||||
api.x_user_auth_token.label(),
|
||||
api.bearer.label()
|
||||
);
|
||||
|
||||
validate_qws_session(&candidate, &device_uuid).await?;
|
||||
eprintln!("[probe] delegated QWS accepted the controller-supplied session UUID");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_from_saved_oauth(client: &QobuzClient) -> Result<(), Box<dyn Error>> {
|
||||
// Match the application's no-ambient-runtime credential derivation.
|
||||
let (keyring, file) = std::thread::spawn(|| {
|
||||
(
|
||||
qbz_credentials::load_oauth_token().ok().flatten(),
|
||||
qbz_credentials::load_oauth_token_from_file().ok().flatten(),
|
||||
)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| "credential loader panicked")?;
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(token) = keyring {
|
||||
candidates.push(token);
|
||||
}
|
||||
if let Some(token) = file {
|
||||
if !candidates.iter().any(|existing| existing == &token) {
|
||||
candidates.push(token);
|
||||
}
|
||||
}
|
||||
for mut token in candidates {
|
||||
let result = client.login_with_token(&token).await;
|
||||
token.zeroize();
|
||||
if result.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("no saved OAuth token could initialize the probe".into())
|
||||
}
|
||||
|
||||
fn print_jwt_shape(label: &str, jwt: &str) {
|
||||
let segments = jwt.split('.').collect::<Vec<_>>();
|
||||
if segments.len() != 3 {
|
||||
eprintln!(
|
||||
"[probe] {label}: opaque token with {} segment(s)",
|
||||
segments.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let header = json_object_keys(segments[0]);
|
||||
let claims = json_object_keys(segments[1]);
|
||||
eprintln!("[probe] {label}: JWT header_keys={header:?} claim_keys={claims:?}");
|
||||
}
|
||||
|
||||
fn json_object_keys(segment: &str) -> BTreeSet<String> {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(segment)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.map(|object| object.into_iter().map(|(key, _)| key).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ProbeResponse {
|
||||
status: u16,
|
||||
user_shape: bool,
|
||||
}
|
||||
|
||||
impl ProbeResponse {
|
||||
fn label(self) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
self.status,
|
||||
if self.user_shape { "user" } else { "other" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ApiProbe {
|
||||
baseline: ProbeResponse,
|
||||
x_user_auth_token: ProbeResponse,
|
||||
bearer: ProbeResponse,
|
||||
}
|
||||
|
||||
async fn probe_api_auth(
|
||||
candidate: &HandoffCandidate,
|
||||
app_id: &str,
|
||||
) -> Result<ApiProbe, Box<dyn Error>> {
|
||||
let mut endpoint = url::Url::parse(candidate.api_token().endpoint())?;
|
||||
if !endpoint.path().ends_with('/') {
|
||||
endpoint.set_path(&format!("{}/", endpoint.path()));
|
||||
}
|
||||
let url = endpoint.join("user/get")?;
|
||||
let http = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let baseline = inspect_api_response(
|
||||
http.get(url.clone())
|
||||
.header("X-App-Id", app_id)
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
.await;
|
||||
let x_user_auth_token = inspect_api_response(
|
||||
http.get(url.clone())
|
||||
.header("X-App-Id", app_id)
|
||||
.header("X-User-Auth-Token", candidate.api_token().jwt())
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
.await;
|
||||
let bearer = inspect_api_response(
|
||||
http.get(url)
|
||||
.header("X-App-Id", app_id)
|
||||
.bearer_auth(candidate.api_token().jwt())
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ApiProbe {
|
||||
baseline,
|
||||
x_user_auth_token,
|
||||
bearer,
|
||||
})
|
||||
}
|
||||
|
||||
async fn inspect_api_response(response: reqwest::Response) -> ProbeResponse {
|
||||
let status = response.status().as_u16();
|
||||
let user_shape = response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.is_some_and(|object| {
|
||||
object.contains_key("id")
|
||||
|| object.contains_key("user")
|
||||
|| object.contains_key("credential")
|
||||
});
|
||||
ProbeResponse { status, user_shape }
|
||||
}
|
||||
|
||||
async fn validate_qws_session(
|
||||
candidate: &HandoffCandidate,
|
||||
device_uuid: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let transport = Arc::new(NativeWsTransport::new());
|
||||
let mut events = transport.subscribe();
|
||||
let mut config = WsTransportConfig::default();
|
||||
config.endpoint_url = candidate.qconnect_token().endpoint().to_string();
|
||||
config.jwt_qws = Some(candidate.qconnect_token().jwt().to_string());
|
||||
config.require_jwt = true;
|
||||
config.subscribe_channels = vec![vec![0x01], vec![0x02], vec![0x03]];
|
||||
config.reconnect_max_attempts = Some(1);
|
||||
transport.connect(config).await?;
|
||||
|
||||
let validation = tokio::time::timeout(CLOUD_VALIDATION_TIMEOUT, async {
|
||||
let mut joined = false;
|
||||
loop {
|
||||
match events.recv().await {
|
||||
Ok(TransportEvent::Subscribed) if !joined => {
|
||||
joined = true;
|
||||
let report = RendererReport::new(
|
||||
RendererReportType::RndrSrvrJoinSession,
|
||||
Uuid::new_v4().to_string(),
|
||||
QueueVersion::default(),
|
||||
json!({
|
||||
"session_uuid": candidate.session_id(),
|
||||
"device_info": {
|
||||
"device_uuid": device_uuid,
|
||||
"friendly_name": "QBZ protocol probe",
|
||||
"brand": "QBZ",
|
||||
"model": "Development harness",
|
||||
"device_type": 5,
|
||||
"capabilities": {
|
||||
"min_audio_quality": 1,
|
||||
"max_audio_quality": 4,
|
||||
"volume_remote_control": 2
|
||||
},
|
||||
"software_version": env!("CARGO_PKG_VERSION")
|
||||
},
|
||||
"is_active": candidate.become_active(),
|
||||
"reason": 1,
|
||||
"initial_state": {
|
||||
"playing_state": 1,
|
||||
"buffer_state": RendererBufferState::Ok.as_i32(),
|
||||
"current_position": 0,
|
||||
"duration": 0,
|
||||
"queue_version": { "major": 0, "minor": 0 }
|
||||
}
|
||||
}),
|
||||
);
|
||||
let envelope = build_qconnect_renderer_outbound_envelope(report)?;
|
||||
transport.send(envelope).await?;
|
||||
}
|
||||
Ok(TransportEvent::Connected) => eprintln!("[probe] QWS connected"),
|
||||
Ok(TransportEvent::Authenticated) => eprintln!("[probe] QWS authenticated"),
|
||||
Ok(TransportEvent::Subscribed) if joined => {
|
||||
eprintln!("[probe] QWS subscribed again after delegated join")
|
||||
}
|
||||
Ok(TransportEvent::SessionEstablished) => {
|
||||
eprintln!("[probe] QWS controller session established")
|
||||
}
|
||||
Ok(TransportEvent::CloudError { code, .. }) => {
|
||||
return Err(format!("QWS rejected delegated join with code {code}").into());
|
||||
}
|
||||
Ok(TransportEvent::OutboundSent { message_type, .. }) => {
|
||||
eprintln!("[probe] QWS outbound {message_type}");
|
||||
}
|
||||
Ok(TransportEvent::InboundFrameDecoded {
|
||||
cloud_message_type,
|
||||
payload_size,
|
||||
}) => {
|
||||
eprintln!(
|
||||
"[probe] QWS inbound cloud_type={cloud_message_type} bytes={payload_size}"
|
||||
);
|
||||
}
|
||||
Ok(TransportEvent::InboundQueueServerEvent(event)) => {
|
||||
eprintln!("[probe] QWS inbound {}", event.message_type());
|
||||
}
|
||||
Ok(TransportEvent::InboundRendererServerCommand(command)) => {
|
||||
eprintln!("[probe] QWS inbound {}", command.message_type());
|
||||
if command.command_type == RendererCommandType::SrvrRndrSetActive
|
||||
&& command.payload.get("active").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
return Ok::<(), Box<dyn Error>>(());
|
||||
}
|
||||
}
|
||||
Ok(TransportEvent::InboundReceived(_)) => {
|
||||
eprintln!("[probe] QWS inbound JSON envelope");
|
||||
}
|
||||
Ok(TransportEvent::TransportError { stage, .. }) => {
|
||||
eprintln!("[probe] QWS transport error at stage={stage}");
|
||||
}
|
||||
Ok(TransportEvent::ReconnectScheduled { attempt, .. }) => {
|
||||
eprintln!("[probe] QWS reconnect scheduled attempt={attempt}");
|
||||
}
|
||||
Ok(TransportEvent::MaxReconnectAttemptsExceeded { attempts, .. }) => {
|
||||
return Err(
|
||||
format!("QWS validation exhausted after {attempts} attempt(s)").into(),
|
||||
);
|
||||
}
|
||||
Ok(TransportEvent::Disconnected) => eprintln!("[probe] QWS disconnected"),
|
||||
Ok(_) => {}
|
||||
Err(_) => return Err("QWS validation event channel closed".into()),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = transport.disconnect().await;
|
||||
validation.map_err(|_| "QWS delegated session validation timed out")?
|
||||
}
|
||||
@@ -16,5 +16,8 @@ pub use model::{
|
||||
ConnectInfo, DeviceType, DisplayInfo, HandoffCandidate, LanJwtToken, MaxAudioQuality,
|
||||
};
|
||||
pub use projection::LanProjection;
|
||||
pub use server::{LanError, LanService, LanServiceConfig, SERVICE_TYPE};
|
||||
pub use server::{
|
||||
LanError, LanHttpMethod, LanHttpRoute, LanRequestObservation, LanService, LanServiceConfig,
|
||||
SERVICE_TYPE,
|
||||
};
|
||||
pub use validation::{EndpointPolicy, ValidationError, MAX_BODY_BYTES};
|
||||
@@ -1,11 +1,16 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use mdns_sd::{DaemonEvent, IfKind, ServiceDaemon, ServiceInfo, UnregisterStatus};
|
||||
|
||||
use crate::server::{LanError, SERVICE_TYPE};
|
||||
|
||||
const ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const GOODBYE_RESEND_DELAY: Duration = Duration::from_millis(150);
|
||||
const DAEMON_SHUTDOWN_RESERVE: Duration = Duration::from_millis(250);
|
||||
|
||||
pub(crate) struct MdnsRegistration {
|
||||
daemon: ServiceDaemon,
|
||||
fullname: String,
|
||||
@@ -32,17 +37,60 @@ impl MdnsRegistration {
|
||||
let info = service_info(device_uuid, port, sdk_version, &addresses)?;
|
||||
let fullname = info.get_fullname().to_string();
|
||||
let daemon = ServiceDaemon::new()?;
|
||||
daemon.register(info)?;
|
||||
let monitor = daemon.monitor()?;
|
||||
if daemon.register(info).is_err() {
|
||||
shutdown_daemon(&daemon, Some(&fullname), SHUTDOWN_TIMEOUT);
|
||||
return Err(LanError::Mdns);
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + ANNOUNCE_TIMEOUT;
|
||||
let announced = loop {
|
||||
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
|
||||
break false;
|
||||
};
|
||||
match monitor.recv_timeout(remaining) {
|
||||
Ok(DaemonEvent::Announce(announced, _))
|
||||
if announced.eq_ignore_ascii_case(&fullname) =>
|
||||
{
|
||||
break true;
|
||||
}
|
||||
Ok(DaemonEvent::Error(_) | DaemonEvent::NameChange(_)) | Err(_) => break false,
|
||||
Ok(_) => {}
|
||||
}
|
||||
};
|
||||
if !announced {
|
||||
shutdown_daemon(&daemon, Some(&fullname), SHUTDOWN_TIMEOUT);
|
||||
return Err(LanError::Mdns);
|
||||
}
|
||||
|
||||
Ok(Self { daemon, fullname })
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown(self) {
|
||||
if let Ok(receiver) = self.daemon.unregister(&self.fullname) {
|
||||
let _ = receiver.recv_timeout(Duration::from_secs(2));
|
||||
shutdown_daemon(&self.daemon, Some(&self.fullname), SHUTDOWN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_daemon(daemon: &ServiceDaemon, fullname: Option<&str>, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
if let Some(fullname) = fullname {
|
||||
if let Ok(receiver) = daemon.unregister(fullname) {
|
||||
if let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
|
||||
if matches!(receiver.recv_timeout(remaining), Ok(UnregisterStatus::OK)) {
|
||||
// mdns-sd schedules a second goodbye 120 ms after the
|
||||
// unregister acknowledgement. Keep the daemon alive long
|
||||
// enough to send it, while reserving time for shutdown.
|
||||
if let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
|
||||
let goodbye_budget = remaining.saturating_sub(DAEMON_SHUTDOWN_RESERVE);
|
||||
std::thread::sleep(GOODBYE_RESEND_DELAY.min(goodbye_budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(receiver) = self.daemon.shutdown() {
|
||||
let _ = receiver.recv_timeout(Duration::from_secs(2));
|
||||
}
|
||||
if let Ok(receiver) = daemon.shutdown() {
|
||||
if let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
|
||||
let _ = receiver.recv_timeout(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,27 +109,36 @@ fn service_info(
|
||||
("sdk_version".to_string(), sdk_version.to_string()),
|
||||
("path".to_string(), String::new()),
|
||||
]);
|
||||
ServiceInfo::new(
|
||||
let mut info = ServiceInfo::new(
|
||||
SERVICE_TYPE,
|
||||
&instance,
|
||||
&hostname,
|
||||
addresses,
|
||||
port,
|
||||
properties,
|
||||
)
|
||||
.map(ServiceInfo::enable_addr_auto)
|
||||
)?;
|
||||
info.set_interfaces(addresses.iter().copied().map(IfKind::Addr).collect());
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn lan_ipv4_addresses() -> Result<Vec<IpAddr>, LanError> {
|
||||
let mut addresses = if_addrs::get_if_addrs()
|
||||
// A connected UDP socket performs route selection without sending data.
|
||||
// Targeting the mDNS multicast group yields the IPv4 interface that can
|
||||
// actually reach LAN controllers, excluding Docker/VM bridges and
|
||||
// loopback records that make strict official clients choose a dead URL.
|
||||
let socket = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)))
|
||||
.map_err(|_| LanError::AddressDiscovery)?;
|
||||
socket
|
||||
.connect(SocketAddr::from((Ipv4Addr::new(224, 0, 0, 251), 5353)))
|
||||
.map_err(|_| LanError::AddressDiscovery)?;
|
||||
let address = socket
|
||||
.local_addr()
|
||||
.map_err(|_| LanError::AddressDiscovery)?
|
||||
.into_iter()
|
||||
.map(|interface| interface.ip())
|
||||
.filter(|ip| ip.is_ipv4() && !ip.is_loopback() && !ip.is_unspecified())
|
||||
.collect::<Vec<_>>();
|
||||
addresses.sort_unstable();
|
||||
addresses.dedup();
|
||||
Ok(addresses)
|
||||
.ip();
|
||||
if !address.is_ipv4() || address.is_loopback() || address.is_unspecified() {
|
||||
return Err(LanError::NoLanAddresses);
|
||||
}
|
||||
Ok(vec![address])
|
||||
}
|
||||
|
||||
fn short_id(device_uuid: &str) -> String {
|
||||
@@ -118,5 +175,12 @@ mod tests {
|
||||
assert_eq!(info.get_property_val_str("device_uuid"), Some(uuid));
|
||||
assert_eq!(info.get_property_val_str("sdk_version"), Some("0.9.5"));
|
||||
assert_eq!(info.get_property_val_str("path"), Some(""));
|
||||
assert!(!info.is_addr_auto());
|
||||
assert_eq!(
|
||||
info.get_addresses(),
|
||||
&["192.0.2.10".parse::<IpAddr>().unwrap()]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
}
|
||||
}
|
||||
+618
-155
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{self, TrySendError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -8,7 +8,7 @@ use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::admission::{admission_channel, AdmissionInbox, AdmissionSender, SubmitError};
|
||||
use crate::mdns::MdnsRegistration;
|
||||
@@ -22,8 +22,36 @@ const POST_RATE_PER_MINUTE: f64 = 6.0;
|
||||
const POST_BURST: f64 = 2.0;
|
||||
const MAX_RATE_ORIGINS: usize = 256;
|
||||
const HTTP_WORKERS: usize = 2;
|
||||
const MAX_HEADER_BYTES: usize = 16 * 1024;
|
||||
const ACCEPT_WAKE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const REJECTION_DRAIN_TIMEOUT: Duration = Duration::from_millis(25);
|
||||
pub const DEFAULT_LOCAL_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LanHttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LanHttpRoute {
|
||||
GetDisplayInfo,
|
||||
GetConnectInfo,
|
||||
ConnectToQconnect,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Sanitized, opt-in request observation for protocol probes and diagnostics.
|
||||
/// It deliberately excludes remote addresses, headers, URLs and bodies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LanRequestObservation {
|
||||
pub method: LanHttpMethod,
|
||||
pub route: LanHttpRoute,
|
||||
pub status_code: u16,
|
||||
pub validation_error: Option<ValidationError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LanError {
|
||||
#[error("qconnect-lan-bind")]
|
||||
@@ -51,15 +79,18 @@ pub struct LanServiceConfig {
|
||||
pub endpoint_policy: EndpointPolicy,
|
||||
pub device_uuid: String,
|
||||
pub sdk_version: String,
|
||||
/// Bounds stalled local request reads. The production default is the
|
||||
/// contract's five-second local read/parse budget.
|
||||
/// Bounds the complete local read and parse operation, including clients
|
||||
/// that trickle bytes without ever going idle.
|
||||
pub read_timeout: Duration,
|
||||
/// Tests and environments without multicast can exercise the exact HTTP
|
||||
/// wire while keeping discovery disabled. Production adapters leave true.
|
||||
pub advertise_mdns: bool,
|
||||
/// Optional explicit address set, primarily for deterministic integration
|
||||
/// tests. Production uses interface discovery and mdns-sd auto updates.
|
||||
/// tests. Production selects the interface routed to mDNS multicast.
|
||||
pub advertised_addresses: Option<Vec<IpAddr>>,
|
||||
/// Optional bounded diagnostics sink. Events are best-effort and dropped
|
||||
/// when the receiver is busy or disconnected.
|
||||
pub request_observer: Option<mpsc::SyncSender<LanRequestObservation>>,
|
||||
}
|
||||
|
||||
impl LanServiceConfig {
|
||||
@@ -77,40 +108,100 @@ impl LanServiceConfig {
|
||||
read_timeout: DEFAULT_LOCAL_READ_TIMEOUT,
|
||||
advertise_mdns: true,
|
||||
advertised_addresses: None,
|
||||
request_observer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LanService {
|
||||
port: u16,
|
||||
server: Arc<Server>,
|
||||
admission: AdmissionSender,
|
||||
accepting: Arc<AtomicBool>,
|
||||
accept_wake_addr: SocketAddr,
|
||||
active_connections: Arc<ActiveConnections>,
|
||||
accept_thread: Option<JoinHandle<()>>,
|
||||
worker_threads: Vec<JoinHandle<()>>,
|
||||
mdns: Option<MdnsRegistration>,
|
||||
}
|
||||
|
||||
struct InFlightPermit {
|
||||
count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl InFlightPermit {
|
||||
fn try_acquire(count: Arc<AtomicUsize>) -> Option<Self> {
|
||||
count
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
(current < HTTP_WORKERS).then_some(current + 1)
|
||||
})
|
||||
.ok()
|
||||
.map(|_| Self { count })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlightPermit {
|
||||
fn drop(&mut self) {
|
||||
self.count.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ActiveConnections {
|
||||
next_id: AtomicUsize,
|
||||
// Cloned handles exist only so shutdown can interrupt reads in workers.
|
||||
// The worker-owned stream remains the sole reader and writer.
|
||||
streams: Mutex<HashMap<usize, TcpStream>>,
|
||||
}
|
||||
|
||||
impl ActiveConnections {
|
||||
fn insert(&self, stream: TcpStream) -> usize {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
recover_lock(&self.streams).insert(id, stream);
|
||||
id
|
||||
}
|
||||
|
||||
fn remove(&self, id: usize) {
|
||||
recover_lock(&self.streams).remove(&id);
|
||||
}
|
||||
|
||||
fn shutdown_all(&self) {
|
||||
for stream in recover_lock(&self.streams).values() {
|
||||
let _ = stream.shutdown(Shutdown::Both);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionJob {
|
||||
stream: TcpStream,
|
||||
active_connections: Arc<ActiveConnections>,
|
||||
active_id: usize,
|
||||
_permit: InFlightPermit,
|
||||
}
|
||||
|
||||
impl Drop for ConnectionJob {
|
||||
fn drop(&mut self) {
|
||||
self.active_connections.remove(self.active_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl LanService {
|
||||
/// Bind first, spawn HTTP workers, then publish DNS-SD. On any discovery
|
||||
/// failure the bound listener is torn down before the error returns.
|
||||
/// Bind first, spawn the fixed HTTP worker set, then publish DNS-SD. On
|
||||
/// any discovery failure the listener is torn down before returning.
|
||||
pub fn start(config: LanServiceConfig) -> Result<(Self, AdmissionInbox), LanError> {
|
||||
validate_config(&config)?;
|
||||
let server = Arc::new(bind_http(config.bind_addr, config.read_timeout)?);
|
||||
let port = server
|
||||
.server_addr()
|
||||
.to_ip()
|
||||
.map(|address| address.port())
|
||||
.ok_or(LanError::Bind)?;
|
||||
let listener = bind_http(config.bind_addr)?;
|
||||
let listener_addr = listener.local_addr().map_err(|_| LanError::Bind)?;
|
||||
let port = listener_addr.port();
|
||||
let accept_wake_addr = accept_wake_addr(listener_addr);
|
||||
|
||||
let (admission, inbox) = admission_channel();
|
||||
let accepting = Arc::new(AtomicBool::new(true));
|
||||
let rate_limiter = Arc::new(Mutex::new(PostRateLimiter::default()));
|
||||
|
||||
// Count queued plus active work explicitly. This avoids a scheduling
|
||||
// race where a worker exists but has not reached `recv` yet.
|
||||
let (request_tx, request_rx) = mpsc::sync_channel::<Request>(HTTP_WORKERS);
|
||||
let (request_tx, request_rx) = mpsc::sync_channel::<ConnectionJob>(HTTP_WORKERS);
|
||||
let request_rx = Arc::new(Mutex::new(request_rx));
|
||||
let in_flight = Arc::new(AtomicUsize::new(0));
|
||||
let active_connections = Arc::new(ActiveConnections::default());
|
||||
|
||||
let mut worker_threads = Vec::with_capacity(HTTP_WORKERS);
|
||||
for _ in 0..HTTP_WORKERS {
|
||||
let worker_rx = Arc::clone(&request_rx);
|
||||
@@ -119,83 +210,112 @@ impl LanService {
|
||||
let worker_projection = config.projection.clone();
|
||||
let worker_policy = config.endpoint_policy.clone();
|
||||
let worker_limiter = Arc::clone(&rate_limiter);
|
||||
let worker_in_flight = Arc::clone(&in_flight);
|
||||
let worker_observer = config.request_observer.clone();
|
||||
let read_timeout = config.read_timeout;
|
||||
worker_threads.push(std::thread::spawn(move || loop {
|
||||
let request = worker_rx
|
||||
.lock()
|
||||
.expect("LAN request queue lock poisoned")
|
||||
.recv();
|
||||
let request = match request {
|
||||
Ok(request) => request,
|
||||
let job = {
|
||||
let receiver = recover_lock(&worker_rx);
|
||||
receiver.recv()
|
||||
};
|
||||
let mut job = match job {
|
||||
Ok(job) => job,
|
||||
Err(_) => break,
|
||||
};
|
||||
if worker_accepting.load(Ordering::Acquire) {
|
||||
handle_request(
|
||||
request,
|
||||
handle_connection(
|
||||
&mut job.stream,
|
||||
read_timeout,
|
||||
&worker_projection,
|
||||
&worker_policy,
|
||||
&worker_admission,
|
||||
&worker_limiter,
|
||||
worker_observer.as_ref(),
|
||||
);
|
||||
} else {
|
||||
let _ = request.respond(error_response(StatusCode(503), "unavailable"));
|
||||
let _ = write_response(&mut job.stream, &error_response(503, "unavailable"));
|
||||
}
|
||||
worker_in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
}));
|
||||
}
|
||||
|
||||
let thread_server = Arc::clone(&server);
|
||||
let thread_accepting = Arc::clone(&accepting);
|
||||
let accept_in_flight = Arc::clone(&in_flight);
|
||||
let accept_thread = std::thread::spawn(move || {
|
||||
while thread_accepting.load(Ordering::Acquire) {
|
||||
match thread_server.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(Some(request)) => {
|
||||
let admitted = accept_in_flight
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
(current < HTTP_WORKERS).then_some(current + 1)
|
||||
})
|
||||
.is_ok();
|
||||
if !admitted {
|
||||
let _ = request.respond(error_response(StatusCode(429), "busy"));
|
||||
let accept_active_connections = Arc::clone(&active_connections);
|
||||
let accept_admission = admission.clone();
|
||||
let accept_observer = config.request_observer.clone();
|
||||
// The listener deliberately has no SO_RCVTIMEO: that option also
|
||||
// times out accept() on Linux. Shutdown wakes this blocking accept
|
||||
// with one bounded local connection after clearing `accepting`.
|
||||
let accept_thread = std::thread::spawn(move || loop {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
if !thread_accepting.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let Some(permit) = InFlightPermit::try_acquire(Arc::clone(&accept_in_flight))
|
||||
else {
|
||||
observe_status(accept_observer.as_ref(), 429);
|
||||
reject_connection(&mut stream, &error_response(429, "busy"));
|
||||
continue;
|
||||
};
|
||||
let shutdown_stream = match stream.try_clone() {
|
||||
Ok(stream) => stream,
|
||||
Err(_) => {
|
||||
drop(permit);
|
||||
observe_status(accept_observer.as_ref(), 503);
|
||||
reject_connection(&mut stream, &error_response(503, "unavailable"));
|
||||
continue;
|
||||
}
|
||||
match request_tx.try_send(request) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(request)) => {
|
||||
accept_in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
let _ = request.respond(error_response(StatusCode(429), "busy"));
|
||||
}
|
||||
Err(TrySendError::Disconnected(request)) => {
|
||||
accept_in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
let _ =
|
||||
request.respond(error_response(StatusCode(503), "unavailable"));
|
||||
break;
|
||||
}
|
||||
};
|
||||
let active_id = accept_active_connections.insert(shutdown_stream);
|
||||
let job = ConnectionJob {
|
||||
stream,
|
||||
active_connections: Arc::clone(&accept_active_connections),
|
||||
active_id,
|
||||
_permit: permit,
|
||||
};
|
||||
match request_tx.try_send(job) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(mut job)) => {
|
||||
observe_status(accept_observer.as_ref(), 429);
|
||||
reject_connection(&mut job.stream, &error_response(429, "busy"));
|
||||
}
|
||||
Err(TrySendError::Disconnected(mut job)) => {
|
||||
observe_status(accept_observer.as_ref(), 503);
|
||||
reject_connection(&mut job.stream, &error_response(503, "unavailable"));
|
||||
thread_accepting.store(false, Ordering::Release);
|
||||
accept_admission.close();
|
||||
accept_active_connections.shutdown_all();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) if !thread_accepting.load(Ordering::Acquire) => break,
|
||||
// Malformed or timed-out client connections are surfaced
|
||||
// by tiny_http through this queue too; they must not kill
|
||||
// discovery for every other controller.
|
||||
Err(_) => {}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(_) if !thread_accepting.load(Ordering::Acquire) => break,
|
||||
Err(_) => {
|
||||
thread_accepting.store(false, Ordering::Release);
|
||||
accept_admission.close();
|
||||
accept_active_connections.shutdown_all();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mdns = if config.advertise_mdns {
|
||||
let advertised_addresses = config.advertised_addresses.or_else(|| {
|
||||
(!config.bind_addr.ip().is_unspecified()).then(|| vec![config.bind_addr.ip()])
|
||||
});
|
||||
match MdnsRegistration::register(
|
||||
&config.device_uuid,
|
||||
port,
|
||||
&config.sdk_version,
|
||||
config.advertised_addresses,
|
||||
advertised_addresses,
|
||||
) {
|
||||
Ok(mdns) => Some(mdns),
|
||||
Err(error) => {
|
||||
admission.close();
|
||||
accepting.store(false, Ordering::Release);
|
||||
server.unblock();
|
||||
active_connections.shutdown_all();
|
||||
wake_acceptor(accept_wake_addr);
|
||||
let _ = accept_thread.join();
|
||||
for worker in worker_threads {
|
||||
let _ = worker.join();
|
||||
@@ -210,9 +330,10 @@ impl LanService {
|
||||
Ok((
|
||||
Self {
|
||||
port,
|
||||
server,
|
||||
admission,
|
||||
accepting,
|
||||
accept_wake_addr,
|
||||
active_connections,
|
||||
accept_thread: Some(accept_thread),
|
||||
worker_threads,
|
||||
mdns,
|
||||
@@ -227,12 +348,17 @@ impl LanService {
|
||||
|
||||
pub fn shutdown(&mut self) {
|
||||
self.admission.close();
|
||||
self.accepting.store(false, Ordering::Release);
|
||||
// Stop advertising before making the HTTP endpoint unreachable. A
|
||||
// strict official client may cache a discovered renderer for minutes,
|
||||
// so publishing a dead port even briefly is worse than keeping the
|
||||
// already-closed admission socket alive during the bounded goodbye.
|
||||
if let Some(mdns) = self.mdns.take() {
|
||||
mdns.shutdown();
|
||||
}
|
||||
self.accepting.store(false, Ordering::Release);
|
||||
self.server.unblock();
|
||||
self.active_connections.shutdown_all();
|
||||
if let Some(thread) = self.accept_thread.take() {
|
||||
wake_acceptor(self.accept_wake_addr);
|
||||
let _ = thread.join();
|
||||
}
|
||||
for thread in self.worker_threads.drain(..) {
|
||||
@@ -267,10 +393,28 @@ fn validate_config(config: &LanServiceConfig) -> Result<(), LanError> {
|
||||
{
|
||||
return Err(LanError::InvalidConfig);
|
||||
}
|
||||
if config.advertise_mdns {
|
||||
let bind_ip = config.bind_addr.ip();
|
||||
if bind_ip.is_loopback() {
|
||||
return Err(LanError::InvalidConfig);
|
||||
}
|
||||
if let Some(addresses) = &config.advertised_addresses {
|
||||
if addresses.is_empty()
|
||||
|| addresses.iter().any(|address| {
|
||||
!address.is_ipv4()
|
||||
|| address.is_loopback()
|
||||
|| address.is_unspecified()
|
||||
|| (!bind_ip.is_unspecified() && *address != bind_ip)
|
||||
})
|
||||
{
|
||||
return Err(LanError::InvalidConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_http(bind_addr: SocketAddr, read_timeout: Duration) -> Result<Server, LanError> {
|
||||
fn bind_http(bind_addr: SocketAddr) -> Result<TcpListener, LanError> {
|
||||
let domain = if bind_addr.is_ipv4() {
|
||||
Domain::IPV4
|
||||
} else {
|
||||
@@ -282,76 +426,289 @@ fn bind_http(bind_addr: SocketAddr, read_timeout: Duration) -> Result<Server, La
|
||||
if bind_addr.is_ipv6() {
|
||||
socket.set_only_v6(true).map_err(|_| LanError::Bind)?;
|
||||
}
|
||||
// Linux propagates SO_RCVTIMEO from a listening socket to accepted TCP
|
||||
// sockets. That bounds stalled request reads inside tiny_http.
|
||||
socket
|
||||
.set_read_timeout(Some(read_timeout))
|
||||
.map_err(|_| LanError::Bind)?;
|
||||
socket.bind(&bind_addr.into()).map_err(|_| LanError::Bind)?;
|
||||
socket.listen(128).map_err(|_| LanError::Bind)?;
|
||||
let listener: TcpListener = socket.into();
|
||||
Server::from_listener(listener, None).map_err(|_| LanError::Bind)
|
||||
Ok(socket.into())
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
mut request: Request,
|
||||
fn accept_wake_addr(listener_addr: SocketAddr) -> SocketAddr {
|
||||
if !listener_addr.ip().is_unspecified() {
|
||||
return listener_addr;
|
||||
}
|
||||
let loopback = if listener_addr.is_ipv4() {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
} else {
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST)
|
||||
};
|
||||
SocketAddr::new(loopback, listener_addr.port())
|
||||
}
|
||||
|
||||
fn wake_acceptor(address: SocketAddr) {
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&address, ACCEPT_WAKE_TIMEOUT) {
|
||||
let _ = stream.shutdown(Shutdown::Both);
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ReadRequestError {
|
||||
Invalid,
|
||||
TooLarge,
|
||||
TimedOut,
|
||||
ExpectationFailed,
|
||||
}
|
||||
|
||||
struct ParsedRequest {
|
||||
method: LanHttpMethod,
|
||||
path: String,
|
||||
content_type_json: bool,
|
||||
body: Zeroizing<Vec<u8>>,
|
||||
remote_ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
fn handle_connection(
|
||||
stream: &mut TcpStream,
|
||||
read_timeout: Duration,
|
||||
projection: &LanProjection,
|
||||
policy: &EndpointPolicy,
|
||||
admission: &AdmissionSender,
|
||||
rate_limiter: &Arc<Mutex<PostRateLimiter>>,
|
||||
observer: Option<&mpsc::SyncSender<LanRequestObservation>>,
|
||||
) {
|
||||
let method = request.method().clone();
|
||||
let path = request.url().to_string();
|
||||
let response = match path.as_str() {
|
||||
"/get-display-info" if method == Method::Get => {
|
||||
json_response(StatusCode(200), &projection.display_info())
|
||||
let _ = stream.set_write_timeout(Some(read_timeout));
|
||||
let request = match read_request(stream, read_timeout) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
let status = match error {
|
||||
ReadRequestError::TooLarge => 413,
|
||||
ReadRequestError::Invalid | ReadRequestError::TimedOut => 400,
|
||||
ReadRequestError::ExpectationFailed => 417,
|
||||
};
|
||||
let code = match error {
|
||||
ReadRequestError::TooLarge => "request_too_large",
|
||||
ReadRequestError::Invalid => "invalid_request",
|
||||
ReadRequestError::TimedOut => "request_timeout",
|
||||
ReadRequestError::ExpectationFailed => "expectation_failed",
|
||||
};
|
||||
observe_status(observer, status);
|
||||
let _ = write_response(stream, &error_response(status, code));
|
||||
return;
|
||||
}
|
||||
"/get-connect-info" if method == Method::Get => {
|
||||
json_response(StatusCode(200), &projection.connect_info())
|
||||
}
|
||||
"/connect-to-qconnect" if method == Method::Post => {
|
||||
handle_connect(&mut request, policy, admission, rate_limiter)
|
||||
}
|
||||
"/get-display-info" | "/get-connect-info" | "/connect-to-qconnect" => {
|
||||
error_response(StatusCode(405), "method_not_allowed")
|
||||
}
|
||||
_ => error_response(StatusCode(404), "not_found"),
|
||||
};
|
||||
let _ = request.respond(response);
|
||||
let observed_route = route_for(&request.path);
|
||||
let mut validation_error = None;
|
||||
let response = handle_request(
|
||||
&request,
|
||||
projection,
|
||||
policy,
|
||||
admission,
|
||||
rate_limiter,
|
||||
&mut validation_error,
|
||||
);
|
||||
if let Some(observer) = observer {
|
||||
let _ = observer.try_send(LanRequestObservation {
|
||||
method: request.method,
|
||||
route: observed_route,
|
||||
status_code: response.status_code,
|
||||
validation_error,
|
||||
});
|
||||
}
|
||||
let _ = write_response(stream, &response);
|
||||
}
|
||||
|
||||
fn handle_connect(
|
||||
request: &mut Request,
|
||||
fn observe_status(observer: Option<&mpsc::SyncSender<LanRequestObservation>>, status_code: u16) {
|
||||
if let Some(observer) = observer {
|
||||
let _ = observer.try_send(LanRequestObservation {
|
||||
method: LanHttpMethod::Other,
|
||||
route: LanHttpRoute::Unknown,
|
||||
status_code,
|
||||
validation_error: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn read_request(
|
||||
stream: &mut TcpStream,
|
||||
timeout: Duration,
|
||||
) -> Result<ParsedRequest, ReadRequestError> {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or(ReadRequestError::Invalid)?;
|
||||
let mut bytes = Zeroizing::new(Vec::with_capacity(4096));
|
||||
let header_end = loop {
|
||||
if let Some(end) = find_header_end(&bytes) {
|
||||
if end > MAX_HEADER_BYTES {
|
||||
return Err(ReadRequestError::TooLarge);
|
||||
}
|
||||
break end;
|
||||
}
|
||||
if bytes.len() >= MAX_HEADER_BYTES {
|
||||
return Err(ReadRequestError::TooLarge);
|
||||
}
|
||||
read_more(stream, &mut bytes, deadline)?;
|
||||
};
|
||||
|
||||
let head =
|
||||
std::str::from_utf8(&bytes[..header_end - 4]).map_err(|_| ReadRequestError::Invalid)?;
|
||||
let mut lines = head.split("\r\n");
|
||||
let mut request_line = lines
|
||||
.next()
|
||||
.ok_or(ReadRequestError::Invalid)?
|
||||
.split_ascii_whitespace();
|
||||
let method = match request_line.next().ok_or(ReadRequestError::Invalid)? {
|
||||
"GET" => LanHttpMethod::Get,
|
||||
"POST" => LanHttpMethod::Post,
|
||||
_ => LanHttpMethod::Other,
|
||||
};
|
||||
let path = request_line
|
||||
.next()
|
||||
.filter(|path| path.starts_with('/'))
|
||||
.ok_or(ReadRequestError::Invalid)?
|
||||
.to_string();
|
||||
let version = request_line.next().ok_or(ReadRequestError::Invalid)?;
|
||||
if request_line.next().is_some() || !matches!(version, "HTTP/1.0" | "HTTP/1.1") {
|
||||
return Err(ReadRequestError::Invalid);
|
||||
}
|
||||
|
||||
let mut content_length = None;
|
||||
let mut content_type_json = false;
|
||||
for line in lines {
|
||||
let (name, value) = line.split_once(':').ok_or(ReadRequestError::Invalid)?;
|
||||
let name = name.trim();
|
||||
let value = value.trim();
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
if content_length.is_some() {
|
||||
return Err(ReadRequestError::Invalid);
|
||||
}
|
||||
content_length = Some(
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| ReadRequestError::Invalid)?,
|
||||
);
|
||||
} else if name.eq_ignore_ascii_case("content-type") {
|
||||
content_type_json = value
|
||||
.split(';')
|
||||
.next()
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json"));
|
||||
} else if name.eq_ignore_ascii_case("transfer-encoding") {
|
||||
return Err(ReadRequestError::Invalid);
|
||||
} else if name.eq_ignore_ascii_case("expect") {
|
||||
return Err(ReadRequestError::ExpectationFailed);
|
||||
}
|
||||
}
|
||||
|
||||
let body_len = content_length.unwrap_or(0);
|
||||
if body_len > MAX_BODY_BYTES {
|
||||
return Err(ReadRequestError::TooLarge);
|
||||
}
|
||||
let total_len = header_end
|
||||
.checked_add(body_len)
|
||||
.ok_or(ReadRequestError::TooLarge)?;
|
||||
while bytes.len() < total_len {
|
||||
read_more(stream, &mut bytes, deadline)?;
|
||||
}
|
||||
if bytes.len() > total_len {
|
||||
bytes[total_len..].fill(0);
|
||||
}
|
||||
bytes.truncate(total_len);
|
||||
let body = Zeroizing::new(bytes[header_end..].to_vec());
|
||||
|
||||
Ok(ParsedRequest {
|
||||
method,
|
||||
path,
|
||||
content_type_json,
|
||||
body,
|
||||
remote_ip: stream.peer_addr().ok().map(|address| address.ip()),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_more(
|
||||
stream: &mut TcpStream,
|
||||
bytes: &mut Vec<u8>,
|
||||
deadline: Instant,
|
||||
) -> Result<(), ReadRequestError> {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.filter(|remaining| !remaining.is_zero())
|
||||
.ok_or(ReadRequestError::TimedOut)?;
|
||||
stream
|
||||
.set_read_timeout(Some(remaining))
|
||||
.map_err(|_| ReadRequestError::Invalid)?;
|
||||
let mut chunk = Zeroizing::new([0_u8; 4096]);
|
||||
match stream.read(&mut chunk[..]) {
|
||||
Ok(0) => Err(ReadRequestError::Invalid),
|
||||
Ok(count) => {
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
Ok(())
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
) =>
|
||||
{
|
||||
Err(ReadRequestError::TimedOut)
|
||||
}
|
||||
Err(_) => Err(ReadRequestError::Invalid),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_header_end(bytes: &[u8]) -> Option<usize> {
|
||||
bytes
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| position + 4)
|
||||
}
|
||||
|
||||
fn route_for(path: &str) -> LanHttpRoute {
|
||||
match path {
|
||||
"/get-display-info" => LanHttpRoute::GetDisplayInfo,
|
||||
"/get-connect-info" => LanHttpRoute::GetConnectInfo,
|
||||
"/connect-to-qconnect" => LanHttpRoute::ConnectToQconnect,
|
||||
_ => LanHttpRoute::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
request: &ParsedRequest,
|
||||
projection: &LanProjection,
|
||||
policy: &EndpointPolicy,
|
||||
admission: &AdmissionSender,
|
||||
rate_limiter: &Arc<Mutex<PostRateLimiter>>,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
if !has_json_content_type(request) {
|
||||
return error_response(StatusCode(415), "json_required");
|
||||
}
|
||||
if declared_body_too_large(request) {
|
||||
return error_response(StatusCode(413), "body_too_large");
|
||||
}
|
||||
let remote_ip = request.remote_addr().map(SocketAddr::ip);
|
||||
if !rate_limiter
|
||||
.lock()
|
||||
.expect("LAN rate-limit lock poisoned")
|
||||
.allow(remote_ip, Instant::now())
|
||||
{
|
||||
return error_response(StatusCode(429), "rate_limited");
|
||||
validation_error: &mut Option<ValidationError>,
|
||||
) -> HttpResponse {
|
||||
match (route_for(&request.path), request.method) {
|
||||
(LanHttpRoute::GetDisplayInfo, LanHttpMethod::Get) => {
|
||||
json_response(200, &projection.display_info())
|
||||
}
|
||||
(LanHttpRoute::GetConnectInfo, LanHttpMethod::Get) => {
|
||||
json_response(200, &projection.connect_info())
|
||||
}
|
||||
(LanHttpRoute::ConnectToQconnect, LanHttpMethod::Post) => {
|
||||
handle_connect(request, policy, admission, rate_limiter, validation_error)
|
||||
}
|
||||
(LanHttpRoute::Unknown, _) => error_response(404, "not_found"),
|
||||
_ => error_response(405, "method_not_allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
if request
|
||||
.as_reader()
|
||||
.take((MAX_BODY_BYTES + 1) as u64)
|
||||
.read_to_end(&mut body)
|
||||
.is_err()
|
||||
{
|
||||
return error_response(StatusCode(400), "body_read_failed");
|
||||
fn handle_connect(
|
||||
request: &ParsedRequest,
|
||||
policy: &EndpointPolicy,
|
||||
admission: &AdmissionSender,
|
||||
rate_limiter: &Arc<Mutex<PostRateLimiter>>,
|
||||
validation_error: &mut Option<ValidationError>,
|
||||
) -> HttpResponse {
|
||||
if !request.content_type_json {
|
||||
return error_response(415, "json_required");
|
||||
}
|
||||
if body.len() > MAX_BODY_BYTES {
|
||||
return error_response(StatusCode(413), "body_too_large");
|
||||
if !recover_lock(rate_limiter).allow(request.remote_ip, Instant::now()) {
|
||||
return error_response(429, "rate_limited");
|
||||
}
|
||||
|
||||
let now = SystemTime::now()
|
||||
@@ -359,60 +716,134 @@ fn handle_connect(
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.min(i64::MAX as u64) as i64;
|
||||
let candidate = match parse_and_validate(&body, policy, now) {
|
||||
let candidate = match parse_and_validate(&request.body, policy, now) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(ValidationError::BecomeActiveRequired) => {
|
||||
return error_response(StatusCode(422), "active_required")
|
||||
Err(error @ ValidationError::BecomeActiveRequired) => {
|
||||
*validation_error = Some(error);
|
||||
return error_response(422, "active_required");
|
||||
}
|
||||
Err(error) => {
|
||||
*validation_error = Some(error);
|
||||
return error_response(400, "invalid_handoff");
|
||||
}
|
||||
Err(_) => return error_response(StatusCode(400), "invalid_handoff"),
|
||||
};
|
||||
|
||||
match admission.submit(candidate) {
|
||||
Ok(()) => json_response(StatusCode(200), &serde_json::json!({})),
|
||||
Err(SubmitError::Closed) => error_response(StatusCode(503), "unavailable"),
|
||||
Ok(()) => json_response(200, &serde_json::json!({})),
|
||||
Err(SubmitError::Closed) => error_response(503, "unavailable"),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_json_content_type(request: &Request) -> bool {
|
||||
request.headers().iter().any(|header| {
|
||||
header.field.equiv("Content-Type")
|
||||
&& header
|
||||
.value
|
||||
.as_str()
|
||||
.split(';')
|
||||
.next()
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json"))
|
||||
})
|
||||
struct HttpResponse {
|
||||
status_code: u16,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
fn declared_body_too_large(request: &Request) -> bool {
|
||||
request.headers().iter().any(|header| {
|
||||
header.field.equiv("Content-Length")
|
||||
&& header
|
||||
.value
|
||||
.as_str()
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.is_ok_and(|length| length > MAX_BODY_BYTES)
|
||||
})
|
||||
fn json_response<T: serde::Serialize>(status_code: u16, value: &T) -> HttpResponse {
|
||||
HttpResponse {
|
||||
status_code,
|
||||
body: serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_response<T: serde::Serialize>(
|
||||
status: StatusCode,
|
||||
value: &T,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
|
||||
Response::from_data(body)
|
||||
.with_status_code(status)
|
||||
.with_header(json_content_type())
|
||||
fn error_response(status_code: u16, code: &'static str) -> HttpResponse {
|
||||
json_response(status_code, &serde_json::json!({ "error": code }))
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, code: &'static str) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
json_response(status, &serde_json::json!({ "error": code }))
|
||||
fn write_response(stream: &mut TcpStream, response: &HttpResponse) -> io::Result<()> {
|
||||
let reason = match response.status_code {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
405 => "Method Not Allowed",
|
||||
413 => "Payload Too Large",
|
||||
415 => "Unsupported Media Type",
|
||||
417 => "Expectation Failed",
|
||||
422 => "Unprocessable Entity",
|
||||
429 => "Too Many Requests",
|
||||
503 => "Service Unavailable",
|
||||
_ => "Error",
|
||||
};
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
response.status_code,
|
||||
reason,
|
||||
response.body.len()
|
||||
)?;
|
||||
stream.write_all(&response.body)?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn json_content_type() -> Header {
|
||||
Header::from_bytes("Content-Type", "application/json").expect("static JSON content-type header")
|
||||
/// Send a bounded overload/shutdown response without immediately closing a
|
||||
/// socket that still has unread request bytes. On Linux, closing with unread
|
||||
/// bytes emits a reset that can hide an otherwise complete 429 from strict
|
||||
/// clients. Read at most one declared request under an absolute 25 ms deadline,
|
||||
/// then respond and close the write half. A trickling peer therefore cannot
|
||||
/// retain the accept thread indefinitely, while a normal official POST gets a
|
||||
/// complete HTTP response and clean EOF.
|
||||
fn reject_connection(stream: &mut TcpStream, response: &HttpResponse) {
|
||||
let Some(deadline) = Instant::now().checked_add(REJECTION_DRAIN_TIMEOUT) else {
|
||||
return;
|
||||
};
|
||||
let drain_cap = MAX_HEADER_BYTES.saturating_add(MAX_BODY_BYTES);
|
||||
let mut drained = Zeroizing::new(Vec::with_capacity(1024));
|
||||
let mut scratch = Zeroizing::new([0_u8; 1024]);
|
||||
while drained.len() < drain_cap {
|
||||
let Some(remaining_time) = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.filter(|remaining| !remaining.is_zero())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let _ = stream.set_read_timeout(Some(remaining_time));
|
||||
let remaining_bytes = (drain_cap - drained.len()).min(scratch.len());
|
||||
match stream.read(&mut scratch[..remaining_bytes]) {
|
||||
Ok(0) => break,
|
||||
Ok(count) => {
|
||||
drained.extend_from_slice(&scratch[..count]);
|
||||
if rejected_request_is_complete(&drained) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
) =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(1)));
|
||||
let _ = write_response(stream, response);
|
||||
let _ = stream.shutdown(Shutdown::Write);
|
||||
}
|
||||
|
||||
fn rejected_request_is_complete(bytes: &[u8]) -> bool {
|
||||
let Some(header_end) = find_header_end(bytes) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(head) = std::str::from_utf8(&bytes[..header_end.saturating_sub(4)]) else {
|
||||
return true;
|
||||
};
|
||||
let mut content_length = 0_usize;
|
||||
for line in head.split("\r\n").skip(1) {
|
||||
let Some((name, value)) = line.split_once(':') else {
|
||||
return true;
|
||||
};
|
||||
if name.trim().eq_ignore_ascii_case("content-length") {
|
||||
let Ok(parsed) = value.trim().parse::<usize>() else {
|
||||
return true;
|
||||
};
|
||||
content_length = parsed.min(MAX_BODY_BYTES);
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes.len() >= header_end.saturating_add(content_length)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -469,4 +900,36 @@ mod tests {
|
||||
assert!(!limiter.allow(ip, now));
|
||||
assert!(limiter.allow(ip, now + Duration::from_secs(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_only_complete_crlf_header_boundary() {
|
||||
assert_eq!(find_header_end(b"GET / HTTP/1.1\r\n\r\nbody"), Some(18));
|
||||
assert_eq!(find_header_end(b"GET / HTTP/1.1\n\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_flight_permit_is_released_during_unwind() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let unwind_count = Arc::clone(&count);
|
||||
let result = std::panic::catch_unwind(move || {
|
||||
let _permit = InFlightPermit::try_acquire(unwind_count).unwrap();
|
||||
panic!("intentional permit-unwind regression");
|
||||
});
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(count.load(Ordering::Acquire), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_server_lock_remains_recoverable() {
|
||||
let value = Mutex::new(0_u8);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = value.lock().unwrap();
|
||||
panic!("intentional lock-poison regression");
|
||||
}));
|
||||
|
||||
assert!(result.is_err());
|
||||
*recover_lock(&value) = 1;
|
||||
assert_eq!(*recover_lock(&value), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use qconnect_lan::{
|
||||
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, LanError, LanProjection, LanService,
|
||||
@@ -81,20 +81,86 @@ fn test_config(bind_addr: SocketAddr) -> LanServiceConfig {
|
||||
config
|
||||
}
|
||||
|
||||
fn stalled_post(port: u16) -> TcpStream {
|
||||
fn stalled_post(port: u16, content_length: usize) -> TcpStream {
|
||||
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"POST /connect-to-qconnect HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 2048\r\nConnection: close\r\n\r\n{{"
|
||||
"POST /connect-to-qconnect HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {content_length}\r\nConnection: close\r\n\r\n{{"
|
||||
)
|
||||
.unwrap();
|
||||
stream.flush().unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
fn stalled_headers(port: u16) -> TcpStream {
|
||||
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(b"POST /connect-to-qconnect HTTP/1.1\r\nHost: localhost\r\nContent-Ty")
|
||||
.unwrap();
|
||||
stream.flush().unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
fn read_raw_response(stream: &mut TcpStream) -> String {
|
||||
let mut response = String::new();
|
||||
match stream.read_to_string(&mut response) {
|
||||
Ok(_) => response,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => response,
|
||||
Err(error) => panic!("failed to read raw HTTP response: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_raw_response_without_reset(stream: &mut TcpStream) -> String {
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.expect("overload response must end with a clean EOF");
|
||||
response
|
||||
}
|
||||
|
||||
fn raw_exchange(port: u16, request: &[u8]) -> String {
|
||||
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
stream.write_all(request).unwrap();
|
||||
stream.flush().unwrap();
|
||||
read_raw_response(&mut stream)
|
||||
}
|
||||
|
||||
async fn wait_for_status(base: &str, expected: StatusCode, budget: Duration) {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(1))
|
||||
.build()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + budget;
|
||||
loop {
|
||||
let status = client
|
||||
.get(format!("{base}/get-display-info"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
if status == expected {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"expected HTTP {expected}, last response was {status}"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn handoff(session_id: &str, become_active: bool) -> serde_json::Value {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -285,36 +351,243 @@ async fn connect_projection_updates_without_restarting_listener() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_bodies_time_out_and_concurrency_stays_bounded() {
|
||||
let (mut service, _, base, _) = start_service_with_timeout(Duration::from_millis(500));
|
||||
let mut first = stalled_post(service.port());
|
||||
let second = stalled_post(service.port());
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(250);
|
||||
loop {
|
||||
let response = reqwest::get(format!("{base}/get-display-info"))
|
||||
.await
|
||||
.unwrap();
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS {
|
||||
break;
|
||||
}
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"workers never saturated"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
async fn listener_survives_idle_beyond_per_request_deadline() {
|
||||
let (mut service, _, base, _) = start_service_with_timeout(Duration::from_millis(100));
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
wait_for_status(&base, StatusCode::OK, Duration::from_secs(1)).await;
|
||||
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_partial_bodies_are_bounded_and_capacity_recovers() {
|
||||
let (mut service, _, base, _) = start_service_with_timeout(Duration::from_millis(750));
|
||||
let mut first = stalled_post(service.port(), 512);
|
||||
let mut second = stalled_post(service.port(), 512);
|
||||
|
||||
wait_for_status(
|
||||
&base,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Duration::from_millis(300),
|
||||
)
|
||||
.await;
|
||||
|
||||
for stream in [&mut first, &mut second] {
|
||||
let response = read_raw_response(stream);
|
||||
assert!(response.starts_with("HTTP/1.1 400"), "{response}");
|
||||
}
|
||||
wait_for_status(&base, StatusCode::OK, Duration::from_secs(1)).await;
|
||||
|
||||
let mut response = String::new();
|
||||
first.read_to_string(&mut response).unwrap();
|
||||
assert!(response.starts_with("HTTP/1.1 400"), "{response}");
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_partial_headers_are_bounded_and_capacity_recovers() {
|
||||
let (mut service, _, base, _) = start_service_with_timeout(Duration::from_millis(750));
|
||||
let mut first = stalled_headers(service.port());
|
||||
let mut second = stalled_headers(service.port());
|
||||
|
||||
wait_for_status(
|
||||
&base,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Duration::from_millis(300),
|
||||
)
|
||||
.await;
|
||||
|
||||
for stream in [&mut first, &mut second] {
|
||||
let response = read_raw_response(stream);
|
||||
assert!(response.starts_with("HTTP/1.1 400"), "{response}");
|
||||
}
|
||||
wait_for_status(&base, StatusCode::OK, Duration::from_secs(1)).await;
|
||||
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturated_full_post_receives_complete_429_without_reset() {
|
||||
let (mut service, _, _, _) = start_service_with_timeout(Duration::from_secs(2));
|
||||
let first = stalled_post(service.port(), 512);
|
||||
let second = stalled_post(service.port(), 512);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
|
||||
let body = vec![b'x'; 32 * 1024];
|
||||
let mut third = TcpStream::connect((Ipv4Addr::LOCALHOST, service.port())).unwrap();
|
||||
third
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
write!(
|
||||
third,
|
||||
"POST /connect-to-qconnect HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
third.write_all(&body).unwrap();
|
||||
third.flush().unwrap();
|
||||
|
||||
let response = read_raw_response_without_reset(&mut third);
|
||||
assert!(response.starts_with("HTTP/1.1 429"), "{response}");
|
||||
let (head, response_body) = response.split_once("\r\n\r\n").unwrap();
|
||||
let declared = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("Content-Length: ")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(response_body.len(), declared);
|
||||
assert_eq!(response_body, r#"{"error":"busy"}"#);
|
||||
|
||||
drop(first);
|
||||
drop(second);
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mdns_failure_after_bind_releases_listener_and_never_advertises_loopback() {
|
||||
fn rejected_header_trickle_cannot_delay_shutdown() {
|
||||
let (mut service, _, _, _) = start_service_with_timeout(Duration::from_secs(2));
|
||||
let first = stalled_post(service.port(), 512);
|
||||
let second = stalled_post(service.port(), 512);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
|
||||
let mut third = TcpStream::connect((Ipv4Addr::LOCALHOST, service.port())).unwrap();
|
||||
third.write_all(b"P").unwrap();
|
||||
third.flush().unwrap();
|
||||
let drip = std::thread::spawn(move || {
|
||||
for _ in 0..100 {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
if third.write_all(b"O").is_err() || third.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::sleep(Duration::from_millis(75));
|
||||
let started = Instant::now();
|
||||
service.shutdown();
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(250),
|
||||
"shutdown was retained by rejected trickle: {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
|
||||
drip.join().unwrap();
|
||||
drop(first);
|
||||
drop(second);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trickled_body_hits_absolute_deadline_and_capacity_recovers() {
|
||||
let timeout = Duration::from_millis(500);
|
||||
let (mut service, _, base, _) = start_service_with_timeout(timeout);
|
||||
let mut writer = TcpStream::connect((Ipv4Addr::LOCALHOST, service.port())).unwrap();
|
||||
writer
|
||||
.set_write_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
let started = Instant::now();
|
||||
writer
|
||||
.write_all(
|
||||
b"POST /connect-to-qconnect HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 64\r\nConnection: close\r\n\r\n{",
|
||||
)
|
||||
.unwrap();
|
||||
writer.flush().unwrap();
|
||||
let mut reader = writer.try_clone().unwrap();
|
||||
reader
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let drip = std::thread::spawn(move || {
|
||||
for _ in 0..4 {
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
if writer.write_all(b"x").is_err() || writer.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = read_raw_response(&mut reader);
|
||||
let elapsed = started.elapsed();
|
||||
drip.join().unwrap();
|
||||
|
||||
assert!(response.starts_with("HTTP/1.1 400"), "{response}");
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(300),
|
||||
"elapsed: {elapsed:?}"
|
||||
);
|
||||
assert!(elapsed < Duration::from_millis(700), "elapsed: {elapsed:?}");
|
||||
wait_for_status(&base, StatusCode::OK, Duration::from_secs(1)).await;
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_declared_body_is_rejected_without_waiting_for_body() {
|
||||
let (mut service, _, _, _) = start_service_with_timeout(Duration::from_secs(2));
|
||||
let request = format!(
|
||||
"POST /connect-to-qconnect HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
MAX_BODY_BYTES + 1
|
||||
);
|
||||
|
||||
let started = Instant::now();
|
||||
let response = raw_exchange(service.port(), request.as_bytes());
|
||||
assert!(response.starts_with("HTTP/1.1 413"), "{response}");
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_incomplete_header_is_rejected_at_the_header_cap() {
|
||||
let (mut service, _, _, _) = start_service_with_timeout(Duration::from_secs(2));
|
||||
let request = format!(
|
||||
"GET /get-display-info HTTP/1.1\r\nHost: localhost\r\nX-Fill: {}",
|
||||
"a".repeat(17 * 1024)
|
||||
);
|
||||
|
||||
let response = raw_exchange(service.port(), request.as_bytes());
|
||||
assert!(response.starts_with("HTTP/1.1 413"), "{response}");
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expect_continue_is_rejected_immediately_without_reading_the_body() {
|
||||
let (mut service, _, _, _) = start_service_with_timeout(Duration::from_secs(2));
|
||||
let request = b"POST /connect-to-qconnect HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 1024\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n";
|
||||
|
||||
let started = Instant::now();
|
||||
let response = raw_exchange(service.port(), request);
|
||||
assert!(response.starts_with("HTTP/1.1 417"), "{response}");
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_interrupts_two_slowloris_connections_and_closes_the_port() {
|
||||
let (mut service, inbox, base, _) = start_service_with_timeout(Duration::from_secs(5));
|
||||
let port = service.port();
|
||||
let first = stalled_headers(port);
|
||||
let second = stalled_headers(port);
|
||||
wait_for_status(
|
||||
&base,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await;
|
||||
|
||||
let started = Instant::now();
|
||||
service.shutdown();
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
assert!(inbox.is_closed());
|
||||
assert!(TcpStream::connect_timeout(
|
||||
&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
service.shutdown();
|
||||
drop(first);
|
||||
drop(second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_mdns_address_is_rejected_before_bind() {
|
||||
let reservation = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let bind_addr = reservation.local_addr().unwrap();
|
||||
drop(reservation);
|
||||
@@ -326,8 +599,21 @@ fn mdns_failure_after_bind_releases_listener_and_never_advertises_loopback() {
|
||||
Ok(_) => panic!("loopback mDNS address unexpectedly accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(matches!(error, LanError::NoLanAddresses));
|
||||
assert!(matches!(error, LanError::InvalidConfig));
|
||||
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
assert!(TcpStream::connect_timeout(&bind_addr, Duration::from_millis(100)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_mdns_address_must_match_specific_bind_address() {
|
||||
let mut config = test_config(SocketAddr::from(([192, 0, 2, 10], 0)));
|
||||
config.advertise_mdns = true;
|
||||
config.advertised_addresses = Some(vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 11))]);
|
||||
|
||||
let error = match LanService::start(config) {
|
||||
Ok(_) => panic!("mismatched bind and advertised addresses unexpectedly accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(matches!(error, LanError::InvalidConfig));
|
||||
}
|
||||
Reference in new issue
Block a user