diff --git a/packages/core/src/http/server/internal.rs b/packages/core/src/http/server/internal.rs new file mode 100644 index 00000000..4e34de78 --- /dev/null +++ b/packages/core/src/http/server/internal.rs @@ -0,0 +1,84 @@ +use crate::http::server::common::error::AppError; +use crate::http::server::common::query::parse_query; +use crate::http::server::common::response::{empty_body, BoxedBody}; +use crate::http::server::AppState; +use http_body_util::BodyExt; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode}; +use serde::Deserialize; +use tokio::sync::mpsc; + +/// Configuration for application-internal endpoints. +pub struct InternalConfig { + /// Token required by the show route. + pub show_token: String, + + /// Channel on which the server emits internal events to the application. + pub event_tx: mpsc::Sender, +} + +/// Events emitted by application-internal endpoints. +#[derive(Debug)] +pub enum InternalEvent { + /// Another application instance requested the running application to show itself. + Show { + /// Command-line arguments forwarded by the other application instance. + args: Vec, + }, +} + +pub(crate) struct InternalState { + show_token: String, + event_tx: mpsc::Sender, +} + +impl InternalState { + pub(crate) fn new(config: InternalConfig) -> Self { + Self { + show_token: config.show_token, + event_tx: config.event_tx, + } + } +} + +#[derive(Default, Deserialize)] +struct ShowRequest { + #[serde(default)] + args: Vec, +} + +pub(crate) async fn show( + req: Request, + state: AppState, +) -> Result, AppError> { + let Some(internal) = &state.internal else { + return Err(AppError::Status(StatusCode::NOT_FOUND)); + }; + + let query = parse_query(req.uri().query()); + if query.get("token") != Some(&internal.show_token) { + return Err(AppError::Message( + StatusCode::FORBIDDEN, + "Invalid token".to_string(), + )); + } + + let body = req.into_body().collect().await?.to_bytes(); + let payload = if body.is_empty() { + ShowRequest::default() + } else { + serde_json::from_slice::(&body).map_err(|err| { + tracing::warn!("Failed to parse show request body: {err:#}"); + AppError::BadRequest("Invalid JSON body".to_string()) + })? + }; + + let _ = internal + .event_tx + .send(InternalEvent::Show { args: payload.args }) + .await; + + let mut response = Response::new(empty_body()); + *response.status_mut() = StatusCode::OK; + Ok(response) +} diff --git a/packages/core/src/http/server/mod.rs b/packages/core/src/http/server/mod.rs index 68a4236a..856d49b8 100644 --- a/packages/core/src/http/server/mod.rs +++ b/packages/core/src/http/server/mod.rs @@ -1,9 +1,11 @@ pub mod common; +pub mod internal; pub mod v2; pub mod v3; pub mod web; use crate::crypto::cert::public_key_from_cert_der; +use crate::http::server::internal::{InternalConfig, InternalState}; use crate::http::server::v2::ServerEventV2; use crate::http::server::web::WebSendConfig; use crate::http::state::ClientInfo; @@ -59,6 +61,9 @@ pub struct AppState { /// State for serving web pages. web: Option>, + /// State for application-internal endpoints. + internal: Option>, + /// Maps client identifiers to nonces that have been received from remote. received_nonce_map: Arc>>>, @@ -72,6 +77,7 @@ pub struct AppState { impl AppState { fn new( info: Arc>, + internal_config: Option, v2_config: Option, web_send_config: Option, ) -> Self { @@ -85,10 +91,12 @@ impl AppState { }); let web = web_send_config.map(|config| Arc::new(WebPageState::new(config))); + let internal = internal_config.map(|config| Arc::new(InternalState::new(config))); Self { info, web, + internal, received_nonce_map: Arc::new(Mutex::new(LruCache::new( NonZeroUsize::new(200).unwrap(), ))), @@ -105,6 +113,7 @@ pub async fn start_with_port( port: u16, tls_config: Option, info: ClientInfo, + internal_config: Option, v2_config: Option, web_send_config: Option, stop_rx: oneshot::Receiver<()>, @@ -112,7 +121,7 @@ pub async fn start_with_port( let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port); let info = Arc::new(Mutex::new(info)); - let state = AppState::new(info.clone(), v2_config, web_send_config); + let state = AppState::new(info.clone(), internal_config, v2_config, web_send_config); let ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?; let ipv6_listener = match bind_ipv6_only(ipv6_socket_addr) { @@ -365,6 +374,8 @@ async fn handle_request_inner(mut req: Request) -> Result internal::show(req, state).await, (&Method::POST, "/api/localsend/v3/nonce") => { Ok(v3::nonce_exchange(req.into_body(), state, client_info) .await? diff --git a/packages/core/src/main.rs b/packages/core/src/main.rs index 036edb6c..12e5e80e 100644 --- a/packages/core/src/main.rs +++ b/packages/core/src/main.rs @@ -8,6 +8,7 @@ use crate::crypto::token; use crate::http::client::LsHttpClientV3; use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto}; use crate::http::server::common::save::FileUploadTarget; +use crate::http::server::internal::{InternalConfig, InternalEvent}; use crate::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2}; use crate::http::server::{ServerConfigV2, TlsConfig}; use crate::model::discovery::DeviceType; @@ -140,8 +141,19 @@ async fn server_test() -> Result<()> { }; let (stop_tx, stop_rx) = oneshot::channel::<()>(); + let (internal_event_tx, mut internal_event_rx) = mpsc::channel::(16); let (event_tx, mut event_rx) = mpsc::channel::(16); + tokio::spawn(async move { + while let Some(event) = internal_event_rx.recv().await { + match event { + InternalEvent::Show { args } => { + tracing::info!("Show application with args: {args:?}"); + } + } + } + }); + tokio::spawn(async move { while let Some(event) = event_rx.recv().await { match event { @@ -202,6 +214,10 @@ async fn server_test() -> Result<()> { private_key: PRIVATE_KEY.to_string(), }), client_info, + Some(InternalConfig { + show_token: "show-token".to_string(), + event_tx: internal_event_tx, + }), Some(ServerConfigV2 { pin: None, event_tx, diff --git a/packages/core/tests/internal_server.rs b/packages/core/tests/internal_server.rs new file mode 100644 index 00000000..a27cf214 --- /dev/null +++ b/packages/core/tests/internal_server.rs @@ -0,0 +1,161 @@ +#![cfg(feature = "http")] + +use localsend::http::server::internal::{InternalConfig, InternalEvent}; +use localsend::http::server::start_with_port; +use localsend::http::state::ClientInfo; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, oneshot, Mutex}; + +struct TestServer { + port: u16, + show_args: Arc>>>, + _stop_tx: oneshot::Sender<()>, +} + +async fn start_test_server(internal_enabled: bool) -> TestServer { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + let port = free_port(); + let show_args = Arc::new(Mutex::new(Vec::new())); + let (event_tx, mut event_rx) = mpsc::channel::(16); + + tokio::spawn({ + let show_args = show_args.clone(); + async move { + while let Some(event) = event_rx.recv().await { + match event { + InternalEvent::Show { args } => show_args.lock().await.push(args), + } + } + } + }); + + let internal_config = internal_enabled.then_some(InternalConfig { + show_token: "show-token".to_string(), + event_tx, + }); + let (stop_tx, stop_rx) = oneshot::channel::<()>(); + + start_with_port( + port, + None, + ClientInfo { + alias: "Test Server".to_string(), + version: "2.1".to_string(), + device_model: Some("Rust".to_string()), + device_type: None, + token: "server-fingerprint".to_string(), + }, + internal_config, + None, + None, + stop_rx, + ) + .await + .expect("Failed to start server"); + + wait_until_reachable(port).await; + + TestServer { + port, + show_args, + _stop_tx: stop_tx, + } +} + +fn free_port() -> u16 { + static PORT_COUNTER: AtomicU16 = AtomicU16::new(42551); + + loop { + let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst); + if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { + return port; + } + } +} + +async fn wait_until_reachable(port: u16) { + for _ in 0..100 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("Server did not become reachable on port {port}"); +} + +#[tokio::test] +async fn test_show() { + let server = start_test_server(true).await; + + let response = localsend::reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{}/api/localsend/v2/show?token=show-token", + server.port + )) + .json(&serde_json::json!({"args": ["file-a", "file-b"]})) + .send() + .await + .unwrap(); + + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + *server.show_args.lock().await, + vec![vec!["file-a".to_string(), "file-b".to_string()]] + ); +} + +#[tokio::test] +async fn test_show_with_empty_body() { + let server = start_test_server(true).await; + + let response = localsend::reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{}/api/localsend/v2/show?token=show-token", + server.port + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status().as_u16(), 200); + assert_eq!(*server.show_args.lock().await, vec![Vec::::new()]); +} + +#[tokio::test] +async fn test_show_with_invalid_token() { + let server = start_test_server(true).await; + + let response = localsend::reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{}/api/localsend/v2/show?token=wrong-token", + server.port + )) + .json(&serde_json::json!({"args": ["file-a"]})) + .send() + .await + .unwrap(); + + assert_eq!(response.status().as_u16(), 403); + assert!(server.show_args.lock().await.is_empty()); +} + +#[tokio::test] +async fn test_show_disabled() { + let server = start_test_server(false).await; + + let response = localsend::reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{}/api/localsend/v2/show?token=show-token", + server.port + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status().as_u16(), 404); +} diff --git a/packages/core/tests/v2_server.rs b/packages/core/tests/v2_server.rs index 5fb249df..32593599 100644 --- a/packages/core/tests/v2_server.rs +++ b/packages/core/tests/v2_server.rs @@ -118,6 +118,7 @@ async fn start_test_server( device_type: None, token: "server-fingerprint".to_string(), }, + None, Some(ServerConfigV2 { pin, event_tx }), None, stop_rx, diff --git a/packages/core/tests/v2_web_send.rs b/packages/core/tests/v2_web_send.rs index e2b05f90..a564db67 100644 --- a/packages/core/tests/v2_web_send.rs +++ b/packages/core/tests/v2_web_send.rs @@ -133,6 +133,7 @@ async fn start_test_server( device_type: None, token: "server-fingerprint".to_string(), }, + None, Some(ServerConfigV2 { pin: None, event_tx: v2_event_tx,