545 lines
19 KiB
Rust
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())
|
|
}
|
|
}
|
|
}
|