a.0.0.6
This commit is contained in:
1 parent
5d01dfb63a
commit
b0d63c9f0f
20 files changed
+1857
-1417
No files matched your search
-179
@@ -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<String> = 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<String> = 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();
|
|
||||||
}
|
|
||||||
@@ -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<AppState>) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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<VecDeque<TextItem>>,
|
|
||||||
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
|
||||||
pub peers: RwLock<HashMap<String, PeerNode>>,
|
|
||||||
pub trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Arc<AppState>>,
|
|
||||||
Json(payload): Json<IncomingTextPayload>,
|
|
||||||
) -> 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<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
body: Bytes,
|
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>LAN Share — Текст в ОЗУ / Медиа на Диск</title>
|
|
||||||
<style>
|
|
||||||
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
|
|
||||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
|
||||||
body { margin: 0; padding: 20px; background: var(--bg); color: var(--text); }
|
|
||||||
.container { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
|
||||||
@media (max-width: 800px) { .container { grid-template-columns: 1fr; } }
|
|
||||||
.header { grid-column: 1 / -1; background: var(--card); border: 1px solid var(--border); padding: 16px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
|
||||||
.pin-badge { background: #1d4ed8; padding: 6px 14px; border-radius: 8px; font-weight: 700; letter-spacing: 1px; font-family: monospace; font-size: 1.1rem; }
|
|
||||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
h2 { margin: 0; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
|
|
||||||
.tag-ram { background: rgba(16, 185, 129, 0.2); color: #34d399; }
|
|
||||||
.tag-disk { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
|
||||||
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: 0.95rem; }
|
|
||||||
textarea { resize: vertical; min-height: 80px; }
|
|
||||||
button { background: var(--accent); color: white; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
|
|
||||||
button:hover { opacity: 0.9; }
|
|
||||||
button.btn-green { background: var(--green); }
|
|
||||||
button.btn-red { background: var(--red); }
|
|
||||||
button.btn-sm { padding: 6px 12px; font-size: 0.82rem; }
|
|
||||||
.peer-item, .msg-item { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
|
||||||
.msg-item { flex-direction: column; align-items: flex-start; }
|
|
||||||
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--muted); }
|
|
||||||
.msg-text { font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
|
|
||||||
.path-box { font-family: monospace; font-size: 0.78rem; color: #93c5fd; background: rgba(59,130,246,0.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; }
|
|
||||||
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 1.2rem; font-weight: 700;" id="my-name">Загрузка...</div>
|
|
||||||
<div style="font-size: 0.85rem; color: var(--muted);" id="my-ip"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style="font-size: 0.85rem; color: var(--muted); margin-right: 8px;">Код сверки сертификата (PIN):</span>
|
|
||||||
<span class="pin-badge" id="my-pin">--------</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status"></div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>1. Устройства в сети (mDNS) <span class="tag tag-disk">TLS Pinning</span></h2>
|
|
||||||
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
|
|
||||||
<div style="color:var(--muted); font-size:0.9rem;">Поиск устройств...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>2. Отправка данных</h2>
|
|
||||||
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
|
|
||||||
<select id="target-peer"><option value="">-- Выберите устройство --</option></select>
|
|
||||||
|
|
||||||
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка)..."></textarea>
|
|
||||||
<button onclick="sendText()">Отправить текст в ОЗУ</button>
|
|
||||||
|
|
||||||
<hr style="border:0; border-top:1px solid var(--border); width:100%; margin: 4px 0;">
|
|
||||||
<input type="file" id="media-input">
|
|
||||||
<button class="btn-green" onclick="sendMedia()">Отправить медиа на диск получателя</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>
|
|
||||||
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
|
|
||||||
<button class="btn-red btn-sm" onclick="clearRam()">Очистить ОЗУ</button>
|
|
||||||
</h2>
|
|
||||||
<div id="ram-texts" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Входящие медиафайлы <span class="tag tag-disk">НА ДИСКЕ (./received_media)</span></h2>
|
|
||||||
<div id="disk-media" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let knownPeersKey = "";
|
|
||||||
|
|
||||||
function showStatus(msg, isError = false) {
|
|
||||||
const el = document.getElementById('status');
|
|
||||||
el.style.display = 'block';
|
|
||||||
el.style.background = isError ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)';
|
|
||||||
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
|
|
||||||
el.textContent = msg;
|
|
||||||
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchState() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/ui/state');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
document.getElementById('my-name').textContent = `Устройство: ${data.device_name}`;
|
|
||||||
document.getElementById('my-ip').textContent = `HTTPS P2P: ${data.my_ip}:${data.p2p_port} | SHA-256: ${data.my_fingerprint.slice(0, 24)}...`;
|
|
||||||
document.getElementById('my-pin').textContent = data.my_pin;
|
|
||||||
|
|
||||||
const peersContainer = document.getElementById('peers-list');
|
|
||||||
if (data.peers.length === 0) {
|
|
||||||
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства в локальной сети пока не обнаружены</div>';
|
|
||||||
} else {
|
|
||||||
peersContainer.innerHTML = data.peers.map(p => `
|
|
||||||
<div class="peer-item">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:600;">${escapeHtml(p.name)} <span style="color:var(--muted); font-size:0.85rem;">(${p.ip}:${p.port})</span></div>
|
|
||||||
<div style="font-size:0.8rem; color:var(--muted); font-family:monospace;">PIN: <b style="color:#fff;">[${p.pin_code}]</b> | Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}" onclick="toggleTrust('${p.ip}', ${!p.verified})">
|
|
||||||
${p.verified ? 'Отозвать' : 'Заверить'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSelectKey = data.peers.map(p => `${p.ip}:${p.verified}`).join('|');
|
|
||||||
if (currentSelectKey !== knownPeersKey) {
|
|
||||||
knownPeersKey = currentSelectKey;
|
|
||||||
const select = document.getElementById('target-peer');
|
|
||||||
const prevVal = select.value;
|
|
||||||
select.innerHTML = '<option value="">-- Выберите устройство --</option>' +
|
|
||||||
data.peers.map(p => `<option value="${p.ip}">${escapeHtml(p.name)} (${p.ip}) ${p.verified ? '✓' : '🔒 [Не заверен]'}</option>`).join('');
|
|
||||||
if (prevVal) select.value = prevVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textsContainer = document.getElementById('ram-texts');
|
|
||||||
if (data.texts.length === 0) {
|
|
||||||
textsContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Буфер ОЗУ пуст</div>';
|
|
||||||
} else {
|
|
||||||
textsContainer.innerHTML = data.texts.map(t => `
|
|
||||||
<div class="msg-item">
|
|
||||||
<div class="msg-meta">
|
|
||||||
<span>От: <b>${escapeHtml(t.sender)}</b></span>
|
|
||||||
<button class="btn-sm" onclick="copyText(${JSON.stringify(t.text).replace(/"/g, '"')})">Скопировать</button>
|
|
||||||
</div>
|
|
||||||
<div class="msg-text">${escapeHtml(t.text)}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaContainer = document.getElementById('disk-media');
|
|
||||||
if (data.media.length === 0) {
|
|
||||||
mediaContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Нет сохраненных медиафайлов</div>';
|
|
||||||
} else {
|
|
||||||
mediaContainer.innerHTML = data.media.map(m => {
|
|
||||||
const isImg = m.mime.startsWith('image/');
|
|
||||||
const fileUrl = `/ui/files/${encodeURIComponent(m.filename)}`;
|
|
||||||
return `
|
|
||||||
<div class="msg-item">
|
|
||||||
<div class="msg-meta">
|
|
||||||
<span>От: <b>${escapeHtml(m.sender)}</b></span>
|
|
||||||
<span>${(m.size / 1024).toFixed(1)} КБ</span>
|
|
||||||
</div>
|
|
||||||
<div style="font-weight:600; margin:2px 0;">
|
|
||||||
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
|
|
||||||
</div>
|
|
||||||
<div class="path-box">Диск: ${escapeHtml(m.saved_path)}</div>
|
|
||||||
${isImg ? `<img src="${fileUrl}" class="preview-img">` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTrust(ip, trust) {
|
|
||||||
const res = await fetch('/ui/trust', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ target_ip: ip, trust })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
showStatus(trust ? `Сертификат узла ${ip} заверен в ОЗУ` : `Доверие узлу ${ip} отозвано`);
|
|
||||||
fetchState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendText() {
|
|
||||||
const target_ip = document.getElementById('target-peer').value;
|
|
||||||
const input = document.getElementById('text-input');
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
|
|
||||||
if (!text) return showStatus('Введите текст для отправки!', true);
|
|
||||||
|
|
||||||
const res = await fetch('/ui/send-text', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ target_ip, text })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
input.value = '';
|
|
||||||
showStatus(`Текст доставлен в ОЗУ узла ${target_ip}`);
|
|
||||||
} else {
|
|
||||||
const err = await res.text();
|
|
||||||
showStatus(err, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMedia() {
|
|
||||||
const target_ip = document.getElementById('target-peer').value;
|
|
||||||
const fileInput = document.getElementById('media-input');
|
|
||||||
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
|
|
||||||
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
|
|
||||||
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
showStatus(`Отправка файла "${file.name}"...`);
|
|
||||||
|
|
||||||
const res = await fetch('/ui/send-media', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-Target-Ip': target_ip,
|
|
||||||
'X-Filename': encodeURIComponent(file.name),
|
|
||||||
'Content-Type': file.type || 'application/octet-stream'
|
|
||||||
},
|
|
||||||
body: file
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
fileInput.value = '';
|
|
||||||
showStatus(`Файл "${file.name}" сохранен на диске узла ${target_ip}`);
|
|
||||||
} else {
|
|
||||||
const err = await res.text();
|
|
||||||
showStatus(err, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearRam() {
|
|
||||||
await fetch('/ui/clear-ram', { method: 'POST' });
|
|
||||||
fetchState();
|
|
||||||
showStatus('Буфер текстовых сообщений в ОЗУ очищен');
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyText(text) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
showStatus('Текст скопирован в буфер обмена');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s]));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('text-input').addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendText();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setInterval(fetchState, 1500);
|
|
||||||
fetchState();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-138
@@ -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<RwLock<HashMap<String, String>>>,
|
|
||||||
provider: Arc<CryptoProvider>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PinnedCertVerifier {
|
|
||||||
pub fn new(trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>) -> 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<ServerCertVerified, TlsError> {
|
|
||||||
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<HandshakeSignatureValid, TlsError> {
|
|
||||||
verify_tls12_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&self.provider.signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &CertificateDer<'_>,
|
|
||||||
dss: &DigitallySignedStruct,
|
|
||||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
|
||||||
verify_tls13_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&self.provider.signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
|
||||||
self.provider
|
|
||||||
.signature_verification_algorithms
|
|
||||||
.supported_schemes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Генерирует самоподписанный сертификат и приватный ключ в ОЗУ
|
|
||||||
pub fn generate_in_memory_cert(ip: &str) -> (Vec<u8>, Vec<u8>, 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<RwLock<HashMap<String, String>>>,
|
|
||||||
) -> 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-клиента")
|
|
||||||
}
|
|
||||||
-165
@@ -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<Arc<AppState>>) -> Json<serde_json::Value> {
|
|
||||||
let trusted_map = state.trusted_fingerprints.read().unwrap();
|
|
||||||
let mut peers_list: Vec<PeerNode> = 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<TextItem> = state.ram_texts.read().unwrap().iter().cloned().collect();
|
|
||||||
let media: Vec<MediaItem> = 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<Arc<AppState>>,
|
|
||||||
Json(req): Json<UiTrustRequest>,
|
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
|
||||||
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<Arc<AppState>>) -> StatusCode {
|
|
||||||
state.ram_texts.write().unwrap().clear();
|
|
||||||
StatusCode::OK
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ui_send_text(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(req): Json<UiSendTextRequest>,
|
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
|
||||||
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<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
body: Bytes,
|
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
|
||||||
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<String>) -> 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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+465
@@ -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<String>,
|
||||||
|
pub yes: bool,
|
||||||
|
/// (trust) сколько секунд ждать подтверждения на втором устройстве
|
||||||
|
pub confirm_wait: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
static DISCOVERY_STARTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn ensure_discovery(state: &Arc<AppState>) {
|
||||||
|
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<PeerDevice> {
|
||||||
|
let mut v: Vec<PeerDevice> = 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<PeerDevice> = 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<Option<PeerDevice>, String> {
|
||||||
|
let sel_l = sel.to_lowercase();
|
||||||
|
let peers: Vec<PeerDevice> = 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<String> = 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<u16>) {
|
||||||
|
if let Some((h, p)) = sel.rsplit_once(':') {
|
||||||
|
if let Ok(port) = p.parse::<u16>() {
|
||||||
|
if h.parse::<IpAddr>().is_ok() {
|
||||||
|
return (h.to_string(), Some(port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(sel.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scan_with_progress(state: &Arc<AppState>, 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<AppState>,
|
||||||
|
sel: &str,
|
||||||
|
opts: &CliOpts,
|
||||||
|
need_online: bool,
|
||||||
|
) -> Result<PeerDevice, String> {
|
||||||
|
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::<IpAddr>() {
|
||||||
|
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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>,
|
||||||
|
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<AppState>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Единый файл конфигурации `<data-dir>/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<Identity, String> {
|
||||||
|
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<Loaded, String> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
+348
-101
@@ -1,8 +1,11 @@
|
|||||||
|
mod cli;
|
||||||
|
mod config;
|
||||||
mod models;
|
mod models;
|
||||||
mod net;
|
mod net;
|
||||||
mod p2p;
|
mod p2p;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod tls;
|
mod tls;
|
||||||
|
mod transfer;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -17,26 +20,87 @@ use std::{
|
|||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, VecDeque},
|
||||||
net::{IpAddr, Ipv4Addr},
|
net::{IpAddr, Ipv4Addr},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{atomic::AtomicBool, Arc},
|
sync::{
|
||||||
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tokio::sync::{Mutex, Semaphore};
|
use tokio::sync::{Mutex, Notify, Semaphore};
|
||||||
|
|
||||||
const USAGE: &str = "\
|
const USAGE: &str = r#"ramshare_ui — обмен текстом и файлами в локальной сети (TLS 1.3, mTLS, пиннинг сертификатов)
|
||||||
Использование: ramshare_ui [параметры]
|
|
||||||
--name <имя> имя устройства
|
Использование: ramshare_ui [параметры] [команда]
|
||||||
--p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443
|
|
||||||
--ui-port <порт> порт локального UI (только 127.0.0.1), по умолчанию 8080
|
Команды:
|
||||||
--ip <IPv4> адрес интерфейса (по умолчанию определяется автоматически)
|
serve запустить узел и веб-интерфейс (команда по умолчанию)
|
||||||
--data-dir <путь> каталог данных (идентичность, доверенные, принятые файлы)
|
scan [диапазон] просканировать сеть и показать найденные устройства
|
||||||
-h, --help эта справка";
|
(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 <IPv4> адрес интерфейса (по умолчанию определяется автоматически)
|
||||||
|
--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<String>),
|
||||||
|
Peers,
|
||||||
|
Config,
|
||||||
|
Trust(String),
|
||||||
|
Untrust(String),
|
||||||
|
SendText { peer: String, words: Vec<String> },
|
||||||
|
SendFile { peer: String, paths: Vec<PathBuf> },
|
||||||
|
}
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
name: String,
|
name: Option<String>,
|
||||||
p2p_port: u16,
|
p2p_port: u16,
|
||||||
ui_port: u16,
|
ui_port: u16,
|
||||||
ip: Option<Ipv4Addr>,
|
ip: Option<Ipv4Addr>,
|
||||||
data_dir: Option<PathBuf>,
|
data_dir: Option<PathBuf>,
|
||||||
|
ping_interval: Option<u64>,
|
||||||
|
json: bool,
|
||||||
|
wait: u64,
|
||||||
|
confirm_wait: u64,
|
||||||
|
scan: Option<String>,
|
||||||
|
yes: bool,
|
||||||
|
no_ui: bool,
|
||||||
|
command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fatal(msg: &str) -> ! {
|
fn fatal(msg: &str) -> ! {
|
||||||
@@ -44,22 +108,50 @@ fn fatal(msg: &str) -> ! {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> Result<Config, String> {
|
fn default_device_name() -> String {
|
||||||
let default_name = std::env::var("COMPUTERNAME")
|
let n = std::env::var("COMPUTERNAME")
|
||||||
.or_else(|_| std::env::var("HOSTNAME"))
|
.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<Config, String> {
|
||||||
let mut cfg = Config {
|
let mut cfg = Config {
|
||||||
name: clean_label(&default_name, 48),
|
name: None,
|
||||||
p2p_port: 8443,
|
p2p_port: 8443,
|
||||||
ui_port: 8080,
|
ui_port: 8080,
|
||||||
ip: None,
|
ip: None,
|
||||||
data_dir: 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<String> = Vec::new();
|
||||||
|
let mut only_pos = false;
|
||||||
let mut it = std::env::args().skip(1);
|
let mut it = std::env::args().skip(1);
|
||||||
while let Some(a) = it.next() {
|
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));
|
let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag));
|
||||||
match a.as_str() {
|
match a.as_str() {
|
||||||
"--name" => cfg.name = clean_label(&val("--name")?, 48),
|
"--name" => cfg.name = Some(clean_label(&val("--name")?, 48)),
|
||||||
"--p2p-port" => {
|
"--p2p-port" => {
|
||||||
cfg.p2p_port = val("--p2p-port")?.parse().map_err(|_| "Некорректный --p2p-port".to_string())?
|
cfg.p2p_port = val("--p2p-port")?.parse().map_err(|_| "Некорректный --p2p-port".to_string())?
|
||||||
}
|
}
|
||||||
@@ -68,14 +160,74 @@ fn parse_args() -> Result<Config, String> {
|
|||||||
}
|
}
|
||||||
"--ip" => cfg.ip = Some(val("--ip")?.parse().map_err(|_| "Некорректный --ip (нужен IPv4)".to_string())?),
|
"--ip" => cfg.ip = Some(val("--ip")?.parse().map_err(|_| "Некорректный --ip (нужен IPv4)".to_string())?),
|
||||||
"--data-dir" => cfg.data_dir = Some(PathBuf::from(val("--data-dir")?)),
|
"--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" => {
|
"-h" | "--help" => {
|
||||||
println!("{}", USAGE);
|
println!("{}", USAGE);
|
||||||
std::process::exit(0);
|
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<String> = 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<PathBuf> = 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());
|
return Err("Пустое имя устройства".into());
|
||||||
}
|
}
|
||||||
if cfg.p2p_port == 0 || cfg.ui_port == 0 {
|
if cfg.p2p_port == 0 || cfg.ui_port == 0 {
|
||||||
@@ -84,39 +236,37 @@ fn parse_args() -> Result<Config, String> {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
struct Built {
|
||||||
async fn main() {
|
state: Arc<AppState>,
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
bind_ip: Ipv4Addr,
|
||||||
|
}
|
||||||
|
|
||||||
let cfg = parse_args().unwrap_or_else(|e| fatal(&e));
|
/// Общая инициализация для `serve` и CLI-команд: адреса, каталоги, идентичность, доверие, клиенты.
|
||||||
|
fn build_state(cfg: &Config, serving: bool) -> Built {
|
||||||
// ── адреса ──
|
let (bind_ip, my_ip, fixed_ip) = match cfg.ip {
|
||||||
let (bind_ip, my_ip) = match cfg.ip {
|
Some(ip) if !ip.is_unspecified() => (ip, ip, true),
|
||||||
Some(ip) if !ip.is_unspecified() => (ip, ip),
|
|
||||||
_ => {
|
_ => {
|
||||||
let ip = net::detect_lan_ip().unwrap_or_else(|| {
|
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::LOCALHOST
|
||||||
});
|
});
|
||||||
(Ipv4Addr::UNSPECIFIED, ip)
|
(Ipv4Addr::UNSPECIFIED, ip, false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── каталоги ──
|
// <папка с ramshare_ui>/config (вся конфигурация) и <папка с ramshare_ui>/received_media
|
||||||
let data_dir = {
|
let dirs = storage::resolve_dirs(cfg.data_dir.as_deref(), cfg.p2p_port).unwrap_or_else(|e| fatal(&e));
|
||||||
let p = cfg.data_dir.clone().unwrap_or_else(|| storage::default_data_dir(cfg.p2p_port));
|
let data_dir = dirs.config; // каталог конфигурации: config.json, trusted.json
|
||||||
if p.is_absolute() {
|
let media_dir = dirs.media;
|
||||||
p
|
if serving {
|
||||||
} else {
|
// .part-файлы чистит только работающий узел (CLI не должен трогать чужие передачи)
|
||||||
std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p)
|
storage::cleanup_partials(&media_dir);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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);
|
|
||||||
|
|
||||||
// ── идентичность и доверие ──
|
// config.json: имя устройства, идентичность (сертификат + ключ), интервал опроса
|
||||||
let identity = tls::load_or_create_identity(&data_dir, cfg.p2p_port).unwrap_or_else(|e| fatal(&e));
|
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<String, PeerDevice> = HashMap::new();
|
let mut known: HashMap<String, PeerDevice> = HashMap::new();
|
||||||
for t in storage::load_trusted(&data_dir) {
|
for t in storage::load_trusted(&data_dir) {
|
||||||
@@ -130,36 +280,38 @@ async fn main() {
|
|||||||
.take(MAX_PEER_IPS)
|
.take(MAX_PEER_IPS)
|
||||||
.collect();
|
.collect();
|
||||||
let port = if t.port == 0 { 8443 } else { t.port };
|
let port = if t.port == 0 { 8443 } else { t.port };
|
||||||
known.insert(
|
let mut p = PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true);
|
||||||
t.fp.clone(),
|
p.trusts_me = t.trusts_me;
|
||||||
PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true),
|
known.insert(t.fp.clone(), p);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let peers = Arc::new(RwLock::new(known));
|
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 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);
|
|
||||||
|
|
||||||
// ── слушатели: привязываем ДО запуска задач, чтобы ошибка порта была фатальной ──
|
// приоритет: --ping-interval > config.json > значение по умолчанию
|
||||||
let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port))
|
let ping_sec = cfg
|
||||||
.await
|
.ping_interval
|
||||||
.unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e)));
|
.unwrap_or(loaded.ping_interval_sec)
|
||||||
let ui_listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port))
|
.clamp(MIN_PING_SEC, MAX_PING_SEC);
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e)));
|
let disk_media: VecDeque<MediaItem> = if serving {
|
||||||
|
storage::scan_media_dir(&media_dir).into_iter().collect()
|
||||||
|
} else {
|
||||||
|
VecDeque::new()
|
||||||
|
};
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
device_name: cfg.name.clone(),
|
device_name,
|
||||||
my_ip: my_ip.to_string(),
|
my_ip: RwLock::new(my_ip.to_string()),
|
||||||
|
fixed_ip,
|
||||||
p2p_port: cfg.p2p_port,
|
p2p_port: cfg.p2p_port,
|
||||||
my_fingerprint: identity.fingerprint.clone(),
|
my_fingerprint: identity.fingerprint.clone(),
|
||||||
cert_der: identity.cert_der,
|
cert_der: identity.cert_der,
|
||||||
key_der: identity.key_der,
|
key_der: identity.key_der,
|
||||||
data_dir: data_dir.clone(),
|
data_dir,
|
||||||
media_dir: media_dir.clone(),
|
media_dir,
|
||||||
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
|
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,
|
peers,
|
||||||
probe_client,
|
probe_client,
|
||||||
transfers: RwLock::new(Vec::new()),
|
transfers: RwLock::new(Vec::new()),
|
||||||
@@ -169,79 +321,174 @@ async fn main() {
|
|||||||
ping_lock: Mutex::new(()),
|
ping_lock: Mutex::new(()),
|
||||||
trust_dirty: AtomicBool::new(false),
|
trust_dirty: AtomicBool::new(false),
|
||||||
probe_cooldown: PlMutex::new(HashMap::new()),
|
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!(
|
state.add_log(format!(
|
||||||
"Узел '{}' запущен на {}:{} (ID: {})",
|
"Узел '{}' запущен на {}:{} (ID: {})",
|
||||||
cfg.name,
|
state.device_name,
|
||||||
my_ip,
|
state.current_ip(),
|
||||||
cfg.p2p_port,
|
cfg.p2p_port,
|
||||||
short_id(&state.my_fingerprint)
|
short_id(&state.my_fingerprint)
|
||||||
));
|
));
|
||||||
|
|
||||||
net::start_discovery(state.clone());
|
net::start_discovery(state.clone());
|
||||||
|
|
||||||
// периодический опрос + обслуживание (без наложения тиков)
|
// периодический опрос + обслуживание; интервал меняется на лету (UI / config.json / --ping-interval)
|
||||||
{
|
{
|
||||||
let st = state.clone();
|
let st = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut iv = tokio::time::interval(Duration::from_secs(5));
|
|
||||||
iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
||||||
loop {
|
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;
|
net::ping_all_peers(&st).await;
|
||||||
st.maintenance();
|
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!("==============================================================");
|
||||||
println!(" Устройство: {}", cfg.name);
|
println!(" Устройство: {}", state.device_name);
|
||||||
println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port);
|
println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port);
|
||||||
println!(" Адрес в сети: {}", my_ip);
|
println!(" Адрес в сети: {}", state.current_ip());
|
||||||
println!(" ID устройства: {}", short_id(&state.my_fingerprint));
|
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!("--------------------------------------------------------------");
|
||||||
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!("==============================================================");
|
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 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! {
|
tokio::select! {
|
||||||
r = ui_task => eprintln!("UI-сервер остановился: {:?}", r),
|
_ = ui_wait => {}
|
||||||
r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r),
|
r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r),
|
||||||
_ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"),
|
_ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.sync_trust_from_disk();
|
||||||
state.persist_trust();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+188
-18
@@ -13,12 +13,12 @@ use std::{
|
|||||||
net::{IpAddr, Ipv4Addr},
|
net::{IpAddr, Ipv4Addr},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
},
|
},
|
||||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
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; // один файл
|
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_INCOMING: usize = 4;
|
||||||
pub const MAX_MEDIA_LIST: usize = 500;
|
pub const MAX_MEDIA_LIST: usize = 500;
|
||||||
pub const DISCOVERY_PORT: u16 = 5354;
|
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);
|
pub struct AppError(pub StatusCode, pub String);
|
||||||
@@ -82,7 +85,10 @@ pub struct PeerDevice {
|
|||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
|
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
|
||||||
pub pin_code: String,
|
pub pin_code: String,
|
||||||
|
/// Мы сверили код и подтвердили это устройство (наше решение; по нему принимаем/отправляем).
|
||||||
pub verified: bool,
|
pub verified: bool,
|
||||||
|
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
||||||
|
pub trusts_me: bool,
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
pub last_seen: u64,
|
pub last_seen: u64,
|
||||||
}
|
}
|
||||||
@@ -97,6 +103,7 @@ impl PeerDevice {
|
|||||||
fingerprint: fp.to_string(),
|
fingerprint: fp.to_string(),
|
||||||
pin_code: sas_code(my_fp, fp),
|
pin_code: sas_code(my_fp, fp),
|
||||||
verified,
|
verified,
|
||||||
|
trusts_me: false,
|
||||||
online: false,
|
online: false,
|
||||||
last_seen: 0,
|
last_seen: 0,
|
||||||
}
|
}
|
||||||
@@ -162,10 +169,16 @@ pub struct UiScanRangeRequest {
|
|||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiSettingsRequest {
|
||||||
|
pub ping_interval_sec: u64,
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────── Состояние ─────────────────────────
|
// ───────────────────────── Состояние ─────────────────────────
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub device_name: String,
|
pub device_name: String,
|
||||||
pub my_ip: String,
|
pub my_ip: RwLock<String>,
|
||||||
|
pub fixed_ip: bool,
|
||||||
pub p2p_port: u16,
|
pub p2p_port: u16,
|
||||||
pub my_fingerprint: String,
|
pub my_fingerprint: String,
|
||||||
pub cert_der: Vec<u8>,
|
pub cert_der: Vec<u8>,
|
||||||
@@ -182,10 +195,37 @@ pub struct AppState {
|
|||||||
pub incoming_slots: Arc<Semaphore>,
|
pub incoming_slots: Arc<Semaphore>,
|
||||||
pub ping_lock: Mutex<()>,
|
pub ping_lock: Mutex<()>,
|
||||||
pub trust_dirty: AtomicBool,
|
pub trust_dirty: AtomicBool,
|
||||||
pub probe_cooldown: PlMutex<HashMap<IpAddr, Instant>>,
|
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
||||||
|
/// Интервал опроса известных пиров (сек), меняется на лету
|
||||||
|
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<Option<SystemTime>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
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<String>) {
|
pub fn add_log(&self, msg: impl Into<String>) {
|
||||||
// защита от log-injection: управляющие символы в именах и т.п.
|
// защита от log-injection: управляющие символы в именах и т.п.
|
||||||
let text: String = msg
|
let text: String = msg
|
||||||
@@ -193,7 +233,9 @@ impl AppState {
|
|||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_control() { ' ' } else { c })
|
.map(|c| if c.is_control() { ' ' } else { c })
|
||||||
.collect();
|
.collect();
|
||||||
println!("[LOG] {}", text);
|
if self.log_stdout {
|
||||||
|
println!("[LOG] {}", text);
|
||||||
|
}
|
||||||
let mut logs = self.logs.write();
|
let mut logs = self.logs.write();
|
||||||
if logs.len() >= MAX_LOGS {
|
if logs.len() >= MAX_LOGS {
|
||||||
logs.pop_back();
|
logs.pop_back();
|
||||||
@@ -260,10 +302,16 @@ impl AppState {
|
|||||||
name: p.name.clone(),
|
name: p.name.clone(),
|
||||||
ips: p.ips.clone(),
|
ips: p.ips.clone(),
|
||||||
port: p.port,
|
port: p.port,
|
||||||
|
trusts_me: p.trusts_me,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if let Err(e) = storage::save_trusted(&self.data_dir, &entries) {
|
match storage::save_trusted(&self.data_dir, &entries) {
|
||||||
self.add_log(format!("Не удалось сохранить список доверенных: {}", e));
|
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()) {
|
match peers.entry(fp.to_string()) {
|
||||||
Entry::Occupied(mut e) => {
|
Entry::Occupied(mut e) => {
|
||||||
let p = e.get_mut();
|
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.name = name.clone();
|
||||||
p.port = port;
|
p.port = port;
|
||||||
p.online = true;
|
p.online = true;
|
||||||
@@ -351,32 +399,141 @@ impl AppState {
|
|||||||
created
|
created
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Пир уже недавно виден по этому адресу — повторный probe по beacon не нужен.
|
/// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене).
|
||||||
pub fn is_fresh(&self, fp: &str, ip: IpAddr) -> bool {
|
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();
|
let ip_s = ip.to_string();
|
||||||
self.peers.read().get(fp).map_or(false, |p| {
|
self.peers.read().get(fp).map_or(false, |p| {
|
||||||
p.online
|
p.online
|
||||||
|
&& p.port == port
|
||||||
&& p.ips.first().map(String::as_str) == Some(ip_s.as_str())
|
&& p.ips.first().map(String::as_str) == Some(ip_s.as_str())
|
||||||
&& now_sec().saturating_sub(p.last_seen) < 10
|
&& 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 mut m = self.probe_cooldown.lock();
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if m.len() > 512 {
|
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) {
|
match m.get(&(ip, port)) {
|
||||||
Some(t) if now.duration_since(*t) < Duration::from_secs(10) => false,
|
Some(t) if now.duration_since(*t) < Duration::from_secs(4) => false,
|
||||||
_ => {
|
_ => {
|
||||||
m.insert(ip, now);
|
m.insert((ip, port), now);
|
||||||
true
|
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<String> = e
|
||||||
|
.ips
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.parse::<IpAddr>().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) {
|
pub fn maintenance(&self) {
|
||||||
|
self.sync_trust_from_disk();
|
||||||
let now = now_sec();
|
let now = now_sec();
|
||||||
self.peers
|
self.peers
|
||||||
.write()
|
.write()
|
||||||
@@ -460,6 +617,13 @@ pub fn clean_label(s: &str, max_chars: usize) -> String {
|
|||||||
.collect()
|
.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 {
|
pub fn pct_decode(s: &str) -> String {
|
||||||
percent_decode_str(s).decode_utf8_lossy().into_owned()
|
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())
|
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 {
|
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),
|
/// Диапазоны: "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<Ipv4Addr>), String> {
|
pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec<Ipv4Addr>), String> {
|
||||||
const MAX_TOTAL: usize = 1024;
|
const MAX_TOTAL: usize = 1024;
|
||||||
let raw = input.trim();
|
let raw = input.trim();
|
||||||
let mine: Option<Ipv4Addr> = my_ip.parse().ok();
|
let mine: Option<Ipv4Addr> = my_ip
|
||||||
|
.parse::<Ipv4Addr>()
|
||||||
|
.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 effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") {
|
||||||
let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?;
|
let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?;
|
||||||
|
|||||||
+208
-43
@@ -5,7 +5,7 @@ use socket2::{Domain, Protocol, Socket, Type};
|
|||||||
use std::{
|
use std::{
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Duration,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tokio::{net::UdpSocket, sync::Semaphore};
|
use tokio::{net::UdpSocket, sync::Semaphore};
|
||||||
|
|
||||||
@@ -14,16 +14,37 @@ const MAX_PROBE_BODY: usize = 8 * 1024;
|
|||||||
pub struct ProbeInfo {
|
pub struct ProbeInfo {
|
||||||
pub fp: String,
|
pub fp: String,
|
||||||
pub name: 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<Ipv4Addr>)> {
|
pub fn lan_interfaces() -> Vec<(Ipv4Addr, Option<Ipv4Addr>)> {
|
||||||
if_addrs::get_if_addrs()
|
if_addrs::get_if_addrs()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|i| match &i.addr {
|
.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,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -40,26 +61,62 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let all = lan_interfaces();
|
let ifs = if_addrs::get_if_addrs().unwrap_or_default();
|
||||||
all.iter()
|
ifs.iter()
|
||||||
.map(|(ip, _)| *ip)
|
.filter(|i| !i.is_loopback() && !is_virtual_if(&i.name))
|
||||||
.find(|ip| !(ip.octets()[0] == 172 && ip.octets()[1] == 17)) // не docker0
|
.find_map(|i| match &i.addr {
|
||||||
.or_else(|| all.first().map(|(ip, _)| *ip))
|
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<IpAddr> {
|
||||||
|
let my_ips: Vec<Ipv4Addr> = 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 ─────────────────────────
|
// ───────────────────────── Probe ─────────────────────────
|
||||||
/// Рукопожатие с узлом. Отпечаток берём из РЕАЛЬНО предъявленного сертификата.
|
/// Рукопожатие с узлом. Отпечаток берём из РЕАЛЬНО предъявленного сертификата.
|
||||||
pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
async fn probe_with_hint(
|
||||||
|
state: &AppState,
|
||||||
|
ip: IpAddr,
|
||||||
|
port: u16,
|
||||||
|
hint_fp: Option<&str>,
|
||||||
|
) -> Result<ProbeInfo, String> {
|
||||||
|
let ip_s = ip.to_string();
|
||||||
|
let trust_hdr: Option<String> = {
|
||||||
|
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 url = format!("https://{}:{}/p2p/info", ip, port);
|
||||||
let mut resp = state
|
let mut req = state
|
||||||
.probe_client
|
.probe_client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("x-node-name", pct_encode(&state.device_name))
|
.header("x-node-name", pct_encode(&state.device_name))
|
||||||
.header("x-node-port", state.p2p_port.to_string())
|
.header("x-node-port", state.p2p_port.to_string());
|
||||||
.send()
|
if let Some(t) = &trust_hdr {
|
||||||
.await
|
req = req.header("x-node-trusts", t);
|
||||||
.map_err(|_| "узел недоступен".to_string())?;
|
}
|
||||||
|
|
||||||
|
let mut resp = req.send().await.map_err(|_| "узел недоступен".to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("узел вернул код {}", resp.status()));
|
return Err(format!("узел вернул код {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -73,7 +130,7 @@ pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo,
|
|||||||
if resp.content_length().map_or(false, |l| l as usize > MAX_PROBE_BODY) {
|
if resp.content_length().map_or(false, |l| l as usize > MAX_PROBE_BODY) {
|
||||||
return Err("слишком большой ответ".into());
|
return Err("слишком большой ответ".into());
|
||||||
}
|
}
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::with_capacity(256);
|
||||||
while let Some(chunk) = resp.chunk().await.map_err(|_| "ошибка чтения ответа".to_string())? {
|
while let Some(chunk) = resp.chunk().await.map_err(|_| "ошибка чтения ответа".to_string())? {
|
||||||
buf.extend_from_slice(&chunk);
|
buf.extend_from_slice(&chunk);
|
||||||
if buf.len() > MAX_PROBE_BODY {
|
if buf.len() > MAX_PROBE_BODY {
|
||||||
@@ -84,23 +141,46 @@ pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo,
|
|||||||
Ok(ProbeInfo {
|
Ok(ProbeInfo {
|
||||||
fp,
|
fp,
|
||||||
name: clean_label(v["name"].as_str().unwrap_or("Node"), 48),
|
name: clean_label(v["name"].as_str().unwrap_or("Node"), 48),
|
||||||
|
trusts_you: v["trusts_you"].as_bool().unwrap_or(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
||||||
|
probe_with_hint(state, ip, port, None).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
||||||
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 {
|
if info.fp == state.my_fingerprint {
|
||||||
return Err("это ваше собственное устройство".into());
|
return Err("это ваше собственное устройство".into());
|
||||||
}
|
}
|
||||||
state.upsert_peer(&info.name, ip, port, &info.fp);
|
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)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── Ping ─────────────────────────
|
// ───────────────────────── Ping и синхронизация доверия ─────────────────────────
|
||||||
pub async fn ping_all_peers(state: &Arc<AppState>) {
|
pub async fn ping_all_peers(state: &Arc<AppState>) {
|
||||||
let Ok(_guard) = state.ping_lock.try_lock() else {
|
let Ok(_guard) = state.ping_lock.try_lock() else {
|
||||||
return; // опрос уже идёт
|
return; // опрос уже идёт
|
||||||
};
|
};
|
||||||
|
if let Some(ip) = detect_lan_ip() {
|
||||||
|
state.update_my_ip(ip);
|
||||||
|
}
|
||||||
let targets: Vec<(String, Vec<String>, u16)> = state
|
let targets: Vec<(String, Vec<String>, u16)> = state
|
||||||
.peers
|
.peers
|
||||||
.read()
|
.read()
|
||||||
@@ -117,11 +197,12 @@ pub async fn ping_all_peers(state: &Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) {
|
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) {
|
||||||
for ip_s in ips {
|
let parsed: Vec<IpAddr> = ips.iter().filter_map(|s| s.parse().ok()).collect();
|
||||||
let Ok(ip) = ip_s.parse::<IpAddr>() else { continue };
|
for ip in prioritize_ips(&parsed) {
|
||||||
if let Ok(info) = probe(state, ip, port).await {
|
if let Ok(info) = probe_with_hint(state, ip, port, Some(id)).await {
|
||||||
if info.fp == id {
|
if info.fp == id {
|
||||||
state.upsert_peer(&info.name, ip, port, id);
|
state.upsert_peer(&info.name, ip, port, id);
|
||||||
|
state.set_remote_trust(id, info.trusts_you);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// по этому адресу теперь другое устройство — игнорируем
|
// по этому адресу теперь другое устройство — игнорируем
|
||||||
@@ -135,6 +216,44 @@ async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, 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<IpAddr> = 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<AppState>, ips: Vec<Ipv4Addr>, port: u16) {
|
pub async fn run_scan(state: Arc<AppState>, ips: Vec<Ipv4Addr>, port: u16) {
|
||||||
let st = &state;
|
let st = &state;
|
||||||
@@ -165,6 +284,22 @@ struct Beacon {
|
|||||||
fp: String,
|
fp: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn broadcast_targets() -> Vec<Ipv4Addr> {
|
||||||
|
let mut targets: Vec<Ipv4Addr> = 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<UdpSocket> {
|
fn bind_discovery_socket() -> std::io::Result<UdpSocket> {
|
||||||
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||||
socket.set_reuse_address(true)?;
|
socket.set_reuse_address(true)?;
|
||||||
@@ -176,7 +311,9 @@ fn bind_discovery_socket() -> std::io::Result<UdpSocket> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_discovery(state: Arc<AppState>) {
|
pub fn start_discovery(state: Arc<AppState>) {
|
||||||
// отправка
|
let serving = state.log_stdout;
|
||||||
|
|
||||||
|
// Отправка маяков (в режиме serve — периодическая + отслеживание смены сети; в CLI — разовый запрос port=0)
|
||||||
let tx = state.clone();
|
let tx = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let sock = match UdpSocket::bind("0.0.0.0:0").await {
|
let sock = match UdpSocket::bind("0.0.0.0:0").await {
|
||||||
@@ -189,26 +326,33 @@ pub fn start_discovery(state: Arc<AppState>) {
|
|||||||
let _ = sock.set_broadcast(true);
|
let _ = sock.set_broadcast(true);
|
||||||
let pkt = serde_json::to_vec(&Beacon {
|
let pkt = serde_json::to_vec(&Beacon {
|
||||||
v: 1,
|
v: 1,
|
||||||
port: tx.p2p_port,
|
port: if serving { tx.p2p_port } else { 0 },
|
||||||
fp: tx.my_fingerprint.clone(),
|
fp: tx.my_fingerprint.clone(),
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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<Ipv4Addr> = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
// интерфейсы пересчитываются каждый цикл — смена сети подхватится сама
|
let cur_ifs: Vec<Ipv4Addr> = lan_interfaces().into_iter().map(|(ip, _)| ip).collect();
|
||||||
let mut targets: Vec<Ipv4Addr> = lan_interfaces()
|
if cur_ifs != last_ifs {
|
||||||
.into_iter()
|
if let Some(ip) = detect_lan_ip() {
|
||||||
.map(|(ip, bc)| {
|
if tx.update_my_ip(ip) {
|
||||||
bc.unwrap_or_else(|| {
|
tx.add_log(format!("Смена сети: активный адрес {}", ip));
|
||||||
let o = ip.octets();
|
}
|
||||||
Ipv4Addr::new(o[0], o[1], o[2], 255)
|
}
|
||||||
})
|
tx.probe_cooldown.lock().clear();
|
||||||
})
|
tx.ping_notify.notify_one();
|
||||||
.collect();
|
last_ifs = cur_ifs;
|
||||||
targets.push(Ipv4Addr::BROADCAST);
|
}
|
||||||
targets.sort();
|
|
||||||
targets.dedup();
|
for t in broadcast_targets() {
|
||||||
for t in targets {
|
|
||||||
let _ = sock
|
let _ = sock
|
||||||
.send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT))
|
.send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT))
|
||||||
.await;
|
.await;
|
||||||
@@ -217,7 +361,7 @@ pub fn start_discovery(state: Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// приём
|
// Приём маяков
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let socket = match bind_discovery_socket() {
|
let socket = match bind_discovery_socket() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
@@ -229,8 +373,16 @@ pub fn start_discovery(state: Arc<AppState>) {
|
|||||||
return;
|
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 slots = Arc::new(Semaphore::new(8));
|
||||||
let mut buf = [0u8; 512];
|
let mut buf = [0u8; 512];
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (len, remote) = match socket.recv_from(&mut buf).await {
|
let (len, remote) = match socket.recv_from(&mut buf).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -239,17 +391,30 @@ pub fn start_discovery(state: Arc<AppState>) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Ok(b) = serde_json::from_slice::<Beacon>(&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 };
|
let IpAddr::V4(ip4) = remote.ip() else { continue };
|
||||||
if !is_lan_v4(ip4) {
|
if !is_lan_v4(ip4) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let Ok(b) = serde_json::from_slice::<Beacon>(&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);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(permit) = slots.clone().try_acquire_owned() else { continue };
|
let Ok(permit) = slots.clone().try_acquire_owned() else { continue };
|
||||||
|
|||||||
+39
-7
@@ -92,7 +92,17 @@ async fn p2p_info(
|
|||||||
let name = hdr(&headers, "x-node-name").map(pct_decode).unwrap_or_default();
|
let name = hdr(&headers, "x-node-name").map(pct_decode).unwrap_or_default();
|
||||||
state.upsert_peer(&name, ctx.addr.ip(), port, &ctx.fp);
|
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(
|
async fn p2p_receive_text(
|
||||||
@@ -103,6 +113,8 @@ async fn p2p_receive_text(
|
|||||||
let sender = state
|
let sender = state
|
||||||
.trusted_name(&ctx.fp)
|
.trusted_name(&ctx.fp)
|
||||||
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
|
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
|
||||||
|
state.touch_peer_ip(&ctx.fp, ctx.addr.ip());
|
||||||
|
|
||||||
if payload.text.trim().is_empty() {
|
if payload.text.trim().is_empty() {
|
||||||
return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст"));
|
return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст"));
|
||||||
}
|
}
|
||||||
@@ -110,6 +122,10 @@ async fn p2p_receive_text(
|
|||||||
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст слишком большой"));
|
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст слишком большой"));
|
||||||
}
|
}
|
||||||
state.add_log(format!("Получен текст в ОЗУ от '{}'", sender));
|
state.add_log(format!("Получен текст в ОЗУ от '{}'", sender));
|
||||||
|
if state.headless {
|
||||||
|
// режим без UI: терминал — единственное место, где можно увидеть входящий текст
|
||||||
|
println!("[ТЕКСТ от {}]\n{}", sender, strip_ctrl(&payload.text));
|
||||||
|
}
|
||||||
let mut q = state.ram_texts.write();
|
let mut q = state.ram_texts.write();
|
||||||
if q.len() >= MAX_RAM_TEXTS {
|
if q.len() >= MAX_RAM_TEXTS {
|
||||||
q.pop_back();
|
q.pop_back();
|
||||||
@@ -150,6 +166,7 @@ async fn p2p_receive_media(
|
|||||||
let sender = state
|
let sender = state
|
||||||
.trusted_name(&ctx.fp)
|
.trusted_name(&ctx.fp)
|
||||||
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
|
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
|
||||||
|
state.touch_peer_ip(&ctx.fp, ctx.addr.ip());
|
||||||
|
|
||||||
let total: u64 = hdr(&headers, "x-total-size")
|
let total: u64 = hdr(&headers, "x-total-size")
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
@@ -157,16 +174,24 @@ async fn p2p_receive_media(
|
|||||||
if total == 0 || total > MAX_FILE_SIZE {
|
if total == 0 || total > MAX_FILE_SIZE {
|
||||||
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Недопустимый размер файла"));
|
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
|
let _slot = state
|
||||||
.incoming_slots
|
.incoming_slots
|
||||||
.clone()
|
.clone()
|
||||||
.try_acquire_owned()
|
.try_acquire_owned()
|
||||||
.map_err(|_| AppError::new(StatusCode::TOO_MANY_REQUESTS, "Слишком много входящих передач"))?;
|
.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")));
|
let safe_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")));
|
||||||
|
|
||||||
tokio::fs::create_dir_all(&state.media_dir)
|
tokio::fs::create_dir_all(&state.media_dir)
|
||||||
@@ -229,8 +254,15 @@ async fn p2p_receive_media(
|
|||||||
}
|
}
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
let final_name = format!("{}_{}_{}", now_sec(), random_hex(3), safe_name);
|
let candidate = state.media_dir.join(&safe_name);
|
||||||
let final_path = state.media_dir.join(&final_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 {
|
if let Err(e) = tokio::fs::rename(&tmp, &final_path).await {
|
||||||
return Err(fail_rx(&state, &id, AppError::internal("rename", e)));
|
return Err(fail_rx(&state, &id, AppError::internal("rename", e)));
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-14
@@ -4,7 +4,7 @@ const $ = (id) => document.getElementById(id);
|
|||||||
let statusTimer = null;
|
let statusTimer = null;
|
||||||
let lastData = null;
|
let lastData = null;
|
||||||
let knownPeersKey = '';
|
let knownPeersKey = '';
|
||||||
let rangeInitialized = false;
|
let lastSubnet = '';
|
||||||
let pendingTrustId = null;
|
let pendingTrustId = null;
|
||||||
|
|
||||||
function esc(v) {
|
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-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
|
||||||
$('my-pin').textContent = d.my_pin;
|
$('my-pin').textContent = d.my_pin;
|
||||||
$('media-dir').textContent = d.media_dir;
|
$('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('.');
|
const p = d.my_ip.split('.');
|
||||||
$('scan-range-input').value = `${p[0]}.${p[1]}.${p[2]}.1-254`;
|
const subnet = `${p[0]}.${p[1]}.${p[2]}`;
|
||||||
rangeInitialized = true;
|
if (subnet !== lastSubnet) {
|
||||||
|
lastSubnet = subnet;
|
||||||
|
$('scan-range-input').value = `${subnet}.1-254`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,13 +104,35 @@ function renderTransfers(list) {
|
|||||||
}).join(''));
|
}).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) {
|
function renderPeers(peers) {
|
||||||
const box = $('peers-list');
|
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) {
|
if (!peers.length) {
|
||||||
setHtml(box, '<div class="muted" style="font-size:.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.</div>');
|
setHtml(box, '<div class="muted" style="font-size:.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.</div>');
|
||||||
} else {
|
} else {
|
||||||
setHtml(box, peers.map((p) => `
|
setHtml(box, peers.map((p) => {
|
||||||
<div class="peer-item">
|
const t = trustInfo(p);
|
||||||
|
return `
|
||||||
|
<div class="peer-item${t.req ? ' request' : ''}">
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
<div style="font-weight:600;">
|
<div style="font-weight:600;">
|
||||||
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
|
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
|
||||||
@@ -111,23 +140,25 @@ function renderPeers(peers) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
||||||
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
|
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
|
||||||
Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}
|
Статус: <span style="color:${t.color};">${esc(t.text)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}"
|
<button class="btn-sm ${t.btnCls}" data-action="trust" data-id="${esc(p.id)}" data-trust="${t.trust}">
|
||||||
data-action="trust" data-id="${esc(p.id)}" data-trust="${p.verified ? '0' : '1'}">
|
${esc(t.btn)}
|
||||||
${p.verified ? 'Отозвать' : 'Заверить'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>`).join(''));
|
</div>`;
|
||||||
|
}).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) {
|
if (key !== knownPeersKey) {
|
||||||
knownPeersKey = key;
|
knownPeersKey = key;
|
||||||
const sel = $('target-peer');
|
const sel = $('target-peer');
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
sel.innerHTML = '<option value="">-- Выберите устройство --</option>' + peers.map((p) =>
|
sel.innerHTML = '<option value="">-- Выберите устройство --</option>' + peers.map((p) => {
|
||||||
`<option value="${esc(p.id)}">${esc(p.name)} (${esc(p.ips[0] || '—')}) ${p.verified ? '✓' : '🔒 не заверен'}</option>`).join('');
|
const mark = p.verified && p.trusts_me ? '✓' : p.verified ? '⏳ ждёт подтверждения' : p.trusts_me ? '🔔 запрос на сверку' : '🔒 не заверен';
|
||||||
|
return `<option value="${esc(p.id)}">${esc(p.name)} (${esc(p.ips[0] || '—')}) ${mark}</option>`;
|
||||||
|
}).join('');
|
||||||
if (prev) sel.value = prev;
|
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() {
|
function pingPeers() {
|
||||||
return guarded(async () => {
|
return guarded(async () => {
|
||||||
await api('/ui/ping', { method: 'POST' });
|
await api('/ui/ping', { method: 'POST' });
|
||||||
@@ -271,6 +315,8 @@ function clearRam() {
|
|||||||
|
|
||||||
// ───────────── события (без inline-обработчиков: CSP script-src 'self') ─────────────
|
// ───────────── события (без inline-обработчиков: CSP script-src 'self') ─────────────
|
||||||
$('btn-ping').addEventListener('click', pingPeers);
|
$('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-manual').addEventListener('click', addManualPeer);
|
||||||
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
|
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
|
||||||
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
|
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
|
||||||
|
|||||||
+11
-3
@@ -17,7 +17,7 @@
|
|||||||
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
|
.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-ram { background: rgba(16,185,129,.2); color: #34d399; }
|
||||||
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
|
.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; }
|
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 { 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; }
|
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; }
|
.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; }
|
.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; }
|
.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; }
|
.warn { color: #fbbf24; font-size: .8rem; }
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.small { font-size: .8rem; }
|
.small { font-size: .8rem; }
|
||||||
@@ -95,8 +96,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="align-items:center;">
|
||||||
|
<label class="small muted" for="ping-interval-input">Опрос известных устройств каждые</label>
|
||||||
|
<input type="number" id="ping-interval-input" min="10" max="30" step="1" style="width:80px;">
|
||||||
|
<span class="small muted">сек (10–30)</span>
|
||||||
|
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="peers-list" class="col"></div>
|
<div id="peers-list" class="col"></div>
|
||||||
<div class="small muted">Доверие должно быть взаимным: заверьте устройство на обеих сторонах после сверки кода.</div>
|
<div class="small muted">Сверка взаимная: когда вы подтверждаете код, второе устройство получает запрос и тоже должно сверить и подтвердить код у себя. Пока оба не подтвердили, передачи не проходят.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -133,7 +141,7 @@
|
|||||||
<h3 style="margin:0 0 8px;">Заверить устройство?</h3>
|
<h3 style="margin:0 0 8px;">Заверить устройство?</h3>
|
||||||
<div>«<b id="td-name"></b>»</div>
|
<div>«<b id="td-name"></b>»</div>
|
||||||
<div class="sas" id="td-code"></div>
|
<div class="sas" id="td-code"></div>
|
||||||
<div class="small muted">Код должен <b>полностью совпадать</b> с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.</div>
|
<div class="small muted">Код должен <b>полностью совпадать</b> с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.<br><br>После вашего подтверждения то же самое будет запрошено на втором устройстве: доверие считается установленным, только когда код подтвердили <b>оба</b>.</div>
|
||||||
<div class="row" style="margin-top:16px;justify-content:flex-end;">
|
<div class="row" style="margin-top:16px;justify-content:flex-end;">
|
||||||
<button class="btn-gray" id="td-cancel">Отмена</button>
|
<button class="btn-gray" id="td-cancel">Отмена</button>
|
||||||
<button class="btn-green" id="td-ok">Коды совпадают — заверить</button>
|
<button class="btn-green" id="td-ok">Коды совпадают — заверить</button>
|
||||||
|
|||||||
+181
-1
@@ -15,9 +15,18 @@ pub struct TrustedEntry {
|
|||||||
pub ips: Vec<String>,
|
pub ips: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
/// последнее известное «собеседник подтвердил нас» (уточняется при каждом опросе)
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusts_me: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_data_dir(p2p_port: u16) -> PathBuf {
|
/// Папка, где лежит исполняемый файл ramshare_ui.
|
||||||
|
fn exe_dir() -> Option<PathBuf> {
|
||||||
|
std::env::current_exe().ok()?.parent().map(Path::to_path_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Прежнее расположение (каталог данных ОС): запасной вариант и источник миграции.
|
||||||
|
fn os_data_dir(p2p_port: u16) -> PathBuf {
|
||||||
let base: Option<PathBuf> = if cfg!(windows) {
|
let base: Option<PathBuf> = if cfg!(windows) {
|
||||||
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
|
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
|
||||||
} else if cfg!(target_os = "macos") {
|
} 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)
|
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<Dirs, String> {
|
||||||
|
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.
|
/// Атомарная запись: tmp + fsync + rename. Для секретов на Unix права 0600.
|
||||||
pub fn write_atomic(path: &Path, data: &[u8], private: bool) -> io::Result<()> {
|
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();
|
let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
|
||||||
@@ -75,6 +169,12 @@ pub fn load_trusted(dir: &Path) -> Vec<TrustedEntry> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Строгое чтение: None при ошибке разбора (в отличие от load_trusted, не подменяет пустым списком).
|
||||||
|
pub fn read_trusted_strict(dir: &Path) -> Option<Vec<TrustedEntry>> {
|
||||||
|
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<()> {
|
pub fn save_trusted(dir: &Path, entries: &[TrustedEntry]) -> io::Result<()> {
|
||||||
let json = serde_json::to_vec_pretty(entries).map_err(io::Error::other)?;
|
let json = serde_json::to_vec_pretty(entries).map_err(io::Error::other)?;
|
||||||
write_atomic(&dir.join("trusted.json"), &json, false)
|
write_atomic(&dir.join("trusted.json"), &json, false)
|
||||||
@@ -160,3 +260,83 @@ pub fn scan_media_dir(dir: &Path) -> Vec<MediaItem> {
|
|||||||
items.truncate(crate::models::MAX_MEDIA_LIST);
|
items.truncate(crate::models::MAX_MEDIA_LIST);
|
||||||
items
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<data-dir>/config.json`. Содержит закрытый ключ — файл создаётся с правами 0600, не публикуйте его.
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub device_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ping_interval_sec: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub identity: Option<IdentityRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path(dir: &Path) -> PathBuf {
|
||||||
|
dir.join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ok(None) — файла нет; Err — файл есть, но не читается/не разбирается (не затираем молча).
|
||||||
|
pub fn load_config(dir: &Path) -> Result<Option<Config>, 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)
|
||||||
|
}
|
||||||
+28
-48
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
models::{fingerprint, PeerDevice},
|
models::{fingerprint, PeerDevice},
|
||||||
storage,
|
storage::IdentityRecord,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use rcgen::{generate_simple_self_signed, CertifiedKey};
|
use rcgen::{generate_simple_self_signed, CertifiedKey};
|
||||||
@@ -11,10 +11,9 @@ use rustls::{
|
|||||||
server::danger::{ClientCertVerified, ClientCertVerifier},
|
server::danger::{ClientCertVerified, ClientCertVerifier},
|
||||||
ClientConfig, DigitallySignedStruct, DistinguishedName, Error as TlsError, ServerConfig, SignatureScheme,
|
ClientConfig, DigitallySignedStruct, DistinguishedName, Error as TlsError, ServerConfig, SignatureScheme,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
path::{Path, PathBuf},
|
path::Path,
|
||||||
sync::{Arc, OnceLock},
|
sync::{Arc, OnceLock},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
@@ -34,60 +33,41 @@ pub struct Identity {
|
|||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
pub fn identity_from_record(r: &IdentityRecord) -> Result<Identity, String> {
|
||||||
struct SavedIdentity {
|
let cert_der = hex::decode(&r.cert_hex).map_err(|_| "identity.cert_hex: неверный hex".to_string())?;
|
||||||
cert_hex: String,
|
let key_der = hex::decode(&r.key_hex).map_err(|_| "identity.key_hex: неверный hex".to_string())?;
|
||||||
key_hex: String,
|
|
||||||
fingerprint: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_identity(path: &Path) -> Result<Identity, String> {
|
|
||||||
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()))?;
|
|
||||||
let fp = fingerprint(&cert_der);
|
let fp = fingerprint(&cert_der);
|
||||||
if fp != saved.fingerprint {
|
if fp != r.fingerprint {
|
||||||
return Err(format!("{}: отпечаток не совпадает с сертификатом", path.display()));
|
return Err("identity.fingerprint не совпадает с сертификатом (данные изменены или повреждены)".into());
|
||||||
}
|
}
|
||||||
Ok(Identity { cert_der, key_der, fingerprint: fp })
|
Ok(Identity { cert_der, key_der, fingerprint: fp })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_identity(path: &Path, id: &Identity) -> Result<(), String> {
|
pub fn identity_to_record(id: &Identity) -> IdentityRecord {
|
||||||
let json = serde_json::to_vec_pretty(&SavedIdentity {
|
IdentityRecord {
|
||||||
cert_hex: hex::encode(&id.cert_der),
|
cert_hex: hex::encode(&id.cert_der),
|
||||||
key_hex: hex::encode(&id.key_der),
|
key_hex: hex::encode(&id.key_der),
|
||||||
fingerprint: id.fingerprint.clone(),
|
fingerprint: id.fingerprint.clone(),
|
||||||
})
|
}
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
storage::write_atomic(path, &json, true).map_err(|e| format!("запись {}: {}", path.display(), e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Битый файл идентичности — ошибка запуска, а НЕ тихая регенерация.
|
/// Старые файлы идентичности (identity.json / .identity_<порт>.json) — для миграции в config.json.
|
||||||
pub fn load_or_create_identity(data_dir: &Path, legacy_port: u16) -> Result<Identity, String> {
|
pub fn read_identity_file(path: &Path) -> Result<Identity, String> {
|
||||||
let path = data_dir.join("identity.json");
|
let content = std::fs::read_to_string(path).map_err(|e| format!("чтение {}: {}", path.display(), e))?;
|
||||||
if path.exists() {
|
let rec: IdentityRecord =
|
||||||
return read_identity(&path);
|
serde_json::from_str(&content).map_err(|e| format!("файл {} повреждён: {}", path.display(), e))?;
|
||||||
}
|
identity_from_record(&rec).map_err(|e| format!("{}: {}", path.display(), e))
|
||||||
// миграция со старой версии (.identity_<port>.json в текущей папке)
|
}
|
||||||
let legacy = PathBuf::from(format!(".identity_{}.json", legacy_port));
|
|
||||||
if legacy.exists() {
|
pub fn generate_identity() -> Result<Identity, String> {
|
||||||
if let Ok(id) = read_identity(&legacy) {
|
let CertifiedKey { cert, key_pair } = generate_simple_self_signed(vec!["ramshare.local".to_string()])
|
||||||
save_identity(&path, &id)?;
|
.map_err(|e| format!("генерация сертификата: {e}"))?;
|
||||||
return Ok(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 cert_der = cert.der().to_vec();
|
||||||
let id = Identity {
|
Ok(Identity {
|
||||||
fingerprint: fingerprint(&cert_der),
|
fingerprint: fingerprint(&cert_der),
|
||||||
cert_der,
|
cert_der,
|
||||||
key_der: key_pair.serialize_der(),
|
key_der: key_pair.serialize_der(),
|
||||||
};
|
})
|
||||||
save_identity(&path, &id)?;
|
|
||||||
Ok(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key_of(der: &[u8]) -> PrivateKeyDer<'static> {
|
fn key_of(der: &[u8]) -> PrivateKeyDer<'static> {
|
||||||
@@ -136,12 +116,12 @@ impl ClientCertVerifier for AnyClientCert {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn server_config(id: &Identity) -> Result<Arc<ServerConfig>, String> {
|
pub fn server_config(cert_der: &[u8], key_der: &[u8]) -> Result<Arc<ServerConfig>, String> {
|
||||||
let mut cfg = ServerConfig::builder_with_provider(provider())
|
let mut cfg = ServerConfig::builder_with_provider(provider())
|
||||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.with_client_cert_verifier(Arc::new(AnyClientCert))
|
.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}"))?;
|
.map_err(|e| format!("сертификат/ключ не подходят друг другу: {e}"))?;
|
||||||
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
|
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
|
||||||
Ok(Arc::new(cfg))
|
Ok(Arc::new(cfg))
|
||||||
@@ -255,7 +235,7 @@ fn base_builder(cfg: ClientConfig) -> reqwest::ClientBuilder {
|
|||||||
.no_proxy() // не читать HTTP(S)_PROXY из окружения
|
.no_proxy() // не читать HTTP(S)_PROXY из окружения
|
||||||
.redirect(reqwest::redirect::Policy::none()) // нет SSRF через 302
|
.redirect(reqwest::redirect::Policy::none()) // нет SSRF через 302
|
||||||
.pool_max_idle_per_host(0) // отзыв доверия действует сразу
|
.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 cfg = client_config(Arc::new(PinVerifier { expected: expected_fp, peers }), cert, key)?;
|
||||||
let mut b = base_builder(cfg);
|
let mut b = base_builder(cfg);
|
||||||
if !streaming {
|
if !streaming {
|
||||||
b = b.timeout(Duration::from_secs(15));
|
b = b.timeout(Duration::from_secs(12));
|
||||||
}
|
}
|
||||||
b.build().map_err(|e| e.to_string())
|
b.build().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -279,7 +259,7 @@ pub fn probe_client(cert: &[u8], key: &[u8]) -> Result<reqwest::Client, String>
|
|||||||
let cfg = client_config(Arc::new(AnyServerCert), cert, key)?;
|
let cfg = client_config(Arc::new(AnyServerCert), cert, key)?;
|
||||||
base_builder(cfg)
|
base_builder(cfg)
|
||||||
.tls_info(true)
|
.tls_info(true)
|
||||||
.timeout(Duration::from_secs(4))
|
.timeout(Duration::from_secs(3))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
+205
@@ -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<IpAddr>, 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<IpAddr> = 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<String, AppError> {
|
||||||
|
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<S>(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
peer_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
total: u64,
|
||||||
|
stream: S,
|
||||||
|
) -> Result<String, AppError>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<Bytes, io::Error>> + 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::<Result<Bytes, io::Error>>(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,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{models::*, net, storage};
|
use crate::{models::*, net, storage, transfer};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{Body, Bytes},
|
body::Body,
|
||||||
extract::{Path, Request, State},
|
extract::{Path, Request, State},
|
||||||
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
|
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
@@ -8,13 +8,7 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use std::{
|
use std::{io, net::IpAddr, sync::Arc};
|
||||||
io,
|
|
||||||
net::IpAddr,
|
|
||||||
sync::Arc,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
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<Arc<AppState>>) -> Json<serde_json::Value> {
|
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||||
|
if let Some(ip) = net::detect_lan_ip() {
|
||||||
|
state.update_my_ip(ip);
|
||||||
|
}
|
||||||
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
||||||
peers.sort_by(|a, b| a.name.cmp(&b.name));
|
peers.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
||||||
@@ -135,11 +132,14 @@ pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json
|
|||||||
|
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"device_name": state.device_name,
|
"device_name": state.device_name,
|
||||||
"my_ip": state.my_ip,
|
"my_ip": state.current_ip(),
|
||||||
"p2p_port": state.p2p_port,
|
"p2p_port": state.p2p_port,
|
||||||
"my_fingerprint": state.my_fingerprint,
|
"my_fingerprint": state.my_fingerprint,
|
||||||
"my_pin": short_id(&state.my_fingerprint),
|
"my_pin": short_id(&state.my_fingerprint),
|
||||||
"media_dir": state.media_dir.display().to_string(),
|
"media_dir": state.media_dir.display().to_string(),
|
||||||
|
"ping_interval_sec": state.ping_interval_sec.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
"ping_min": MIN_PING_SEC,
|
||||||
|
"ping_max": MAX_PING_SEC,
|
||||||
"peers": peers,
|
"peers": peers,
|
||||||
"texts": texts,
|
"texts": texts,
|
||||||
"media": media,
|
"media": media,
|
||||||
@@ -173,9 +173,13 @@ pub async fn ui_scan_range(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<UiScanRangeRequest>,
|
Json(req): Json<UiScanRangeRequest>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
|
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 port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port);
|
||||||
|
let my_ip = state.current_ip();
|
||||||
let (label, ips) =
|
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();
|
let total = ips.len();
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -231,6 +235,19 @@ pub async fn ui_trust_peer(
|
|||||||
};
|
};
|
||||||
state.persist_trust();
|
state.persist_trust();
|
||||||
state.add_log(msg);
|
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<Arc<AppState>>,
|
||||||
|
Json(req): Json<UiSettingsRequest>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
state
|
||||||
|
.set_ping_interval(req.ping_interval_sec)
|
||||||
|
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,78 +257,16 @@ pub async fn ui_clear_ram(State(state): State<Arc<AppState>>) -> StatusCode {
|
|||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── Отправка ─────────────────────────
|
// ───────────────────────── Отправка (логика — в transfer.rs, общая с CLI) ─────────────────────────
|
||||||
fn resolve_target(state: &AppState, id: &str) -> Result<(Vec<IpAddr>, 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<IpAddr> = 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()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ui_send_text(
|
pub async fn ui_send_text(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<UiSendTextRequest>,
|
Json(req): Json<UiSendTextRequest>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
if req.text.trim().is_empty() {
|
transfer::send_text(&state, &req.peer_id, req.text).await?;
|
||||||
return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст"));
|
Ok(StatusCode::OK)
|
||||||
}
|
|
||||||
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,
|
|
||||||
"Не удалось установить защищённое соединение (узел недоступен или сертификат не совпал)",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Потоковая отправка: браузер → (канал с backpressure) → TLS → получатель. Память O(1).
|
/// Потоковая отправка: браузер → канал с backpressure → TLS → получатель. Память O(1).
|
||||||
pub async fn ui_send_media(
|
pub async fn ui_send_media(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -320,107 +275,15 @@ pub async fn ui_send_media(
|
|||||||
let peer_id = hdr(&headers, "x-target-id")
|
let peer_id = hdr(&headers, "x-target-id")
|
||||||
.ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))?
|
.ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))?
|
||||||
.to_string();
|
.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")
|
let total: u64 = hdr(&headers, "content-length")
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?;
|
.ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?;
|
||||||
if total == 0 {
|
let stream = body
|
||||||
return Err(AppError::new(StatusCode::BAD_REQUEST, "Файл пустой"));
|
.into_data_stream()
|
||||||
}
|
.map(|r| r.map_err(|_| io::Error::new(io::ErrorKind::Other, "client aborted")));
|
||||||
if total > MAX_FILE_SIZE {
|
transfer::send_stream(&state, &peer_id, &name, total, stream).await?;
|
||||||
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Файл больше 2 ГБ"));
|
Ok(StatusCode::OK)
|
||||||
}
|
|
||||||
|
|
||||||
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::<Result<Bytes, io::Error>>(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,
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── Раздача принятых файлов ─────────────────────────
|
// ───────────────────────── Раздача принятых файлов ─────────────────────────
|
||||||
|
|||||||
Binary file not shown.
Reference in new issue
Block a user