diff --git a/src-bak/main.rs b/src-bak/main.rs deleted file mode 100644 index 0022fe1..0000000 --- a/src-bak/main.rs +++ /dev/null @@ -1,179 +0,0 @@ -mod mdns; -mod models; -mod p2p; -mod tls; -mod ui; - -use axum::{ - extract::DefaultBodyLimit, - routing::{get, post}, - Router, -}; -use axum_server::tls_rustls::RustlsConfig; -use local_ip_address::local_ip; -use models::{AppState, MAX_PAYLOAD_SIZE, MAX_RAM_TEXTS, MEDIA_DIR}; -use std::{ - collections::{HashMap, VecDeque}, - net::SocketAddr, - sync::{Arc, RwLock}, -}; - -#[tokio::main] -async fn main() { - // 0. Регистрация глобального CryptoProvider (ring) для rustls 0.23+ - let _ = rustls::crypto::ring::default_provider().install_default(); - - let args: Vec = std::env::args().collect(); - - // Значения по умолчанию - let mut device_name = "My-Device".to_string(); - let mut p2p_port: u16 = 8443; - let mut ui_port: u16 = 8080; - let mut custom_ip: Option = None; - - // Парсинг как именованных флагов (--ip, --name), так и позиционных аргументов - let mut i = 1; - let mut positional_idx = 0; - while i < args.len() { - match args[i].as_str() { - "--ip" => { - if i + 1 < args.len() { - custom_ip = Some(args[i + 1].clone()); - i += 1; - } - } - "--name" => { - if i + 1 < args.len() { - device_name = args[i + 1].clone(); - i += 1; - } - } - "--p2p-port" => { - if i + 1 < args.len() { - if let Ok(p) = args[i + 1].parse() { - p2p_port = p; - } - i += 1; - } - } - "--ui-port" => { - if i + 1 < args.len() { - if let Ok(p) = args[i + 1].parse() { - ui_port = p; - } - i += 1; - } - } - arg if !arg.starts_with("--") => { - match positional_idx { - 0 => device_name = arg.to_string(), - 1 => { - if let Ok(p) = arg.parse() { - p2p_port = p; - } - } - 2 => { - if let Ok(p) = arg.parse() { - ui_port = p; - } - } - 3 => custom_ip = Some(arg.to_string()), - _ => {} - } - positional_idx += 1; - } - _ => {} - } - i += 1; - } - - // Определение адреса привязки (bind_ip) и сетевого IP для сертификата/mDNS - let (bind_ip, my_ip) = match custom_ip { - Some(ip) => { - if ip == "0.0.0.0" { - let detected = local_ip() - .map(|addr| addr.to_string()) - .unwrap_or_else(|_| "127.0.0.1".to_string()); - ("0.0.0.0".to_string(), detected) - } else { - (ip.clone(), ip) - } - } - None => { - let detected = local_ip().expect("Не удалось определить локальный IP").to_string(); - ("0.0.0.0".to_string(), detected) - } - }; - - tokio::fs::create_dir_all(MEDIA_DIR) - .await - .expect("Не удалось создать папку received_media на диске"); - - // 1. Генерация сертификата в ОЗУ под выбранный IP и настройка HTTPS-клиента - let (cert_der, key_der, my_fingerprint) = tls::generate_in_memory_cert(&my_ip); - let trusted_fingerprints = Arc::new(RwLock::new(HashMap::new())); - let http_client = tls::build_pinned_http_client(trusted_fingerprints.clone()); - - let state = Arc::new(AppState { - device_name: device_name.clone(), - my_ip: my_ip.clone(), - p2p_port, - my_fingerprint: my_fingerprint.clone(), - ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)), - disk_media: RwLock::new(VecDeque::new()), - peers: RwLock::new(HashMap::new()), - trusted_fingerprints, - http_client, - }); - - // 2. Запуск mDNS-обнаружения - mdns::start_mdns_service(state.clone()); - - // 3. Запуск внешнего P2P HTTPS-сервера на указанном IP - let tls_config = RustlsConfig::from_der(vec![cert_der], key_der) - .await - .expect("Ошибка конфигурации TLS"); - - let p2p_router = Router::new() - .route("/p2p/text", post(p2p::p2p_receive_text)) - .route("/p2p/media", post(p2p::p2p_receive_media)) - .layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE)) - .with_state(state.clone()); - - let p2p_addr: SocketAddr = format!("{}:{}", bind_ip, p2p_port) - .parse() - .unwrap_or_else(|_| panic!("Некорректный IP или порт: {}:{}", bind_ip, p2p_port)); - - tokio::spawn(async move { - axum_server::bind_rustls(p2p_addr, tls_config) - .serve(p2p_router.into_make_service()) - .await - .expect("Ошибка P2P HTTPS-сервера"); - }); - - // 4. Запуск локального UI-сервера (127.0.0.1) - let ui_router = Router::new() - .route("/", get(ui::ui_index)) - .route("/ui/state", get(ui::ui_get_state)) - .route("/ui/trust", post(ui::ui_trust_peer)) - .route("/ui/clear-ram", post(ui::ui_clear_ram)) - .route("/ui/send-text", post(ui::ui_send_text)) - .route("/ui/send-media", post(ui::ui_send_media)) - .route("/ui/files/:filename", get(ui::ui_serve_saved_file)) - .layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE)) - .with_state(state); - - let ui_addr: SocketAddr = format!("127.0.0.1:{}", ui_port).parse().unwrap(); - println!("=============================================================="); - println!(" Устройство: {}", device_name); - println!(" P2P сокет (порт открыт на): https://{}:{}", bind_ip, p2p_port); - println!(" Сетевой IP (mDNS / TLS SAN): {}", my_ip); - println!(" КОД СВЕРКИ СЕРТИФИКАТА (PIN): [ {} ]", &my_fingerprint[..8].to_uppercase()); - println!(" Папка для медиа на диске: ./{}/", MEDIA_DIR); - println!("--------------------------------------------------------------"); - println!(" ОТКРОЙТЕ ИНТЕРФЕЙС В БРАУЗЕРЕ: http://127.0.0.1:{}", ui_port); - println!("=============================================================="); - - let listener = tokio::net::TcpListener::bind(ui_addr).await.unwrap(); - axum::serve(listener, ui_router).await.unwrap(); -} diff --git a/src-bak/mdns.rs b/src-bak/mdns.rs deleted file mode 100644 index b4904db..0000000 --- a/src-bak/mdns.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::models::{AppState, PeerNode, SERVICE_TYPE}; -use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo}; -use std::sync::Arc; - -pub fn start_mdns_service(state: Arc) { - let mdns = ServiceDaemon::new().expect("Не удалось запустить mDNS"); - - let instance_name = format!("{}_{}", state.device_name, &state.my_fingerprint[..6]); - let host_name = format!("{}.local.", instance_name); - let properties = [ - ("fp", state.my_fingerprint.as_str()), - ("name", state.device_name.as_str()), - ]; - - let service_info = ServiceInfo::new( - SERVICE_TYPE, - &instance_name, - &host_name, - &state.my_ip, - state.p2p_port, - &properties[..], - ) - .expect("Ошибка создания mDNS записи"); - - mdns.register(service_info).expect("Ошибка регистрации mDNS"); - - let receiver = mdns.browse(SERVICE_TYPE).expect("Ошибка mDNS browse"); - tokio::spawn(async move { - while let Ok(event) = receiver.recv_async().await { - if let ServiceEvent::ServiceResolved(info) = event { - let fp = info - .get_property_val_str("fp") - .unwrap_or_default() - .to_string(); - let name = info - .get_property_val_str("name") - .unwrap_or("Unknown") - .to_string(); - - if fp == state.my_fingerprint || fp.len() < 8 { - continue; - } - - if let Some(ip) = info.get_addresses().iter().next() { - let ip_str = ip.to_string(); - let pin_code = fp[..8].to_uppercase(); - let peer = PeerNode { - name, - ip: ip_str.clone(), - port: info.get_port(), - fingerprint: fp, - pin_code, - verified: false, - }; - state.peers.write().unwrap().insert(ip_str, peer); - } - } - } - }); -} diff --git a/src-bak/models.rs b/src-bak/models.rs deleted file mode 100644 index 2637011..0000000 --- a/src-bak/models.rs +++ /dev/null @@ -1,123 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::{ - collections::{HashMap, VecDeque}, - sync::{Arc, RwLock}, - time::{SystemTime, UNIX_EPOCH}, -}; - -/// Лимит одного передаваемого медиафайла (200 МБ) -pub const MAX_PAYLOAD_SIZE: usize = 200 * 1024 * 1024; -/// Максимум текстовых сообщений, хранимых в ОЗУ -pub const MAX_RAM_TEXTS: usize = 50; -/// Папка на диске для сохранения входящего медиаконтента -pub const MEDIA_DIR: &str = "received_media"; -/// Тип сервиса для обнаружения через mDNS -pub const SERVICE_TYPE: &str = "_ramshare._tcp.local."; - -/// Текстовое сообщение (хранится ТОЛЬКО в ОЗУ) -#[derive(Clone, Serialize, Deserialize)] -pub struct TextItem { - pub sender: String, - pub text: String, - pub timestamp: u64, -} - -/// Метаданные медиафайла (сам файл сохраняется НА ДИСК) -#[derive(Clone, Serialize, Deserialize)] -pub struct MediaItem { - pub sender: String, - pub filename: String, - pub saved_path: String, - pub mime: String, - pub size: usize, - pub timestamp: u64, -} - -/// Устройство в локальной сети, найденное через mDNS -#[derive(Clone, Serialize, Deserialize)] -pub struct PeerNode { - pub name: String, - pub ip: String, - pub port: u16, - pub fingerprint: String, - pub pin_code: String, - pub verified: bool, -} - -/// Глобальное состояние приложения -pub struct AppState { - pub device_name: String, - pub my_ip: String, - pub p2p_port: u16, - pub my_fingerprint: String, - pub ram_texts: RwLock>, - pub disk_media: RwLock>, - pub peers: RwLock>, - pub trusted_fingerprints: Arc>>, - pub http_client: reqwest::Client, -} - -#[derive(Serialize, Deserialize)] -pub struct IncomingTextPayload { - pub sender: String, - pub text: String, -} - -#[derive(Deserialize)] -pub struct UiSendTextRequest { - pub target_ip: String, - pub text: String, -} - -#[derive(Deserialize)] -pub struct UiTrustRequest { - pub target_ip: String, - pub trust: bool, -} - -pub fn now_sec() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -/// Декодирование URL-encoded имени файла (для поддержки кириллицы и пробелов) -pub fn url_decode(input: &str) -> String { - let mut bytes = Vec::with_capacity(input.len()); - let mut chars = input.as_bytes().iter().copied(); - while let Some(b) = chars.next() { - if b == b'%' { - if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) { - if let Ok(decoded) = u8::from_str_radix(&format!("{}{}", c1 as char, c2 as char), 16) - { - bytes.push(decoded); - continue; - } - } - } else if b == b'+' { - bytes.push(b' '); - } else { - bytes.push(b); - } - } - String::from_utf8_lossy(&bytes).into_owned() -} - -/// Защита от Path Traversal при сохранении медиа на диск -pub fn sanitize_filename(name: &str) -> String { - let decoded = url_decode(name); - let clean: String = decoded - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_', - _ => c, - }) - .collect(); - let trimmed = clean.trim_matches('.').trim(); - if trimmed.is_empty() { - "media_file.bin".to_string() - } else { - trimmed.to_string() - } -} diff --git a/src-bak/p2p.rs b/src-bak/p2p.rs deleted file mode 100644 index 1727721..0000000 --- a/src-bak/p2p.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::models::{ - now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem, - MAX_RAM_TEXTS, MEDIA_DIR, -}; -use axum::{ - body::Bytes, - extract::State, - http::{header, HeaderMap, StatusCode}, - Json, -}; -use std::{path::PathBuf, sync::Arc}; - -/// Прием текста от другого устройства -> сохраняется СТРОГО В ОЗУ -pub async fn p2p_receive_text( - State(state): State>, - Json(payload): Json, -) -> StatusCode { - let mut ram_texts = state.ram_texts.write().unwrap(); - if ram_texts.len() >= MAX_RAM_TEXTS { - ram_texts.pop_back(); - } - ram_texts.push_front(TextItem { - sender: payload.sender, - text: payload.text, - timestamp: now_sec(), - }); - StatusCode::OK -} - -/// Прием медиаконтента от другого устройства -> сохраняется НА ДИСК -pub async fn p2p_receive_media( - State(state): State>, - headers: HeaderMap, - body: Bytes, -) -> Result { - let sender = headers - .get("X-Sender") - .and_then(|v| v.to_str().ok()) - .map(url_decode) - .unwrap_or_else(|| "Unknown".to_string()); - - let raw_filename = headers - .get("X-Filename") - .and_then(|v| v.to_str().ok()) - .unwrap_or("file.bin"); - - let mime = headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - - tokio::fs::create_dir_all(MEDIA_DIR) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let safe_name = sanitize_filename(raw_filename); - let final_filename = format!("{}_{}", now_sec(), safe_name); - let file_path = PathBuf::from(MEDIA_DIR).join(&final_filename); - - tokio::fs::write(&file_path, &body) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let abs_path = std::fs::canonicalize(&file_path) - .unwrap_or_else(|_| file_path.clone()) - .display() - .to_string(); - - let mut media_list = state.disk_media.write().unwrap(); - media_list.push_front(MediaItem { - sender, - filename: final_filename, - saved_path: abs_path, - mime, - size: body.len(), - timestamp: now_sec(), - }); - - Ok(StatusCode::OK) -} diff --git a/src-bak/static/index.html b/src-bak/static/index.html deleted file mode 100644 index 6c190f6..0000000 --- a/src-bak/static/index.html +++ /dev/null @@ -1,261 +0,0 @@ - - - - - -LAN Share — Текст в ОЗУ / Медиа на Диск - - - -
-
-
-
Загрузка...
-
-
-
- Код сверки сертификата (PIN): - -------- -
-
- -
- -
-

1. Устройства в сети (mDNS) TLS Pinning

-
-
Поиск устройств...
-
-
- -
-

2. Отправка данных

- - - - - - -
- - -
- -
-

- Входящий текст ТОЛЬКО В ОЗУ - -

-
-
- -
-

Входящие медиафайлы НА ДИСКЕ (./received_media)

