feat(core): add internal show route

This commit is contained in:
Tien Do Nam
2026-07-15 15:44:08 +02:00
parent b43b795049
commit 1a6d5eaeae
6 changed files with 275 additions and 1 deletions
+84
View File
@@ -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<InternalEvent>,
}
/// 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<String>,
},
}
pub(crate) struct InternalState {
show_token: String,
event_tx: mpsc::Sender<InternalEvent>,
}
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<String>,
}
pub(crate) async fn show(
req: Request<Incoming>,
state: AppState,
) -> Result<Response<BoxedBody>, 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::<ShowRequest>(&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)
}
+12 -1
View File
@@ -1,9 +1,11 @@
pub mod common; pub mod common;
pub mod internal;
pub mod v2; pub mod v2;
pub mod v3; pub mod v3;
pub mod web; pub mod web;
use crate::crypto::cert::public_key_from_cert_der; 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::v2::ServerEventV2;
use crate::http::server::web::WebSendConfig; use crate::http::server::web::WebSendConfig;
use crate::http::state::ClientInfo; use crate::http::state::ClientInfo;
@@ -59,6 +61,9 @@ pub struct AppState {
/// State for serving web pages. /// State for serving web pages.
web: Option<Arc<WebPageState>>, web: Option<Arc<WebPageState>>,
/// State for application-internal endpoints.
internal: Option<Arc<InternalState>>,
/// Maps client identifiers to nonces that have been received from remote. /// Maps client identifiers to nonces that have been received from remote.
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>, received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
@@ -72,6 +77,7 @@ pub struct AppState {
impl AppState { impl AppState {
fn new( fn new(
info: Arc<Mutex<ClientInfo>>, info: Arc<Mutex<ClientInfo>>,
internal_config: Option<InternalConfig>,
v2_config: Option<ServerConfigV2>, v2_config: Option<ServerConfigV2>,
web_send_config: Option<WebSendConfig>, web_send_config: Option<WebSendConfig>,
) -> Self { ) -> Self {
@@ -85,10 +91,12 @@ impl AppState {
}); });
let web = web_send_config.map(|config| Arc::new(WebPageState::new(config))); let web = web_send_config.map(|config| Arc::new(WebPageState::new(config)));
let internal = internal_config.map(|config| Arc::new(InternalState::new(config)));
Self { Self {
info, info,
web, web,
internal,
received_nonce_map: Arc::new(Mutex::new(LruCache::new( received_nonce_map: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(200).unwrap(), NonZeroUsize::new(200).unwrap(),
))), ))),
@@ -105,6 +113,7 @@ pub async fn start_with_port(
port: u16, port: u16,
tls_config: Option<TlsConfig>, tls_config: Option<TlsConfig>,
info: ClientInfo, info: ClientInfo,
internal_config: Option<InternalConfig>,
v2_config: Option<ServerConfigV2>, v2_config: Option<ServerConfigV2>,
web_send_config: Option<WebSendConfig>, web_send_config: Option<WebSendConfig>,
stop_rx: oneshot::Receiver<()>, 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 ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port);
let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port); let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port);
let info = Arc::new(Mutex::new(info)); 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 ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?;
let ipv6_listener = match bind_ipv6_only(ipv6_socket_addr) { let ipv6_listener = match bind_ipv6_only(ipv6_socket_addr) {
@@ -365,6 +374,8 @@ async fn handle_request_inner(mut req: Request<Incoming>) -> Result<Response<Box
v2::cancel(req, state).await v2::cancel(req, state).await
} }
// The versioned path is retained for compatibility, but this endpoint is internal.
(&Method::POST, "/api/localsend/v2/show") => internal::show(req, state).await,
(&Method::POST, "/api/localsend/v3/nonce") => { (&Method::POST, "/api/localsend/v3/nonce") => {
Ok(v3::nonce_exchange(req.into_body(), state, client_info) Ok(v3::nonce_exchange(req.into_body(), state, client_info)
.await? .await?
+16
View File
@@ -8,6 +8,7 @@ use crate::crypto::token;
use crate::http::client::LsHttpClientV3; use crate::http::client::LsHttpClientV3;
use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto}; use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto};
use crate::http::server::common::save::FileUploadTarget; 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::v2::{PrepareUploadDecisionV2, ServerEventV2};
use crate::http::server::{ServerConfigV2, TlsConfig}; use crate::http::server::{ServerConfigV2, TlsConfig};
use crate::model::discovery::DeviceType; use crate::model::discovery::DeviceType;
@@ -140,8 +141,19 @@ async fn server_test() -> Result<()> {
}; };
let (stop_tx, stop_rx) = oneshot::channel::<()>(); let (stop_tx, stop_rx) = oneshot::channel::<()>();
let (internal_event_tx, mut internal_event_rx) = mpsc::channel::<InternalEvent>(16);
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16); let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(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 { tokio::spawn(async move {
while let Some(event) = event_rx.recv().await { while let Some(event) = event_rx.recv().await {
match event { match event {
@@ -202,6 +214,10 @@ async fn server_test() -> Result<()> {
private_key: PRIVATE_KEY.to_string(), private_key: PRIVATE_KEY.to_string(),
}), }),
client_info, client_info,
Some(InternalConfig {
show_token: "show-token".to_string(),
event_tx: internal_event_tx,
}),
Some(ServerConfigV2 { Some(ServerConfigV2 {
pin: None, pin: None,
event_tx, event_tx,
+161
View File
@@ -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<Mutex<Vec<Vec<String>>>>,
_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::<InternalEvent>(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::<String>::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);
}
+1
View File
@@ -118,6 +118,7 @@ async fn start_test_server(
device_type: None, device_type: None,
token: "server-fingerprint".to_string(), token: "server-fingerprint".to_string(),
}, },
None,
Some(ServerConfigV2 { pin, event_tx }), Some(ServerConfigV2 { pin, event_tx }),
None, None,
stop_rx, stop_rx,
+1
View File
@@ -133,6 +133,7 @@ async fn start_test_server(
device_type: None, device_type: None,
token: "server-fingerprint".to_string(), token: "server-fingerprint".to_string(),
}, },
None,
Some(ServerConfigV2 { Some(ServerConfigV2 {
pin: None, pin: None,
event_tx: v2_event_tx, event_tx: v2_event_tx,