Files
aria2-rust-pro/crates/aria2-rust-pro-rpc/src/server/http_surface.rs
T

545 lines
19 KiB
Rust

use std::{
collections::BTreeMap,
io::{self, Read},
net::TcpStream,
str,
sync::{Arc, Mutex},
time::Duration,
};
use crate::{
InProcessRpcDispatcher, JsonRpcRequest,
jsonrpc::{
JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json,
jsonrpc_response_to_json,
},
model::{RpcError, RpcErrorCode, RpcMeta, RpcValue},
xmlrpc::{
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
xmlrpc_method_call_from_xml, xmlrpc_method_response_to_xml,
},
};
use super::{RpcServerConfig, websocket_surface::websocket_upgrade_response};
#[derive(Debug, Clone)]
/// Buffered HTTP request model used by the synchronous transport helpers.
pub(super) struct HttpRequest {
/// Request method token from the HTTP start line.
pub(super) method: String,
/// Raw request target path from the HTTP start line.
pub(super) path: String,
/// Lower-cased request headers.
pub(super) headers: BTreeMap<String, String>,
/// Fully buffered request body bytes.
pub(super) body: Vec<u8>,
}
#[derive(Debug)]
/// Prepared HTTP RPC request after transport-level validation and body parsing.
enum PreparedHttpRpcRequest {
/// An XML-RPC request body ready for dispatcher execution.
Xml(XmlRpcMethodCall),
/// A JSON-RPC payload ready for dispatcher execution.
Json(JsonRpcPayload),
}
/// Strips any query string or fragment from an inbound request target.
pub(super) fn request_path_without_query_or_fragment(path: &str) -> &str {
path.split(['?', '#']).next().unwrap_or(path)
}
/// Normalizes supported RPC paths so equivalent HTTP targets share one route key.
pub(super) fn normalized_rpc_path(path: &str) -> &str {
let path = request_path_without_query_or_fragment(path);
if path.len() > 1 {
path.trim_end_matches('/')
} else {
path
}
}
/// Looks up an HTTP header by name using case-insensitive matching.
pub(super) fn header_value<'a>(
headers: &'a BTreeMap<String, String>,
name: &str,
) -> Option<&'a str> {
headers
.iter()
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
/// Returns whether an HTTP header exists, ignoring header-name case.
pub(super) fn has_header(headers: &BTreeMap<String, String>, name: &str) -> bool {
header_value(headers, name).is_some()
}
/// Reads and buffers a single HTTP request from a client stream.
pub(super) fn read_http_request(
stream: &mut TcpStream,
request_timeout: Duration,
) -> io::Result<Option<HttpRequest>> {
stream.set_read_timeout(Some(request_timeout))?;
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk)?;
if read == 0 {
if buffer.is_empty() {
return Ok(None);
}
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
if buffer.len() > 1024 * 1024 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"request too large",
));
}
}
let header_end = buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request headers"))?;
let (header_bytes, body_prefix) = buffer.split_at(header_end);
let header_text = str::from_utf8(header_bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
let mut header_lines = header_text.lines();
let request_line = header_lines
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
.to_owned();
let path = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
.to_owned();
let headers = header_lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned()))
.collect::<BTreeMap<_, _>>();
let content_length = headers
.get("content-length")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let mut body = body_prefix.to_vec();
while body.len() < content_length {
let read = stream.read(&mut chunk)?;
if read == 0 {
break;
}
body.extend_from_slice(&chunk[..read]);
}
body.truncate(content_length);
Ok(Some(HttpRequest {
method,
path,
headers,
body,
}))
}
/// Builds a minimal HTTP response with the supplied content type and body.
pub(super) fn http_response(status: &str, content_type: &str, body: &[u8]) -> Vec<u8> {
let mut response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
response.extend_from_slice(body);
response
}
/// Builds an empty `204 No Content` HTTP response.
pub(super) fn http_no_content_response() -> Vec<u8> {
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
}
/// Builds a minimal HTTP response while allowing additional headers to be injected.
pub(super) fn http_response_with_headers(
status: &str,
content_type: Option<&str>,
extra_headers: &[(&str, String)],
body: &[u8],
) -> Vec<u8> {
let mut response = format!("HTTP/1.1 {status}\r\n").into_bytes();
if let Some(content_type) = content_type {
response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes());
}
response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
for (name, value) in extra_headers {
response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
}
response.extend_from_slice(b"\r\n");
response.extend_from_slice(body);
response
}
/// Produces the CORS headers required for either a normal RPC response or a preflight reply.
pub(super) fn cors_response_headers(
config: &RpcServerConfig,
requested_headers: Option<&str>,
preflight: bool,
) -> Vec<(&'static str, String)> {
let Some(origin) = &config.allow_origin else {
return Vec::new();
};
let mut headers = vec![("Access-Control-Allow-Origin", origin.clone())];
if preflight {
headers.push((
"Access-Control-Allow-Methods",
"POST, GET, OPTIONS".to_owned(),
));
headers.push((
"Access-Control-Allow-Headers",
requested_headers
.filter(|value| !value.trim().is_empty())
.unwrap_or("content-type")
.to_owned(),
));
}
headers
}
/// Builds an RPC HTTP response and attaches configured CORS headers.
pub(super) fn rpc_http_response(
config: &RpcServerConfig,
status: &str,
content_type: &str,
body: &[u8],
) -> Vec<u8> {
let headers = cors_response_headers(config, None, false);
http_response_with_headers(status, Some(content_type), &headers, body)
}
/// Builds an empty RPC HTTP response and attaches configured CORS headers.
pub(super) fn rpc_http_no_content_response(
config: &RpcServerConfig,
requested_headers: Option<&str>,
) -> Vec<u8> {
let headers = cors_response_headers(config, requested_headers, true);
http_response_with_headers("204 No Content", None, &headers, b"")
}
/// Returns whether an HTTP request looks like a WebSocket upgrade handshake for RPC.
pub(super) fn is_websocket_upgrade_candidate(request: &HttpRequest) -> bool {
request.method.eq_ignore_ascii_case("GET")
&& normalized_rpc_path(&request.path) == "/jsonrpc"
&& (header_value(&request.headers, "upgrade")
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"))
|| has_header(&request.headers, "sec-websocket-key")
|| has_header(&request.headers, "sec-websocket-version")
|| header_value(&request.headers, "connection")
.is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade")))
}
/// Removes an optional leading `token:` secret from JSON-RPC positional parameters.
fn extract_rpc_token(params: &mut Vec<RpcValue>) -> Option<String> {
if let Some(RpcValue::String(token)) = params.first()
&& let Some(value) = token.strip_prefix("token:")
{
let value = value.to_owned();
params.remove(0);
return Some(value);
}
None
}
/// Returns whether an RPC method remains callable without the shared secret token.
fn rpc_method_skips_secret(method: &str) -> bool {
matches!(method, "system.listMethods" | "system.listNotifications")
}
/// Dispatches one JSON-RPC request after applying the shared-secret compatibility rules.
pub(super) fn dispatch_json_request(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
mut request: JsonRpcRequest,
) -> JsonRpcResponse {
let provided_token = extract_rpc_token(&mut request.params);
if let Some(secret) = &config.secret_token
&& !rpc_method_skips_secret(&request.method)
&& provided_token.as_deref() != Some(secret.as_str())
{
return JsonRpcResponse::error(
request.id,
RpcError::unauthorized("RPC secret required or invalid token"),
);
}
dispatcher.dispatch_json(request)
}
/// Dispatches one XML-RPC request after applying the shared-secret compatibility rules.
pub(super) fn dispatch_xml_request(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
mut request: XmlRpcMethodCall,
) -> XmlRpcMethodResponse {
let provided_token = if let Some(XmlRpcParam {
value: XmlRpcValue::String(token),
}) = request.params.first()
&& let Some(value) = token.strip_prefix("token:")
{
Some(value.to_owned())
} else {
None
};
if provided_token.is_some() {
request.params.remove(0);
}
if let Some(secret) = &config.secret_token
&& !rpc_method_skips_secret(&request.method_name)
&& provided_token.as_deref() != Some(secret.as_str())
{
return XmlRpcMethodResponse {
value: None,
fault: Some(XmlRpcFault {
code: 1,
message: "RPC secret required or invalid token".to_owned(),
error: Some(RpcError::unauthorized(
"RPC secret required or invalid token",
)),
}),
meta: RpcMeta::default(),
};
}
dispatcher.dispatch_xml(request)
}
/// Trims a UTF-8 BOM, comments, and processing instructions before XML-RPC sniffing.
fn trim_xml_prelude(mut body: &str) -> &str {
loop {
body = body.trim_start();
if let Some(rest) = body.strip_prefix("<?")
&& let Some(end) = rest.find("?>")
{
body = &rest[end + 2..];
continue;
}
if let Some(rest) = body.strip_prefix("<!--")
&& let Some(end) = rest.find("-->")
{
body = &rest[end + 3..];
continue;
}
return body;
}
}
/// Returns whether an HTTP request body should be treated as XML-RPC input.
fn looks_like_xmlrpc_request_body(body_text: &str) -> bool {
let body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
body.starts_with("<methodCall") || body.starts_with("<methodResponse")
}
/// Handles one HTTP RPC request, including JSON-RPC, XML-RPC, CORS, and upgrade paths.
#[cfg(test)]
pub(super) fn handle_rpc_http_request(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
request: HttpRequest,
) -> Vec<u8> {
match prepare_rpc_http_request(config, request) {
Ok(Some(prepared)) => render_rpc_http_dispatch_response(dispatcher, config, prepared),
Ok(None) => http_no_content_response(),
Err(response) => response,
}
}
/// Handles one HTTP RPC request while locking the dispatcher only for the actual dispatch path.
pub(super) fn handle_rpc_http_request_shared(
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
config: &RpcServerConfig,
request: HttpRequest,
) -> io::Result<Vec<u8>> {
match prepare_rpc_http_request(config, request) {
Ok(Some(prepared)) => {
let mut dispatcher = dispatcher
.lock()
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
Ok(render_rpc_http_dispatch_response(
&mut dispatcher,
config,
prepared,
))
}
Ok(None) => Ok(http_no_content_response()),
Err(response) => Ok(response),
}
}
/// Parses and validates one HTTP RPC request before any dispatcher locking occurs.
fn prepare_rpc_http_request(
config: &RpcServerConfig,
request: HttpRequest,
) -> Result<Option<PreparedHttpRpcRequest>, Vec<u8>> {
if is_websocket_upgrade_candidate(&request) {
return Err(websocket_upgrade_response(config, &request));
}
let path = normalized_rpc_path(&request.path);
let is_rpc_endpoint = matches!(path, "/jsonrpc" | "/rpc");
if request.method.eq_ignore_ascii_case("OPTIONS")
&& is_rpc_endpoint
&& config.allow_origin.is_some()
{
return Err(rpc_http_no_content_response(
config,
header_value(&request.headers, "access-control-request-headers"),
));
}
if request.method != "POST" {
return Err(http_response(
"405 Method Not Allowed",
"text/plain",
b"method not allowed",
));
}
let content_type = header_value(&request.headers, "content-type").unwrap_or("");
let body_text = String::from_utf8_lossy(&request.body);
let normalized_xml_body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
let is_xml = contains_ascii_case_insensitive(content_type, "xml")
|| path.ends_with(".xml")
|| (path == "/rpc" && looks_like_xmlrpc_request_body(&body_text))
|| request.body.starts_with(b"<?xml")
|| body_text.trim_start().starts_with("<?xml");
if is_xml {
if !config.enable_xml_rpc {
return Err(http_response(
"404 Not Found",
"text/plain",
b"xml-rpc disabled",
));
}
let request = match xmlrpc_method_call_from_xml(normalized_xml_body) {
Ok(request) => request,
Err(error) => {
return Err(http_response(
"400 Bad Request",
"text/plain",
error.as_bytes(),
));
}
};
Ok(Some(PreparedHttpRpcRequest::Xml(request)))
} else {
if !config.enable_json_rpc {
return Err(http_response(
"404 Not Found",
"text/plain",
b"json-rpc disabled",
));
}
let payload = match jsonrpc_payload_from_json(&body_text) {
Ok(payload) => payload,
Err(error) => {
let response = JsonRpcResponse::error(
None,
RpcError {
code: if error.contains("must not be empty") {
RpcErrorCode::InvalidRequest
} else {
RpcErrorCode::ParseError
},
kind: crate::model::RpcErrorKind::InvalidParams,
message: error,
},
);
let body = jsonrpc_response_to_json(&response)
.unwrap_or_else(|render_error| format!(r#"{{"error":"{render_error}"}}"#));
return Err(rpc_http_response(
config,
"200 OK",
"application/json",
body.as_bytes(),
));
}
};
Ok(Some(PreparedHttpRpcRequest::Json(payload)))
}
}
/// Returns whether `needle` appears in `haystack`, ignoring ASCII case.
pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
haystack
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
/// Renders a prepared HTTP RPC request once dispatcher access has been acquired.
fn render_rpc_http_dispatch_response(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
prepared: PreparedHttpRpcRequest,
) -> Vec<u8> {
match prepared {
PreparedHttpRpcRequest::Xml(request) => {
let response = dispatch_xml_request(dispatcher, config, request);
let body = xmlrpc_method_response_to_xml(&response);
rpc_http_response(config, "200 OK", "text/xml", body.as_bytes())
}
PreparedHttpRpcRequest::Json(payload) => {
render_json_http_dispatch_response(dispatcher, config, payload)
}
}
}
/// Renders a prepared JSON-RPC HTTP request once dispatcher access has been acquired.
fn render_json_http_dispatch_response(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
payload: JsonRpcPayload,
) -> Vec<u8> {
match payload {
JsonRpcPayload::Single(request) => {
let response = dispatch_json_request(dispatcher, config, request);
if response.id.is_none() {
return http_no_content_response();
}
let body = jsonrpc_response_to_json(&response)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
}
JsonRpcPayload::Batch(items) => {
let mut responses = Vec::new();
for item in items {
match item {
Ok(request) => {
let response = dispatch_json_request(dispatcher, config, request);
if response.id.is_some() {
responses.push(response);
}
}
Err(error) => responses.push(JsonRpcResponse::error(
None,
RpcError {
code: RpcErrorCode::InvalidRequest,
kind: crate::model::RpcErrorKind::InvalidParams,
message: error,
},
)),
}
}
let body = jsonrpc_batch_response_to_json(&responses)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
}
}
}