-
-
-
- - - - diff --git a/src-bak/tls.rs b/src-bak/tls.rs deleted file mode 100644 index ffa8c02..0000000 --- a/src-bak/tls.rs +++ /dev/null @@ -1,138 +0,0 @@ -use rcgen::{generate_simple_self_signed, CertifiedKey}; -use rustls::{ - client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, - crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider}, - pki_types::{CertificateDer, ServerName, UnixTime}, - ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme, -}; -use sha2::{Digest, Sha256}; -use std::{ - collections::HashMap, - sync::{Arc, RwLock}, - time::Duration, -}; - -#[derive(Debug)] -pub struct PinnedCertVerifier { - trusted_fingerprints: Arc>>, - provider: Arc, -} - -impl PinnedCertVerifier { - pub fn new(trusted_fingerprints: Arc>>) -> Self { - Self { - trusted_fingerprints, - provider: Arc::new(rustls::crypto::ring::default_provider()), - } - } - - fn cert_fingerprint(cert: &CertificateDer<'_>) -> String { - let mut hasher = Sha256::new(); - hasher.update(cert.as_ref()); - hex::encode(hasher.finalize()) - } -} - -impl ServerCertVerifier for PinnedCertVerifier { - fn verify_server_cert( - &self, - end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> Result { - let actual_fp = Self::cert_fingerprint(end_entity); - let host = match server_name { - ServerName::DnsName(name) => name.as_ref().to_string(), - ServerName::IpAddress(ip) => std::net::IpAddr::from(*ip).to_string(), - _ => return Err(TlsError::General("Неподдерживаемое имя узла".into())), - }; - - let trusted = self.trusted_fingerprints.read().unwrap(); - match trusted.get(&host) { - Some(expected_fp) if expected_fp.eq_ignore_ascii_case(&actual_fp) => { - Ok(ServerCertVerified::assertion()) - } - Some(_) => Err(TlsError::General(format!( - "ВНИМАНИЕ: Сертификат узла {} не совпадает с заверенным!", - host - ))), - None => Err(TlsError::General(format!( - "Узел {} не заверен! Нажмите «Заверить» в списке устройств.", - host - ))), - } - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, - ) -> Result { - verify_tls12_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, - ) -> Result { - verify_tls13_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec { - self.provider - .signature_verification_algorithms - .supported_schemes() - } -} - -/// Генерирует самоподписанный сертификат и приватный ключ в ОЗУ -pub fn generate_in_memory_cert(ip: &str) -> (Vec, Vec, String) { - let san = vec!["localhost".to_string(), ip.to_string()]; - let CertifiedKey { cert, key_pair } = - generate_simple_self_signed(san).expect("Ошибка генерации сертификата"); - - let cert_der = cert.der().to_vec(); - let key_der = key_pair.serialize_der(); - - let mut hasher = Sha256::new(); - hasher.update(&cert_der); - let fingerprint = hex::encode(hasher.finalize()); - - (cert_der, key_der, fingerprint) -} - -/// Создает преднастроенный HTTPS-клиент с кастомным верификатором -pub fn build_pinned_http_client( - trusted_fingerprints: Arc>>, -) -> reqwest::Client { - let verifier = Arc::new(PinnedCertVerifier::new(trusted_fingerprints)); - let rustls_client_config = ClientConfig::builder_with_provider(Arc::new( - rustls::crypto::ring::default_provider(), - )) - .with_safe_default_protocol_versions() - .unwrap() - .dangerous() - .with_custom_certificate_verifier(verifier) - .with_no_client_auth(); - - reqwest::Client::builder() - .use_preconfigured_tls(rustls_client_config) - .timeout(Duration::from_secs(30)) - .build() - .expect("Ошибка создания HTTPS-клиента") -} diff --git a/src-bak/ui.rs b/src-bak/ui.rs deleted file mode 100644 index 2c76b14..0000000 --- a/src-bak/ui.rs +++ /dev/null @@ -1,165 +0,0 @@ -use crate::models::{ - sanitize_filename, AppState, IncomingTextPayload, MediaItem, PeerNode, TextItem, - UiSendTextRequest, UiTrustRequest, MEDIA_DIR, -}; -use axum::{ - body::Bytes, - extract::{Path, State}, - http::{header, HeaderMap, StatusCode}, - response::{Html, IntoResponse}, - Json, -}; -use std::{path::PathBuf, sync::Arc}; - -/// Вшиваем внешний файл src/static/index.html в бинарник на этапе компиляции -const INDEX_HTML: &str = include_str!("static/index.html"); - -pub async fn ui_index() -> Html<&'static str> { - Html(INDEX_HTML) -} - -pub async fn ui_get_state(State(state): State>) -> Json { - let trusted_map = state.trusted_fingerprints.read().unwrap(); - let mut peers_list: Vec = state - .peers - .read() - .unwrap() - .values() - .cloned() - .map(|mut p| { - p.verified = trusted_map - .get(&p.ip) - .map(|fp| fp.eq_ignore_ascii_case(&p.fingerprint)) - .unwrap_or(false); - p - }) - .collect(); - peers_list.sort_by(|a, b| a.name.cmp(&b.name)); - - let texts: Vec = state.ram_texts.read().unwrap().iter().cloned().collect(); - let media: Vec = state.disk_media.read().unwrap().iter().cloned().collect(); - - Json(serde_json::json!({ - "device_name": state.device_name, - "my_ip": state.my_ip, - "p2p_port": state.p2p_port, - "my_fingerprint": state.my_fingerprint, - "my_pin": state.my_fingerprint[..8].to_uppercase(), - "peers": peers_list, - "texts": texts, - "media": media - })) -} - -pub async fn ui_trust_peer( - State(state): State>, - Json(req): Json, -) -> Result { - let peers = state.peers.read().unwrap(); - let peer = peers - .get(&req.target_ip) - .ok_or((StatusCode::NOT_FOUND, "Узел не найден".to_string()))?; - - let mut trusted = state.trusted_fingerprints.write().unwrap(); - if req.trust { - trusted.insert(peer.ip.clone(), peer.fingerprint.clone()); - } else { - trusted.remove(&peer.ip); - } - Ok(StatusCode::OK) -} - -pub async fn ui_clear_ram(State(state): State>) -> StatusCode { - state.ram_texts.write().unwrap().clear(); - StatusCode::OK -} - -pub async fn ui_send_text( - State(state): State>, - Json(req): Json, -) -> Result { - let port = state - .peers - .read() - .unwrap() - .get(&req.target_ip) - .map(|p| p.port) - .unwrap_or(state.p2p_port); - - let url = format!("https://{}:{}/p2p/text", req.target_ip, port); - let payload = IncomingTextPayload { - sender: state.device_name.clone(), - text: req.text, - }; - - let resp = state - .http_client - .post(&url) - .json(&payload) - .send() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?; - - if resp.status().is_success() { - Ok(StatusCode::OK) - } else { - Err((resp.status(), "Удаленный узел отклонил запрос".to_string())) - } -} - -pub async fn ui_send_media( - State(state): State>, - headers: HeaderMap, - body: Bytes, -) -> Result { - let target_ip = headers - .get("X-Target-Ip") - .and_then(|v| v.to_str().ok()) - .ok_or((StatusCode::BAD_REQUEST, "Не указан IP получателя".to_string()))?; - - let filename = headers - .get("X-Filename") - .and_then(|v| v.to_str().ok()) - .unwrap_or("file.bin"); - - let mime = headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream"); - - let port = state - .peers - .read() - .unwrap() - .get(target_ip) - .map(|p| p.port) - .unwrap_or(state.p2p_port); - - let url = format!("https://{}:{}/p2p/media", target_ip, port); - - let resp = state - .http_client - .post(&url) - .header("X-Sender", &state.device_name) - .header("X-Filename", filename) - .header(header::CONTENT_TYPE, mime) - .body(body) - .send() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?; - - if resp.status().is_success() { - Ok(StatusCode::OK) - } else { - Err((resp.status(), "Удаленный узел отклонил медиафайл".to_string())) - } -} - -pub async fn ui_serve_saved_file(Path(filename): Path) -> impl IntoResponse { - let safe_name = sanitize_filename(&filename); - let path = PathBuf::from(MEDIA_DIR).join(&safe_name); - match tokio::fs::read(&path).await { - Ok(bytes) => (StatusCode::OK, bytes).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Файл не найден на диске").into_response(), - } -} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..da63adb --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,465 @@ +//! Консольный режим: те же возможности, что и в веб-интерфейсе, без запуска сервера. +//! Результаты команд — в stdout, прогресс и подсказки — в stderr (удобно для `--json` и пайпов). + +use crate::{models::*, net, transfer}; +use axum::http::StatusCode; +use std::{ + io::{self, BufRead, IsTerminal, Read, Write}, + net::IpAddr, + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; +use tokio_util::io::ReaderStream; + +pub struct CliOpts { + pub json: bool, + /// сколько секунд слушать UDP-beacon при поиске устройства + pub wait: u64, + /// диапазон для сканирования, если устройство не нашлось иначе (--scan) + pub scan: Option, + pub yes: bool, + /// (trust) сколько секунд ждать подтверждения на втором устройстве + pub confirm_wait: u64, +} + +static DISCOVERY_STARTED: AtomicBool = AtomicBool::new(false); + +fn ensure_discovery(state: &Arc) { + if !DISCOVERY_STARTED.swap(true, Ordering::SeqCst) { + net::start_discovery(state.clone()); + } +} + +fn fmt_bytes(b: u64) -> String { + if b < 1024 { + format!("{} Б", b) + } else if b < 1024 * 1024 { + format!("{:.1} КБ", b as f64 / 1024.0) + } else if b < 1024 * 1024 * 1024 { + format!("{:.2} МБ", b as f64 / 1048576.0) + } else { + format!("{:.2} ГБ", b as f64 / 1073741824.0) + } +} + +fn app_err(e: AppError) -> String { + if e.0 == StatusCode::FORBIDDEN { + format!("{} (выполните: ramshare_ui trust <устройство>)", e.1) + } else { + e.1 + } +} + +fn confirm(prompt: &str) -> bool { + eprint!("{} [y/N]: ", prompt); + let _ = io::stderr().flush(); + let mut s = String::new(); + io::stdin().lock().read_line(&mut s).is_ok() + && matches!(s.trim().to_lowercase().as_str(), "y" | "yes" | "д" | "да") +} + +// ───────────────────────── Таблица устройств ───────────────────────── +fn snapshot(state: &AppState) -> Vec { + let mut v: Vec = state.peers.read().values().cloned().collect(); + v.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.cmp(&b.name))); + v +} + +fn trunc(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +fn trust_label(p: &PeerDevice) -> &'static str { + match (p.verified, p.trusts_me) { + (true, true) => "взаимно", + (true, false) => "ждём", + (false, true) => "запрос", + _ => "нет", + } +} + +fn print_peers(state: &AppState, json: bool, only_online: bool) { + let v: Vec = snapshot(state) + .into_iter() + .filter(|p| !only_online || p.online) + .collect(); + if json { + println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into())); + return; + } + if v.is_empty() { + println!("Устройства не найдены."); + return; + } + println!( + "{:<9} {:<9} {:<24} {:<22} {:<24} {}", + "СТАТУС", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID" + ); + for p in v { + let addr = p + .ips + .first() + .map(|ip| format!("{}:{}", ip, p.port)) + .unwrap_or_else(|| "—".into()); + println!( + "{:<9} {:<9} {:<24} {:<22} {:<24} {}", + if p.online { "● online" } else { "○ offline" }, + trust_label(&p), + trunc(&p.name, 24), + addr, + p.pin_code, + short_id(&p.fingerprint) + ); + } +} + +// ───────────────────────── Поиск устройства ───────────────────────── +fn stage_match(stage: u8, p: &PeerDevice, sel: &str, sel_l: &str) -> bool { + match stage { + 0 => p.fingerprint == sel_l, + 1 => p.ips.iter().any(|i| i == sel), + 2 => p.name.to_lowercase() == sel_l, + 3 => sel_l.len() >= 4 && sel_l.bytes().all(|b| b.is_ascii_hexdigit()) && p.fingerprint.starts_with(sel_l), + _ => p.name.to_lowercase().starts_with(sel_l), + } +} + +/// Имя, IP, префикс ID или полный отпечаток. Неоднозначность — ошибка. +fn resolve(state: &AppState, sel: &str) -> Result, String> { + let sel_l = sel.to_lowercase(); + let peers: Vec = state.peers.read().values().cloned().collect(); + for stage in 0..5u8 { + let m: Vec<&PeerDevice> = peers.iter().filter(|p| stage_match(stage, p, sel, &sel_l)).collect(); + match m.len() { + 0 => continue, + 1 => return Ok(Some(m[0].clone())), + _ => { + let names: Vec = m + .iter() + .map(|p| format!("{} [{}]", p.name, short_id(&p.fingerprint))) + .collect(); + return Err(format!( + "«{}» подходит нескольким устройствам: {}. Уточните (IP или префикс ID).", + sel, + names.join(", ") + )); + } + } + } + Ok(None) +} + +/// "192.168.1.5:8444" → ("192.168.1.5", Some(8444)) +fn split_sel(sel: &str) -> (String, Option) { + if let Some((h, p)) = sel.rsplit_once(':') { + if let Ok(port) = p.parse::() { + if h.parse::().is_ok() { + return (h.to_string(), Some(port)); + } + } + } + (sel.to_string(), None) +} + +async fn scan_with_progress(state: &Arc, range: &str) -> Result<(), String> { + let my_ip = state.current_ip(); + let (label, ips) = parse_ip_range(range, &my_ip)?; + let total = ips.len(); + let port = state.p2p_port; + *state.scan_status.write() = ScanProgress { + active: true, + range: label.clone(), + scanned: 0, + total, + found: 0, + }; + let tty = io::stderr().is_terminal(); + if tty { + eprintln!("Сканирование {} ({} адресов, порт {})", label, total, port); + } + let worker = tokio::spawn(net::run_scan(state.clone(), ips, port)); + while !worker.is_finished() { + if tty { + let s = state.scan_status.read().clone(); + eprint!("\r проверено {}/{} найдено: {} ", s.scanned, s.total, s.found); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + let _ = worker.await; + let s = { + let mut g = state.scan_status.write(); + g.active = false; + g.clone() + }; + if tty { + eprintln!("\r проверено {}/{} найдено: {} ", s.scanned, s.total, s.found); + } + Ok(()) +} + +/// Ищет устройство: 1) последние известные адреса доверенных, 2) прямой IP, +/// 3) UDP-beacon (--wait сек), 4) сканирование диапазона (--scan). +async fn find_peer( + state: &Arc, + sel: &str, + opts: &CliOpts, + need_online: bool, +) -> Result { + let (host, port_opt) = split_sel(sel); + + net::ping_all_peers(state).await; + if let Some(p) = resolve(state, &host)? { + if p.online || !need_online { + return Ok(p); + } + } + + if let Ok(IpAddr::V4(v4)) = host.parse::() { + if is_lan_v4(v4) { + let port = port_opt.unwrap_or(state.p2p_port); + if net::probe_and_register(state, IpAddr::V4(v4), port).await.is_ok() { + if let Some(p) = resolve(state, &host)? { + return Ok(p); + } + } + } + } + + ensure_discovery(state); + if io::stderr().is_terminal() { + eprintln!("Поиск «{}» (UDP-обнаружение, до {} с)…", sel, opts.wait); + } + let deadline = Instant::now() + Duration::from_secs(opts.wait); + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(300)).await; + if let Some(p) = resolve(state, &host)? { + if p.online || !need_online { + return Ok(p); + } + } + } + + if let Some(r) = &opts.scan { + scan_with_progress(state, r).await?; + if let Some(p) = resolve(state, &host)? { + if p.online || !need_online { + return Ok(p); + } + } + } + + Err(format!( + "устройство «{}» не найдено в сети. Убедитесь, что на нём запущен `ramshare_ui serve`, \ + или укажите IP / используйте `scan` и `--scan <диапазон>`", + sel + )) +} + +// ───────────────────────── Команды ───────────────────────── +pub async fn cmd_scan(state: &Arc, range: &str, opts: &CliOpts) -> Result<(), String> { + net::ping_all_peers(state).await; + scan_with_progress(state, range).await?; + print_peers(state, opts.json, true); + Ok(()) +} + +pub async fn cmd_peers(state: &Arc, opts: &CliOpts) -> Result<(), String> { + net::ping_all_peers(state).await; + ensure_discovery(state); + if let Some(r) = &opts.scan { + scan_with_progress(state, r).await?; + } + if io::stderr().is_terminal() { + eprintln!("Слушаю UDP-обнаружение {} с…", opts.wait); + } + tokio::time::sleep(Duration::from_secs(opts.wait)).await; + print_peers(state, opts.json, false); + Ok(()) +} + +pub async fn cmd_trust(state: &Arc, sel: &str, opts: &CliOpts) -> Result<(), String> { + let p = find_peer(state, sel, opts, true).await?; + if p.verified && p.trusts_me { + println!("«{}» уже заверено взаимно.", p.name); + return Ok(()); + } + + if !p.verified { + eprintln!("Устройство: {}", p.name); + eprintln!("Адрес: {}:{}", p.ips.first().map(String::as_str).unwrap_or("—"), p.port); + eprintln!("ID: {}", p.fingerprint); + eprintln!("Код сверки: {}", p.pin_code); + if p.trusts_me { + eprintln!("«{}» уже подтвердил сверку на своей стороне и ждёт вашего подтверждения.", p.name); + } + eprintln!("Сравните код с тем, что «{}» показывает на своём экране для вашего устройства.", p.name); + eprintln!("Если коды различаются — соединение может перехватываться, НЕ заверяйте."); + if !opts.yes && !confirm("Коды совпадают, заверить?") { + return Err("отменено пользователем".into()); + } + if let Some(x) = state.peers.write().get_mut(&p.id) { + x.verified = true; + } + state.persist_trust(); + println!("Вы подтвердили: {} [{}]", p.name, p.pin_code); + } else { + eprintln!("Вы уже подтвердили «{}», ждём подтверждения с его стороны.", p.name); + } + + // Сообщаем собеседнику: он увидит запрос на сверку и должен подтвердить код на СВОЁМ устройстве. + net::push_trust(state, &p.id).await; + let mutual = || { + state + .peers + .read() + .get(&p.id) + .map_or(false, |x| x.verified && x.trusts_me) + }; + if mutual() { + println!("✓ Взаимная сверка завершена: {}", p.name); + return Ok(()); + } + if opts.confirm_wait == 0 { + eprintln!( + "Ожидается подтверждение на «{}»: там нужно сверить код и подтвердить (CLI: `trust`, UI: «Сверить и подтвердить»).", + p.name + ); + eprintln!("Состояние: ramshare_ui peers (или повторите команду с --confirm-wait <сек>)"); + return Ok(()); + } + eprintln!("Ждём подтверждения на «{}» до {} с…", p.name, opts.confirm_wait); + let deadline = Instant::now() + Duration::from_secs(opts.confirm_wait); + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_secs(2)).await; + net::push_trust(state, &p.id).await; + if mutual() { + println!("✓ Взаимная сверка завершена: {}", p.name); + return Ok(()); + } + } + Err(format!( + "«{}» не подтвердил сверку за {} с. Пока доверие одностороннее, передачи к нему не пройдут.", + p.name, opts.confirm_wait + )) +} + +pub async fn cmd_untrust(state: &Arc, sel: &str, opts: &CliOpts) -> Result<(), String> { + let p = find_peer(state, sel, opts, false).await?; + if !p.verified { + println!("«{}» и так не заверено.", p.name); + return Ok(()); + } + if let Some(x) = state.peers.write().get_mut(&p.id) { + x.verified = false; + } + state.persist_trust(); + net::push_trust(state, &p.id).await; // собеседник узнает об отзыве сразу + println!("Доверие отозвано: {} [{}]", p.name, p.pin_code); + Ok(()) +} + +pub async fn cmd_send_text( + state: &Arc, + sel: &str, + words: &[String], + opts: &CliOpts, +) -> Result<(), String> { + let text = if words.len() == 1 && words[0] == "-" { + let mut s = String::new(); + io::stdin() + .take(MAX_TEXT_BYTES as u64 + 1) + .read_to_string(&mut s) + .map_err(|e| format!("чтение stdin: {}", e))?; + s + } else { + words.join(" ") + }; + if text.trim().is_empty() { + return Err("пустой текст (укажите слова после имени устройства или «-» для stdin)".into()); + } + let p = find_peer(state, sel, opts, true).await?; + let name = transfer::send_text(state, &p.id, text).await.map_err(app_err)?; + println!("✓ Текст доставлен в ОЗУ устройства «{}»", name); + Ok(()) +} + +pub async fn cmd_send_files( + state: &Arc, + sel: &str, + paths: &[PathBuf], + opts: &CliOpts, +) -> Result<(), String> { + // проверяем файлы ДО сетевых операций + let mut files: Vec<(PathBuf, u64)> = Vec::new(); + for path in paths { + let meta = tokio::fs::metadata(path) + .await + .map_err(|e| format!("{}: {}", path.display(), e))?; + if !meta.is_file() { + return Err(format!("{}: это не обычный файл", path.display())); + } + files.push((path.clone(), meta.len())); + } + + let p = find_peer(state, sel, opts, true).await?; + let tty = io::stderr().is_terminal(); + + for (path, size) in files { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file.bin".into()); + let file = tokio::fs::File::open(&path) + .await + .map_err(|e| format!("{}: {}", path.display(), e))?; + + let st = state.clone(); + let prog = tokio::spawn(async move { + if !tty { + return; + } + let start = Instant::now(); + loop { + tokio::time::sleep(Duration::from_millis(200)).await; + let cur = st + .transfers + .read() + .iter() + .find(|t| t.direction == Direction::Out && !t.done) + .cloned(); + if let Some(t) = cur { + let speed = (t.transferred_bytes as f64 / start.elapsed().as_secs_f64().max(0.001)) as u64; + eprint!( + "\r {:>3}% {} / {} {}/с ", + t.percent, + fmt_bytes(t.transferred_bytes), + fmt_bytes(t.total_bytes), + fmt_bytes(speed) + ); + } + } + }); + + let res = transfer::send_stream( + state, + &p.id, + &name, + size, + ReaderStream::with_capacity(file, 256 * 1024), + ) + .await; + prog.abort(); + if tty { + eprint!("\r{}\r", " ".repeat(72)); + } + match res { + Ok(peer) => println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size)), + Err(e) => return Err(format!("{}: {}", name, app_err(e))), + } + } + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9f73181 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,86 @@ +//! Единый файл конфигурации `/config.json`: +//! имя устройства, идентичность (сертификат + закрытый ключ) и интервал опроса. + +use crate::{ + models::{clean_label, MAX_PING_SEC, MIN_PING_SEC}, + storage, + tls::{self, Identity}, +}; +use std::path::{Path, PathBuf}; + +pub struct Loaded { + pub identity: Identity, + pub device_name: String, + pub ping_interval_sec: u64, +} + +/// Идентичность из старых мест хранения (identity.json / .identity_<порт>.json) или новая. +/// Битый старый файл — ошибка, а не тихая регенерация (иначе поменяется отпечаток устройства). +fn migrate_or_generate(data_dir: &Path, legacy_port: u16) -> Result { + let old = data_dir.join("identity.json"); + if old.exists() { + let id = tls::read_identity_file(&old)?; + eprintln!("[INFO] идентичность перенесена из {} в config.json (старый файл можно удалить)", old.display()); + return Ok(id); + } + let legacy = PathBuf::from(format!(".identity_{}.json", legacy_port)); + if legacy.exists() { + if let Ok(id) = tls::read_identity_file(&legacy) { + eprintln!("[INFO] идентичность перенесена из {} в config.json (старый файл можно удалить)", legacy.display()); + return Ok(id); + } + } + tls::generate_identity() +} + +/// Читает config.json; недостающие значения создаёт и дописывает в файл. +/// Повреждённый config.json — фатальная ошибка (в нём лежит идентичность устройства). +pub fn load_or_init(data_dir: &Path, legacy_port: u16, default_name: &str) -> Result { + let existed = storage::config_path(data_dir).exists(); + let mut cfg = storage::load_config(data_dir)?.unwrap_or_default(); + let mut dirty = false; + + let identity = match &cfg.identity { + Some(rec) => tls::identity_from_record(rec).map_err(|e| format!("config.json: {}", e))?, + None => { + let id = migrate_or_generate(data_dir, legacy_port)?; + cfg.identity = Some(tls::identity_to_record(&id)); + dirty = true; + id + } + }; + + let device_name = match cfg + .device_name + .as_deref() + .map(|s| clean_label(s, 48)) + .filter(|s| !s.is_empty()) + { + Some(n) => n, + None => { + cfg.device_name = Some(default_name.to_string()); + dirty = true; + default_name.to_string() + } + }; + + let ping_interval_sec = match cfg.ping_interval_sec { + Some(p) => p.clamp(MIN_PING_SEC, MAX_PING_SEC), + None => { + // миграция со старого settings.json (или значение по умолчанию) + let v = storage::load_settings(data_dir).ping_interval_sec; + cfg.ping_interval_sec = Some(v); + dirty = true; + v + } + }; + + if dirty { + storage::save_config(data_dir, &cfg) + .map_err(|e| format!("запись {}: {}", storage::config_path(data_dir).display(), e))?; + if !existed { + eprintln!("[INFO] создан {}", storage::config_path(data_dir).display()); + } + } + Ok(Loaded { identity, device_name, ping_interval_sec }) +} diff --git a/src/main.rs b/src/main.rs index af90a12..e1109ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ +mod cli; +mod config; mod models; mod net; mod p2p; mod storage; mod tls; +mod transfer; mod ui; use axum::{ @@ -17,26 +20,87 @@ use std::{ collections::{HashMap, VecDeque}, net::{IpAddr, Ipv4Addr}, path::PathBuf, - sync::{atomic::AtomicBool, Arc}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, time::Duration, }; -use tokio::sync::{Mutex, Semaphore}; +use tokio::sync::{Mutex, Notify, Semaphore}; -const USAGE: &str = "\ -Использование: ramshare_ui [параметры] - --name <имя> имя устройства - --p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443 - --ui-port <порт> порт локального UI (только 127.0.0.1), по умолчанию 8080 - --ip адрес интерфейса (по умолчанию определяется автоматически) - --data-dir <путь> каталог данных (идентичность, доверенные, принятые файлы) - -h, --help эта справка"; +const USAGE: &str = r#"ramshare_ui — обмен текстом и файлами в локальной сети (TLS 1.3, mTLS, пиннинг сертификатов) + +Использование: ramshare_ui [параметры] [команда] + +Команды: + serve запустить узел и веб-интерфейс (команда по умолчанию) + scan [диапазон] просканировать сеть и показать найденные устройства + (all, 192.168.1.1-254, 10.0.0.0/24, список через запятую) + peers показать, кто онлайн (известные + UDP-обнаружение) + config показать путь и значения config.json (создаёт файл при первом запуске) + trust <устройство> сверить код и заверить устройство (сверка взаимная: второе + устройство получит запрос и тоже должно подтвердить код) + untrust <устройство> отозвать доверие + send-text <устройство> <текст…|-> отправить текст ("-" — читать из stdin) + send-file <устройство> <файл…> отправить один или несколько файлов + + <устройство> — имя, IP[:порт], префикс ID (≥4 hex) или полный отпечаток + + Колонка ДОВЕРИЕ в peers/scan: + взаимно — оба подтвердили код; ждём — вы подтвердили, ждём его; + запрос — он подтвердил и ждёт вас (выполните trust); нет — не заверено + +Параметры: + --name <имя> имя устройства на этот запуск (постоянное задаётся в config.json) + --p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443; для scan/send — порт поиска + --ui-port <порт> порт локального веб-интерфейса (только 127.0.0.1), по умолчанию 8080 + --ip адрес интерфейса (по умолчанию определяется автоматически) + --data-dir <путь> базовый каталог вместо папки с ramshare_ui; внутри создаются + config/ (config.json, trusted.json) и received_media/ (принятые файлы) + --ping-interval <с> интервал опроса известных устройств, 10–30 с (иначе config.json, иначе 15) + --no-ui (serve) без веб-интерфейса; входящий текст печатается в терминал + --wait <с> (CLI) сколько слушать UDP-обнаружение, 1–60, по умолчанию 4 + --scan <диапазон> (CLI) если устройство не найдено — просканировать этот диапазон + --confirm-wait <с> (trust) ждать подтверждения на втором устройстве, 0–600 (по умолчанию 0) + --json (scan/peers) вывод в JSON + -y, --yes (trust) не спрашивать подтверждение (использовать осознанно!) + -h, --help эта справка + +Примеры: + ramshare_ui serve --no-ui --name Server + ramshare_ui scan + ramshare_ui peers --json + ramshare_ui trust laptop + ramshare_ui send-text laptop "привет из консоли" + echo "секрет" | ramshare_ui send-text 192.168.1.20 - + ramshare_ui send-file laptop ./report.pdf ./photo.jpg +"#; + +enum Command { + Serve, + Scan(Option), + Peers, + Config, + Trust(String), + Untrust(String), + SendText { peer: String, words: Vec }, + SendFile { peer: String, paths: Vec }, +} struct Config { - name: String, + name: Option, p2p_port: u16, ui_port: u16, ip: Option, data_dir: Option, + ping_interval: Option, + json: bool, + wait: u64, + confirm_wait: u64, + scan: Option, + yes: bool, + no_ui: bool, + command: Command, } fn fatal(msg: &str) -> ! { @@ -44,22 +108,50 @@ fn fatal(msg: &str) -> ! { std::process::exit(1); } -fn parse_args() -> Result { - let default_name = std::env::var("COMPUTERNAME") +fn default_device_name() -> String { + let n = std::env::var("COMPUTERNAME") .or_else(|_| std::env::var("HOSTNAME")) - .unwrap_or_else(|_| "My-Device".into()); + .unwrap_or_default(); + let n = clean_label(&n, 48); + if n.is_empty() { + "My-Device".into() + } else { + n + } +} + +fn parse_args() -> Result { let mut cfg = Config { - name: clean_label(&default_name, 48), + name: None, p2p_port: 8443, ui_port: 8080, ip: None, data_dir: None, + ping_interval: None, + json: false, + wait: 4, + confirm_wait: 0, + scan: None, + yes: false, + no_ui: false, + command: Command::Serve, }; + + let mut positional: Vec = Vec::new(); + let mut only_pos = false; let mut it = std::env::args().skip(1); while let Some(a) = it.next() { + if !only_pos && a == "--" { + only_pos = true; + continue; + } + if only_pos || (!a.starts_with("--") && a != "-h" && a != "-y") { + positional.push(a); + continue; + } let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag)); match a.as_str() { - "--name" => cfg.name = clean_label(&val("--name")?, 48), + "--name" => cfg.name = Some(clean_label(&val("--name")?, 48)), "--p2p-port" => { cfg.p2p_port = val("--p2p-port")?.parse().map_err(|_| "Некорректный --p2p-port".to_string())? } @@ -68,14 +160,74 @@ fn parse_args() -> Result { } "--ip" => cfg.ip = Some(val("--ip")?.parse().map_err(|_| "Некорректный --ip (нужен IPv4)".to_string())?), "--data-dir" => cfg.data_dir = Some(PathBuf::from(val("--data-dir")?)), + "--ping-interval" => { + let s: u64 = val("--ping-interval")? + .parse() + .map_err(|_| "Некорректный --ping-interval".to_string())?; + if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&s) { + return Err(format!("--ping-interval: допустимо {}–{} секунд", MIN_PING_SEC, MAX_PING_SEC)); + } + cfg.ping_interval = Some(s); + } + "--wait" => { + let w: u64 = val("--wait")?.parse().map_err(|_| "Некорректный --wait".to_string())?; + if !(1..=60).contains(&w) { + return Err("--wait: допустимо 1–60 секунд".into()); + } + cfg.wait = w; + } + "--scan" => cfg.scan = Some(val("--scan")?), + "--confirm-wait" => { + let w: u64 = val("--confirm-wait")?.parse().map_err(|_| "Некорректный --confirm-wait".to_string())?; + if w > 600 { + return Err("--confirm-wait: допустимо 0–600 секунд".into()); + } + cfg.confirm_wait = w; + } + "--json" => cfg.json = true, + "--no-ui" => cfg.no_ui = true, + "-y" | "--yes" => cfg.yes = true, "-h" | "--help" => { println!("{}", USAGE); std::process::exit(0); } - other => return Err(format!("Неизвестный аргумент: {}\n\n{}", other, USAGE)), + other => return Err(format!("Неизвестный параметр: {}\n\n{}", other, USAGE)), } } - if cfg.name.is_empty() { + + let mut pos = positional.into_iter(); + let sub = pos.next(); + cfg.command = match sub.as_deref() { + None | Some("serve") => Command::Serve, + Some("scan") => Command::Scan(pos.next()), + Some("peers") | Some("list") | Some("ls") => Command::Peers, + Some("config") => Command::Config, + Some("trust") => Command::Trust(pos.next().ok_or("trust: укажите устройство")?), + Some("untrust") => Command::Untrust(pos.next().ok_or("untrust: укажите устройство")?), + Some("send-text") | Some("text") => { + let peer = pos.next().ok_or("send-text: укажите устройство")?; + let words: Vec = pos.collect(); + if words.is_empty() { + return Err("send-text: укажите текст (или «-» для чтения из stdin)".into()); + } + Command::SendText { peer, words } + } + Some("send-file") | Some("file") => { + let peer = pos.next().ok_or("send-file: укажите устройство")?; + let paths: Vec = pos.map(PathBuf::from).collect(); + if paths.is_empty() { + return Err("send-file: укажите хотя бы один файл".into()); + } + Command::SendFile { peer, paths } + } + Some("help") => { + println!("{}", USAGE); + std::process::exit(0); + } + Some(other) => return Err(format!("Неизвестная команда: {}\n\n{}", other, USAGE)), + }; + + if cfg.name.as_deref() == Some("") { return Err("Пустое имя устройства".into()); } if cfg.p2p_port == 0 || cfg.ui_port == 0 { @@ -84,39 +236,37 @@ fn parse_args() -> Result { Ok(cfg) } -#[tokio::main] -async fn main() { - let _ = rustls::crypto::ring::default_provider().install_default(); +struct Built { + state: Arc, + bind_ip: Ipv4Addr, +} - let cfg = parse_args().unwrap_or_else(|e| fatal(&e)); - - // ── адреса ── - let (bind_ip, my_ip) = match cfg.ip { - Some(ip) if !ip.is_unspecified() => (ip, ip), +/// Общая инициализация для `serve` и CLI-команд: адреса, каталоги, идентичность, доверие, клиенты. +fn build_state(cfg: &Config, serving: bool) -> Built { + let (bind_ip, my_ip, fixed_ip) = match cfg.ip { + Some(ip) if !ip.is_unspecified() => (ip, ip, true), _ => { let ip = net::detect_lan_ip().unwrap_or_else(|| { - eprintln!("[WARN] Локальный IP не определён, используется 127.0.0.1 (укажите --ip)"); + eprintln!("[WARN] Локальный IP не определён, используется 127.0.0.1 (подхватится при появлении сети)"); Ipv4Addr::LOCALHOST }); - (Ipv4Addr::UNSPECIFIED, ip) + (Ipv4Addr::UNSPECIFIED, ip, false) } }; - // ── каталоги ── - let data_dir = { - let p = cfg.data_dir.clone().unwrap_or_else(|| storage::default_data_dir(cfg.p2p_port)); - if p.is_absolute() { - p - } else { - std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p) - } - }; - let media_dir = data_dir.join("received_media"); - std::fs::create_dir_all(&media_dir).unwrap_or_else(|e| fatal(&format!("создание {}: {}", media_dir.display(), e))); - storage::cleanup_partials(&media_dir); + // <папка с ramshare_ui>/config (вся конфигурация) и <папка с ramshare_ui>/received_media + let dirs = storage::resolve_dirs(cfg.data_dir.as_deref(), cfg.p2p_port).unwrap_or_else(|e| fatal(&e)); + let data_dir = dirs.config; // каталог конфигурации: config.json, trusted.json + let media_dir = dirs.media; + if serving { + // .part-файлы чистит только работающий узел (CLI не должен трогать чужие передачи) + storage::cleanup_partials(&media_dir); + } - // ── идентичность и доверие ── - let identity = tls::load_or_create_identity(&data_dir, cfg.p2p_port).unwrap_or_else(|e| fatal(&e)); + // config.json: имя устройства, идентичность (сертификат + ключ), интервал опроса + let loaded = config::load_or_init(&data_dir, cfg.p2p_port, &default_device_name()).unwrap_or_else(|e| fatal(&e)); + let device_name = cfg.name.clone().unwrap_or(loaded.device_name); // --name важнее config.json + let identity = loaded.identity; let mut known: HashMap = HashMap::new(); for t in storage::load_trusted(&data_dir) { @@ -130,36 +280,38 @@ async fn main() { .take(MAX_PEER_IPS) .collect(); let port = if t.port == 0 { 8443 } else { t.port }; - known.insert( - t.fp.clone(), - PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true), - ); + let mut p = PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true); + p.trusts_me = t.trusts_me; + known.insert(t.fp.clone(), p); } let peers = Arc::new(RwLock::new(known)); let probe_client = tls::probe_client(&identity.cert_der, &identity.key_der).unwrap_or_else(|e| fatal(&e)); - let server_cfg = tls::server_config(&identity).unwrap_or_else(|e| fatal(&e)); - let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg); - // ── слушатели: привязываем ДО запуска задач, чтобы ошибка порта была фатальной ── - let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port)) - .await - .unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e))); - let ui_listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port)) - .await - .unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e))); + // приоритет: --ping-interval > config.json > значение по умолчанию + let ping_sec = cfg + .ping_interval + .unwrap_or(loaded.ping_interval_sec) + .clamp(MIN_PING_SEC, MAX_PING_SEC); + + let disk_media: VecDeque = if serving { + storage::scan_media_dir(&media_dir).into_iter().collect() + } else { + VecDeque::new() + }; let state = Arc::new(AppState { - device_name: cfg.name.clone(), - my_ip: my_ip.to_string(), + device_name, + my_ip: RwLock::new(my_ip.to_string()), + fixed_ip, p2p_port: cfg.p2p_port, my_fingerprint: identity.fingerprint.clone(), cert_der: identity.cert_der, key_der: identity.key_der, - data_dir: data_dir.clone(), - media_dir: media_dir.clone(), + data_dir, + media_dir, ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)), - disk_media: RwLock::new(storage::scan_media_dir(&media_dir).into_iter().collect()), + disk_media: RwLock::new(disk_media), peers, probe_client, transfers: RwLock::new(Vec::new()), @@ -169,79 +321,174 @@ async fn main() { ping_lock: Mutex::new(()), trust_dirty: AtomicBool::new(false), probe_cooldown: PlMutex::new(HashMap::new()), + ping_interval_sec: AtomicU64::new(ping_sec), + ping_notify: Notify::new(), + log_stdout: serving, + headless: serving && cfg.no_ui, + trust_mtime: PlMutex::new(None), }); + Built { state, bind_ip } +} + +async fn run_serve(cfg: &Config, built: Built) { + let Built { state, bind_ip } = built; + + let server_cfg = tls::server_config(&state.cert_der, &state.key_der).unwrap_or_else(|e| fatal(&e)); + let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg); + + // слушатели привязываем ДО запуска задач: занятый порт — фатальная ошибка + let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port)) + .await + .unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e))); + let ui_listener = if cfg.no_ui { + None + } else { + Some( + tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port)) + .await + .unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e))), + ) + }; + state.add_log(format!( "Узел '{}' запущен на {}:{} (ID: {})", - cfg.name, - my_ip, + state.device_name, + state.current_ip(), cfg.p2p_port, short_id(&state.my_fingerprint) )); net::start_discovery(state.clone()); - // периодический опрос + обслуживание (без наложения тиков) + // периодический опрос + обслуживание; интервал меняется на лету (UI / config.json / --ping-interval) { let st = state.clone(); tokio::spawn(async move { - let mut iv = tokio::time::interval(Duration::from_secs(5)); - iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - iv.tick().await; + let secs = st.ping_interval_sec.load(Ordering::Relaxed); + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(secs)) => {} + _ = st.ping_notify.notified() => {} + } net::ping_all_peers(&st).await; st.maintenance(); } }); } - // ── UI ── - let guard = Arc::new(ui::UiGuard { - token: random_hex(16), - cookie_name: format!("rs_token_{}", cfg.ui_port), - hosts: vec![ - format!("127.0.0.1:{}", cfg.ui_port), - format!("localhost:{}", cfg.ui_port), - ], - }); - let ui_url = format!("http://127.0.0.1:{}/?t={}", cfg.ui_port, guard.token); - - let ui_router = Router::new() - .route("/", get(ui::ui_index)) - .route("/app.js", get(ui::ui_js)) - .route("/ui/state", get(ui::ui_get_state)) - .route("/ui/manual-peer", post(ui::ui_add_manual_peer)) - .route("/ui/scan-range", post(ui::ui_scan_range)) - .route("/ui/ping", post(ui::ui_ping_peers)) - .route("/ui/trust", post(ui::ui_trust_peer)) - .route("/ui/clear-ram", post(ui::ui_clear_ram)) - .route("/ui/send-text", post(ui::ui_send_text)) - .route("/ui/send-media", post(ui::ui_send_media)) // сырой Body — без лимита экстрактора - .route("/ui/files/:filename", get(ui::ui_serve_saved_file)) - .layer(DefaultBodyLimit::max(512 * 1024)) // для JSON-эндпоинтов - .layer(middleware::from_fn_with_state(guard, ui::ui_guard)) - .with_state(state.clone()); - println!("=============================================================="); - println!(" Устройство: {}", cfg.name); + println!(" Устройство: {}", state.device_name); println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port); - println!(" Адрес в сети: {}", my_ip); + println!(" Адрес в сети: {}", state.current_ip()); println!(" ID устройства: {}", short_id(&state.my_fingerprint)); - println!(" Каталог данных: {}", data_dir.display()); + println!(" Папка config: {}", state.data_dir.display()); + println!(" Файл настроек: {}", state.data_dir.join("config.json").display()); + println!(" Принятые файлы: {}", state.media_dir.display()); + println!( + " Опрос устройств: каждые {} с (UI / config.json / --ping-interval)", + state.ping_interval_sec.load(Ordering::Relaxed) + ); println!("--------------------------------------------------------------"); - println!(" ОТКРОЙТЕ UI (ссылка содержит одноразовый токен сессии):"); - println!(" {}", ui_url); + + let ui_task = match ui_listener { + Some(listener) => { + let guard = Arc::new(ui::UiGuard { + token: random_hex(16), + cookie_name: format!("rs_token_{}", cfg.ui_port), + hosts: vec![ + format!("127.0.0.1:{}", cfg.ui_port), + format!("localhost:{}", cfg.ui_port), + ], + }); + println!(" ОТКРОЙТЕ UI (ссылка содержит одноразовый токен сессии):"); + println!(" http://127.0.0.1:{}/?t={}", cfg.ui_port, guard.token); + + let ui_router = Router::new() + .route("/", get(ui::ui_index)) + .route("/app.js", get(ui::ui_js)) + .route("/ui/state", get(ui::ui_get_state)) + .route("/ui/manual-peer", post(ui::ui_add_manual_peer)) + .route("/ui/scan-range", post(ui::ui_scan_range)) + .route("/ui/ping", post(ui::ui_ping_peers)) + .route("/ui/trust", post(ui::ui_trust_peer)) + .route("/ui/clear-ram", post(ui::ui_clear_ram)) + .route("/ui/settings", post(ui::ui_set_settings)) + .route("/ui/send-text", post(ui::ui_send_text)) + .route("/ui/send-media", post(ui::ui_send_media)) // сырой Body — без лимита экстрактора + .route("/ui/files/:filename", get(ui::ui_serve_saved_file)) + .layer(DefaultBodyLimit::max(512 * 1024)) // для JSON-эндпоинтов + .layer(middleware::from_fn_with_state(guard, ui::ui_guard)) + .with_state(state.clone()); + Some(tokio::spawn(async move { axum::serve(listener, ui_router).await })) + } + None => { + println!(" Режим без UI (--no-ui). Входящий текст печатается в терминал,"); + println!(" файлы сохраняются в {}", state.media_dir.display()); + None + } + }; println!("=============================================================="); - let ui_task = tokio::spawn(async move { axum::serve(ui_listener, ui_router).await }); let p2p_task = tokio::spawn(p2p::serve(p2p_listener, acceptor, p2p::router(state.clone()))); + let ui_wait = async { + match ui_task { + Some(t) => eprintln!("UI-сервер остановился: {:?}", t.await), + None => std::future::pending::<()>().await, + } + }; + tokio::select! { - r = ui_task => eprintln!("UI-сервер остановился: {:?}", r), + _ = ui_wait => {} r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r), _ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"), } + state.sync_trust_from_disk(); state.persist_trust(); - storage::cleanup_partials(&media_dir); + storage::cleanup_partials(&state.media_dir); +} + +#[tokio::main] +async fn main() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let cfg = parse_args().unwrap_or_else(|e| fatal(&e)); + let serving = matches!(cfg.command, Command::Serve); + let built = build_state(&cfg, serving); + + if serving { + run_serve(&cfg, built).await; + return; + } + + let state = built.state; + let opts = cli::CliOpts { + json: cfg.json, + wait: cfg.wait, + scan: cfg.scan.clone(), + yes: cfg.yes, + confirm_wait: cfg.confirm_wait, + }; + let res = match &cfg.command { + Command::Scan(r) => cli::cmd_scan(&state, r.as_deref().unwrap_or("all"), &opts).await, + Command::Peers => cli::cmd_peers(&state, &opts).await, + Command::Trust(s) => cli::cmd_trust(&state, s, &opts).await, + Command::Untrust(s) => cli::cmd_untrust(&state, s, &opts).await, + Command::SendText { peer, words } => cli::cmd_send_text(&state, peer, words, &opts).await, + Command::SendFile { peer, paths } => cli::cmd_send_files(&state, peer, paths, &opts).await, + Command::Config => { + println!("Файл конфигурации: {}", state.data_dir.join("config.json").display()); + println!("device_name: {}", state.device_name); + println!("ping_interval_sec: {}", state.ping_interval_sec.load(Ordering::Relaxed)); + println!("identity.fingerprint: {}", state.my_fingerprint); + Ok(()) + } + Command::Serve => Ok(()), + }; + if let Err(e) = res { + eprintln!("Ошибка: {}", e); + std::process::exit(1); + } } diff --git a/src/models.rs b/src/models.rs index 85cbedd..844d7cc 100644 --- a/src/models.rs +++ b/src/models.rs @@ -13,12 +13,12 @@ use std::{ net::{IpAddr, Ipv4Addr}, path::PathBuf, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use tokio::sync::{Mutex, Semaphore}; +use tokio::sync::{Mutex, Notify, Semaphore}; // ───────────────────────── Лимиты ───────────────────────── pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // один файл @@ -32,6 +32,9 @@ pub const MAX_TRANSFERS: usize = 20; pub const MAX_INCOMING: usize = 4; pub const MAX_MEDIA_LIST: usize = 500; pub const DISCOVERY_PORT: u16 = 5354; +pub const MIN_PING_SEC: u64 = 10; +pub const MAX_PING_SEC: u64 = 30; +pub const DEFAULT_PING_SEC: u64 = 15; // ───────────────────────── Ошибки ───────────────────────── pub struct AppError(pub StatusCode, pub String); @@ -82,7 +85,10 @@ pub struct PeerDevice { pub fingerprint: String, /// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков. pub pin_code: String, + /// Мы сверили код и подтвердили это устройство (наше решение; по нему принимаем/отправляем). pub verified: bool, + /// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info). + pub trusts_me: bool, pub online: bool, pub last_seen: u64, } @@ -97,6 +103,7 @@ impl PeerDevice { fingerprint: fp.to_string(), pin_code: sas_code(my_fp, fp), verified, + trusts_me: false, online: false, last_seen: 0, } @@ -162,10 +169,16 @@ pub struct UiScanRangeRequest { pub port: Option, } +#[derive(Deserialize)] +pub struct UiSettingsRequest { + pub ping_interval_sec: u64, +} + // ───────────────────────── Состояние ───────────────────────── pub struct AppState { pub device_name: String, - pub my_ip: String, + pub my_ip: RwLock, + pub fixed_ip: bool, pub p2p_port: u16, pub my_fingerprint: String, pub cert_der: Vec, @@ -182,10 +195,37 @@ pub struct AppState { pub incoming_slots: Arc, pub ping_lock: Mutex<()>, pub trust_dirty: AtomicBool, - pub probe_cooldown: PlMutex>, + pub probe_cooldown: PlMutex>, + /// Интервал опроса известных пиров (сек), меняется на лету + pub ping_interval_sec: AtomicU64, + pub ping_notify: Notify, + /// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом) + pub log_stdout: bool, + /// true в `serve --no-ui`: входящий текст печатается в терминал + pub headless: bool, + /// mtime trusted.json, чтобы подхватывать правки из CLI (trust/untrust) работающим узлом + pub trust_mtime: PlMutex>, } impl AppState { + pub fn current_ip(&self) -> String { + self.my_ip.read().clone() + } + + pub fn update_my_ip(&self, ip: Ipv4Addr) -> bool { + if self.fixed_ip { + return false; + } + let s = ip.to_string(); + let mut cur = self.my_ip.write(); + if *cur != s { + *cur = s; + true + } else { + false + } + } + pub fn add_log(&self, msg: impl Into) { // защита от log-injection: управляющие символы в именах и т.п. let text: String = msg @@ -193,7 +233,9 @@ impl AppState { .chars() .map(|c| if c.is_control() { ' ' } else { c }) .collect(); - println!("[LOG] {}", text); + if self.log_stdout { + println!("[LOG] {}", text); + } let mut logs = self.logs.write(); if logs.len() >= MAX_LOGS { logs.pop_back(); @@ -260,10 +302,16 @@ impl AppState { name: p.name.clone(), ips: p.ips.clone(), port: p.port, + trusts_me: p.trusts_me, }) .collect(); - if let Err(e) = storage::save_trusted(&self.data_dir, &entries) { - self.add_log(format!("Не удалось сохранить список доверенных: {}", e)); + match storage::save_trusted(&self.data_dir, &entries) { + Ok(()) => { + if let Ok(mt) = std::fs::metadata(self.data_dir.join("trusted.json")).and_then(|m| m.modified()) { + *self.trust_mtime.lock() = Some(mt); + } + } + Err(e) => self.add_log(format!("Не удалось сохранить список доверенных: {}", e)), } } @@ -314,7 +362,7 @@ impl AppState { match peers.entry(fp.to_string()) { Entry::Occupied(mut e) => { let p = e.get_mut(); - let changed = p.ips.first() != Some(&ip_s) || p.port != port; + let changed = p.ips.first() != Some(&ip_s) || p.port != port || p.name != name; p.name = name.clone(); p.port = port; p.online = true; @@ -351,32 +399,141 @@ impl AppState { created } - /// Пир уже недавно виден по этому адресу — повторный probe по beacon не нужен. - pub fn is_fresh(&self, fp: &str, ip: IpAddr) -> bool { + /// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене). + pub fn touch_peer_ip(&self, fp: &str, ip: IpAddr) { + if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() { + return; + } + let ip_s = ip.to_string(); + let mut dirty = false; + if let Some(p) = self.peers.write().get_mut(fp) { + let changed = p.ips.first() != Some(&ip_s); + p.online = true; + p.last_seen = now_sec(); + if changed { + p.ips.retain(|x| x != &ip_s); + p.ips.insert(0, ip_s); + p.ips.truncate(MAX_PEER_IPS); + if p.verified { + dirty = true; + } + } + } + if dirty { + self.trust_dirty.store(true, Ordering::Relaxed); + } + } + + /// Пир уже недавно виден по этому адресу и порту — повторный probe по beacon не нужен. + pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool { let ip_s = ip.to_string(); self.peers.read().get(fp).map_or(false, |p| { p.online + && p.port == port && p.ips.first().map(String::as_str) == Some(ip_s.as_str()) && now_sec().saturating_sub(p.last_seen) < 10 }) } - pub fn probe_allowed(&self, ip: IpAddr) -> bool { + pub fn probe_allowed(&self, ip: IpAddr, port: u16) -> bool { let mut m = self.probe_cooldown.lock(); let now = Instant::now(); if m.len() > 512 { - m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(30)); + m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(15)); } - match m.get(&ip) { - Some(t) if now.duration_since(*t) < Duration::from_secs(10) => false, + match m.get(&(ip, port)) { + Some(t) if now.duration_since(*t) < Duration::from_secs(4) => false, _ => { - m.insert(ip, now); + m.insert((ip, port), now); true } } } + /// Меняет интервал опроса: применяется сразу и сохраняется в config.json. + pub fn set_ping_interval(&self, sec: u64) -> Result<(), String> { + if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&sec) { + return Err(format!("Интервал должен быть от {} до {} секунд", MIN_PING_SEC, MAX_PING_SEC)); + } + self.ping_interval_sec.store(sec, Ordering::Relaxed); + self.ping_notify.notify_one(); // будим цикл опроса, чтобы он взял новое значение + if let Err(e) = storage::update_config(&self.data_dir, |c| c.ping_interval_sec = Some(sec)) { + self.add_log(format!("Не удалось сохранить config.json: {}", e)); + } + self.add_log(format!("Интервал опроса устройств: {} с", sec)); + Ok(()) + } + + /// Бит «собеседник подтвердил сверку с нами». Источник — только аутентифицированный (mTLS) + /// канал: заголовок/ответ /p2p/info от владельца отпечатка. + pub fn set_remote_trust(&self, fp: &str, v: bool) { + let (msg, hint) = { + let mut peers = self.peers.write(); + let Some(p) = peers.get_mut(fp) else { return }; + if p.trusts_me == v { + return; + } + p.trusts_me = v; + if v && !p.verified { + ( + format!( + "«{}» запрашивает взаимную сверку. Код сверки: {} — сравните его на экране устройства и подтвердите", + p.name, p.pin_code + ), + Some(short_id(fp)), + ) + } else if v { + (format!("«{}» подтвердил сверку — доверие взаимное", p.name), None) + } else { + (format!("«{}» отозвал доверие к вашему устройству", p.name), None) + } + }; + self.trust_dirty.store(true, Ordering::Relaxed); + self.add_log(msg); + if let (true, Some(id)) = (self.headless, hint) { + println!(" → подтвердить: ramshare_ui trust {}", id); + } + } + + /// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust). + /// Повреждённый файл игнорируется, а не «разжалует» всех пиров. + pub fn sync_trust_from_disk(&self) { + let path = self.data_dir.join("trusted.json"); + let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return }; + { + let mut last = self.trust_mtime.lock(); + if *last == Some(mt) { + return; + } + *last = Some(mt); + } + let Some(entries) = storage::read_trusted_strict(&self.data_dir) else { return }; + let mut peers = self.peers.write(); + for p in peers.values_mut() { + p.verified = entries.iter().any(|e| e.fp == p.id); + } + for e in entries { + if !is_valid_fp(&e.fp) || e.fp == self.my_fingerprint || peers.contains_key(&e.fp) { + continue; + } + if peers.len() >= MAX_PEERS { + break; + } + let ips: Vec = e + .ips + .into_iter() + .filter(|s| s.parse::().is_ok()) + .take(MAX_PEER_IPS) + .collect(); + let port = if e.port == 0 { 8443 } else { e.port }; + let mut np = PeerDevice::new(&e.fp, clean_label(&e.name, 48), ips, port, &self.my_fingerprint, true); + np.trusts_me = e.trusts_me; + peers.insert(e.fp.clone(), np); + } + } + pub fn maintenance(&self) { + self.sync_trust_from_disk(); let now = now_sec(); self.peers .write() @@ -460,6 +617,13 @@ pub fn clean_label(s: &str, max_chars: usize) -> String { .collect() } +/// Для печати чужого текста в терминал: убирает управляющие/escape-символы (кроме \n и \t). +pub fn strip_ctrl(s: &str) -> String { + s.chars() + .map(|c| if c.is_control() && c != '\n' && c != '\t' { '\u{fffd}' } else { c }) + .collect() +} + pub fn pct_decode(s: &str) -> String { percent_decode_str(s).decode_utf8_lossy().into_owned() } @@ -472,8 +636,10 @@ pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> { h.get(name).and_then(|v| v.to_str().ok()) } +/// Локальные IPv4: RFC 1918 (10/8, 172.16/12, 192.168/16), Link-Local (169.254/16) и CGNAT/VPN (100.64/10). pub fn is_lan_v4(ip: Ipv4Addr) -> bool { - ip.is_private() || ip.is_link_local() + let o = ip.octets(); + ip.is_private() || ip.is_link_local() || (o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000) } /// Принимает уже раскодированное имя. Ничего не декодирует. @@ -505,11 +671,15 @@ pub fn sanitize_filename(name: &str) -> String { } /// Диапазоны: "all", "192.168.1.1-254", "192.168.1.10-192.168.1.80", "192.168.1.0/24" (маски /22–/32), -/// одиночные адреса и списки через запятую. Только локальные (private/link-local) IPv4. +/// одиночные адреса и списки через запятую. Только локальные IPv4. pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec), String> { const MAX_TOTAL: usize = 1024; let raw = input.trim(); - let mine: Option = my_ip.parse().ok(); + let mine: Option = my_ip + .parse::() + .ok() + .filter(|ip| is_lan_v4(*ip)) + .or_else(crate::net::detect_lan_ip); let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") { let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?; diff --git a/src/net.rs b/src/net.rs index 2df83db..722c1a9 100644 --- a/src/net.rs +++ b/src/net.rs @@ -5,7 +5,7 @@ use socket2::{Domain, Protocol, Socket, Type}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; use tokio::{net::UdpSocket, sync::Semaphore}; @@ -14,16 +14,37 @@ const MAX_PROBE_BODY: usize = 8 * 1024; pub struct ProbeInfo { pub fp: String, pub name: String, + pub trusts_you: bool, } // ───────────────────────── Интерфейсы ───────────────────────── -/// (ip, broadcast) всех локальных (private/link-local) IPv4-интерфейсов. +fn is_virtual_if(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + n.starts_with("docker") + || n.starts_with("br-") + || n.starts_with("veth") + || n.starts_with("virbr") + || n.starts_with("vmnet") + || n.starts_with("vboxnet") +} + +/// (ip, broadcast) всех локальных (private/link-local/CGNAT) IPv4-интерфейсов. pub fn lan_interfaces() -> Vec<(Ipv4Addr, Option)> { if_addrs::get_if_addrs() .unwrap_or_default() .into_iter() .filter_map(|i| match &i.addr { - if_addrs::IfAddr::V4(v4) if !i.is_loopback() && is_lan_v4(v4.ip) => Some((v4.ip, v4.broadcast)), + if_addrs::IfAddr::V4(v4) if !i.is_loopback() && is_lan_v4(v4.ip) => { + let bc = v4.broadcast.or_else(|| { + let mask = u32::from(v4.netmask); + if mask != 0 && mask != u32::MAX { + Some(Ipv4Addr::from(u32::from(v4.ip) | !mask)) + } else { + None + } + }); + Some((v4.ip, bc)) + } _ => None, }) .collect() @@ -40,26 +61,62 @@ pub fn detect_lan_ip() -> Option { } } } - let all = lan_interfaces(); - all.iter() - .map(|(ip, _)| *ip) - .find(|ip| !(ip.octets()[0] == 172 && ip.octets()[1] == 17)) // не docker0 - .or_else(|| all.first().map(|(ip, _)| *ip)) + let ifs = if_addrs::get_if_addrs().unwrap_or_default(); + ifs.iter() + .filter(|i| !i.is_loopback() && !is_virtual_if(&i.name)) + .find_map(|i| match &i.addr { + if_addrs::IfAddr::V4(v4) if is_lan_v4(v4.ip) => Some(v4.ip), + _ => None, + }) + .or_else(|| lan_interfaces().first().map(|(ip, _)| *ip)) +} + +/// Сортирует список IP пира так, чтобы адреса из наших текущих подсетей (/24) проверялись первыми. +pub fn prioritize_ips(ips: &[IpAddr]) -> Vec { + let my_ips: Vec = lan_interfaces().into_iter().map(|(ip, _)| ip).collect(); + let in_local_subnet = |ip: &IpAddr| match ip { + IpAddr::V4(v4) => { + let o = v4.octets(); + my_ips.iter().any(|m| { + let mo = m.octets(); + mo[0] == o[0] && mo[1] == o[1] && mo[2] == o[2] + }) + } + IpAddr::V6(_) => false, + }; + let mut out = ips.to_vec(); + out.sort_by_key(|ip| !in_local_subnet(ip)); + out } // ───────────────────────── Probe ───────────────────────── /// Рукопожатие с узлом. Отпечаток берём из РЕАЛЬНО предъявленного сертификата. -pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result { +async fn probe_with_hint( + state: &AppState, + ip: IpAddr, + port: u16, + hint_fp: Option<&str>, +) -> Result { + let ip_s = ip.to_string(); + let trust_hdr: Option = { + let peers = state.peers.read(); + let target = hint_fp + .and_then(|fp| peers.get(fp)) + .or_else(|| peers.values().find(|p| p.ips.iter().any(|x| x == &ip_s))); + target.map(|p| if p.verified { p.id.clone() } else { "0".to_string() }) + }; + let url = format!("https://{}:{}/p2p/info", ip, port); - let mut resp = state + let mut req = state .probe_client .get(&url) .header("x-node-name", pct_encode(&state.device_name)) - .header("x-node-port", state.p2p_port.to_string()) - .send() - .await - .map_err(|_| "узел недоступен".to_string())?; + .header("x-node-port", state.p2p_port.to_string()); + if let Some(t) = &trust_hdr { + req = req.header("x-node-trusts", t); + } + let mut resp = req.send().await.map_err(|_| "узел недоступен".to_string())?; if !resp.status().is_success() { return Err(format!("узел вернул код {}", resp.status())); } @@ -73,7 +130,7 @@ pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result MAX_PROBE_BODY) { return Err("слишком большой ответ".into()); } - let mut buf: Vec = Vec::new(); + let mut buf: Vec = Vec::with_capacity(256); while let Some(chunk) = resp.chunk().await.map_err(|_| "ошибка чтения ответа".to_string())? { buf.extend_from_slice(&chunk); if buf.len() > MAX_PROBE_BODY { @@ -84,23 +141,46 @@ pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result Result { + probe_with_hint(state, ip, port, None).await +} + pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result { - let info = probe(state, ip, port).await?; + let ip_s = ip.to_string(); + let had_ip = state + .peers + .read() + .values() + .any(|p| p.ips.iter().any(|x| x == &ip_s)); + + let info = probe_with_hint(state, ip, port, None).await?; if info.fp == state.my_fingerprint { return Err("это ваше собственное устройство".into()); } state.upsert_peer(&info.name, ip, port, &info.fp); + state.set_remote_trust(&info.fp, info.trusts_you); + + // Если доверенный узел объявился на новом IP, сразу сообщаем ему наш статус доверия + if !had_ip && state.trusted_name(&info.fp).is_some() { + if let Ok(info2) = probe_with_hint(state, ip, port, Some(&info.fp)).await { + state.set_remote_trust(&info.fp, info2.trusts_you); + } + } Ok(info) } -// ───────────────────────── Ping ───────────────────────── +// ───────────────────────── Ping и синхронизация доверия ───────────────────────── pub async fn ping_all_peers(state: &Arc) { let Ok(_guard) = state.ping_lock.try_lock() else { return; // опрос уже идёт }; + if let Some(ip) = detect_lan_ip() { + state.update_my_ip(ip); + } let targets: Vec<(String, Vec, u16)> = state .peers .read() @@ -117,11 +197,12 @@ pub async fn ping_all_peers(state: &Arc) { } async fn ping_one(state: &AppState, id: &str, ips: Vec, port: u16) { - for ip_s in ips { - let Ok(ip) = ip_s.parse::() else { continue }; - if let Ok(info) = probe(state, ip, port).await { + let parsed: Vec = ips.iter().filter_map(|s| s.parse().ok()).collect(); + for ip in prioritize_ips(&parsed) { + if let Ok(info) = probe_with_hint(state, ip, port, Some(id)).await { if info.fp == id { state.upsert_peer(&info.name, ip, port, id); + state.set_remote_trust(id, info.trusts_you); return; } // по этому адресу теперь другое устройство — игнорируем @@ -135,6 +216,44 @@ async fn ping_one(state: &AppState, id: &str, ips: Vec, port: u16) { } } +/// Мгновенно отправляет обновлённый статус доверия пиру и обновляет его `trusts_me`. +pub async fn push_trust(state: &AppState, id: &str) { + let Some((ips, port)) = state + .peers + .read() + .get(id) + .map(|p| (p.ips.clone(), p.port)) + else { + return; + }; + ping_one(state, id, ips, port).await; +} + +/// Возвращает рабочий IP пира (для потоковой отправки файлов при возможной смене подсети). +pub async fn select_peer_ip(state: &AppState, id: &str) -> Option<(IpAddr, u16)> { + let (ips, port, fresh) = { + let peers = state.peers.read(); + let p = peers.get(id)?; + let parsed: Vec = p.ips.iter().filter_map(|s| s.parse().ok()).collect(); + let fresh = p.online && now_sec().saturating_sub(p.last_seen) < 5; + (parsed, p.port, fresh) + }; + let ordered = prioritize_ips(&ips); + if fresh && ordered.first() == ips.first() { + return ordered.first().copied().map(|ip| (ip, port)); + } + for ip in &ordered { + if let Ok(info) = probe_with_hint(state, *ip, port, Some(id)).await { + if info.fp == id { + state.upsert_peer(&info.name, *ip, port, id); + state.set_remote_trust(id, info.trusts_you); + return Some((*ip, port)); + } + } + } + ordered.first().copied().map(|ip| (ip, port)) +} + // ───────────────────────── Сканер ───────────────────────── pub async fn run_scan(state: Arc, ips: Vec, port: u16) { let st = &state; @@ -165,6 +284,22 @@ struct Beacon { fp: String, } +fn broadcast_targets() -> Vec { + let mut targets: Vec = lan_interfaces() + .into_iter() + .map(|(ip, bc)| { + bc.unwrap_or_else(|| { + let o = ip.octets(); + Ipv4Addr::new(o[0], o[1], o[2], 255) + }) + }) + .collect(); + targets.push(Ipv4Addr::BROADCAST); + targets.sort(); + targets.dedup(); + targets +} + fn bind_discovery_socket() -> std::io::Result { let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; socket.set_reuse_address(true)?; @@ -176,7 +311,9 @@ fn bind_discovery_socket() -> std::io::Result { } pub fn start_discovery(state: Arc) { - // отправка + let serving = state.log_stdout; + + // Отправка маяков (в режиме serve — периодическая + отслеживание смены сети; в CLI — разовый запрос port=0) let tx = state.clone(); tokio::spawn(async move { let sock = match UdpSocket::bind("0.0.0.0:0").await { @@ -189,26 +326,33 @@ pub fn start_discovery(state: Arc) { let _ = sock.set_broadcast(true); let pkt = serde_json::to_vec(&Beacon { v: 1, - port: tx.p2p_port, + port: if serving { tx.p2p_port } else { 0 }, fp: tx.my_fingerprint.clone(), }) .unwrap_or_default(); + if !serving { + for t in broadcast_targets() { + let _ = sock.send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT)).await; + } + return; + } + + let mut last_ifs: Vec = Vec::new(); loop { - // интерфейсы пересчитываются каждый цикл — смена сети подхватится сама - let mut targets: Vec = lan_interfaces() - .into_iter() - .map(|(ip, bc)| { - bc.unwrap_or_else(|| { - let o = ip.octets(); - Ipv4Addr::new(o[0], o[1], o[2], 255) - }) - }) - .collect(); - targets.push(Ipv4Addr::BROADCAST); - targets.sort(); - targets.dedup(); - for t in targets { + let cur_ifs: Vec = lan_interfaces().into_iter().map(|(ip, _)| ip).collect(); + if cur_ifs != last_ifs { + if let Some(ip) = detect_lan_ip() { + if tx.update_my_ip(ip) { + tx.add_log(format!("Смена сети: активный адрес {}", ip)); + } + } + tx.probe_cooldown.lock().clear(); + tx.ping_notify.notify_one(); + last_ifs = cur_ifs; + } + + for t in broadcast_targets() { let _ = sock .send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT)) .await; @@ -217,7 +361,7 @@ pub fn start_discovery(state: Arc) { } }); - // приём + // Приём маяков tokio::spawn(async move { let socket = match bind_discovery_socket() { Ok(s) => s, @@ -229,8 +373,16 @@ pub fn start_discovery(state: Arc) { return; } }; + let reply_pkt = serde_json::to_vec(&Beacon { + v: 1, + port: state.p2p_port, + fp: state.my_fingerprint.clone(), + }) + .unwrap_or_default(); + let mut last_reply = Instant::now() - Duration::from_secs(2); let slots = Arc::new(Semaphore::new(8)); let mut buf = [0u8; 512]; + loop { let (len, remote) = match socket.recv_from(&mut buf).await { Ok(v) => v, @@ -239,17 +391,30 @@ pub fn start_discovery(state: Arc) { continue; } }; - let Ok(b) = serde_json::from_slice::(&buf[..len]) else { continue }; - if b.v != 1 || b.port == 0 || !is_valid_fp(&b.fp) || b.fp == state.my_fingerprint { - continue; - } - // адрес — только реальный адрес источника пакета let IpAddr::V4(ip4) = remote.ip() else { continue }; if !is_lan_v4(ip4) { continue; } + let Ok(b) = serde_json::from_slice::(&buf[..len]) else { continue }; + if b.v != 1 || !is_valid_fp(&b.fp) || b.fp == state.my_fingerprint { + continue; + } + + // port == 0 — запрос от CLI или нового узла; отвечаем своим маяком без ожидания 3 с + if b.port == 0 { + if serving && last_reply.elapsed() >= Duration::from_millis(500) { + last_reply = Instant::now(); + for t in broadcast_targets() { + let _ = socket + .send_to(&reply_pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT)) + .await; + } + } + continue; + } + let ip = IpAddr::V4(ip4); - if state.is_fresh(&b.fp, ip) || !state.probe_allowed(ip) { + if state.is_fresh(&b.fp, ip, b.port) || !state.probe_allowed(ip, b.port) { continue; } let Ok(permit) = slots.clone().try_acquire_owned() else { continue }; diff --git a/src/p2p.rs b/src/p2p.rs index b9affa4..09e3531 100644 --- a/src/p2p.rs +++ b/src/p2p.rs @@ -92,7 +92,17 @@ async fn p2p_info( let name = hdr(&headers, "x-node-name").map(pct_decode).unwrap_or_default(); state.upsert_peer(&name, ctx.addr.ip(), port, &ctx.fp); } - Json(serde_json::json!({ "name": state.device_name, "port": state.p2p_port })) + // Взаимная сверка: собеседник сообщает, подтвердил ли он НАС. Отпечаток — из TLS, подделать нельзя. + if let Some(v) = hdr(&headers, "x-node-trusts") { + if v == "1" || v == state.my_fingerprint { + state.set_remote_trust(&ctx.fp, true); + } else if v == "0" { + state.set_remote_trust(&ctx.fp, false); + } + } + // и мы отвечаем, подтвердили ли мы его + let trusts_you = state.trusted_name(&ctx.fp).is_some(); + Json(serde_json::json!({ "name": state.device_name, "port": state.p2p_port, "trusts_you": trusts_you })) } async fn p2p_receive_text( @@ -103,6 +113,8 @@ async fn p2p_receive_text( let sender = state .trusted_name(&ctx.fp) .ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?; + state.touch_peer_ip(&ctx.fp, ctx.addr.ip()); + if payload.text.trim().is_empty() { return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст")); } @@ -110,6 +122,10 @@ async fn p2p_receive_text( return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст слишком большой")); } state.add_log(format!("Получен текст в ОЗУ от '{}'", sender)); + if state.headless { + // режим без UI: терминал — единственное место, где можно увидеть входящий текст + println!("[ТЕКСТ от {}]\n{}", sender, strip_ctrl(&payload.text)); + } let mut q = state.ram_texts.write(); if q.len() >= MAX_RAM_TEXTS { q.pop_back(); @@ -150,6 +166,7 @@ async fn p2p_receive_media( let sender = state .trusted_name(&ctx.fp) .ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?; + state.touch_peer_ip(&ctx.fp, ctx.addr.ip()); let total: u64 = hdr(&headers, "x-total-size") .and_then(|v| v.parse().ok()) @@ -157,16 +174,24 @@ async fn p2p_receive_media( if total == 0 || total > MAX_FILE_SIZE { return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Недопустимый размер файла")); } - let used: u64 = state.disk_media.read().iter().map(|m| m.size).sum(); - if used.saturating_add(total) > MAX_MEDIA_QUOTA { - return Err(AppError::new(StatusCode::INSUFFICIENT_STORAGE, "Превышена квота хранилища")); - } let _slot = state .incoming_slots .clone() .try_acquire_owned() .map_err(|_| AppError::new(StatusCode::TOO_MANY_REQUESTS, "Слишком много входящих передач"))?; + let disk_used: u64 = state.disk_media.read().iter().map(|m| m.size).sum(); + let in_flight: u64 = state + .transfers + .read() + .iter() + .filter(|t| t.direction == Direction::In && !t.done) + .map(|t| t.total_bytes) + .sum(); + if disk_used.saturating_add(in_flight).saturating_add(total) > MAX_MEDIA_QUOTA { + return Err(AppError::new(StatusCode::INSUFFICIENT_STORAGE, "Превышена квота хранилища")); + } + let safe_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin"))); tokio::fs::create_dir_all(&state.media_dir) @@ -229,8 +254,15 @@ async fn p2p_receive_media( } drop(file); - let final_name = format!("{}_{}_{}", now_sec(), random_hex(3), safe_name); - let final_path = state.media_dir.join(&final_name); + let candidate = state.media_dir.join(&safe_name); + let (final_name, final_path) = if tokio::fs::try_exists(&candidate).await.unwrap_or(true) { + let name = format!("{}_{}_{}", now_sec(), random_hex(3), safe_name); + let path = state.media_dir.join(&name); + (name, path) + } else { + (safe_name.clone(), candidate) + }; + if let Err(e) = tokio::fs::rename(&tmp, &final_path).await { return Err(fail_rx(&state, &id, AppError::internal("rename", e))); } diff --git a/src/static/app.js b/src/static/app.js index a07cdf1..1e78269 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -4,7 +4,7 @@ const $ = (id) => document.getElementById(id); let statusTimer = null; let lastData = null; let knownPeersKey = ''; -let rangeInitialized = false; +let lastSubnet = ''; let pendingTrustId = null; function esc(v) { @@ -53,10 +53,17 @@ function renderHeader(d) { $('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`; $('my-pin').textContent = d.my_pin; $('media-dir').textContent = d.media_dir; - if (!rangeInitialized && d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) { + const pi = $('ping-interval-input'); + pi.min = d.ping_min; + pi.max = d.ping_max; + if (document.activeElement !== pi) pi.value = d.ping_interval_sec; // не мешаем вводу + if (d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) { const p = d.my_ip.split('.'); - $('scan-range-input').value = `${p[0]}.${p[1]}.${p[2]}.1-254`; - rangeInitialized = true; + const subnet = `${p[0]}.${p[1]}.${p[2]}`; + if (subnet !== lastSubnet) { + lastSubnet = subnet; + $('scan-range-input').value = `${subnet}.1-254`; + } } } @@ -97,13 +104,35 @@ function renderTransfers(list) { }).join('')); } +const notifiedRequests = new Set(); + +function trustInfo(p) { + if (p.verified && p.trusts_me) return { color: '#34d399', text: 'Взаимно заверено', btn: 'Отозвать', btnCls: 'btn-red', trust: '0', req: false }; + if (p.verified) return { color: '#fbbf24', text: `Вы подтвердили. Ждём подтверждения на «${p.name}»`, btn: 'Отозвать', btnCls: 'btn-red', trust: '0', req: false }; + if (p.trusts_me) return { color: '#fb923c', text: `«${p.name}» подтвердил сверку и ждёт вашего подтверждения`, btn: 'Сверить и подтвердить', btnCls: 'btn-green', trust: '1', req: true }; + return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false }; +} + function renderPeers(peers) { const box = $('peers-list'); + + // уведомление о входящем запросе на сверку (один раз на запрос) + const pending = new Set(peers.filter((p) => !p.verified && p.trusts_me).map((p) => p.id)); + for (const p of peers) { + if (pending.has(p.id) && !notifiedRequests.has(p.id)) { + notifiedRequests.add(p.id); + showStatus(`🔔 «${p.name}» запрашивает сверку. Сравните код и подтвердите.`); + } + } + for (const id of [...notifiedRequests]) if (!pending.has(id)) notifiedRequests.delete(id); + if (!peers.length) { setHtml(box, '
Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.
'); } else { - setHtml(box, peers.map((p) => ` -
+ setHtml(box, peers.map((p) => { + const t = trustInfo(p); + return ` +
${esc(p.name)} @@ -111,23 +140,25 @@ function renderPeers(peers) {
Код сверки: ${esc(p.pin_code)}
- Статус: ${p.verified ? 'Заверен' : 'Не заверен'} + Статус: ${esc(t.text)}
- -
`).join('')); +
`; + }).join('')); } - const key = peers.map((p) => `${p.id}:${p.verified}:${p.ips[0] || ''}:${p.name}`).join('|'); + const key = peers.map((p) => `${p.id}:${p.verified}:${p.trusts_me}:${p.ips[0] || ''}:${p.name}`).join('|'); if (key !== knownPeersKey) { knownPeersKey = key; const sel = $('target-peer'); const prev = sel.value; - sel.innerHTML = '' + peers.map((p) => - ``).join(''); + sel.innerHTML = '' + peers.map((p) => { + const mark = p.verified && p.trusts_me ? '✓' : p.verified ? '⏳ ждёт подтверждения' : p.trusts_me ? '🔔 запрос на сверку' : '🔒 не заверен'; + return ``; + }).join(''); if (prev) sel.value = prev; } } @@ -203,6 +234,19 @@ function addManualPeer() { }); } +function saveSettings() { + return guarded(async () => { + const el = $('ping-interval-input'); + const v = parseInt(el.value, 10); + const min = Number(el.min) || 10, max = Number(el.max) || 30; + if (!Number.isInteger(v) || v < min || v > max) return showStatus(`Интервал должен быть от ${min} до ${max} секунд`, true); + await postJson('/ui/settings', { ping_interval_sec: v }); + showStatus(`Опрос устройств каждые ${v} с`); + el.blur(); + fetchState().catch(() => {}); + }); +} + function pingPeers() { return guarded(async () => { await api('/ui/ping', { method: 'POST' }); @@ -271,6 +315,8 @@ function clearRam() { // ───────────── события (без inline-обработчиков: CSP script-src 'self') ───────────── $('btn-ping').addEventListener('click', pingPeers); +$('btn-save-settings').addEventListener('click', saveSettings); +$('ping-interval-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') saveSettings(); }); $('btn-manual').addEventListener('click', addManualPeer); $('btn-scan-range').addEventListener('click', () => scanIpRange(false)); $('btn-scan-all').addEventListener('click', () => scanIpRange(true)); diff --git a/src/static/index.html b/src/static/index.html index 9dd1c8b..5b60690 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -17,7 +17,7 @@ .tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; } .tag-ram { background: rgba(16,185,129,.2); color: #34d399; } .tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; } - input[type="text"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: .92rem; } + input[type="text"], input[type="number"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: .92rem; } textarea { resize: vertical; min-height: 80px; } button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity .15s; } button:hover { opacity: .9; } @@ -32,6 +32,7 @@ .msg-text { font-size: .95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; } .path-box { font-family: monospace; font-size: .78rem; color: #93c5fd; background: rgba(59,130,246,.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; } .preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; } + .peer-item.request { border-color: #fb923c; background: rgba(251,146,60,.08); } .warn { color: #fbbf24; font-size: .8rem; } .muted { color: var(--muted); } .small { font-size: .8rem; } @@ -95,8 +96,15 @@ +
+ + + сек (10–30) + +
+
-
Доверие должно быть взаимным: заверьте устройство на обеих сторонах после сверки кода.
+
Сверка взаимная: когда вы подтверждаете код, второе устройство получает запрос и тоже должно сверить и подтвердить код у себя. Пока оба не подтвердили, передачи не проходят.
@@ -133,7 +141,7 @@

Заверить устройство?

«»
-
Код должен полностью совпадать с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.
+
Код должен полностью совпадать с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.

После вашего подтверждения то же самое будет запрошено на втором устройстве: доверие считается установленным, только когда код подтвердили оба.
diff --git a/src/storage.rs b/src/storage.rs index 5e2d2bb..76f7a4e 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -15,9 +15,18 @@ pub struct TrustedEntry { pub ips: Vec, #[serde(default)] pub port: u16, + /// последнее известное «собеседник подтвердил нас» (уточняется при каждом опросе) + #[serde(default)] + pub trusts_me: bool, } -pub fn default_data_dir(p2p_port: u16) -> PathBuf { +/// Папка, где лежит исполняемый файл ramshare_ui. +fn exe_dir() -> Option { + std::env::current_exe().ok()?.parent().map(Path::to_path_buf) +} + +/// Прежнее расположение (каталог данных ОС): запасной вариант и источник миграции. +fn os_data_dir(p2p_port: u16) -> PathBuf { let base: Option = if cfg!(windows) { std::env::var_os("LOCALAPPDATA").map(PathBuf::from) } else if cfg!(target_os = "macos") { @@ -35,6 +44,91 @@ pub fn default_data_dir(p2p_port: u16) -> PathBuf { base.unwrap_or_else(|| PathBuf::from(".")).join(name) } +pub struct Dirs { + /// config.json, trusted.json — все файлы конфигурации и настроек + pub config: PathBuf, + /// принятые файлы + pub media: PathBuf, +} + +fn make_dirs(d: &Dirs) -> io::Result<()> { + fs::create_dir_all(&d.config)?; + fs::create_dir_all(&d.media) +} + +/// Копирует файлы настроек из прежнего каталога данных ОС при первом запуске новой раскладки. +fn migrate_legacy(config: &Path, p2p_port: u16) { + if config.join("config.json").exists() { + return; + } + let old = os_data_dir(p2p_port); + if !old.is_dir() || old == config { + return; + } + let mut copied = false; + for name in ["config.json", "trusted.json", "identity.json", "settings.json"] { + let (src, dst) = (old.join(name), config.join(name)); + if src.is_file() && !dst.exists() { + match fs::copy(&src, &dst) { + Ok(_) => copied = true, + Err(e) => eprintln!("[WARN] перенос {}: {}", src.display(), e), + } + } + } + if copied { + eprintln!("[INFO] настройки скопированы из {} в {}", old.display(), config.display()); + } + let old_media = old.join("received_media"); + if old_media.is_dir() { + eprintln!("[INFO] ранее принятые файлы остались в {}", old_media.display()); + } +} + +/// Раскладка каталогов: +/// <папка с ramshare_ui>/config/ — config.json, trusted.json +/// <папка с ramshare_ui>/received_media/ — принятые файлы +/// `--data-dir X` заменяет «папку с ramshare_ui» на X. Для не-стандартного P2P-порта без --data-dir +/// каталоги получают суффикс (config-8444), чтобы экземпляры не делили одну идентичность. +/// Если папка программы недоступна для записи — запасной вариант: каталог данных ОС (с предупреждением). +pub fn resolve_dirs(base_override: Option<&Path>, p2p_port: u16) -> Result { + if let Some(b) = base_override { + let base = if b.is_absolute() { + b.to_path_buf() + } else { + std::env::current_dir().map_err(|e| e.to_string())?.join(b) + }; + let d = Dirs { config: base.join("config"), media: base.join("received_media") }; + make_dirs(&d).map_err(|e| format!("создание каталогов в {}: {}", base.display(), e))?; + return Ok(d); + } + + if let Some(base) = exe_dir() { + let suffix = if p2p_port == 8443 { String::new() } else { format!("-{}", p2p_port) }; + let d = Dirs { + config: base.join(format!("config{}", suffix)), + media: base.join(format!("received_media{}", suffix)), + }; + match make_dirs(&d) { + Ok(()) => { + migrate_legacy(&d.config, p2p_port); + return Ok(d); + } + Err(e) => eprintln!( + "[WARN] не удалось создать {} ({}). Используется каталог данных ОС; \ + чтобы выбрать место явно, задайте --data-dir.", + d.config.display(), + e + ), + } + } + + let base = os_data_dir(p2p_port); + let d = Dirs { config: base.join("config"), media: base.join("received_media") }; + make_dirs(&d).map_err(|e| format!("создание каталогов в {}: {}", base.display(), e))?; + migrate_legacy(&d.config, p2p_port); + Ok(d) +} + /// Атомарная запись: tmp + fsync + rename. Для секретов на Unix права 0600. pub fn write_atomic(path: &Path, data: &[u8], private: bool) -> io::Result<()> { let mut tmp_name = path.file_name().unwrap_or_default().to_os_string(); @@ -75,6 +169,12 @@ pub fn load_trusted(dir: &Path) -> Vec { } } +/// Строгое чтение: None при ошибке разбора (в отличие от load_trusted, не подменяет пустым списком). +pub fn read_trusted_strict(dir: &Path) -> Option> { + let s = fs::read_to_string(dir.join("trusted.json")).ok()?; + serde_json::from_str(&s).ok() +} + pub fn save_trusted(dir: &Path, entries: &[TrustedEntry]) -> io::Result<()> { let json = serde_json::to_vec_pretty(entries).map_err(io::Error::other)?; write_atomic(&dir.join("trusted.json"), &json, false) @@ -160,3 +260,83 @@ pub fn scan_media_dir(dir: &Path) -> Vec { items.truncate(crate::models::MAX_MEDIA_LIST); items } + +// ───────────────────────── Устаревший settings.json (только миграция) ───────────────────────── +fn default_ping() -> u64 { + crate::models::DEFAULT_PING_SEC +} + +/// Устаревший `settings.json`: читается только для миграции в config.json. +#[derive(Serialize, Deserialize)] +pub struct Settings { + #[serde(default = "default_ping")] + pub ping_interval_sec: u64, +} + +pub fn load_settings(dir: &Path) -> Settings { + use crate::models::{MAX_PING_SEC, MIN_PING_SEC}; + let mut s = match fs::read_to_string(dir.join("settings.json")) { + Ok(txt) => serde_json::from_str(&txt).unwrap_or_else(|e| { + eprintln!("[WARN] settings.json повреждён ({}), используются значения по умолчанию", e); + Settings { ping_interval_sec: default_ping() } + }), + Err(_) => Settings { ping_interval_sec: default_ping() }, + }; + s.ping_interval_sec = s.ping_interval_sec.clamp(MIN_PING_SEC, MAX_PING_SEC); + s +} + +// ───────────────────────── config.json ───────────────────────── +#[derive(Clone, Serialize, Deserialize)] +pub struct IdentityRecord { + pub cert_hex: String, + pub key_hex: String, + pub fingerprint: String, +} + +/// `/config.json`. Содержит закрытый ключ — файл создаётся с правами 0600, не публикуйте его. +#[derive(Default, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub device_name: Option, + #[serde(default)] + pub ping_interval_sec: Option, + #[serde(default)] + pub identity: Option, +} + +pub fn config_path(dir: &Path) -> PathBuf { + dir.join("config.json") +} + +/// Ok(None) — файла нет; Err — файл есть, но не читается/не разбирается (не затираем молча). +pub fn load_config(dir: &Path) -> Result, String> { + let path = config_path(dir); + match fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s).map(Some).map_err(|e| { + format!( + "{} содержит ошибку: {}. Исправьте файл (в нём хранится идентичность устройства — \ + не удаляйте его без резервной копии)", + path.display(), + e + ) + }), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("чтение {}: {}", path.display(), e)), + } +} + +pub fn save_config(dir: &Path, cfg: &Config) -> io::Result<()> { + let json = serde_json::to_vec_pretty(cfg).map_err(io::Error::other)?; + write_atomic(&config_path(dir), &json, true) +} + +/// Меняет одно значение, сохраняя остальные (в т.ч. идентичность). Файл обязан существовать, +/// чтобы случайно не создать конфиг без идентичности. +pub fn update_config(dir: &Path, f: impl FnOnce(&mut Config)) -> io::Result<()> { + let mut cfg = load_config(dir) + .map_err(io::Error::other)? + .ok_or_else(|| io::Error::other("config.json не найден"))?; + f(&mut cfg); + save_config(dir, &cfg) +} diff --git a/src/tls.rs b/src/tls.rs index 0699923..2d27fd5 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,6 +1,6 @@ use crate::{ models::{fingerprint, PeerDevice}, - storage, + storage::IdentityRecord, }; use parking_lot::RwLock; use rcgen::{generate_simple_self_signed, CertifiedKey}; @@ -11,10 +11,9 @@ use rustls::{ server::danger::{ClientCertVerified, ClientCertVerifier}, ClientConfig, DigitallySignedStruct, DistinguishedName, Error as TlsError, ServerConfig, SignatureScheme, }; -use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, - path::{Path, PathBuf}, + path::Path, sync::{Arc, OnceLock}, time::Duration, }; @@ -34,60 +33,41 @@ pub struct Identity { pub fingerprint: String, } -#[derive(Serialize, Deserialize)] -struct SavedIdentity { - cert_hex: String, - key_hex: String, - fingerprint: String, -} - -fn read_identity(path: &Path) -> Result { - let content = std::fs::read_to_string(path).map_err(|e| format!("чтение {}: {}", path.display(), e))?; - let saved: SavedIdentity = - serde_json::from_str(&content).map_err(|e| format!("файл {} повреждён: {}", path.display(), e))?; - let cert_der = hex::decode(&saved.cert_hex).map_err(|_| format!("{}: неверный cert_hex", path.display()))?; - let key_der = hex::decode(&saved.key_hex).map_err(|_| format!("{}: неверный key_hex", path.display()))?; +pub fn identity_from_record(r: &IdentityRecord) -> Result { + let cert_der = hex::decode(&r.cert_hex).map_err(|_| "identity.cert_hex: неверный hex".to_string())?; + let key_der = hex::decode(&r.key_hex).map_err(|_| "identity.key_hex: неверный hex".to_string())?; let fp = fingerprint(&cert_der); - if fp != saved.fingerprint { - return Err(format!("{}: отпечаток не совпадает с сертификатом", path.display())); + if fp != r.fingerprint { + return Err("identity.fingerprint не совпадает с сертификатом (данные изменены или повреждены)".into()); } Ok(Identity { cert_der, key_der, fingerprint: fp }) } -fn save_identity(path: &Path, id: &Identity) -> Result<(), String> { - let json = serde_json::to_vec_pretty(&SavedIdentity { +pub fn identity_to_record(id: &Identity) -> IdentityRecord { + IdentityRecord { cert_hex: hex::encode(&id.cert_der), key_hex: hex::encode(&id.key_der), fingerprint: id.fingerprint.clone(), - }) - .map_err(|e| e.to_string())?; - storage::write_atomic(path, &json, true).map_err(|e| format!("запись {}: {}", path.display(), e)) + } } -/// Битый файл идентичности — ошибка запуска, а НЕ тихая регенерация. -pub fn load_or_create_identity(data_dir: &Path, legacy_port: u16) -> Result { - let path = data_dir.join("identity.json"); - if path.exists() { - return read_identity(&path); - } - // миграция со старой версии (.identity_.json в текущей папке) - let legacy = PathBuf::from(format!(".identity_{}.json", legacy_port)); - if legacy.exists() { - if let Ok(id) = read_identity(&legacy) { - save_identity(&path, &id)?; - return Ok(id); - } - } - let CertifiedKey { cert, key_pair } = - generate_simple_self_signed(vec!["ramshare.local".to_string()]).map_err(|e| format!("генерация сертификата: {e}"))?; +/// Старые файлы идентичности (identity.json / .identity_<порт>.json) — для миграции в config.json. +pub fn read_identity_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| format!("чтение {}: {}", path.display(), e))?; + let rec: IdentityRecord = + serde_json::from_str(&content).map_err(|e| format!("файл {} повреждён: {}", path.display(), e))?; + identity_from_record(&rec).map_err(|e| format!("{}: {}", path.display(), e)) +} + +pub fn generate_identity() -> Result { + let CertifiedKey { cert, key_pair } = generate_simple_self_signed(vec!["ramshare.local".to_string()]) + .map_err(|e| format!("генерация сертификата: {e}"))?; let cert_der = cert.der().to_vec(); - let id = Identity { + Ok(Identity { fingerprint: fingerprint(&cert_der), cert_der, key_der: key_pair.serialize_der(), - }; - save_identity(&path, &id)?; - Ok(id) + }) } fn key_of(der: &[u8]) -> PrivateKeyDer<'static> { @@ -136,12 +116,12 @@ impl ClientCertVerifier for AnyClientCert { } } -pub fn server_config(id: &Identity) -> Result, String> { +pub fn server_config(cert_der: &[u8], key_der: &[u8]) -> Result, String> { let mut cfg = ServerConfig::builder_with_provider(provider()) .with_protocol_versions(&[&rustls::version::TLS13]) .map_err(|e| e.to_string())? .with_client_cert_verifier(Arc::new(AnyClientCert)) - .with_single_cert(vec![CertificateDer::from(id.cert_der.clone())], key_of(&id.key_der)) + .with_single_cert(vec![CertificateDer::from(cert_der.to_vec())], key_of(key_der)) .map_err(|e| format!("сертификат/ключ не подходят друг другу: {e}"))?; cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; Ok(Arc::new(cfg)) @@ -255,7 +235,7 @@ fn base_builder(cfg: ClientConfig) -> reqwest::ClientBuilder { .no_proxy() // не читать HTTP(S)_PROXY из окружения .redirect(reqwest::redirect::Policy::none()) // нет SSRF через 302 .pool_max_idle_per_host(0) // отзыв доверия действует сразу - .connect_timeout(Duration::from_secs(5)) + .connect_timeout(Duration::from_secs(2)) } /// Клиент на одну передачу к конкретному пиру. @@ -270,7 +250,7 @@ pub fn pinned_client( let cfg = client_config(Arc::new(PinVerifier { expected: expected_fp, peers }), cert, key)?; let mut b = base_builder(cfg); if !streaming { - b = b.timeout(Duration::from_secs(15)); + b = b.timeout(Duration::from_secs(12)); } b.build().map_err(|e| e.to_string()) } @@ -279,7 +259,7 @@ pub fn probe_client(cert: &[u8], key: &[u8]) -> Result let cfg = client_config(Arc::new(AnyServerCert), cert, key)?; base_builder(cfg) .tls_info(true) - .timeout(Duration::from_secs(4)) + .timeout(Duration::from_secs(3)) .build() .map_err(|e| e.to_string()) } diff --git a/src/transfer.rs b/src/transfer.rs new file mode 100644 index 0000000..c639eac --- /dev/null +++ b/src/transfer.rs @@ -0,0 +1,205 @@ +//! Общая логика исходящих передач: используется и веб-интерфейсом, и CLI. + +use crate::{models::*, net}; +use axum::{ + body::Bytes, + http::{header, StatusCode}, +}; +use futures_util::{Stream, StreamExt}; +use std::{ + io, + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio_stream::wrappers::ReceiverStream; + +/// (адреса, порт, имя) заверенного получателя. +pub fn resolve_target(state: &AppState, id: &str) -> Result<(Vec, u16, String), AppError> { + let peers = state.peers.read(); + let p = peers + .get(id) + .ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Получатель не найден"))?; + if !p.verified { + return Err(AppError::new( + StatusCode::FORBIDDEN, + format!("Сначала заверьте устройство '{}' (код сверки {})", p.name, p.pin_code), + )); + } + let ips: Vec = p.ips.iter().filter_map(|s| s.parse().ok()).collect(); + if ips.is_empty() { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Адрес получателя неизвестен. Выполните опрос или сканирование.", + )); + } + Ok((net::prioritize_ips(&ips), p.port, p.name.clone())) +} + +/// Отправляет текст (в ОЗУ получателя). Возвращает имя получателя. +pub async fn send_text(state: &AppState, peer_id: &str, text: String) -> Result { + if text.trim().is_empty() { + return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст")); + } + if text.len() > MAX_TEXT_BYTES { + return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст больше 64 КБ")); + } + let (ips, port, peer_name) = resolve_target(state, peer_id)?; + let client = state.client_for_peer(peer_id, false)?; + let body = serde_json::to_vec(&IncomingTextPayload { text }).map_err(|e| AppError::internal("json", e))?; + + for ip in ips { + let url = format!("https://{}:{}/p2p/text", ip, port); + match client + .post(&url) + .header(header::CONTENT_TYPE, "application/json") + .body(body.clone()) + .send() + .await + { + Ok(r) if r.status().is_success() => { + state.touch_peer_ip(peer_id, ip); + state.add_log(format!("Текст доставлен в ОЗУ устройства '{}'", peer_name)); + return Ok(peer_name); + } + Ok(r) if r.status() == StatusCode::FORBIDDEN => { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Получатель ещё не заверил ваше устройство (доверие должно быть взаимным)", + )) + } + Ok(r) => { + return Err(AppError::new( + StatusCode::BAD_GATEWAY, + format!("Получатель отклонил запрос ({})", r.status()), + )) + } + Err(_) => continue, // пробуем следующий адрес + } + } + Err(AppError::new( + StatusCode::BAD_GATEWAY, + "Не удалось установить защищённое соединение (узел недоступен или сертификат не совпал)", + )) +} + +/// Потоково отправляет файл заверенному получателю. Прогресс виден в `state.transfers`. +/// `total` — точный размер потока (используется как Content-Length). Возвращает имя получателя. +pub async fn send_stream( + state: &Arc, + peer_id: &str, + file_name: &str, + total: u64, + stream: S, +) -> Result +where + S: Stream> + Send + 'static, +{ + let display_name = sanitize_filename(file_name); + if total == 0 { + return Err(AppError::new(StatusCode::BAD_REQUEST, "Файл пустой")); + } + if total > MAX_FILE_SIZE { + return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Файл больше 2 ГБ")); + } + + let (ips, default_port, peer_name) = resolve_target(state, peer_id)?; + let (target_ip, port) = net::select_peer_ip(state, peer_id) + .await + .unwrap_or((ips[0], default_port)); + let client = state.client_for_peer(peer_id, true)?; + let url = format!("https://{}:{}/p2p/media", target_ip, port); + + let tid = format!("tx_{}", random_hex(6)); + state.add_transfer(TransferProgress { + id: tid.clone(), + filename: display_name.clone(), + peer_name: peer_name.clone(), + direction: Direction::Out, + transferred_bytes: 0, + total_bytes: total, + percent: 0, + done: false, + error: None, + updated_at: now_sec(), + }); + + let (tx, rx) = tokio::sync::mpsc::channel::>(4); + let fwd_state = state.clone(); + let fwd_id = tid.clone(); + let fwd = tokio::spawn(async move { + let mut stream = Box::pin(stream); + let mut sent: u64 = 0; + let mut last = Instant::now(); + while let Some(item) = stream.next().await { + let chunk = match item { + Ok(c) => c, + Err(e) => { + let _ = tx.send(Err(e)).await; + return; + } + }; + sent += chunk.len() as u64; + if sent > total { + let _ = tx.send(Err(io::Error::new(io::ErrorKind::Other, "body too long"))).await; + return; + } + // если получатель не читает 30 с — закрываем поток, запрос завершится ошибкой + match tokio::time::timeout(Duration::from_secs(30), tx.send(Ok(chunk))).await { + Ok(Ok(())) => {} + _ => return, + } + if last.elapsed() >= Duration::from_millis(100) { + fwd_state.update_transfer_bytes(&fwd_id, sent); + last = Instant::now(); + } + } + if sent != total { + let _ = tx + .send(Err(io::Error::new(io::ErrorKind::UnexpectedEof, "truncated stream"))) + .await; + } + }); + + let result = client + .post(&url) + .header("x-filename", pct_encode(&display_name)) + .header("x-total-size", total.to_string()) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, total.to_string()) + .body(reqwest::Body::wrap_stream(ReceiverStream::new(rx))) + .send() + .await; + fwd.abort(); + + let fail = |msg: &str, status: StatusCode| { + state.fail_transfer(&tid, msg.to_string()); + remove_transfer_later(state, tid.clone(), 10); + AppError::new(status, msg) + }; + + match result { + Ok(r) if r.status().is_success() => { + state.touch_peer_ip(peer_id, target_ip); + state.finish_transfer(&tid); + remove_transfer_later(state, tid.clone(), 4); + state.add_log(format!( + "Файл '{}' ({} байт) доставлен на '{}'", + display_name, total, peer_name + )); + Ok(peer_name) + } + Ok(r) if r.status() == StatusCode::FORBIDDEN => Err(fail( + "Получатель ещё не заверил ваше устройство", + StatusCode::FORBIDDEN, + )), + Ok(r) => Err(fail( + &format!("Получатель отклонил передачу (код {})", r.status()), + StatusCode::BAD_GATEWAY, + )), + Err(_) => Err(fail( + "Ошибка сети/TLS при передаче (узел недоступен, сертификат не совпал или обрыв)", + StatusCode::BAD_GATEWAY, + )), + } +} diff --git a/src/ui.rs b/src/ui.rs index 1685f9f..8dbd829 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,6 @@ -use crate::{models::*, net, storage}; +use crate::{models::*, net, storage, transfer}; use axum::{ - body::{Body, Bytes}, + body::Body, extract::{Path, Request, State}, http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, middleware::Next, @@ -8,13 +8,7 @@ use axum::{ Json, }; use futures_util::StreamExt; -use std::{ - io, - net::IpAddr, - sync::Arc, - time::{Duration, Instant}, -}; -use tokio_stream::wrappers::ReceiverStream; +use std::{io, net::IpAddr, sync::Arc}; use tokio_util::io::ReaderStream; const INDEX_HTML: &str = include_str!("static/index.html"); @@ -126,6 +120,9 @@ pub async fn ui_js() -> impl IntoResponse { } pub async fn ui_get_state(State(state): State>) -> Json { + if let Some(ip) = net::detect_lan_ip() { + state.update_my_ip(ip); + } let mut peers: Vec = state.peers.read().values().cloned().collect(); peers.sort_by(|a, b| a.name.cmp(&b.name)); let texts: Vec = state.ram_texts.read().iter().cloned().collect(); @@ -135,11 +132,14 @@ pub async fn ui_get_state(State(state): State>) -> Json>, Json(req): Json, ) -> Result { + if let Some(ip) = net::detect_lan_ip() { + state.update_my_ip(ip); + } let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port); + let my_ip = state.current_ip(); let (label, ips) = - parse_ip_range(&req.range, &state.my_ip).map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?; + parse_ip_range(&req.range, &my_ip).map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?; let total = ips.len(); { @@ -231,6 +235,19 @@ pub async fn ui_trust_peer( }; state.persist_trust(); state.add_log(msg); + // сообщаем собеседнику сразу: он увидит запрос на сверку (или отзыв) и должен подтвердить у себя + let (st, id) = (state.clone(), req.peer_id.clone()); + tokio::spawn(async move { net::push_trust(&st, &id).await }); + Ok(StatusCode::OK) +} + +pub async fn ui_set_settings( + State(state): State>, + Json(req): Json, +) -> Result { + state + .set_ping_interval(req.ping_interval_sec) + .map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?; Ok(StatusCode::OK) } @@ -240,78 +257,16 @@ pub async fn ui_clear_ram(State(state): State>) -> StatusCode { StatusCode::OK } -// ───────────────────────── Отправка ───────────────────────── -fn resolve_target(state: &AppState, id: &str) -> Result<(Vec, u16, String), AppError> { - let peers = state.peers.read(); - let p = peers - .get(id) - .ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Получатель не найден"))?; - if !p.verified { - return Err(AppError::new( - StatusCode::FORBIDDEN, - format!("Сначала заверьте устройство '{}' (код сверки {})", p.name, p.pin_code), - )); - } - let ips: Vec = p.ips.iter().filter_map(|s| s.parse().ok()).collect(); - if ips.is_empty() { - return Err(AppError::new( - StatusCode::BAD_REQUEST, - "Адрес получателя неизвестен. Выполните опрос или сканирование.", - )); - } - Ok((ips, p.port, p.name.clone())) -} - +// ───────────────────────── Отправка (логика — в transfer.rs, общая с CLI) ───────────────────────── pub async fn ui_send_text( State(state): State>, Json(req): Json, ) -> Result { - if req.text.trim().is_empty() { - return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст")); - } - if req.text.len() > MAX_TEXT_BYTES { - return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст больше 64 КБ")); - } - let (ips, port, peer_name) = resolve_target(&state, &req.peer_id)?; - let client = state.client_for_peer(&req.peer_id, false)?; - let body = serde_json::to_vec(&IncomingTextPayload { text: req.text }) - .map_err(|e| AppError::internal("json", e))?; - - for ip in ips { - let url = format!("https://{}:{}/p2p/text", ip, port); - match client - .post(&url) - .header(header::CONTENT_TYPE, "application/json") - .body(body.clone()) - .send() - .await - { - Ok(r) if r.status().is_success() => { - state.add_log(format!("Текст доставлен в ОЗУ устройства '{}'", peer_name)); - return Ok(StatusCode::OK); - } - Ok(r) if r.status() == StatusCode::FORBIDDEN => { - return Err(AppError::new( - StatusCode::FORBIDDEN, - "Получатель ещё не заверил ваше устройство (доверие должно быть взаимным)", - )) - } - Ok(r) => { - return Err(AppError::new( - StatusCode::BAD_GATEWAY, - format!("Получатель отклонил запрос ({})", r.status()), - )) - } - Err(_) => continue, // пробуем следующий адрес - } - } - Err(AppError::new( - StatusCode::BAD_GATEWAY, - "Не удалось установить защищённое соединение (узел недоступен или сертификат не совпал)", - )) + transfer::send_text(&state, &req.peer_id, req.text).await?; + Ok(StatusCode::OK) } -/// Потоковая отправка: браузер → (канал с backpressure) → TLS → получатель. Память O(1). +/// Потоковая отправка: браузер → канал с backpressure → TLS → получатель. Память O(1). pub async fn ui_send_media( State(state): State>, headers: HeaderMap, @@ -320,107 +275,15 @@ pub async fn ui_send_media( let peer_id = hdr(&headers, "x-target-id") .ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))? .to_string(); - let display_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin"))); + let name = pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")); let total: u64 = hdr(&headers, "content-length") .and_then(|v| v.parse().ok()) .ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?; - if total == 0 { - return Err(AppError::new(StatusCode::BAD_REQUEST, "Файл пустой")); - } - if total > MAX_FILE_SIZE { - return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Файл больше 2 ГБ")); - } - - let (ips, port, peer_name) = resolve_target(&state, &peer_id)?; - let client = state.client_for_peer(&peer_id, true)?; - let url = format!("https://{}:{}/p2p/media", ips[0], port); - - let tid = format!("tx_{}", random_hex(6)); - state.add_transfer(TransferProgress { - id: tid.clone(), - filename: display_name.clone(), - peer_name: peer_name.clone(), - direction: Direction::Out, - transferred_bytes: 0, - total_bytes: total, - percent: 0, - done: false, - error: None, - updated_at: now_sec(), - }); - - let (tx, rx) = tokio::sync::mpsc::channel::>(4); - let fwd_state = state.clone(); - let fwd_id = tid.clone(); - let fwd = tokio::spawn(async move { - let mut stream = std::pin::pin!(body.into_data_stream()); - let mut sent: u64 = 0; - let mut last = Instant::now(); - while let Some(item) = stream.next().await { - let chunk = match item { - Ok(c) => c, - Err(_) => { - let _ = tx.send(Err(io::Error::new(io::ErrorKind::Other, "client aborted"))).await; - return; - } - }; - sent += chunk.len() as u64; - if sent > total { - let _ = tx.send(Err(io::Error::new(io::ErrorKind::Other, "body too long"))).await; - return; - } - // если получатель не читает 30 с — закрываем поток, запрос завершится ошибкой - match tokio::time::timeout(Duration::from_secs(30), tx.send(Ok(chunk))).await { - Ok(Ok(())) => {} - _ => return, - } - if last.elapsed() >= Duration::from_millis(100) { - fwd_state.update_transfer_bytes(&fwd_id, sent); - last = Instant::now(); - } - } - }); - - let result = client - .post(&url) - .header("x-filename", pct_encode(&display_name)) - .header("x-total-size", total.to_string()) - .header(header::CONTENT_TYPE, "application/octet-stream") - .header(header::CONTENT_LENGTH, total.to_string()) - .body(reqwest::Body::wrap_stream(ReceiverStream::new(rx))) - .send() - .await; - fwd.abort(); - - let fail = |msg: &str, status: StatusCode| { - state.fail_transfer(&tid, msg.to_string()); - remove_transfer_later(&state, tid.clone(), 10); - AppError::new(status, msg) - }; - - match result { - Ok(r) if r.status().is_success() => { - state.finish_transfer(&tid); - remove_transfer_later(&state, tid.clone(), 4); - state.add_log(format!( - "Файл '{}' ({} байт) доставлен на '{}'", - display_name, total, peer_name - )); - Ok(StatusCode::OK) - } - Ok(r) if r.status() == StatusCode::FORBIDDEN => Err(fail( - "Получатель ещё не заверил ваше устройство", - StatusCode::FORBIDDEN, - )), - Ok(r) => Err(fail( - &format!("Получатель отклонил передачу (код {})", r.status()), - StatusCode::BAD_GATEWAY, - )), - Err(_) => Err(fail( - "Ошибка сети/TLS при передаче (узел недоступен, сертификат не совпал или обрыв)", - StatusCode::BAD_GATEWAY, - )), - } + let stream = body + .into_data_stream() + .map(|r| r.map_err(|_| io::Error::new(io::ErrorKind::Other, "client aborted"))); + transfer::send_stream(&state, &peer_id, &name, total, stream).await?; + Ok(StatusCode::OK) } // ───────────────────────── Раздача принятых файлов ───────────────────────── diff --git a/target/aarch64-linux-android/release/ramshare_ui b/target/aarch64-linux-android/release/ramshare_ui index 6ef7d34..216877e 100755 Binary files a/target/aarch64-linux-android/release/ramshare_ui and b/target/aarch64-linux-android/release/ramshare_ui differ