Skip to main content

lanflare_core/server/
mod.rs

1//! Hyper v1 HTTP & HTTPS server implementing LocalSend v2.2 specification.
2
3pub mod dufs;
4pub mod handlers;
5pub mod router;
6pub mod session;
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10use hyper_util::rt::TokioIo;
11use rustls::pki_types::pem::PemObject;
12use rustls::pki_types::{CertificateDer, PrivateKeyDer};
13use tokio::io::{AsyncRead, AsyncWrite};
14use tokio::net::TcpListener;
15use tokio::sync::mpsc;
16use tokio_util::sync::CancellationToken;
17use tokio_util::task::TaskTracker;
18
19pub use dufs::{DufsConfig, DufsManager, DufsServerHandle, ServerResponseBody};
20pub use handlers::{
21    extract_password_from_url, percent_decode, ServerState, ServerTransferEvent,
22};
23pub use router::{route_request, ClientConnectionInfo};
24pub use session::{
25    DownloadFile, DownloadManager, DownloadSession, FileTransferStatus, PrepareUploadDecision,
26    SessionEndReason, SessionFile, SessionManager,
27};
28
29use crate::config::AppConfig;
30use crate::crypto::cert::TlsIdentity;
31use crate::crypto::verifier::PinnedClientCertVerifier;
32use crate::discovery::registry::PeerRegistry;
33
34/// Handle representing a running HTTP/HTTPS server instance.
35pub struct ServerHandle {
36    /// The actual port the server bound to.
37    bound_port: u16,
38    /// Cancellation token used to signal graceful shutdown.
39    cancel_token: CancellationToken,
40    /// Join handle of the background server task.
41    join_handle: tokio::task::JoinHandle<()>,
42    /// Download manager for registering/unregistering reverse transfer files.
43    download_manager: Arc<DownloadManager>,
44    /// Dufs manager for serving shared folders to local peers.
45    dufs_manager: Arc<DufsManager>,
46}
47
48impl ServerHandle {
49    /// Returns the port the server is listening on.
50    pub fn bound_port(&self) -> u16 {
51        self.bound_port
52    }
53
54    /// Returns a reference to the download manager.
55    pub fn download_manager(&self) -> &Arc<DownloadManager> {
56        &self.download_manager
57    }
58
59    /// Returns a reference to the Dufs folder manager.
60    pub fn dufs_manager(&self) -> &Arc<DufsManager> {
61        &self.dufs_manager
62    }
63
64    /// Signals the server to stop accepting connections and terminate.
65    pub fn stop(&self) {
66        self.cancel_token.cancel();
67    }
68
69    /// Waits for the server task to finish completely.
70    pub async fn wait(self) {
71        let _ = self.join_handle.await;
72    }
73}
74
75/// Helper wrapper allowing start_server to accept either AppConfig or Arc<RwLock<AppConfig>>.
76#[derive(Clone)]
77pub struct ServerConfigHolder(pub Arc<tokio::sync::RwLock<AppConfig>>);
78
79impl From<AppConfig> for ServerConfigHolder {
80    fn from(c: AppConfig) -> Self {
81        ServerConfigHolder(Arc::new(tokio::sync::RwLock::new(c)))
82    }
83}
84
85impl From<Arc<tokio::sync::RwLock<AppConfig>>> for ServerConfigHolder {
86    fn from(a: Arc<tokio::sync::RwLock<AppConfig>>) -> Self {
87        ServerConfigHolder(a)
88    }
89}
90
91/// Starts an HTTP/HTTPS server listening on the configured address.
92///
93/// If `tls_identity` is `Some`, the server terminates TLS using the identity
94/// and validates client certificates if present (mTLS).
95/// If `tls_identity` is `None`, standard plaintext HTTP/1.1 is served.
96pub async fn start_server(
97    config: impl Into<ServerConfigHolder>,
98    tls_identity: Option<TlsIdentity>,
99    registry: Arc<PeerRegistry>,
100    event_tx: Option<mpsc::Sender<ServerTransferEvent>>,
101) -> anyhow::Result<ServerHandle> {
102    start_server_ext(config, tls_identity, registry, event_tx, None, None).await
103}
104
105/// Extended server launcher permitting injected download and dufs managers.
106pub async fn start_server_ext(
107    config: impl Into<ServerConfigHolder>,
108    tls_identity: Option<TlsIdentity>,
109    registry: Arc<PeerRegistry>,
110    event_tx: Option<mpsc::Sender<ServerTransferEvent>>,
111    download_manager: Option<Arc<DownloadManager>>,
112    dufs_manager: Option<Arc<DufsManager>>,
113) -> anyhow::Result<ServerHandle> {
114    let cancel_token = CancellationToken::new();
115    let config_holder = config.into();
116    let port = config_holder.0.read().await.port;
117    let addr = SocketAddr::from(([0, 0, 0, 0], port));
118    let listener = TcpListener::bind(addr).await?;
119    let bound_port = listener.local_addr()?.port();
120
121    let cert_fingerprint = match tls_identity.as_ref() {
122        Some(ident) => ident.fingerprint.clone(),
123        None => String::new(),
124    };
125
126    let tls_acceptor = match tls_identity.as_ref() {
127        Some(ident) => {
128            let cert_der = CertificateDer::from_pem_slice(ident.cert_pem.as_bytes())?;
129            let key_der = PrivateKeyDer::from_pem_slice(ident.key_pem.as_bytes())?;
130
131            // LocalSend spec: Client cert is optional in HTTP v2 server, but verified if provided
132            let client_verifier = PinnedClientCertVerifier::new(&ident.cert_pem, false, None)?;
133
134            let server_config = rustls::ServerConfig::builder()
135                .with_client_cert_verifier(Arc::new(client_verifier))
136                .with_single_cert(vec![cert_der], key_der)?;
137
138            Some(tokio_rustls::TlsAcceptor::from(Arc::new(server_config)))
139        }
140        None => None,
141    };
142
143    let download_manager = download_manager.unwrap_or_else(|| Arc::new(DownloadManager::new()));
144    let dufs_manager = dufs_manager.unwrap_or_else(|| Arc::new(DufsManager::new()));
145
146    let state = ServerState {
147        config: config_holder.0,
148        cert_fingerprint,
149        session_manager: Arc::new(SessionManager::new()),
150        download_manager: download_manager.clone(),
151        registry,
152        event_tx,
153        dufs_manager: dufs_manager.clone(),
154        tls_identity: tls_identity.clone(),
155    };
156
157    let cancel = cancel_token.clone();
158    let join_handle = tokio::spawn(async move {
159        let tracker = TaskTracker::new();
160
161        loop {
162            tokio::select! {
163                _ = cancel.cancelled() => {
164                    tracing::info!("Server shutdown signaled, closing listener");
165                    break;
166                }
167                accept_res = listener.accept() => {
168                    let (tcp_stream, remote_addr) = match accept_res {
169                        Ok(res) => res,
170                        Err(err) => {
171                            tracing::warn!("Failed to accept incoming TCP connection: {err}");
172                            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
173                            continue;
174                        }
175                    };
176
177                    let _ = tcp_stream.set_nodelay(true);
178                    let tls_acceptor = tls_acceptor.clone();
179                    let state = state.clone();
180                    let conn_cancel = cancel.clone();
181
182                    tracker.spawn(async move {
183                        tokio::select! {
184                            _ = conn_cancel.cancelled() => {}
185                            _ = serve_connection(tcp_stream, remote_addr, tls_acceptor, state) => {}
186                        }
187                    });
188                }
189            }
190        }
191
192        tracker.close();
193        tracker.wait().await;
194        tracing::info!("Server terminated all active connections");
195    });
196
197    Ok(ServerHandle {
198        bound_port,
199        cancel_token,
200        join_handle,
201        download_manager,
202        dufs_manager,
203    })
204}
205
206async fn serve_connection(
207    tcp_stream: tokio::net::TcpStream,
208    remote_addr: SocketAddr,
209    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
210    state: ServerState,
211) {
212    if let Some(acceptor) = tls_acceptor {
213        // Peek at the first byte to detect whether the incoming connection is a TLS handshake
214        // (TLS record ContentType 0x16 = 22) or a plaintext HTTP request (starts with ASCII e.g. 'G', 'P', 'H', etc.)
215        let mut peek_buf = [0u8; 1];
216        match tcp_stream.peek(&mut peek_buf).await {
217            Ok(0) => return, // Connection closed by peer
218            Ok(_) => {
219                if peek_buf[0] != 0x16 {
220                    serve_plaintext_http_redirect(tcp_stream, remote_addr, state).await;
221                    return;
222                }
223            }
224            Err(err) => {
225                tracing::debug!("Failed to peek incoming connection from {remote_addr}: {err}");
226                return;
227            }
228        }
229
230        match acceptor.accept(tcp_stream).await {
231            Ok(tls_stream) => {
232                let (_, conn) = tls_stream.get_ref();
233                let client_cert_fingerprint = conn
234                    .peer_certificates()
235                    .and_then(|certs| certs.first())
236                    .map(|c| crate::crypto::cert::fingerprint_from_cert_der(c.as_ref()));
237
238                let client_info = ClientConnectionInfo {
239                    remote_ip: remote_addr.ip(),
240                    cert_fingerprint: client_cert_fingerprint,
241                };
242
243                serve_http_stream(tls_stream, client_info, state).await;
244            }
245            Err(err) => {
246                tracing::debug!("TLS handshake failed with {remote_addr}: {err}");
247            }
248        }
249    } else {
250        let client_info = ClientConnectionInfo {
251            remote_ip: remote_addr.ip(),
252            cert_fingerprint: None,
253        };
254        serve_http_stream(tcp_stream, client_info, state).await;
255    }
256}
257
258async fn serve_plaintext_http_redirect(
259    stream: tokio::net::TcpStream,
260    remote_addr: SocketAddr,
261    state: ServerState,
262) {
263    let io = TokioIo::new(stream);
264    let state_for_svc = state.clone();
265    let service = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
266        let state = state_for_svc.clone();
267        async move {
268            let host_header = req
269                .headers()
270                .get(hyper::header::HOST)
271                .and_then(|h| h.to_str().ok())
272                .unwrap_or("");
273
274            let host_part = if !host_header.is_empty() {
275                host_header.to_string()
276            } else {
277                let port = state.config.read().await.port;
278                format!("localhost:{}", port)
279            };
280
281            let uri = req.uri();
282            let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
283            let target_url = format!("https://{}{}", host_part, path_and_query);
284
285            let status = match *req.method() {
286                hyper::Method::GET | hyper::Method::HEAD => hyper::StatusCode::MOVED_PERMANENTLY,
287                _ => hyper::StatusCode::TEMPORARY_REDIRECT,
288            };
289
290            let res = hyper::Response::builder()
291                .status(status)
292                .header(hyper::header::LOCATION, target_url)
293                .header(hyper::header::CONNECTION, "close")
294                .header(hyper::header::CONTENT_LENGTH, "0")
295                .body(dufs::full_box_body(bytes::Bytes::new()));
296
297            Ok::<_, std::convert::Infallible>(res.unwrap())
298        }
299    });
300
301    if let Err(err) = hyper::server::conn::http1::Builder::new()
302        .max_buf_size(16 * 1024)
303        .serve_connection(io, service)
304        .await
305    {
306        tracing::debug!("Plaintext HTTP redirect failed with {remote_addr}: {err}");
307    }
308}
309
310async fn serve_http_stream<IO>(stream: IO, client_info: ClientConnectionInfo, state: ServerState)
311where
312    IO: AsyncRead + AsyncWrite + Unpin + Send + 'static,
313{
314    let io = TokioIo::new(stream);
315    let service = hyper::service::service_fn(move |req| {
316        let client_info = client_info.clone();
317        let state = state.clone();
318        async move { route_request(req, client_info, &state).await }
319    });
320
321    if let Err(err) = hyper::server::conn::http1::Builder::new()
322        .max_buf_size(128 * 1024)
323        .serve_connection(io, service)
324        .await
325    {
326        tracing::debug!("HTTP/1.1 connection error: {err}");
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use std::collections::HashMap;
334    use sha2::{Digest, Sha256};
335    use lanflare_proto::discovery::{InfoResponseDtoV2, ProtocolType, RegisterDtoV2, RegisterResponseDtoV2};
336    use lanflare_proto::transfer::{FileDto, PrepareUploadRequestDtoV2, PrepareUploadResponseDtoV2};
337
338    #[tokio::test]
339    async fn test_server_plain_http_lifecycle_and_endpoints() {
340        let temp_dir = std::env::temp_dir().join(format!("lanflare_test_{}", uuid::Uuid::new_v4()));
341        tokio::fs::create_dir_all(&temp_dir).await.unwrap();
342
343        let config = AppConfig {
344            alias: "Test Node 1".to_string(),
345            port: 0, // OS assigns random port
346            download_dir: temp_dir.clone(),
347            auto_accept: true,
348            pin: None,
349            ..Default::default()
350        };
351
352        let registry = PeerRegistry::new();
353        let (event_tx, mut event_rx) = mpsc::channel(16);
354
355        let server_handle = start_server(config, None, registry, Some(event_tx))
356            .await
357            .expect("Server should bind");
358
359        let port = server_handle.bound_port();
360        assert!(port > 0);
361
362        let client = reqwest::Client::new();
363        let base_url = format!("http://127.0.0.1:{port}");
364
365        // 1. GET /api/localsend/v2/info
366        let info_resp = client
367            .get(format!("{base_url}/api/localsend/v2/info"))
368            .send()
369            .await
370            .expect("Info request should succeed");
371        assert_eq!(info_resp.status(), reqwest::StatusCode::OK);
372        let info_dto: InfoResponseDtoV2 = info_resp.json().await.unwrap();
373        assert_eq!(info_dto.alias, "Test Node 1");
374
375        // 2. POST /api/localsend/v2/register
376        let reg_payload = RegisterDtoV2 {
377            alias: "Remote Peer".to_string(),
378            version: "2.0".to_string(),
379            device_model: Some("Linux".to_string()),
380            device_type: Some(lanflare_proto::discovery::DeviceType::Desktop),
381            fingerprint: "PEER_FINGERPRINT_123".to_string(),
382            port: 53317,
383            protocol: ProtocolType::Http,
384            download: false,
385        };
386
387        let reg_resp = client
388            .post(format!("{base_url}/api/localsend/v2/register"))
389            .json(&reg_payload)
390            .send()
391            .await
392            .expect("Register request should succeed");
393        assert_eq!(reg_resp.status(), reqwest::StatusCode::OK);
394        let reg_res_dto: RegisterResponseDtoV2 = reg_resp.json().await.unwrap();
395        assert_eq!(reg_res_dto.alias, "Test Node 1");
396
397        // Verify DeviceRegistered event
398        match event_rx.recv().await {
399            Some(ServerTransferEvent::DeviceRegistered { ip, info }) => {
400                assert_eq!(ip.to_string(), "127.0.0.1");
401                assert_eq!(info.alias, "Remote Peer");
402            }
403            other => panic!("Expected DeviceRegistered event, got: {other:?}"),
404        }
405
406        // 3. POST /api/localsend/v2/prepare-upload
407        let file_content = b"Hello, LanFlare LocalSend Transfer Engine!";
408        let mut hasher = Sha256::new();
409        hasher.update(file_content);
410        let expected_sha256 = format!("{:x}", hasher.finalize());
411
412        let mut files = HashMap::new();
413        files.insert(
414            "file_1".to_string(),
415            FileDto {
416                id: "file_1".to_string(),
417                file_name: "greeting.txt".to_string(),
418                size: file_content.len() as u64,
419                file_type: "text/plain".to_string(),
420                sha256: Some(expected_sha256.clone()),
421                preview: None,
422                metadata: None,
423            },
424        );
425
426        let prep_req = PrepareUploadRequestDtoV2 {
427            info: reg_payload.clone(),
428            files,
429        };
430
431        let prep_resp = client
432            .post(format!("{base_url}/api/localsend/v2/prepare-upload"))
433            .json(&prep_req)
434            .send()
435            .await
436            .expect("Prepare upload should succeed");
437        assert_eq!(prep_resp.status(), reqwest::StatusCode::OK);
438
439        let prep_res_dto: PrepareUploadResponseDtoV2 = prep_resp.json().await.unwrap();
440        assert!(!prep_res_dto.session_id.is_empty());
441        let file_token = prep_res_dto.files.get("file_1").expect("Token for file_1");
442
443        // 4. POST /api/localsend/v2/upload (Streaming upload)
444        let upload_url = format!(
445            "{base_url}/api/localsend/v2/upload?sessionId={}&fileId=file_1&token={}",
446            prep_res_dto.session_id, file_token
447        );
448
449        let upload_resp = client
450            .post(upload_url)
451            .body(file_content.to_vec())
452            .send()
453            .await
454            .expect("Upload request should succeed");
455        assert_eq!(upload_resp.status(), reqwest::StatusCode::OK);
456
457        // Check disk file content
458        let saved_file = temp_dir.join("greeting.txt");
459        assert!(saved_file.exists(), "File must exist on disk");
460        let saved_bytes = tokio::fs::read(&saved_file).await.unwrap();
461        assert_eq!(saved_bytes, file_content);
462
463        // Verify FileStarted, FileCompleted, and SessionEnded events
464        let started = event_rx.recv().await.unwrap();
465        assert!(matches!(started, ServerTransferEvent::FileStarted { .. }));
466
467        let completed = event_rx.recv().await.unwrap();
468        assert!(matches!(completed, ServerTransferEvent::FileCompleted { .. }));
469
470        let ended = event_rx.recv().await.unwrap();
471        assert!(matches!(ended, ServerTransferEvent::SessionEnded { .. }));
472
473        // 5. Clean server shutdown
474        server_handle.stop();
475        server_handle.wait().await;
476
477        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
478    }
479
480    #[tokio::test]
481    async fn test_server_pin_protection_and_cancellation() {
482        let temp_dir = std::env::temp_dir().join(format!("lanflare_pin_test_{}", uuid::Uuid::new_v4()));
483        tokio::fs::create_dir_all(&temp_dir).await.unwrap();
484
485        let config = AppConfig {
486            alias: "Protected Node".to_string(),
487            port: 0,
488            download_dir: temp_dir.clone(),
489            auto_accept: true,
490            pin: Some("54321".to_string()),
491            ..Default::default()
492        };
493
494        let registry = PeerRegistry::new();
495        let server_handle = start_server(config, None, registry, None)
496            .await
497            .expect("Server should bind");
498
499        let port = server_handle.bound_port();
500        let client = reqwest::Client::new();
501        let base_url = format!("http://127.0.0.1:{port}");
502
503        let mut files = HashMap::new();
504        files.insert(
505            "file_1".to_string(),
506            FileDto {
507                id: "file_1".to_string(),
508                file_name: "test.txt".to_string(),
509                size: 10,
510                file_type: "text/plain".to_string(),
511                sha256: None,
512                preview: None,
513                metadata: None,
514            },
515        );
516
517        let prep_req = PrepareUploadRequestDtoV2 {
518            info: RegisterDtoV2 {
519                alias: "Sender".to_string(),
520                version: "2.0".to_string(),
521                device_model: None,
522                device_type: None,
523                fingerprint: "FP".to_string(),
524                port: 53317,
525                protocol: ProtocolType::Http,
526                download: false,
527            },
528            files,
529        };
530
531        // Prepare upload without PIN -> 401
532        let resp_no_pin = client
533            .post(format!("{base_url}/api/localsend/v2/prepare-upload"))
534            .json(&prep_req)
535            .send()
536            .await
537            .unwrap();
538        assert_eq!(resp_no_pin.status(), reqwest::StatusCode::UNAUTHORIZED);
539
540        // Prepare upload with wrong PIN -> 401
541        let resp_wrong_pin = client
542            .post(format!("{base_url}/api/localsend/v2/prepare-upload?pin=00000"))
543            .json(&prep_req)
544            .send()
545            .await
546            .unwrap();
547        assert_eq!(resp_wrong_pin.status(), reqwest::StatusCode::UNAUTHORIZED);
548
549        // Prepare upload with correct PIN -> 200
550        let resp_ok_pin = client
551            .post(format!("{base_url}/api/localsend/v2/prepare-upload?pin=54321"))
552            .json(&prep_req)
553            .send()
554            .await
555            .unwrap();
556        assert_eq!(resp_ok_pin.status(), reqwest::StatusCode::OK);
557
558        let prep_dto: PrepareUploadResponseDtoV2 = resp_ok_pin.json().await.unwrap();
559
560        // Cancel session via POST /api/localsend/v2/cancel?sessionId=...
561        let cancel_resp = client
562            .post(format!(
563                "{base_url}/api/localsend/v2/cancel?sessionId={}",
564                prep_dto.session_id
565            ))
566            .send()
567            .await
568            .unwrap();
569        assert_eq!(cancel_resp.status(), reqwest::StatusCode::OK);
570
571        // Subsequent upload on cancelled session should fail with 403
572        let token = prep_dto.files.get("file_1").unwrap();
573        let upload_resp = client
574            .post(format!(
575                "{base_url}/api/localsend/v2/upload?sessionId={}&fileId=file_1&token={}",
576                prep_dto.session_id, token
577            ))
578            .body(vec![0u8; 10])
579            .send()
580            .await
581            .unwrap();
582        assert_eq!(upload_resp.status(), reqwest::StatusCode::FORBIDDEN);
583
584        server_handle.stop();
585        server_handle.wait().await;
586        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
587    }
588
589    #[tokio::test]
590    async fn test_server_upload_checksum_mismatch_returns_422() {
591        let temp_dir = std::env::temp_dir().join(format!("lanflare_chk_test_{}", uuid::Uuid::new_v4()));
592        tokio::fs::create_dir_all(&temp_dir).await.unwrap();
593
594        let config = AppConfig {
595            alias: "Checksum Node".to_string(),
596            port: 0,
597            download_dir: temp_dir.clone(),
598            auto_accept: true,
599            pin: None,
600            ..Default::default()
601        };
602
603        let registry = PeerRegistry::new();
604        let server_handle = start_server(config, None, registry, None)
605            .await
606            .expect("Server should bind");
607
608        let port = server_handle.bound_port();
609        let client = reqwest::Client::new();
610        let base_url = format!("http://127.0.0.1:{port}");
611
612        let mut files = HashMap::new();
613        files.insert(
614            "file_chk".to_string(),
615            FileDto {
616                id: "file_chk".to_string(),
617                file_name: "corrupt.txt".to_string(),
618                size: 5,
619                file_type: "text/plain".to_string(),
620                // Wrong expected sha256
621                sha256: Some("0000000000000000000000000000000000000000000000000000000000000000".to_string()),
622                preview: None,
623                metadata: None,
624            },
625        );
626
627        let prep_req = PrepareUploadRequestDtoV2 {
628            info: RegisterDtoV2 {
629                alias: "Sender".to_string(),
630                version: "2.0".to_string(),
631                device_model: None,
632                device_type: None,
633                fingerprint: "FP".to_string(),
634                port: 53317,
635                protocol: ProtocolType::Http,
636                download: false,
637            },
638            files,
639        };
640
641        let prep_resp = client
642            .post(format!("{base_url}/api/localsend/v2/prepare-upload"))
643            .json(&prep_req)
644            .send()
645            .await
646            .unwrap();
647        assert_eq!(prep_resp.status(), reqwest::StatusCode::OK);
648
649        let prep_dto: PrepareUploadResponseDtoV2 = prep_resp.json().await.unwrap();
650        let token = prep_dto.files.get("file_chk").unwrap();
651
652        // Send 5 bytes
653        let upload_resp = client
654            .post(format!(
655                "{base_url}/api/localsend/v2/upload?sessionId={}&fileId=file_chk&token={}",
656                prep_dto.session_id, token
657            ))
658            .body(b"hello".to_vec())
659            .send()
660            .await
661            .unwrap();
662
663        // LocalSend spec Section 4.2: 422 Checksum mismatch
664        assert_eq!(upload_resp.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
665
666        server_handle.stop();
667        server_handle.wait().await;
668        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
669    }
670
671    #[tokio::test]
672    async fn test_server_https_mtls() {
673        let temp_dir = std::env::temp_dir().join(format!("lanflare_tls_test_{}", uuid::Uuid::new_v4()));
674        tokio::fs::create_dir_all(&temp_dir).await.unwrap();
675
676        let server_ident = crate::crypto::cert::generate_self_signed().unwrap();
677        let client_ident = crate::crypto::cert::generate_self_signed().unwrap();
678
679        let config = AppConfig {
680            alias: "Secure TLS Node".to_string(),
681            port: 0,
682            download_dir: temp_dir.clone(),
683            auto_accept: true,
684            pin: None,
685            ..Default::default()
686        };
687
688        let registry = PeerRegistry::new();
689        let server_handle = start_server(config, Some(server_ident.clone()), registry, None)
690            .await
691            .expect("HTTPS Server should bind");
692
693        let port = server_handle.bound_port();
694
695        // Build reqwest client with client cert and trusting server cert
696        let client_cert_pkcs8 = reqwest::Identity::from_pem(
697            format!("{}\n{}", client_ident.cert_pem, client_ident.key_pem).as_bytes(),
698        )
699        .unwrap();
700
701        let client = reqwest::Client::builder()
702            .danger_accept_invalid_certs(true) // Self-signed in test
703            .identity(client_cert_pkcs8)
704            .build()
705            .unwrap();
706
707        let base_url = format!("https://127.0.0.1:{port}");
708
709        let info_resp = client
710            .get(format!("{base_url}/api/localsend/v2/info"))
711            .send()
712            .await
713            .expect("TLS request should succeed");
714        assert_eq!(info_resp.status(), reqwest::StatusCode::OK);
715
716        let info_dto: InfoResponseDtoV2 = info_resp.json().await.unwrap();
717        assert_eq!(info_dto.alias, "Secure TLS Node");
718        assert_eq!(info_dto.fingerprint, server_ident.fingerprint);
719
720        server_handle.stop();
721        server_handle.wait().await;
722        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
723    }
724}