a.0.1.0 move to creates src

This commit is contained in:
netadmin committed 2026-10-27 22:23:15 +03:00
1 parent 86d3a3b47f
commit f22d954105
942 files changed
+275635 -225

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
[package]
name = "ramshare-android-jni"
version.workspace = true
edition.workspace = true
[lib]
# Имя выходной библиотеки libramshare_ui.so совпадает с System.loadLibrary("ramshare_ui") в Kotlin
name = "ramshare_ui"
path = "src/lib.rs"
crate-type = ["cdylib"]
[dependencies]
ramshare-core = { path = "../core" }
tokio.workspace = true
tokio-rustls.workspace = true
axum.workspace = true
parking_lot.workspace = true
jni = "0.21"
+153
View File
@@ -0,0 +1,153 @@
use axum::Router;
use jni::{
objects::{JClass, JString},
sys::{jboolean, jint, JNI_FALSE, JNI_TRUE},
JNIEnv,
};
use parking_lot::Mutex as PlMutex;
use ramshare_core::{api, init_app_state, models::AppState, net, p2p, storage, tls};
use std::{
net::Ipv4Addr,
path::Path,
sync::{atomic::Ordering, Arc, OnceLock},
time::Duration,
};
use tokio::sync::Notify;
struct RunningNode {
state: Arc<AppState>,
stop: Arc<Notify>,
}
static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
static NODE: PlMutex<Option<RunningNode>> = PlMutex::new(None);
fn rt() -> &'static tokio::runtime::Runtime {
RUNTIME.get_or_init(|| {
tls::init_crypto();
tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.expect("Tokio runtime init failed")
})
}
#[no_mangle]
pub extern "system" fn Java_com_ramshare_app_RamshareNative_startServer<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
base_dir: JString<'local>,
device_name: JString<'local>,
p2p_port: jint,
ui_port: jint,
) -> jboolean {
if NODE.lock().is_some() {
return JNI_TRUE;
}
let base_str: String = match env.get_string(&base_dir) {
Ok(s) => s.into(),
Err(_) => return JNI_FALSE,
};
let dev_name: String = match env.get_string(&device_name) {
Ok(s) => s.into(),
Err(_) => "Android-Device".to_string(),
};
let p2p_port = (p2p_port as u16).max(1024);
let ui_port = (ui_port as u16).max(1024);
let dirs = match storage::resolve_dirs(Some(Path::new(&base_str)), p2p_port) {
Ok(d) => d,
Err(e) => {
eprintln!("[ANDROID ERR] resolve_dirs: {}", e);
return JNI_FALSE;
}
};
let (state, bind_ip) = match init_app_state(
dirs.config,
dirs.media,
None,
&dev_name,
p2p_port,
None,
None,
true,
false,
) {
Ok(v) => v,
Err(e) => {
eprintln!("[ANDROID ERR] init_app_state: {}", e);
return JNI_FALSE;
}
};
let stop = Arc::new(Notify::new());
let stop_wait = stop.clone();
let st = state.clone();
let started = rt().block_on(async move {
let server_cfg = tls::server_config(&st.cert_der, &st.key_der)?;
let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg);
let p2p_listener = tokio::net::TcpListener::bind((bind_ip, p2p_port))
.await
.map_err(|e| e.to_string())?;
let ui_listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, ui_port))
.await
.map_err(|e| e.to_string())?;
net::start_discovery(st.clone());
let st_ping = st.clone();
let ping_task = tokio::spawn(async move {
net::ping_all_peers(&st_ping, false).await;
st_ping.maintenance();
loop {
let secs = st_ping.ping_interval_sec.load(Ordering::Relaxed);
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(secs)) => {}
_ = st_ping.ping_notify.notified() => {}
}
net::ping_all_peers(&st_ping, false).await;
st_ping.maintenance();
}
});
let ui_router = api::control_router(Router::new(), st.clone(), ui_port);
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(st.clone())));
tokio::spawn(async move {
stop_wait.notified().await;
ping_task.abort();
ui_task.abort();
p2p_task.abort();
});
Ok::<(), String>(())
});
match started {
Ok(()) => {
*NODE.lock() = Some(RunningNode { state, stop });
JNI_TRUE
}
Err(e) => {
eprintln!("[ANDROID ERR] bind/start: {}", e);
JNI_FALSE
}
}
}
#[no_mangle]
pub extern "system" fn Java_com_ramshare_app_RamshareNative_stopServer<'local>(
_env: JNIEnv<'local>,
_class: JClass<'local>,
) {
if let Some(node) = NODE.lock().take() {
node.state.sync_trust_from_disk();
node.state.persist_trust();
storage::cleanup_partials(&node.state.media_dir);
node.stop.notify_waiters();
}
}
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "ramshare-core"
version.workspace = true
edition.workspace = true
[lib]
name = "ramshare_core"
path = "src/lib.rs"
[dependencies]
tokio.workspace = true
tokio-stream.workspace = true
tokio-util.workspace = true
tokio-rustls.workspace = true
futures-util.workspace = true
axum.workspace = true
hyper.workspace = true
hyper-util.workspace = true
reqwest.workspace = true
rustls.workspace = true
rcgen.workspace = true
sha2.workspace = true
hex.workspace = true
getrandom.workspace = true
percent-encoding.workspace = true
socket2.workspace = true
if-addrs.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
+314
View File
@@ -0,0 +1,314 @@
//! Локальный HTTP API управления узлом (127.0.0.1): используется Web UI на ПК и Jetpack Compose на Android.
use crate::{models::*, net, storage, transfer};
use axum::{
body::Body,
extract::{DefaultBodyLimit, Path, Request, State},
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use futures_util::StreamExt;
use std::{
io,
net::IpAddr,
sync::{atomic::Ordering, Arc},
};
use tokio_util::io::ReaderStream;
const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; \
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
pub struct UiGuard {
pub hosts: Vec<String>,
}
/// Создаёт базовый роутер `/ui/*` (без статических веб-файлов) с защитой Host/Origin.
pub fn control_router(extra_routes: Router<Arc<AppState>>, state: Arc<AppState>, ui_port: u16) -> Router {
let guard = Arc::new(UiGuard {
hosts: vec![
format!("127.0.0.1:{}", ui_port),
format!("localhost:{}", ui_port),
],
});
Router::new()
.merge(extra_routes)
.route("/ui/state", get(ui_get_state))
.route("/ui/manual-peer", post(ui_add_manual_peer))
.route("/ui/scan-range", post(ui_scan_range))
.route("/ui/ping", post(ui_ping_peers))
.route("/ui/ping-peer", post(ui_ping_one_peer))
.route("/ui/trust", post(ui_trust_peer))
.route("/ui/clear-ram", post(ui_clear_ram))
.route("/ui/settings", post(ui_set_settings))
.route("/ui/send-text", post(ui_send_text))
.route("/ui/send-media", post(ui_send_media))
.route("/ui/files/:filename", get(ui_serve_saved_file))
.layer(DefaultBodyLimit::max(512 * 1024))
.layer(middleware::from_fn_with_state(guard, ui_guard))
.with_state(state)
}
fn with_security_headers(mut resp: Response) -> Response {
let items: [(HeaderName, &'static str); 5] = [
(header::CONTENT_SECURITY_POLICY, CSP),
(HeaderName::from_static("x-content-type-options"), "nosniff"),
(header::X_FRAME_OPTIONS, "DENY"),
(header::REFERRER_POLICY, "no-referrer"),
(header::CACHE_CONTROL, "no-store"),
];
let h = resp.headers_mut();
for (k, v) in items {
h.entry(k).or_insert(HeaderValue::from_static(v));
}
resp
}
pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -> Response {
let headers = req.headers();
let host_ok = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map_or(false, |h| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(h)));
if !host_ok {
return StatusCode::FORBIDDEN.into_response();
}
if !matches!(*req.method(), Method::GET | Method::HEAD) {
let origin_ok = headers
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.and_then(|o| o.strip_prefix("http://"))
.map_or(false, |o| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(o)));
if !origin_ok {
return StatusCode::FORBIDDEN.into_response();
}
}
with_security_headers(next.run(req).await)
}
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();
peers.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.cmp(&b.name)));
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
let transfers: Vec<TransferProgress> = state.transfers.read().clone();
let scan = state.scan_status.read().clone();
Json(serde_json::json!({
"device_name": state.device_name,
"my_ip": state.current_ip(),
"p2p_port": state.p2p_port,
"my_fingerprint": state.my_fingerprint,
"my_pin": short_id(&state.my_fingerprint),
"media_dir": state.media_dir.display().to_string(),
"ping_interval_sec": state.ping_interval_sec.load(Ordering::Relaxed),
"last_ping": state.last_ping.load(Ordering::Relaxed),
"max_ping_fails": MAX_PING_FAILS,
"now": now_sec(),
"ping_min": MIN_PING_SEC,
"ping_max": MAX_PING_SEC,
"peers": peers,
"texts": texts,
"media": media,
"transfers": transfers,
"scan": scan,
}))
}
pub async fn ui_add_manual_peer(
State(state): State<Arc<AppState>>,
Json(req): Json<UiManualPeerRequest>,
) -> Result<StatusCode, AppError> {
let ip: IpAddr = req
.ip
.trim()
.parse()
.map_err(|_| AppError::new(StatusCode::BAD_REQUEST, "Некорректный IP-адрес"))?;
match ip {
IpAddr::V4(v4) if is_lan_v4(v4) => {}
_ => return Err(AppError::new(StatusCode::BAD_REQUEST, "Разрешены только локальные IPv4-адреса")),
}
let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port);
net::probe_and_register(&state, ip, port)
.await
.map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Узел {}: {}", ip, e)))?;
Ok(StatusCode::OK)
}
pub async fn ui_scan_range(
State(state): State<Arc<AppState>>,
Json(req): Json<UiScanRangeRequest>,
) -> 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 my_ip = state.current_ip();
let (label, ips) =
parse_ip_range(&req.range, &my_ip).map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
let total = ips.len();
{
let mut s = state.scan_status.write();
if s.active {
return Err(AppError::new(StatusCode::CONFLICT, "Сканирование уже выполняется"));
}
*s = ScanProgress {
active: true,
range: label.clone(),
scanned: 0,
total,
found: 0,
};
}
state.add_log(format!("Запущено сканирование {} ({} адресов, порт {})", label, total, port));
let st = state.clone();
let worker = tokio::spawn(net::run_scan(state.clone(), ips, port));
tokio::spawn(async move {
let _ = worker.await;
let found = {
let mut s = st.scan_status.write();
s.active = false;
s.found
};
st.add_log(format!("Сканирование {} завершено. Найдено устройств: {}", label, found));
});
Ok(StatusCode::OK)
}
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
net::ping_all_peers(&state, true).await;
StatusCode::OK
}
pub async fn ui_ping_one_peer(
State(state): State<Arc<AppState>>,
Json(req): Json<UiPingPeerRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let online = net::ping_peer_by_id(&state, &req.peer_id)
.await
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
Ok(Json(serde_json::json!({ "online": online })))
}
pub async fn ui_trust_peer(
State(state): State<Arc<AppState>>,
Json(req): Json<UiTrustRequest>,
) -> Result<StatusCode, AppError> {
let msg = {
let mut peers = state.peers.write();
let p = peers
.get_mut(&req.peer_id)
.ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Устройство не найдено"))?;
p.verified = req.trust;
if req.trust {
p.fail_count = 0;
format!("Устройство '{}' [{}] ЗАВЕРЕНО", p.name, p.pin_code)
} else {
format!("Доверие устройству '{}' [{}] ОТОЗВАНО", p.name, p.pin_code)
}
};
state.persist_trust();
state.add_log(msg);
let (st, id) = (state.clone(), req.peer_id.clone());
tokio::spawn(async move { net::push_trust(&st, &id).await });
Ok(StatusCode::OK)
}
pub async fn ui_set_settings(
State(state): State<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)
}
pub async fn ui_clear_ram(State(state): State<Arc<AppState>>) -> StatusCode {
state.ram_texts.write().clear();
state.add_log("Текстовый буфер в ОЗУ очищен");
StatusCode::OK
}
pub async fn ui_send_text(
State(state): State<Arc<AppState>>,
Json(req): Json<UiSendTextRequest>,
) -> Result<StatusCode, AppError> {
transfer::send_text(&state, &req.peer_id, req.text).await?;
Ok(StatusCode::OK)
}
pub async fn ui_send_media(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Body,
) -> Result<StatusCode, AppError> {
let peer_id = hdr(&headers, "x-target-id")
.ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))?
.to_string();
let name = pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin"));
let total: u64 = hdr(&headers, "content-length")
.and_then(|v| v.parse().ok())
.ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?;
let stream = body
.into_data_stream()
.map(|r| r.map_err(|_| io::Error::new(io::ErrorKind::Other, "client aborted")));
transfer::send_stream(&state, &peer_id, &name, total, stream).await?;
Ok(StatusCode::OK)
}
pub async fn ui_serve_saved_file(State(state): State<Arc<AppState>>, Path(filename): Path<String>) -> Response {
let not_found = || (StatusCode::NOT_FOUND, "Файл не найден").into_response();
if filename.is_empty()
|| filename.starts_with('.')
|| filename.contains(|c: char| matches!(c, '/' | '\\' | '\0'))
{
return not_found();
}
let path = state.media_dir.join(&filename);
let Ok(meta) = tokio::fs::metadata(&path).await else { return not_found() };
if !meta.is_file() {
return not_found();
}
let Ok(file) = tokio::fs::File::open(&path).await else { return not_found() };
let inline = storage::is_previewable(&filename);
let ctype = if inline {
storage::mime_from_name(&filename)
} else {
"application/octet-stream"
};
let disposition = format!(
"{}; filename*=UTF-8''{}",
if inline { "inline" } else { "attachment" },
pct_encode(&filename)
);
let mut resp = Response::new(Body::from_stream(ReaderStream::new(file)));
let h = resp.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));
h.insert(header::CONTENT_LENGTH, HeaderValue::from(meta.len()));
if let Ok(v) = HeaderValue::from_str(&disposition) {
h.insert(header::CONTENT_DISPOSITION, v);
}
h.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'none'; sandbox"),
);
h.insert(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
resp
}
+495
View File
@@ -0,0 +1,495 @@
//! Консольный режим: те же возможности, что и в веб-интерфейсе, без запуска сервера.
//! Результаты команд — в 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 fmt_ago(ts: u64, now: u64) -> String {
if ts == 0 {
return "никогда".into();
}
let d = now.saturating_sub(ts);
if d < 3 {
"сейчас".into()
} else if d < 60 {
format!("{} с назад", d)
} else if d < 3600 {
format!("{} мин назад", d / 60)
} else if d < 86400 {
format!("{} ч назад", d / 3600)
} else {
format!("{} дн назад", d / 86400)
}
}
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;
}
let now = now_sec();
let last_p = state.last_ping.load(Ordering::Relaxed);
eprintln!("Последний опрос (ping): {}", fmt_ago(last_p, now));
if v.is_empty() {
println!("Устройства не найдены.");
return;
}
println!(
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
"СТАТУС", "БЫЛ ОНЛАЙН", "ПОСЛ. ПИНГ", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID"
);
for p in v {
let addr = p
.ips
.first()
.map(|ip| format!("{}:{}", ip, p.port))
.unwrap_or_else(|| "—".into());
println!(
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
if p.online { "● online" } else { "○ offline" },
fmt_ago(p.last_seen, now),
fmt_ago(p.last_ping, now),
trust_label(&p),
trunc(&p.name, 20),
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, true).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, true).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, true).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;
x.fail_count = 0;
}
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() {
state.persist_trust();
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() {
state.persist_trust();
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)?;
state.persist_trust();
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) => {
state.persist_trust();
println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size));
}
Err(e) => return Err(format!("{}: {}", name, app_err(e))),
}
}
Ok(())
}
+91
View File
@@ -0,0 +1,91 @@
//! Единый файл конфигурации `<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,
}
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()
}
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 => {
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,
})
}
+123
View File
@@ -0,0 +1,123 @@
pub mod api;
pub mod config;
pub mod models;
pub mod net;
pub mod p2p;
pub mod storage;
pub mod tls;
pub mod transfer;
use models::*;
use parking_lot::{Mutex as PlMutex, RwLock};
use std::{
collections::{HashMap, VecDeque},
net::{IpAddr, Ipv4Addr},
path::PathBuf,
sync::{
atomic::{AtomicBool, AtomicU64},
Arc,
},
};
use tokio::sync::{Mutex, Notify, Semaphore};
/// Единая точка инициализации состояния узла для Desktop, CLI и Android.
pub fn init_app_state(
data_dir: PathBuf,
media_dir: PathBuf,
name_override: Option<String>,
default_name: &str,
p2p_port: u16,
ip_override: Option<Ipv4Addr>,
ping_override: Option<u64>,
serving: bool,
headless: bool,
) -> Result<(Arc<AppState>, Ipv4Addr), String> {
tls::init_crypto();
let (bind_ip, my_ip, fixed_ip) = match ip_override {
Some(ip) if !ip.is_unspecified() => (ip, ip, true),
_ => {
let ip = net::detect_lan_ip().unwrap_or(Ipv4Addr::LOCALHOST);
(Ipv4Addr::UNSPECIFIED, ip, false)
}
};
if serving {
storage::cleanup_partials(&media_dir);
}
let loaded = config::load_or_init(&data_dir, p2p_port, default_name)?;
let device_name = name_override
.map(|s| clean_label(&s, 48))
.filter(|s| !s.is_empty())
.unwrap_or(loaded.device_name);
let identity = loaded.identity;
let mut known: HashMap<String, PeerDevice> = HashMap::new();
for t in storage::load_trusted(&data_dir) {
if !is_valid_fp(&t.fp) || t.fp == identity.fingerprint {
continue;
}
let ips: Vec<String> = t
.ips
.into_iter()
.filter(|s| s.parse::<IpAddr>().is_ok())
.take(MAX_PEER_IPS)
.collect();
let port = if t.port == 0 { 8443 } else { t.port };
let mut p = PeerDevice::new(
&t.fp,
clean_label(&t.name, 48),
ips,
port,
&identity.fingerprint,
true,
);
p.trusts_me = t.trusts_me;
p.last_seen = t.last_seen;
known.insert(t.fp.clone(), p);
}
let peers = Arc::new(RwLock::new(known));
let probe_client = tls::probe_client(&identity.cert_der, &identity.key_der)?;
let ping_sec = ping_override
.unwrap_or(loaded.ping_interval_sec)
.clamp(MIN_PING_SEC, MAX_PING_SEC);
let disk_media: VecDeque<MediaItem> = if serving {
storage::scan_media_dir(&media_dir).into_iter().collect()
} else {
VecDeque::new()
};
let state = Arc::new(AppState {
device_name,
my_ip: RwLock::new(my_ip.to_string()),
fixed_ip,
p2p_port,
my_fingerprint: identity.fingerprint.clone(),
cert_der: identity.cert_der,
key_der: identity.key_der,
data_dir,
media_dir,
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
disk_media: RwLock::new(disk_media),
peers,
probe_client,
transfers: RwLock::new(Vec::new()),
scan_status: RwLock::new(ScanProgress::default()),
logs: RwLock::new(VecDeque::new()),
incoming_slots: Arc::new(Semaphore::new(MAX_INCOMING)),
ping_lock: Mutex::new(()),
trust_dirty: AtomicBool::new(false),
probe_cooldown: PlMutex::new(HashMap::new()),
ping_interval_sec: AtomicU64::new(ping_sec),
last_ping: AtomicU64::new(0),
ping_notify: Notify::new(),
log_stdout: serving,
headless,
trust_mtime: PlMutex::new(None),
});
Ok((state, bind_ip))
}
+498
View File
@@ -0,0 +1,498 @@
mod cli;
mod config;
mod models;
mod net;
mod p2p;
mod storage;
mod tls;
mod transfer;
mod ui;
use axum::{
extract::DefaultBodyLimit,
middleware,
routing::{get, post},
Router,
};
use models::*;
use parking_lot::{Mutex as PlMutex, RwLock};
use std::{
collections::{HashMap, VecDeque},
net::{IpAddr, Ipv4Addr},
path::PathBuf,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
},
time::Duration,
};
use tokio::sync::{Mutex, Notify, Semaphore};
const USAGE: &str = r#"ramshare_ui — обмен текстом и файлами в локальной сети (TLS 1.3, mTLS, пиннинг сертификатов)
Использование: ramshare_ui [параметры] [команда]
Команды:
serve запустить узел и веб-интерфейс (команда по умолчанию)
scan [диапазон] просканировать сеть и показать найденные устройства
(all, 192.168.1.1-254, 10.0.0.0/24, список через запятую)
peers показать, кто онлайн (известные + UDP-обнаружение)
config показать путь и значения config.json (создаёт файл при первом запуске)
trust <устройство> сверить код и заверить устройство (сверка взаимная: второе
устройство получит запрос и тоже должно подтвердить код)
untrust <устройство> отозвать доверие
send-text <устройство> <текст…|-> отправить текст ("-" — читать из stdin)
send-file <устройство> <файл…> отправить один или несколько файлов
<устройство> — имя, IP[:порт], префикс ID (≥4 hex) или полный отпечаток
Колонка ДОВЕРИЕ в peers/scan:
взаимно — оба подтвердили код; ждём — вы подтвердили, ждём его;
запрос — он подтвердил и ждёт вас (выполните trust); нет — не заверено
Параметры:
--name <имя> имя устройства на этот запуск (постоянное задаётся в config.json)
--p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443; для scan/send — порт поиска
--ui-port <порт> порт локального веб-интерфейса (только 127.0.0.1), по умолчанию 8080
--ip <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 {
name: Option<String>,
p2p_port: u16,
ui_port: u16,
ip: Option<Ipv4Addr>,
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) -> ! {
eprintln!("Ошибка: {}", msg);
std::process::exit(1);
}
fn default_device_name() -> String {
let n = std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.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 {
name: None,
p2p_port: 8443,
ui_port: 8080,
ip: None,
data_dir: None,
ping_interval: None,
json: false,
wait: 4,
confirm_wait: 0,
scan: None,
yes: false,
no_ui: false,
command: Command::Serve,
};
let mut positional: Vec<String> = Vec::new();
let mut only_pos = false;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
if !only_pos && a == "--" {
only_pos = true;
continue;
}
if only_pos || (!a.starts_with("--") && a != "-h" && a != "-y") {
positional.push(a);
continue;
}
let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag));
match a.as_str() {
"--name" => cfg.name = Some(clean_label(&val("--name")?, 48)),
"--p2p-port" => {
cfg.p2p_port = val("--p2p-port")?.parse().map_err(|_| "Некорректный --p2p-port".to_string())?
}
"--ui-port" => {
cfg.ui_port = val("--ui-port")?.parse().map_err(|_| "Некорректный --ui-port".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")?)),
"--ping-interval" => {
let s: u64 = val("--ping-interval")?
.parse()
.map_err(|_| "Некорректный --ping-interval".to_string())?;
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&s) {
return Err(format!("--ping-interval: допустимо {}–{} секунд", MIN_PING_SEC, MAX_PING_SEC));
}
cfg.ping_interval = Some(s);
}
"--wait" => {
let w: u64 = val("--wait")?.parse().map_err(|_| "Некорректный --wait".to_string())?;
if !(1..=60).contains(&w) {
return Err("--wait: допустимо 1–60 секунд".into());
}
cfg.wait = w;
}
"--scan" => cfg.scan = Some(val("--scan")?),
"--confirm-wait" => {
let w: u64 = val("--confirm-wait")?.parse().map_err(|_| "Некорректный --confirm-wait".to_string())?;
if w > 600 {
return Err("--confirm-wait: допустимо 0–600 секунд".into());
}
cfg.confirm_wait = w;
}
"--json" => cfg.json = true,
"--no-ui" => cfg.no_ui = true,
"-y" | "--yes" => cfg.yes = true,
"-h" | "--help" => {
println!("{}", USAGE);
std::process::exit(0);
}
other => return Err(format!("Неизвестный параметр: {}\n\n{}", other, USAGE)),
}
}
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());
}
if cfg.p2p_port == 0 || cfg.ui_port == 0 {
return Err("Порт не может быть 0".into());
}
Ok(cfg)
}
struct Built {
state: Arc<AppState>,
bind_ip: Ipv4Addr,
}
/// Общая инициализация для `serve` и CLI-команд: адреса, каталоги, идентичность, доверие, клиенты.
fn build_state(cfg: &Config, serving: bool) -> Built {
let (bind_ip, my_ip, fixed_ip) = match cfg.ip {
Some(ip) if !ip.is_unspecified() => (ip, ip, true),
_ => {
let ip = net::detect_lan_ip().unwrap_or_else(|| {
eprintln!("[WARN] Локальный IP не определён, используется 127.0.0.1 (подхватится при появлении сети)");
Ipv4Addr::LOCALHOST
});
(Ipv4Addr::UNSPECIFIED, ip, false)
}
};
// <папка с ramshare_ui>/config (вся конфигурация) и <папка с ramshare_ui>/received_media
let dirs = storage::resolve_dirs(cfg.data_dir.as_deref(), cfg.p2p_port).unwrap_or_else(|e| fatal(&e));
let data_dir = dirs.config; // каталог конфигурации: config.json, trusted.json
let media_dir = dirs.media;
if serving {
// .part-файлы чистит только работающий узел (CLI не должен трогать чужие передачи)
storage::cleanup_partials(&media_dir);
}
// config.json: имя устройства, идентичность (сертификат + ключ), интервал опроса
let loaded = config::load_or_init(&data_dir, cfg.p2p_port, &default_device_name()).unwrap_or_else(|e| fatal(&e));
let device_name = cfg.name.clone().unwrap_or(loaded.device_name); // --name важнее config.json
let identity = loaded.identity;
let mut known: HashMap<String, PeerDevice> = HashMap::new();
for t in storage::load_trusted(&data_dir) {
if !is_valid_fp(&t.fp) || t.fp == identity.fingerprint {
continue;
}
let ips: Vec<String> = t
.ips
.into_iter()
.filter(|s| s.parse::<IpAddr>().is_ok())
.take(MAX_PEER_IPS)
.collect();
let port = if t.port == 0 { 8443 } else { t.port };
let mut p = PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true);
p.trusts_me = t.trusts_me;
p.last_seen = t.last_seen;
known.insert(t.fp.clone(), p);
}
let peers = Arc::new(RwLock::new(known));
let probe_client = tls::probe_client(&identity.cert_der, &identity.key_der).unwrap_or_else(|e| fatal(&e));
// приоритет: --ping-interval > config.json > значение по умолчанию
let ping_sec = cfg
.ping_interval
.unwrap_or(loaded.ping_interval_sec)
.clamp(MIN_PING_SEC, MAX_PING_SEC);
let disk_media: VecDeque<MediaItem> = if serving {
storage::scan_media_dir(&media_dir).into_iter().collect()
} else {
VecDeque::new()
};
let state = Arc::new(AppState {
device_name,
my_ip: RwLock::new(my_ip.to_string()),
fixed_ip,
p2p_port: cfg.p2p_port,
my_fingerprint: identity.fingerprint.clone(),
cert_der: identity.cert_der,
key_der: identity.key_der,
data_dir,
media_dir,
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
disk_media: RwLock::new(disk_media),
peers,
probe_client,
transfers: RwLock::new(Vec::new()),
scan_status: RwLock::new(ScanProgress::default()),
logs: RwLock::new(VecDeque::new()),
incoming_slots: Arc::new(Semaphore::new(MAX_INCOMING)),
ping_lock: Mutex::new(()),
trust_dirty: AtomicBool::new(false),
probe_cooldown: PlMutex::new(HashMap::new()),
ping_interval_sec: AtomicU64::new(ping_sec),
last_ping: AtomicU64::new(0),
ping_notify: Notify::new(),
log_stdout: serving,
headless: serving && cfg.no_ui,
trust_mtime: PlMutex::new(None),
});
Built { state, bind_ip }
}
async fn run_serve(cfg: &Config, built: Built) {
let Built { state, bind_ip } = built;
let server_cfg = tls::server_config(&state.cert_der, &state.key_der).unwrap_or_else(|e| fatal(&e));
let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg);
// слушатели привязываем ДО запуска задач: занятый порт — фатальная ошибка
let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port))
.await
.unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e)));
let ui_listener = if cfg.no_ui {
None
} else {
Some(
tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port))
.await
.unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e))),
)
};
state.add_log(format!(
"Узел '{}' запущен на {}:{} (ID: {})",
state.device_name,
state.current_ip(),
cfg.p2p_port,
short_id(&state.my_fingerprint)
));
net::start_discovery(state.clone());
// периодический опрос + обслуживание; после 5 неудач пир переводится в offline и больше не опрашивается автоматически
{
let st = state.clone();
tokio::spawn(async move {
net::ping_all_peers(&st, false).await;
st.maintenance();
loop {
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, false).await;
st.maintenance();
}
});
}
println!("==============================================================");
println!(" Устройство: {}", state.device_name);
println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port);
println!(" Адрес в сети: {}", state.current_ip());
println!(" ID устройства: {}", short_id(&state.my_fingerprint));
println!(" Папка config: {}", state.data_dir.display());
println!(" Файл настроек: {}", state.data_dir.join("config.json").display());
println!(" Принятые файлы: {}", state.media_dir.display());
println!(
" Опрос устройств: каждые {} с (до {} попыток при недоступности)",
state.ping_interval_sec.load(Ordering::Relaxed),
MAX_PING_FAILS
);
println!("--------------------------------------------------------------");
let ui_task = match ui_listener {
Some(listener) => {
let guard = Arc::new(ui::UiGuard {
hosts: vec![
format!("127.0.0.1:{}", cfg.ui_port),
format!("localhost:{}", cfg.ui_port),
],
});
println!(" ОТКРОЙТЕ ВЕБ-ИНТЕРФЕЙС:");
println!(" http://127.0.0.1:{}/", cfg.ui_port);
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/ping-peer", post(ui::ui_ping_one_peer))
.route("/ui/trust", post(ui::ui_trust_peer))
.route("/ui/clear-ram", post(ui::ui_clear_ram))
.route("/ui/settings", post(ui::ui_set_settings))
.route("/ui/send-text", post(ui::ui_send_text))
.route("/ui/send-media", post(ui::ui_send_media)) // сырой Body — без лимита экстрактора
.route("/ui/files/:filename", get(ui::ui_serve_saved_file))
.layer(DefaultBodyLimit::max(512 * 1024)) // для JSON-эндпоинтов
.layer(middleware::from_fn_with_state(guard, ui::ui_guard))
.with_state(state.clone());
Some(tokio::spawn(async move { axum::serve(listener, ui_router).await }))
}
None => {
println!(" Режим без UI (--no-ui). Входящий текст печатается в терминал,");
println!(" файлы сохраняются в {}", state.media_dir.display());
None
}
};
println!("==============================================================");
let 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! {
_ = ui_wait => {}
r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r),
_ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"),
}
state.sync_trust_from_disk();
state.persist_trust();
storage::cleanup_partials(&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);
}
}
+788
View File
@@ -0,0 +1,788 @@
use crate::storage::{self, TrustedEntry};
use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use parking_lot::{Mutex as PlMutex, RwLock};
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{hash_map::Entry, BTreeSet, HashMap, VecDeque},
fmt::Display,
net::{IpAddr, Ipv4Addr},
path::PathBuf,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tokio::sync::{Mutex, Notify, Semaphore};
// ───────────────────────── Лимиты ─────────────────────────
pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // 2 ГБ на один файл
pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024; // 10 ГБ всего в received_media
pub const MAX_TEXT_BYTES: usize = 64 * 1024;
pub const MAX_RAM_TEXTS: usize = 50;
pub const MAX_LOGS: usize = 100;
pub const MAX_PEERS: usize = 256;
pub const MAX_PEER_IPS: usize = 4;
pub const MAX_TRANSFERS: usize = 20;
pub const MAX_INCOMING: usize = 4;
pub const MAX_MEDIA_LIST: usize = 500;
pub const DISCOVERY_PORT: u16 = 5354;
pub const MIN_PING_SEC: u64 = 10;
pub const MAX_PING_SEC: u64 = 30;
pub const DEFAULT_PING_SEC: u64 = 15;
pub const MAX_PING_FAILS: u8 = 5;
// ───────────────────────── Ошибки ─────────────────────────
pub struct AppError(pub StatusCode, pub String);
impl AppError {
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
Self(status, msg.into())
}
/// Детали пишем в stderr, наружу отдаём общий текст (без путей и внутренностей).
pub fn internal(ctx: &str, e: impl Display) -> Self {
eprintln!("[ERR] {}: {}", ctx, e);
Self(StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка".into())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.0, self.1).into_response()
}
}
// ───────────────────────── DTO ─────────────────────────
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TextItem {
pub sender: String,
pub text: String,
pub timestamp: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MediaItem {
pub sender: String,
pub filename: String,
pub saved_path: String,
pub mime: String,
pub size: u64,
pub timestamp: u64,
pub previewable: bool,
pub risky: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PeerDevice {
pub id: String,
pub name: String,
pub ips: Vec<String>,
pub port: u16,
pub fingerprint: String,
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
pub pin_code: String,
/// Мы сверили код и подтвердили это устройство (наше решение; по нему принимаем/отправляем).
pub verified: bool,
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
pub trusts_me: bool,
pub online: bool,
/// Когда устройство последний раз отвечало по сети (unix timestamp, сек).
pub last_seen: u64,
/// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек).
pub last_ping: u64,
/// Количество подряд неудачных попыток пинга (при достижении 5 автоопрос прекращается).
pub fail_count: u8,
}
impl PeerDevice {
pub fn new(fp: &str, name: String, ips: Vec<String>, port: u16, my_fp: &str, verified: bool) -> Self {
Self {
id: fp.to_string(),
name,
ips,
port,
fingerprint: fp.to_string(),
pin_code: sas_code(my_fp, fp),
verified,
trusts_me: false,
online: false,
last_seen: 0,
last_ping: 0,
fail_count: 0,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
In,
Out,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransferProgress {
pub id: String,
pub filename: String,
pub peer_name: String,
pub direction: Direction,
pub transferred_bytes: u64,
pub total_bytes: u64,
pub percent: u8,
pub done: bool,
pub error: Option<String>,
pub updated_at: u64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ScanProgress {
pub active: bool,
pub range: String,
pub scanned: usize,
pub total: usize,
pub found: usize,
}
#[derive(Serialize, Deserialize)]
pub struct IncomingTextPayload {
pub text: String,
}
#[derive(Deserialize)]
pub struct UiSendTextRequest {
pub peer_id: String,
pub text: String,
}
#[derive(Deserialize)]
pub struct UiTrustRequest {
pub peer_id: String,
pub trust: bool,
}
#[derive(Deserialize)]
pub struct UiPingPeerRequest {
pub peer_id: String,
}
#[derive(Deserialize)]
pub struct UiManualPeerRequest {
pub ip: String,
pub port: Option<u16>,
}
#[derive(Deserialize)]
pub struct UiScanRangeRequest {
pub range: String,
pub port: Option<u16>,
}
#[derive(Deserialize)]
pub struct UiSettingsRequest {
pub ping_interval_sec: u64,
}
// ───────────────────────── Состояние ─────────────────────────
pub struct AppState {
pub device_name: String,
pub my_ip: RwLock<String>,
pub fixed_ip: bool,
pub p2p_port: u16,
pub my_fingerprint: String,
pub cert_der: Vec<u8>,
pub key_der: Vec<u8>,
pub data_dir: PathBuf,
pub media_dir: PathBuf,
pub ram_texts: RwLock<VecDeque<TextItem>>,
pub disk_media: RwLock<VecDeque<MediaItem>>,
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
pub probe_client: reqwest::Client,
pub transfers: RwLock<Vec<TransferProgress>>,
pub scan_status: RwLock<ScanProgress>,
pub logs: RwLock<VecDeque<String>>,
pub incoming_slots: Arc<Semaphore>,
pub ping_lock: Mutex<()>,
pub trust_dirty: AtomicBool,
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
/// Интервал опроса известных пиров (сек), меняется на лету
pub ping_interval_sec: AtomicU64,
/// Время последнего глобального опроса устройств (unix timestamp, сек)
pub last_ping: 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 {
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>) {
let text: String = msg
.into()
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if self.log_stdout {
println!("[LOG] {}", text);
}
let mut logs = self.logs.write();
if logs.len() >= MAX_LOGS {
logs.pop_back();
}
logs.push_front(format!("[{}] {}", now_sec(), text));
}
// ── передачи ──
pub fn add_transfer(&self, t: TransferProgress) {
let mut l = self.transfers.write();
l.insert(0, t);
l.truncate(MAX_TRANSFERS);
}
pub fn update_transfer_bytes(&self, id: &str, bytes: u64) {
let mut l = self.transfers.write();
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
t.transferred_bytes = bytes;
t.percent = percent(bytes, t.total_bytes);
t.updated_at = now_sec();
}
}
pub fn finish_transfer(&self, id: &str) {
let mut l = self.transfers.write();
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
t.transferred_bytes = t.total_bytes;
t.percent = 100;
t.done = true;
t.updated_at = now_sec();
}
}
pub fn fail_transfer(&self, id: &str, msg: String) {
let mut l = self.transfers.write();
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
t.done = true;
t.error = Some(msg);
t.updated_at = now_sec();
}
}
pub fn remove_transfer(&self, id: &str) {
self.transfers.write().retain(|t| t.id != id);
}
// ── доверие ──
pub fn trusted_name(&self, fp: &str) -> Option<String> {
self.peers
.read()
.get(fp)
.filter(|p| p.verified)
.map(|p| p.name.clone())
}
pub fn persist_trust(&self) {
let entries: Vec<TrustedEntry> = self
.peers
.read()
.values()
.filter(|p| p.verified)
.map(|p| TrustedEntry {
fp: p.id.clone(),
name: p.name.clone(),
ips: p.ips.clone(),
port: p.port,
trusts_me: p.trusts_me,
last_seen: p.last_seen,
})
.collect();
match storage::save_trusted(&self.data_dir, &entries) {
Ok(()) => {
if let Ok(mt) = std::fs::metadata(self.data_dir.join("trusted.json")).and_then(|m| m.modified()) {
*self.trust_mtime.lock() = Some(mt);
}
}
Err(e) => self.add_log(format!("Не удалось сохранить список доверенных: {}", e)),
}
}
pub fn client_for_peer(&self, fp: &str, streaming: bool) -> Result<reqwest::Client, AppError> {
crate::tls::pinned_client(
fp.to_string(),
self.peers.clone(),
&self.cert_der,
&self.key_der,
streaming,
)
.map_err(|e| AppError::internal("tls client", e))
}
// ── пиры ──
/// Регистрирует/обновляет пира. Вызывать ТОЛЬКО с отпечатком, полученным из TLS-рукопожатия.
pub fn upsert_peer(&self, name: &str, ip: IpAddr, port: u16, fp: &str) -> bool {
if fp == self.my_fingerprint || !is_valid_fp(fp) || port == 0 {
return false;
}
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
return false;
}
let mut name = clean_label(name, 48);
if name.is_empty() {
name = "Unknown".into();
}
let ip_s = ip.to_string();
let now = now_sec();
let mut created = false;
let mut dirty = false;
{
let mut peers = self.peers.write();
if !peers.contains_key(fp) && peers.len() >= MAX_PEERS {
let victim = peers
.values()
.filter(|p| !p.verified && !p.online)
.min_by_key(|p| p.last_seen)
.map(|p| p.id.clone());
match victim {
Some(v) => {
peers.remove(&v);
}
None => return false,
}
}
match peers.entry(fp.to_string()) {
Entry::Occupied(mut e) => {
let p = e.get_mut();
let changed = p.ips.first() != Some(&ip_s) || p.port != port || p.name != name;
p.name = name.clone();
p.port = port;
p.online = true;
p.fail_count = 0;
p.last_seen = now;
p.last_ping = now;
p.ips.retain(|x| x != &ip_s);
p.ips.insert(0, ip_s.clone());
p.ips.truncate(MAX_PEER_IPS);
if p.verified && changed {
dirty = true;
}
}
Entry::Vacant(v) => {
let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false);
p.online = true;
p.fail_count = 0;
p.last_seen = now;
p.last_ping = now;
v.insert(p);
created = true;
}
}
}
if dirty {
self.trust_dirty.store(true, Ordering::Relaxed);
}
if created {
self.add_log(format!(
"Обнаружено устройство '{}' ({}:{}), код сверки: {}",
name,
ip,
port,
sas_code(&self.my_fingerprint, fp)
));
}
created
}
/// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене).
pub fn touch_peer_ip(&self, fp: &str, ip: IpAddr) {
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
return;
}
let ip_s = ip.to_string();
let now = now_sec();
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.fail_count = 0;
p.last_seen = now;
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);
}
}
/// Сбрасывает счётчик неудачных пингов у всех известных устройств.
pub fn reset_all_ping_failures(&self) {
let mut peers = self.peers.write();
for p in peers.values_mut() {
p.fail_count = 0;
}
}
/// Пир уже недавно виден по этому адресу и порту — повторный probe по beacon не нужен.
pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool {
let ip_s = ip.to_string();
self.peers.read().get(fp).map_or(false, |p| {
p.online
&& p.port == port
&& p.ips.first().map(String::as_str) == Some(ip_s.as_str())
&& now_sec().saturating_sub(p.last_seen) < 10
})
}
pub fn probe_allowed(&self, ip: IpAddr, port: u16) -> bool {
let mut m = self.probe_cooldown.lock();
let now = Instant::now();
if m.len() > 512 {
m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(15));
}
match m.get(&(ip, port)) {
Some(t) if now.duration_since(*t) < Duration::from_secs(4) => false,
_ => {
m.insert((ip, port), now);
true
}
}
}
/// Меняет интервал опроса: применяется сразу и сохраняется в config.json.
pub fn set_ping_interval(&self, sec: u64) -> Result<(), String> {
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&sec) {
return Err(format!("Интервал должен быть от {} до {} секунд", MIN_PING_SEC, MAX_PING_SEC));
}
self.ping_interval_sec.store(sec, Ordering::Relaxed);
self.ping_notify.notify_one();
if let Err(e) = storage::update_config(&self.data_dir, |c| c.ping_interval_sec = Some(sec)) {
self.add_log(format!("Не удалось сохранить config.json: {}", e));
}
self.add_log(format!("Интервал опроса устройств: {} с", sec));
Ok(())
}
/// Бит «собеседник подтвердил сверку с нами».
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;
np.last_seen = e.last_seen;
peers.insert(e.fp.clone(), np);
}
}
pub fn maintenance(&self) {
self.sync_trust_from_disk();
let now = now_sec();
self.peers
.write()
.retain(|_, p| p.verified || p.online || now.saturating_sub(p.last_seen) < 600);
if self.trust_dirty.swap(false, Ordering::Relaxed) {
self.persist_trust();
}
}
}
pub fn remove_transfer_later(state: &Arc<AppState>, id: String, secs: u64) {
let st = state.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(secs)).await;
st.remove_transfer(&id);
});
}
// ───────────────────────── Утилиты ─────────────────────────
pub fn now_sec() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn percent(done: u64, total: u64) -> u8 {
if total == 0 {
100
} else {
((done as u128 * 100) / total as u128).min(100) as u8
}
}
pub fn fingerprint(der: &[u8]) -> String {
hex::encode(Sha256::digest(der))
}
pub fn is_valid_fp(fp: &str) -> bool {
fp.len() == 64 && fp.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
pub fn short_id(fp: &str) -> String {
fp.get(..8).unwrap_or(fp).to_uppercase()
}
/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа.
pub fn sas_code(a: &str, b: &str) -> String {
let (x, y) = if a <= b { (a, b) } else { (b, a) };
let mut h = Sha256::new();
h.update(b"ramshare-sas-v1");
h.update(x.as_bytes());
h.update(b"|");
h.update(y.as_bytes());
let digest = h.finalize();
let s = hex::encode_upper(&digest[..10]);
s.as_bytes()
.chunks(4)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect::<Vec<_>>()
.join("-")
}
pub fn random_hex(n: usize) -> String {
let mut b = vec![0u8; n];
getrandom::getrandom(&mut b).expect("ОС не предоставила случайные байты");
hex::encode(b)
}
pub fn clean_label(s: &str, max_chars: usize) -> String {
s.chars()
.filter(|c| {
!c.is_control() && !matches!(*c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}')
})
.collect::<String>()
.trim()
.chars()
.take(max_chars)
.collect()
}
pub fn strip_ctrl(s: &str) -> String {
s.chars()
.map(|c| if c.is_control() && c != '\n' && c != '\t' { '\u{fffd}' } else { c })
.collect()
}
pub fn pct_decode(s: &str) -> String {
percent_decode_str(s).decode_utf8_lossy().into_owned()
}
pub fn pct_encode(s: &str) -> String {
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
}
pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
h.get(name).and_then(|v| v.to_str().ok())
}
/// Локальные IPv4: RFC 1918 (10/8, 172.16/12, 192.168/16), Link-Local (169.254/16) и CGNAT/VPN (100.64/10).
pub fn is_lan_v4(ip: Ipv4Addr) -> bool {
let o = ip.octets();
ip.is_private() || ip.is_link_local() || (o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000)
}
pub fn sanitize_filename(name: &str) -> String {
let clean: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let trimmed = clean.trim_matches(|c: char| c == '.' || c.is_whitespace());
if trimmed.is_empty() {
return "file.bin".into();
}
const MAX: usize = 120;
if trimmed.chars().count() <= MAX {
return trimmed.to_string();
}
match trimmed.rfind('.') {
Some(i) if trimmed[i..].chars().count() <= 16 => {
let ext = &trimmed[i..];
let keep = MAX - ext.chars().count();
format!("{}{}", trimmed[..i].chars().take(keep).collect::<String>(), ext)
}
_ => trimmed.chars().take(MAX).collect(),
}
}
pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec<Ipv4Addr>), String> {
const MAX_TOTAL: usize = 1024;
let raw = input.trim();
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 base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?;
let o = base.octets();
format!("{}.{}.{}.1-254", o[0], o[1], o[2])
} else {
raw.to_string()
};
if effective.len() > 256 {
return Err("Слишком длинная строка диапазона".into());
}
let mut set: BTreeSet<u32> = BTreeSet::new();
let mut tokens = 0;
for part in effective.split(',') {
let token = part.trim();
if token.is_empty() {
continue;
}
tokens += 1;
if tokens > 16 {
return Err("Слишком много элементов в списке (максимум 16)".into());
}
let (start, end): (u32, u32) = if let Some((base, pfx)) = token.split_once('/') {
let ip: Ipv4Addr = base.trim().parse().map_err(|_| format!("Некорректный CIDR адрес: {}", token))?;
let p: u32 = pfx.trim().parse().map_err(|_| format!("Некорректная маска: {}", token))?;
if !(22..=32).contains(&p) {
return Err("Поддерживаются маски от /22 до /32".into());
}
let mask: u32 = !0u32 << (32 - p);
let s = u32::from(ip) & mask;
(s, s | !mask)
} else if let Some((left, right)) = token.split_once('-') {
let (left, right) = (left.trim(), right.trim());
let s_ip: Ipv4Addr = left.parse().map_err(|_| format!("Некорректный начальный IP: {}", left))?;
let s = u32::from(s_ip);
let e = if right.contains('.') {
let e_ip: Ipv4Addr = right.parse().map_err(|_| format!("Некорректный конечный IP: {}", right))?;
u32::from(e_ip)
} else {
let last: u8 = right.parse().map_err(|_| format!("Некорректный конец диапазона: {}", right))?;
(s & !0xFF) | last as u32
};
if e < s {
return Err("Конец диапазона меньше начала".into());
}
(s, e)
} else {
let ip: Ipv4Addr = token.parse().map_err(|_| format!("Некорректный IP-адрес: {}", token))?;
(u32::from(ip), u32::from(ip))
};
if end - start > MAX_TOTAL as u32 {
return Err(format!("Диапазон слишком велик (максимум {} адресов)", MAX_TOTAL));
}
for v in start..=end {
let ip = Ipv4Addr::from(v);
let last = ip.octets()[3];
if start != end && (last == 0 || last == 255) {
continue;
}
if !is_lan_v4(ip) {
return Err(format!("Сканирование разрешено только в локальных сетях: {}", ip));
}
set.insert(v);
if set.len() > MAX_TOTAL {
return Err(format!("Слишком много адресов (максимум {})", MAX_TOTAL));
}
}
}
let result: Vec<Ipv4Addr> = set
.into_iter()
.map(Ipv4Addr::from)
.filter(|ip| Some(*ip) != mine && !ip.is_loopback() && !ip.is_unspecified())
.collect();
if result.is_empty() {
return Err("Диапазон не содержит доступных для сканирования IP-адресов".into());
}
Ok((effective, result))
}
+500
View File
@@ -0,0 +1,500 @@
use crate::models::*;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use socket2::{Domain, Protocol, Socket, Type};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
sync::{atomic::Ordering, Arc},
time::{Duration, Instant},
};
use tokio::{net::UdpSocket, sync::Semaphore};
const MAX_PROBE_BODY: usize = 8 * 1024;
pub struct ProbeInfo {
pub fp: String,
pub name: String,
pub trusts_you: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LanIf {
pub ip: Ipv4Addr,
pub netmask: Ipv4Addr,
pub broadcast: Ipv4Addr,
}
// ───────────────────────── Интерфейсы ─────────────────────────
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")
}
fn detect_lan_ip_udp() -> Option<Ipv4Addr> {
if let Ok(s) = StdUdpSocket::bind("0.0.0.0:0") {
if s.connect("192.0.2.1:9").is_ok() {
if let Ok(SocketAddr::V4(a)) = s.local_addr() {
if is_lan_v4(*a.ip()) {
return Some(*a.ip());
}
}
}
}
None
}
/// Все локальные (private/link-local/CGNAT) IPv4-интерфейсы с реальными масками подсети.
/// На Android при блокировке getifaddrs() через SELinux используется fallback через UDP-маршрут.
pub fn lan_interfaces() -> Vec<LanIf> {
let mut list: Vec<LanIf> = if_addrs::get_if_addrs()
.unwrap_or_default()
.into_iter()
.filter_map(|i| match &i.addr {
if_addrs::IfAddr::V4(v4) if !i.is_loopback() && is_lan_v4(v4.ip) => {
let mask_u32 = u32::from(v4.netmask);
let netmask = if mask_u32 != 0 && mask_u32 != u32::MAX {
v4.netmask
} else {
Ipv4Addr::new(255, 255, 255, 0)
};
let broadcast = v4.broadcast.unwrap_or_else(|| {
Ipv4Addr::from(u32::from(v4.ip) | !u32::from(netmask))
});
Some(LanIf {
ip: v4.ip,
netmask,
broadcast,
})
}
_ => None,
})
.collect();
if list.is_empty() {
if let Some(ip) = detect_lan_ip_udp() {
let o = ip.octets();
list.push(LanIf {
ip,
netmask: Ipv4Addr::new(255, 255, 255, 0),
broadcast: Ipv4Addr::new(o[0], o[1], o[2], 255),
});
}
}
list
}
/// Адрес основного локального интерфейса.
pub fn detect_lan_ip() -> Option<Ipv4Addr> {
if let Some(ip) = detect_lan_ip_udp() {
return Some(ip);
}
let ifs = if_addrs::get_if_addrs().unwrap_or_default();
ifs.iter()
.filter(|i| !i.is_loopback() && !is_virtual_if(&i.name))
.find_map(|i| match &i.addr {
if_addrs::IfAddr::V4(v4) if is_lan_v4(v4.ip) => Some(v4.ip),
_ => None,
})
.or_else(|| lan_interfaces().first().map(|i| i.ip))
}
/// Сортирует список IP пира так, чтобы адреса из наших текущих подсетей проверялись первыми.
pub fn prioritize_ips(ips: &[IpAddr]) -> Vec<IpAddr> {
let my_ifs = lan_interfaces();
let in_local_subnet = |ip: &IpAddr| match ip {
IpAddr::V4(v4) => {
let target = u32::from(*v4);
my_ifs.iter().any(|iff| {
let mask = u32::from(iff.netmask);
(target & mask) == (u32::from(iff.ip) & mask)
})
}
IpAddr::V6(_) => false,
};
let mut out = ips.to_vec();
out.sort_by_key(|ip| !in_local_subnet(ip));
out
}
// ───────────────────────── Probe ─────────────────────────
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 mut req = state
.probe_client
.get(&url)
.header("x-node-name", pct_encode(&state.device_name))
.header("x-node-port", state.p2p_port.to_string());
if let Some(t) = &trust_hdr {
req = req.header("x-node-trusts", t);
}
let mut resp = req.send().await.map_err(|_| "узел недоступен".to_string())?;
if !resp.status().is_success() {
return Err(format!("узел вернул код {}", resp.status()));
}
let fp = resp
.extensions()
.get::<reqwest::tls::TlsInfo>()
.and_then(|t| t.peer_certificate())
.map(fingerprint)
.ok_or_else(|| "не удалось получить сертификат узла".to_string())?;
if resp.content_length().map_or(false, |l| l as usize > MAX_PROBE_BODY) {
return Err("слишком большой ответ".into());
}
let mut buf: Vec<u8> = Vec::with_capacity(256);
while let Some(chunk) = resp.chunk().await.map_err(|_| "ошибка чтения ответа".to_string())? {
buf.extend_from_slice(&chunk);
if buf.len() > MAX_PROBE_BODY {
return Err("слишком большой ответ".into());
}
}
let v: serde_json::Value = serde_json::from_slice(&buf).map_err(|_| "некорректный ответ".to_string())?;
Ok(ProbeInfo {
fp,
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_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
probe_and_register_hint(state, ip, port, None).await
}
pub async fn probe_and_register_hint(
state: &AppState,
ip: IpAddr,
port: u16,
hint_fp: Option<&str>,
) -> Result<ProbeInfo, String> {
let ip_s = ip.to_string();
let had_hint_or_ip = hint_fp.is_some()
|| state
.peers
.read()
.values()
.any(|p| p.ips.iter().any(|x| x == &ip_s));
let info = probe_with_hint(state, ip, port, hint_fp).await?;
if info.fp == state.my_fingerprint {
return Err("это ваше собственное устройство".into());
}
state.upsert_peer(&info.name, ip, port, &info.fp);
state.set_remote_trust(&info.fp, info.trusts_you);
// Если сканер/ручной ввод нашёл доверенный узел на новом IP без hint_fp, сразу сообщаем ему наш статус доверия
if !had_hint_or_ip && state.trusted_name(&info.fp).is_some() {
if let Ok(info2) = probe_with_hint(state, ip, port, Some(&info.fp)).await {
state.set_remote_trust(&info.fp, info2.trusts_you);
}
}
Ok(info)
}
// ───────────────────────── Ping и синхронизация доверия ─────────────────────────
/// `force = true`: ручной опрос (дожидается замка, сбрасывает `fail_count` и опрашивает всех).
/// `force = false`: фоновый автоопрос (пропускает устройства, не ответившие 5 раз подряд).
pub async fn ping_all_peers(state: &Arc<AppState>, force: bool) {
if force {
state.reset_all_ping_failures();
}
let _guard = if force {
state.ping_lock.lock().await
} else {
match state.ping_lock.try_lock() {
Ok(g) => g,
Err(_) => return,
}
};
if let Some(ip) = detect_lan_ip() {
state.update_my_ip(ip);
}
let targets: Vec<(String, Vec<String>, u16)> = state
.peers
.read()
.values()
.filter(|p| !p.ips.is_empty() && (force || p.fail_count < MAX_PING_FAILS))
.map(|p| (p.id.clone(), p.ips.clone(), p.port))
.collect();
futures_util::stream::iter(targets)
.for_each_concurrent(8, |(id, ips, port)| async move {
let _ = ping_one(state, &id, ips, port).await;
})
.await;
state.last_ping.store(now_sec(), Ordering::Relaxed);
}
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) -> bool {
let parsed: Vec<IpAddr> = ips.iter().filter_map(|s| s.parse().ok()).collect();
for ip in prioritize_ips(&parsed) {
if let Ok(info) = probe_with_hint(state, ip, port, Some(id)).await {
if info.fp == id {
state.upsert_peer(&info.name, ip, port, id);
state.set_remote_trust(id, info.trusts_you);
return true;
}
}
}
let now = now_sec();
let mut stopped_name: Option<String> = None;
{
let mut peers = state.peers.write();
if let Some(p) = peers.get_mut(id) {
p.last_ping = now;
p.online = false;
if p.fail_count < MAX_PING_FAILS {
p.fail_count += 1;
if p.fail_count == MAX_PING_FAILS {
stopped_name = Some(p.name.clone());
}
}
}
}
if let Some(name) = stopped_name {
state.add_log(format!(
"Устройство '{}' не ответило {} раз подряд — переведено в offline, автоопрос остановлен",
name, MAX_PING_FAILS
));
}
false
}
/// Ручной пинг одного конкретного пира (сбрасывает его `fail_count`, давая ещё 5 попыток).
pub async fn ping_peer_by_id(state: &AppState, id: &str) -> Result<bool, String> {
let (ips, port) = {
let mut peers = state.peers.write();
let p = peers.get_mut(id).ok_or_else(|| "Устройство не найдено".to_string())?;
if p.ips.is_empty() {
return Err("У устройства нет известных IP-адресов".into());
}
p.fail_count = 0;
(p.ips.clone(), p.port)
};
let ok = ping_one(state, id, ips, port).await;
state.last_ping.store(now_sec(), Ordering::Relaxed);
Ok(ok)
}
/// Мгновенно отправляет обновлённый статус доверия пиру и обновляет его `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;
};
let _ = 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) {
state.reset_all_ping_failures();
let st = &state;
futures_util::stream::iter(ips)
.for_each_concurrent(48, |ip| async move {
let addr = SocketAddr::new(IpAddr::V4(ip), port);
let open = matches!(
tokio::time::timeout(Duration::from_millis(500), tokio::net::TcpStream::connect(addr)).await,
Ok(Ok(_))
);
let found = open && probe_and_register(st, IpAddr::V4(ip), port).await.is_ok();
let mut s = st.scan_status.write();
s.scanned += 1;
if found {
s.found += 1;
}
})
.await;
}
// ───────────────────────── UDP-обнаружение (без mDNS) ─────────────────────────
#[derive(Serialize, Deserialize)]
struct Beacon {
v: u8,
port: u16,
fp: String,
}
fn broadcast_targets() -> Vec<Ipv4Addr> {
let mut targets: Vec<Ipv4Addr> = lan_interfaces().into_iter().map(|i| i.broadcast).collect();
targets.push(Ipv4Addr::BROADCAST);
targets.sort();
targets.dedup();
targets
}
fn bind_discovery_socket() -> std::io::Result<UdpSocket> {
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
socket.set_reuse_address(true)?;
socket.set_broadcast(true)?;
socket.set_nonblocking(true)?;
let addr: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), DISCOVERY_PORT);
socket.bind(&addr.into())?;
UdpSocket::from_std(socket.into())
}
pub fn start_discovery(state: Arc<AppState>) {
let serving = state.log_stdout;
let tx = state.clone();
tokio::spawn(async move {
let sock = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
tx.add_log(format!("Ошибка UDP-сокета отправки: {}", e));
return;
}
};
let _ = sock.set_broadcast(true);
let pkt = serde_json::to_vec(&Beacon {
v: 1,
port: if serving { tx.p2p_port } else { 0 },
fp: tx.my_fingerprint.clone(),
})
.unwrap_or_default();
if !serving {
for t in broadcast_targets() {
let _ = sock.send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT)).await;
}
return;
}
let mut last_ifs: Vec<LanIf> = Vec::new();
loop {
let cur_ifs = lan_interfaces();
if cur_ifs != last_ifs {
if let Some(ip) = detect_lan_ip() {
if tx.update_my_ip(ip) {
tx.add_log(format!("Смена сети: активный адрес {}", ip));
}
}
tx.probe_cooldown.lock().clear();
tx.reset_all_ping_failures();
tx.ping_notify.notify_one();
last_ifs = cur_ifs;
}
for t in broadcast_targets() {
let _ = sock
.send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT))
.await;
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
});
tokio::spawn(async move {
let socket = match bind_discovery_socket() {
Ok(s) => s,
Err(e) => {
state.add_log(format!(
"UDP-порт {} недоступен ({}). Автообнаружение отключено, используйте сканер.",
DISCOVERY_PORT, e
));
return;
}
};
let reply_pkt = serde_json::to_vec(&Beacon {
v: 1,
port: state.p2p_port,
fp: state.my_fingerprint.clone(),
})
.unwrap_or_default();
let mut last_reply = Instant::now() - Duration::from_secs(2);
let slots = Arc::new(Semaphore::new(8));
let mut buf = [0u8; 512];
loop {
let (len, remote) = match socket.recv_from(&mut buf).await {
Ok(v) => v,
Err(_) => {
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let IpAddr::V4(ip4) = remote.ip() else { continue };
if !is_lan_v4(ip4) {
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;
}
if b.port == 0 {
if serving && last_reply.elapsed() >= Duration::from_millis(500) {
last_reply = Instant::now();
for t in broadcast_targets() {
let _ = socket
.send_to(&reply_pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT))
.await;
}
}
continue;
}
let ip = IpAddr::V4(ip4);
if state.is_fresh(&b.fp, ip, b.port) || !state.probe_allowed(ip, b.port) {
continue;
}
let Ok(permit) = slots.clone().try_acquire_owned() else { continue };
let st = state.clone();
let port = b.port;
let fp_hint = b.fp;
tokio::spawn(async move {
let _permit = permit;
let _ = probe_and_register_hint(&st, ip, port, Some(&fp_hint)).await;
});
}
});
}
+307
View File
@@ -0,0 +1,307 @@
use crate::{models::*, storage};
use axum::{
body::Body,
extract::{DefaultBodyLimit, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Extension, Json, Router,
};
use futures_util::StreamExt;
use hyper_util::{
rt::{TokioIo, TokioTimer},
service::TowerToHyperService,
};
use std::{
net::SocketAddr,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
use tokio::{io::AsyncWriteExt, net::TcpListener, sync::Semaphore};
use tokio_rustls::TlsAcceptor;
/// Личность собеседника из TLS-рукопожатия (а не из заголовков!).
#[derive(Clone)]
pub struct PeerCtx {
pub fp: String,
pub addr: SocketAddr,
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/p2p/info", get(p2p_info))
.route(
"/p2p/text",
post(p2p_receive_text).layer(DefaultBodyLimit::max(512 * 1024)),
)
.route("/p2p/media", post(p2p_receive_media))
.with_state(state)
}
pub async fn serve(listener: TcpListener, acceptor: TlsAcceptor, app: Router) {
let conn_slots = Arc::new(Semaphore::new(128));
loop {
let (tcp, addr) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
eprintln!("[ERR] accept: {}", e);
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let Ok(permit) = conn_slots.clone().try_acquire_owned() else {
continue;
};
let acceptor = acceptor.clone();
let app = app.clone();
tokio::spawn(async move {
let _permit = permit;
let _ = tcp.set_nodelay(true);
let tls = match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(tcp)).await {
Ok(Ok(t)) => t,
_ => return,
};
let fp = match tls.get_ref().1.peer_certificates().and_then(|c| c.first()) {
Some(c) => fingerprint(c.as_ref()),
None => return,
};
let svc = app.layer(Extension(PeerCtx { fp, addr }));
let mut http = hyper::server::conn::http1::Builder::new();
http.timer(TokioTimer::new());
http.header_read_timeout(Duration::from_secs(10));
let _ = http
.serve_connection(TokioIo::new(tls), TowerToHyperService::new(svc))
.await;
});
}
}
// ───────────────────────── Handlers ─────────────────────────
async fn p2p_info(
State(state): State<Arc<AppState>>,
Extension(ctx): Extension<PeerCtx>,
headers: HeaderMap,
) -> Json<serde_json::Value> {
if let Some(port) = hdr(&headers, "x-node-port").and_then(|v| v.parse::<u16>().ok()) {
let name = hdr(&headers, "x-node-name").map(pct_decode).unwrap_or_default();
state.upsert_peer(&name, ctx.addr.ip(), port, &ctx.fp);
}
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(
State(state): State<Arc<AppState>>,
Extension(ctx): Extension<PeerCtx>,
Json(payload): Json<IncomingTextPayload>,
) -> Result<StatusCode, AppError> {
let sender = state
.trusted_name(&ctx.fp)
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
state.touch_peer_ip(&ctx.fp, ctx.addr.ip());
if payload.text.trim().is_empty() {
return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст"));
}
if payload.text.len() > MAX_TEXT_BYTES {
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст слишком большой"));
}
state.add_log(format!("Получен текст в ОЗУ от '{}'", sender));
if state.headless {
println!("[ТЕКСТ от {}]\n{}", sender, strip_ctrl(&payload.text));
}
let mut q = state.ram_texts.write();
if q.len() >= MAX_RAM_TEXTS {
q.pop_back();
}
q.push_front(TextItem {
sender,
text: payload.text,
timestamp: now_sec(),
});
Ok(StatusCode::OK)
}
struct TempGuard {
path: PathBuf,
armed: bool,
}
impl Drop for TempGuard {
fn drop(&mut self) {
if self.armed {
let _ = std::fs::remove_file(&self.path);
}
}
}
fn fail_rx(state: &Arc<AppState>, id: &str, e: AppError) -> AppError {
state.fail_transfer(id, e.1.clone());
remove_transfer_later(state, id.to_string(), 10);
e
}
async fn p2p_receive_media(
State(state): State<Arc<AppState>>,
Extension(ctx): Extension<PeerCtx>,
headers: HeaderMap,
body: Body,
) -> Result<StatusCode, AppError> {
let sender = state
.trusted_name(&ctx.fp)
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
state.touch_peer_ip(&ctx.fp, ctx.addr.ip());
let total: u64 = hdr(&headers, "x-total-size")
.and_then(|v| v.parse().ok())
.ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан размер файла"))?;
if total == 0 || total > MAX_FILE_SIZE {
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Недопустимый размер файла"));
}
let _slot = state
.incoming_slots
.clone()
.try_acquire_owned()
.map_err(|_| AppError::new(StatusCode::TOO_MANY_REQUESTS, "Слишком много входящих передач"))?;
let disk_used: u64 = state.disk_media.read().iter().map(|m| m.size).sum();
let in_flight: u64 = state
.transfers
.read()
.iter()
.filter(|t| t.direction == Direction::In && !t.done)
.map(|t| t.total_bytes)
.sum();
if disk_used.saturating_add(in_flight).saturating_add(total) > MAX_MEDIA_QUOTA {
return Err(AppError::new(StatusCode::INSUFFICIENT_STORAGE, "Превышена квота хранилища"));
}
let safe_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")));
tokio::fs::create_dir_all(&state.media_dir)
.await
.map_err(|e| AppError::internal("mkdir", e))?;
let tmp = state.media_dir.join(format!(".part_{}", random_hex(8)));
let mut guard = TempGuard {
path: tmp.clone(),
armed: true,
};
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
.await
.map_err(|e| AppError::internal("open tmp", e))?;
let id = format!("in_{}", random_hex(6));
state.add_transfer(TransferProgress {
id: id.clone(),
filename: safe_name.clone(),
peer_name: sender.clone(),
direction: Direction::In,
transferred_bytes: 0,
total_bytes: total,
percent: 0,
done: false,
error: None,
updated_at: now_sec(),
});
let mut stream = std::pin::pin!(body.into_data_stream());
let mut got: u64 = 0;
let mut last = Instant::now();
loop {
let next = match tokio::time::timeout(Duration::from_secs(30), stream.next()).await {
Ok(n) => n,
Err(_) => {
return Err(fail_rx(
&state,
&id,
AppError::new(StatusCode::REQUEST_TIMEOUT, "Таймаут передачи"),
))
}
};
let Some(chunk) = next else { break };
let chunk = match chunk {
Ok(c) => c,
Err(_) => {
return Err(fail_rx(
&state,
&id,
AppError::new(StatusCode::BAD_REQUEST, "Обрыв передачи"),
))
}
};
got += chunk.len() as u64;
if got > total {
return Err(fail_rx(
&state,
&id,
AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Данных больше заявленного"),
));
}
if let Err(e) = file.write_all(&chunk).await {
return Err(fail_rx(&state, &id, AppError::internal("write", e)));
}
if last.elapsed() >= Duration::from_millis(100) {
state.update_transfer_bytes(&id, got);
last = Instant::now();
}
}
if got != total {
return Err(fail_rx(
&state,
&id,
AppError::new(StatusCode::BAD_REQUEST, "Размер не совпал с заявленным"),
));
}
if let Err(e) = file.sync_all().await {
return Err(fail_rx(&state, &id, AppError::internal("sync", e)));
}
drop(file);
let candidate = state.media_dir.join(&safe_name);
let (final_name, final_path) = if tokio::fs::try_exists(&candidate).await.unwrap_or(true) {
let name = format!("{}_{}_{}", now_sec(), random_hex(3), safe_name);
let path = state.media_dir.join(&name);
(name, path)
} else {
(safe_name.clone(), candidate)
};
if let Err(e) = tokio::fs::rename(&tmp, &final_path).await {
return Err(fail_rx(&state, &id, AppError::internal("rename", e)));
}
guard.armed = false;
{
let mut list = state.disk_media.write();
list.push_front(MediaItem {
sender: sender.clone(),
filename: final_name.clone(),
saved_path: final_path.display().to_string(),
mime: storage::mime_from_name(&safe_name).into(),
size: total,
timestamp: now_sec(),
previewable: storage::is_previewable(&safe_name),
risky: storage::is_risky(&safe_name),
});
list.truncate(MAX_MEDIA_LIST);
}
state.finish_transfer(&id);
remove_transfer_later(&state, id, 4);
state.add_log(format!("Принят файл '{}' ({} байт) от '{}'", final_name, total, sender));
Ok(StatusCode::OK)
}
+496
View File
@@ -0,0 +1,496 @@
'use strict';
const $ = (id) => document.getElementById(id);
let statusTimer = null;
let lastData = null;
let knownPeersKey = '';
let lastSubnet = '';
let pendingTrustId = null;
function esc(v) {
return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[s]));
}
function showStatus(msg, isError = false) {
const el = $('status');
clearTimeout(statusTimer);
el.style.display = 'block';
el.style.background = isError ? 'rgba(239,68,68,.2)' : 'rgba(16,185,129,.2)';
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
el.textContent = msg;
statusTimer = setTimeout(() => { el.style.display = 'none'; }, 4500);
}
function formatBytes(b) {
if (b < 1024) return `${b} Б`;
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`;
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(2)} МБ`;
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
}
function formatTimeAgo(ts, nowSec) {
if (!ts) return 'никогда';
const now = nowSec || Math.floor(Date.now() / 1000);
const diff = Math.max(0, now - ts);
const dt = new Date(ts * 1000);
const timeStr = dt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (diff < 3) return `только что (${timeStr})`;
if (diff < 60) return `${diff} с назад (${timeStr})`;
if (diff < 3600) return `${Math.floor(diff / 60)} мин назад (${timeStr})`;
if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад (${timeStr})`;
const dateStr = dt.toLocaleDateString('ru-RU');
return `${dateStr} ${timeStr}`;
}
// Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки)
function setHtml(el, html) {
if (el._h !== html) { el._h = html; el.innerHTML = html; }
}
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: 'same-origin', ...opts });
if (!res.ok) throw new Error((await res.text()) || `Ошибка ${res.status}`);
return res;
}
function postJson(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
// ───────────── вкладки ─────────────
function switchTab(tab) {
const isMain = tab === 'main';
$('view-main').style.display = isMain ? 'grid' : 'none';
$('view-trusted').style.display = isMain ? 'none' : 'block';
$('tab-btn-main').classList.toggle('active', isMain);
$('tab-btn-trusted').classList.toggle('active', !isMain);
}
// ───────────── рендер ─────────────
function renderHeader(d) {
$('my-name').textContent = `Устройство: ${d.device_name}`;
$('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
$('my-pin').textContent = d.my_pin;
$('media-dir').textContent = d.media_dir;
const pingTxt = `Последний пинг: ${formatTimeAgo(d.last_ping, d.now)}`;
$('last-ping-info').textContent = pingTxt;
$('trusted-last-ping').textContent = pingTxt;
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 subnet = `${p[0]}.${p[1]}.${p[2]}`;
if (subnet !== lastSubnet) {
lastSubnet = subnet;
$('scan-range-input').value = `${subnet}.1-254`;
}
}
}
function renderScan(scan) {
const box = $('scan-progress-box');
if (scan && (scan.active || scan.total > 0)) {
box.style.display = 'block';
const pct = scan.total > 0 ? Math.round((scan.scanned / scan.total) * 100) : 0;
$('scan-label').textContent = scan.active
? `Сканирование ${scan.range}… (найдено: ${scan.found})`
: `Сканирование ${scan.range} завершено (найдено: ${scan.found})`;
$('scan-numbers').textContent = `${scan.scanned} / ${scan.total} (${pct}%)`;
$('scan-bar-fill').style.width = `${pct}%`;
$('btn-scan-range').disabled = scan.active;
$('btn-scan-all').disabled = scan.active;
$('btn-scan-all-trusted').disabled = scan.active;
}
}
function renderTransfers(list) {
setHtml($('transfers-list'), (list || []).map((t) => {
const dir = t.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Приём от';
const status = t.error ? '✗ Ошибка' : t.done ? '✓ Завершено' : `${t.percent}%`;
const note = t.error
? `<span style="color:#f87171;">${esc(t.error)}</span>`
: (t.done ? 'Готово' : 'Передача по TLS…');
return `
<div class="progress-wrap">
<div style="display:flex;justify-content:space-between;font-size:.83rem;font-weight:600;gap:8px;">
<span>${dir} ${esc(t.peer_name)}: <span style="color:#60a5fa;">${esc(t.filename)}</span></span>
<span>${esc(status)}</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${Number(t.percent)}%;"></div></div>
<div class="small muted" style="display:flex;justify-content:space-between;gap:8px;">
<span>${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)}</span>
<span>${note}</span>
</div>
</div>`;
}).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, nowSec, maxFails) {
const activePeers = peers.filter((p) => p.online);
const trustedPeers = peers.filter((p) => p.verified);
$('badge-active').textContent = String(activePeers.length);
$('badge-trusted').textContent = String(trustedPeers.length);
// уведомление о входящем запросе на сверку (один раз на запрос)
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);
// 1) Главная вкладка: ТОЛЬКО активные (online) пиры
const mainBox = $('peers-list');
if (!activePeers.length) {
setHtml(mainBox, '<div class="muted" style="font-size:.9rem;">Активных устройств сейчас нет. Нажмите «Вся подсеть», «Опросить всех известных» или перейдите во вкладку «Заверенные устройства».</div>');
} else {
setHtml(mainBox, activePeers.map((p) => {
const t = trustInfo(p);
const seenLabel = `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`;
const pingLabel = formatTimeAgo(p.last_ping, nowSec);
return `
<div class="peer-item${t.req ? ' request' : ''}">
<div style="min-width:0;">
<div style="font-weight:600;">
<span class="dot dot-on"></span>${esc(p.name)}
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
</div>
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
Статус: <span style="color:${t.color};">${esc(t.text)}</span><br>
Был онлайн: <span style="color:#cbd5e1;">${esc(seenLabel)}</span> | Пинг: <span style="color:#cbd5e1;">${esc(pingLabel)}</span>
</div>
</div>
<div class="peer-actions">
<button class="btn-sm" data-action="ping-one" data-id="${esc(p.id)}">Пинг</button>
<button class="btn-sm ${t.btnCls}" data-action="trust" data-id="${esc(p.id)}" data-trust="${t.trust}">
${esc(t.btn)}
</button>
</div>
</div>`;
}).join(''));
}
// 2) Вкладка «Заверенные устройства»: все verified пиры (и online, и offline)
const trustedBox = $('trusted-peers-list');
if (!trustedPeers.length) {
setHtml(trustedBox, '<div class="muted" style="font-size:.9rem;">У вас пока нет заверенных устройств. Найдите устройство на главной вкладке и нажмите «Заверить».</div>');
} else {
setHtml(trustedBox, trustedPeers.map((p) => {
const t = trustInfo(p);
const seenLabel = p.online
? `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`
: formatTimeAgo(p.last_seen, nowSec);
const pingLabel = formatTimeAgo(p.last_ping, nowSec);
const limit = maxFails || 5;
const failNote = p.online
? '<span style="color:#34d399;">на связи (0 ошибок)</span>'
: p.fail_count >= limit
? `<span style="color:#f87171;">не ответил ${p.fail_count}/${limit} раз — автоопрос остановлен (нажмите «Пинг» или «Сканировать»)</span>`
: `<span style="color:#fbbf24;">оффлайн (попыток пинга: ${p.fail_count}/${limit})</span>`;
return `
<div class="peer-item">
<div style="min-width:0;">
<div style="font-weight:600;">
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
</div>
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b> | ID: ${esc(p.fingerprint.slice(0, 8).toUpperCase())}<br>
Доверие: <span style="color:${t.color};">${esc(t.text)}</span><br>
Был онлайн: <span style="color:#cbd5e1;">${esc(seenLabel)}</span> | Посл. пинг: <span style="color:#cbd5e1;">${esc(pingLabel)}</span><br>
Опрос: ${failNote}
</div>
</div>
<div class="peer-actions">
<button class="btn-sm" data-action="ping-one" data-id="${esc(p.id)}">Пинг</button>
<button class="btn-sm btn-red" data-action="trust" data-id="${esc(p.id)}" data-trust="0">Отозвать</button>
</div>
</div>`;
}).join(''));
}
// Выпадающий список получателей: только активные (online) устройства
const key = activePeers.map((p) => `${p.id}:${p.verified}:${p.trusts_me}:${p.ips[0] || ''}:${p.name}`).join('|');
if (key !== knownPeersKey) {
knownPeersKey = key;
const sel = $('target-peer');
const prev = sel.value;
sel.innerHTML = '<option value="">-- Выберите активное устройство --</option>' + activePeers.map((p) => {
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;
}
}
function renderTexts(texts) {
const box = $('ram-texts');
if (!texts.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Буфер ОЗУ пуст</div>');
setHtml(box, texts.map((t, i) => `
<div class="msg-item">
<div class="msg-meta">
<span>От: <b>${esc(t.sender)}</b></span>
<button class="btn-sm" data-action="copy" data-idx="${i}">Скопировать</button>
</div>
<div class="msg-text">${esc(t.text)}</div>
</div>`).join(''));
}
function renderMedia(media) {
const box = $('disk-media');
if (!media.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Нет сохранённых файлов</div>');
setHtml(box, media.map((m) => {
const url = `/ui/files/${encodeURIComponent(m.filename)}`;
return `
<div class="msg-item">
<div class="msg-meta"><span>От: <b>${esc(m.sender)}</b></span><span>${formatBytes(m.size)}</span></div>
<div style="font-weight:600;margin:2px 0;word-break:break-all;">
<a href="${url}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:none;">${esc(m.filename)} ↗</a>
</div>
${m.risky ? '<div class="warn">⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.</div>' : ''}
${m.previewable ? `<img src="${url}" class="preview-img" alt="">` : ''}
</div>`;
}).join(''));
}
async function fetchState() {
const d = await (await api('/ui/state')).json();
lastData = d;
renderHeader(d);
renderScan(d.scan);
renderTransfers(d.transfers);
renderPeers(d.peers, d.now, d.max_ping_fails);
renderTexts(d.texts);
renderMedia(d.media);
}
// ───────────── действия ─────────────
async function guarded(fn) {
try { await fn(); } catch (e) { showStatus(e.message || String(e), true); }
}
function scanIpRange(full) {
return guarded(async () => {
const range = full ? 'all' : $('scan-range-input').value.trim();
if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true);
await postJson('/ui/scan-range', { range });
showStatus('Сканирование запущено (автоопрос всех пиров возобновлён)…');
fetchState().catch(() => {});
});
}
function addManualPeer() {
return guarded(async () => {
const raw = $('manual-ip-input').value.trim();
if (!raw) return showStatus('Введите IP-адрес устройства', true);
let ip = raw, port = null;
const i = raw.lastIndexOf(':');
if (i > 0) { ip = raw.slice(0, i); port = parseInt(raw.slice(i + 1), 10) || null; }
showStatus(`Связь с узлом ${ip}…`);
await postJson('/ui/manual-peer', port ? { ip, port } : { ip });
$('manual-ip-input').value = '';
showStatus(`Узел ${ip} найден. Сверьте код и заверьте устройство.`);
fetchState().catch(() => {});
});
}
function saveSettings() {
return guarded(async () => {
const el = $('ping-interval-input');
const v = parseInt(el.value, 10);
const min = Number(el.min) || 10, max = Number(el.max) || 30;
if (!Number.isInteger(v) || v < min || v > max) return showStatus(`Интервал должен быть от ${min} до ${max} секунд`, true);
await postJson('/ui/settings', { ping_interval_sec: v });
showStatus(`Опрос устройств каждые ${v} с`);
el.blur();
fetchState().catch(() => {});
});
}
function pingPeers() {
return guarded(async () => {
$('btn-ping').disabled = true;
$('btn-ping-trusted').disabled = true;
try {
await api('/ui/ping', { method: 'POST' });
showStatus('Ручной опрос выполнен (счётчики попыток сброшены)');
await fetchState();
} finally {
$('btn-ping').disabled = false;
$('btn-ping-trusted').disabled = false;
}
});
}
function pingOnePeer(id, btn) {
return guarded(async () => {
if (btn) btn.disabled = true;
try {
const res = await (await postJson('/ui/ping-peer', { peer_id: id })).json();
const peer = lastData && lastData.peers.find((p) => p.id === id);
const name = peer ? peer.name : id.slice(0, 8);
if (res.online) {
showStatus(`✓ Устройство «${name}» онлайн!`);
} else {
showStatus(`Устройство «${name}» не ответило (автоопрос возобновлён на 5 попыток)`, true);
}
await fetchState();
} finally {
if (btn) btn.disabled = false;
}
});
}
function setTrust(id, trust) {
return guarded(async () => {
await postJson('/ui/trust', { peer_id: id, trust });
showStatus(trust ? 'Устройство заверено' : 'Доверие отозвано');
fetchState().catch(() => {});
});
}
function sendText() {
return guarded(async () => {
const peer_id = $('target-peer').value;
const input = $('text-input');
const text = input.value.trim();
if (!peer_id) return showStatus('Выберите активное устройство-получатель!', true);
if (!text) return showStatus('Введите текст для отправки!', true);
if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true);
await postJson('/ui/send-text', { peer_id, text });
input.value = '';
showStatus('Текст доставлен в ОЗУ получателя');
});
}
async function sendMedia() {
const peer_id = $('target-peer').value;
const fileInput = $('media-input');
const btn = $('send-media-btn');
if (!peer_id) return showStatus('Выберите активное устройство-получатель!', true);
if (!fileInput.files.length) return showStatus('Выберите файл!', true);
const file = fileInput.files[0];
btn.disabled = true;
try {
await api('/ui/send-media', {
method: 'POST',
headers: {
'X-Target-Id': peer_id,
'X-Filename': encodeURIComponent(file.name),
'Content-Type': 'application/octet-stream',
},
body: file,
});
fileInput.value = '';
showStatus(`Файл «${file.name}» передан`);
} catch (e) {
showStatus(e.message || String(e), true);
} finally {
btn.disabled = false;
fetchState().catch(() => {});
}
}
function clearRam() {
return guarded(async () => {
await api('/ui/clear-ram', { method: 'POST' });
showStatus('Буфер текстов в ОЗУ очищен');
fetchState().catch(() => {});
});
}
function handlePeerListClick(e) {
const pingBtn = e.target.closest('button[data-action="ping-one"]');
if (pingBtn) {
pingOnePeer(pingBtn.dataset.id, pingBtn);
return;
}
const btn = e.target.closest('button[data-action="trust"]');
if (!btn || !lastData) return;
const id = btn.dataset.id;
if (btn.dataset.trust === '0') return setTrust(id, false);
const peer = lastData.peers.find((p) => p.id === id);
if (!peer) return;
pendingTrustId = id;
$('td-name').textContent = peer.name;
$('td-code').textContent = peer.pin_code;
$('trust-dialog').showModal();
}
// ───────────── события (без inline-обработчиков: CSP script-src 'self') ─────────────
$('tab-btn-main').addEventListener('click', () => switchTab('main'));
$('tab-btn-trusted').addEventListener('click', () => switchTab('trusted'));
$('btn-ping').addEventListener('click', pingPeers);
$('btn-ping-trusted').addEventListener('click', pingPeers);
$('btn-save-settings').addEventListener('click', saveSettings);
$('ping-interval-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') saveSettings(); });
$('btn-manual').addEventListener('click', addManualPeer);
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
$('btn-scan-all-trusted').addEventListener('click', () => {
switchTab('main');
scanIpRange(true);
});
$('btn-send-text').addEventListener('click', sendText);
$('send-media-btn').addEventListener('click', sendMedia);
$('btn-clear-ram').addEventListener('click', clearRam);
$('text-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendText(); }
});
$('peers-list').addEventListener('click', handlePeerListClick);
$('trusted-peers-list').addEventListener('click', handlePeerListClick);
$('td-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); });
$('td-ok').addEventListener('click', () => {
const id = pendingTrustId;
pendingTrustId = null;
$('trust-dialog').close();
if (id) setTrust(id, true);
});
$('ram-texts').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action="copy"]');
if (!btn || !lastData) return;
const item = lastData.texts[Number(btn.dataset.idx)];
if (!item) return;
navigator.clipboard.writeText(item.text).then(
() => showStatus('Текст скопирован в буфер обмена'),
() => showStatus('Не удалось скопировать', true),
);
});
// ───────────── опрос состояния: следующий запрос только после завершения предыдущего ─────────────
async function poll() {
try {
if (!document.hidden) await fetchState();
$('offline').style.display = 'none';
} catch (_) {
$('offline').style.display = 'block';
}
setTimeout(poll, document.hidden ? 2500 : 700);
}
poll();
+192
View File
@@ -0,0 +1,192 @@
<!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: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 800px) { .grid-2 { grid-template-columns: 1fr; } }
.header { 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.15rem; }
.tabs-bar { display: flex; gap: 10px; background: var(--card); border: 1px solid var(--border); padding: 8px; border-radius: 12px; flex-wrap: wrap; }
.tab-btn { background: transparent; color: var(--muted); border: 1px solid transparent; border-radius: 8px; padding: 9px 16px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,.04); }
.tab-btn.active { background: var(--accent); color: #fff; }
.badge { background: rgba(15,23,42,.55); padding: 2px 8px; border-radius: 999px; font-size: .78rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
h2 { margin: 0; font-size: 1.08rem; display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
.tag-ram { background: rgba(16,185,129,.2); color: #34d399; }
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
input[type="text"], input[type="number"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: .92rem; }
textarea { resize: vertical; min-height: 80px; }
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
button:hover { opacity: .9; }
button:disabled { opacity: .5; cursor: not-allowed; }
.btn-green { background: var(--green); }
.btn-red { background: var(--red); }
.btn-gray { background: #475569; }
.btn-sm { padding: 7px 12px; font-size: .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; }
.peer-actions { display: flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
.msg-item { flex-direction: column; align-items: flex-start; }
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: .8rem; color: var(--muted); }
.msg-text { font-size: .95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
.preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; }
.peer-item.request { border-color: #fb923c; background: rgba(251,146,60,.08); }
.warn { color: #fbbf24; font-size: .8rem; }
.muted { color: var(--muted); }
.small { font-size: .8rem; }
.mono { font-family: monospace; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot-on { background: #10b981; }
.dot-off { background: #64748b; }
.progress-wrap { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
.progress-bar-bg { width: 100%; height: 10px; background: #0b1120; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width .2s ease; }
.sub-box { background: rgba(15,23,42,.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.col { display: flex; flex-direction: column; gap: 8px; }
#status { padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
#offline { padding: 10px 16px; border-radius: 8px; display: none; background: rgba(239,68,68,.25); border: 1px solid var(--red); }
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 12px; padding: 22px; max-width: 460px; }
dialog::backdrop { background: rgba(0,0,0,.65); }
.sas { font-family: monospace; font-size: 1.5rem; font-weight: 700; letter-spacing: 2px; background: #1d4ed8; border-radius: 8px; padding: 12px; text-align: center; margin: 14px 0; word-break: break-all; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<div style="font-size:1.2rem;font-weight:700;" id="my-name">Загрузка...</div>
<div class="small muted" id="my-ip"></div>
</div>
<div>
<span class="small muted" style="margin-right:8px;">ID устройства:</span>
<span class="pin-badge" id="my-pin">--------</span>
</div>
</div>
<div class="tabs-bar">
<button class="tab-btn active" id="tab-btn-main">
<span>Главная (Активные в сети)</span>
<span class="badge" id="badge-active">0</span>
</button>
<button class="tab-btn" id="tab-btn-trusted">
<span>Заверенные устройства</span>
<span class="badge" id="badge-trusted">0</span>
</button>
</div>
<div id="offline">Нет связи с приложением. Проверьте, что оно запущено.</div>
<div id="status"></div>
<!-- ВКЛАДКА 1: ГЛАВНАЯ (ТОЛЬКО АКТИВНЫЕ) -->
<div id="view-main" class="grid-2">
<div class="card">
<h2>
<span>1. Активные устройства в сети</span>
<button class="btn-sm" id="btn-ping">Опросить всех известных</button>
</h2>
<div class="row" style="flex-wrap:nowrap;">
<input type="text" id="manual-ip-input" placeholder="Один IP[:порт] (напр. 192.168.1.55 или 192.168.1.55:8443)">
<button class="btn-sm" style="white-space:nowrap;" id="btn-manual">Найти IP</button>
</div>
<div class="sub-box">
<div class="small muted" style="font-weight:600;">Сканер диапазона IP-адресов (только локальные сети):</div>
<div class="row">
<input type="text" id="scan-range-input" style="flex:1;min-width:190px;" placeholder="Напр. 192.168.1.1-254, 10.0.0.0/24">
<button class="btn-sm" id="btn-scan-range">Сканировать диапазон</button>
<button class="btn-sm btn-green" id="btn-scan-all">Вся подсеть (.1–254)</button>
</div>
<div id="scan-progress-box" style="display:none;">
<div class="small muted" style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span id="scan-label">Сканирование...</span>
<span id="scan-numbers">0 / 0</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" id="scan-bar-fill" style="width:0%;"></div></div>
</div>
</div>
<div class="row" style="align-items:center;justify-content:space-between;">
<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:74px;">
<span class="small muted">сек (10–30)</span>
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
</div>
<div class="small muted" id="last-ping-info">Последний пинг: —</div>
</div>
<div id="peers-list" class="col"></div>
<div class="small muted">На главной показаны только устройства, которые сейчас онлайн. Если устройство не отвечает 5 раз подряд, его автоопрос прекращается (посмотреть все заверенные и пингануть вручную можно во вкладке «Заверенные устройства»).</div>
</div>
<div class="card">
<h2>2. Отправка данных</h2>
<label class="small muted">Активный получатель (должен быть заверен):</label>
<select id="target-peer"><option value="">-- Выберите активное устройство --</option></select>
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка, до 64 КБ)..."></textarea>
<button id="btn-send-text">Отправить текст в ОЗУ</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" id="send-media-btn">Отправить файл на диск получателя</button>
<div id="transfers-list" class="col"></div>
</div>
<div class="card">
<h2>
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
<button class="btn-red btn-sm" id="btn-clear-ram">Очистить ОЗУ</button>
</h2>
<div id="ram-texts" class="col" style="max-height:400px;overflow-y:auto;"></div>
</div>
<div class="card">
<h2>Входящие файлы <span class="tag tag-disk">НА ДИСКЕ</span></h2>
<div class="small muted mono" id="media-dir"></div>
<div id="disk-media" class="col" style="max-height:400px;overflow-y:auto;"></div>
</div>
</div>
<!-- ВКЛАДКА 2: ВСЕ ЗАВЕРЕННЫЕ ПИРЫ -->
<div id="view-trusted" style="display:none;">
<div class="card">
<h2>
<span>Все заверенные устройства (онлайн и оффлайн)</span>
<div class="row">
<button class="btn-sm" id="btn-ping-trusted">Пингануть все заверенные</button>
<button class="btn-sm btn-green" id="btn-scan-all-trusted">Сканировать всю подсеть</button>
</div>
</h2>
<div class="small muted" id="trusted-last-ping">Последний общий пинг: —</div>
<div id="trusted-peers-list" class="col"></div>
<div class="small muted">Если устройство недоступно, узел пробует пингануть его 5 раз. После 5 неудач устройство переводится в offline и больше не опрашивается автоматически, пока вы не нажмёте «Пинг» или «Сканировать всю подсеть».</div>
</div>
</div>
</div>
<dialog id="trust-dialog">
<h3 style="margin:0 0 8px;">Заверить устройство?</h3>
<div>«<b id="td-name"></b>»</div>
<div class="sas" id="td-code"></div>
<div class="small muted">Код должен <b>полностью совпадать</b> с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.<br><br>После вашего подтверждения то же самое будет запрошено на втором устройстве: доверие считается установленным, только когда код подтвердили <b>оба</b>.</div>
<div class="row" style="margin-top:16px;justify-content:flex-end;">
<button class="btn-gray" id="td-cancel">Отмена</button>
<button class="btn-green" id="td-ok">Коды совпадают — заверить</button>
</div>
</dialog>
<script src="/app.js"></script>
</body>
</html>
+327
View File
@@ -0,0 +1,327 @@
use crate::models::MediaItem;
use serde::{Deserialize, Serialize};
use std::{
fs,
io::{self, Write},
path::{Path, PathBuf},
time::UNIX_EPOCH,
};
#[derive(Serialize, Deserialize)]
pub struct TrustedEntry {
pub fp: String,
pub name: String,
#[serde(default)]
pub ips: Vec<String>,
#[serde(default)]
pub port: u16,
#[serde(default)]
pub trusts_me: bool,
#[serde(default)]
pub last_seen: u64,
}
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) {
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
} else if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"))
} else {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
};
let name = if p2p_port == 8443 {
"ramshare".to_string()
} else {
format!("ramshare-{}", p2p_port)
};
base.unwrap_or_else(|| PathBuf::from(".")).join(name)
}
pub struct Dirs {
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());
}
}
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)
}
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();
tmp_name.push(".tmp");
let tmp = path.with_file_name(tmp_name);
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
if private {
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
}
let _ = private;
let res = (|| {
let mut f = opts.open(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
fs::rename(&tmp, path)
})();
if res.is_err() {
let _ = fs::remove_file(&tmp);
}
res
}
pub fn load_trusted(dir: &Path) -> Vec<TrustedEntry> {
let path = dir.join("trusted.json");
match fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
eprintln!("[WARN] trusted.json повреждён ({}), список доверенных пуст", e);
Vec::new()
}),
Err(_) => Vec::new(),
}
}
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<()> {
let json = serde_json::to_vec_pretty(entries).map_err(io::Error::other)?;
write_atomic(&dir.join("trusted.json"), &json, false)
}
fn ext_of(name: &str) -> String {
Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default()
}
pub fn mime_from_name(name: &str) -> &'static str {
match ext_of(name).as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"pdf" => "application/pdf",
"txt" | "md" | "log" => "text/plain",
"mp4" => "video/mp4",
"mp3" => "audio/mpeg",
"zip" => "application/zip",
_ => "application/octet-stream",
}
}
pub fn is_previewable(name: &str) -> bool {
matches!(ext_of(name).as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp")
}
pub fn is_risky(name: &str) -> bool {
matches!(
ext_of(name).as_str(),
"exe" | "bat" | "cmd" | "com" | "scr" | "msi" | "lnk" | "js" | "jse" | "vbs" | "vbe" | "wsf" | "ps1"
| "jar" | "apk" | "sh" | "html" | "htm" | "svg" | "hta" | "dll" | "reg" | "app" | "dmg" | "pif" | "cpl"
)
}
pub fn cleanup_partials(dir: &Path) {
if let Ok(rd) = fs::read_dir(dir) {
for e in rd.flatten() {
if e.file_name().to_string_lossy().starts_with(".part_") {
let _ = fs::remove_file(e.path());
}
}
}
}
pub fn scan_media_dir(dir: &Path) -> Vec<MediaItem> {
let mut items = Vec::new();
if let Ok(rd) = fs::read_dir(dir) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let Ok(meta) = e.metadata() else { continue };
if !meta.is_file() {
continue;
}
let ts = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs());
items.push(MediaItem {
sender: "—".into(),
mime: mime_from_name(&name).into(),
previewable: is_previewable(&name),
risky: is_risky(&name),
saved_path: e.path().display().to_string(),
size: meta.len(),
timestamp: ts,
filename: name,
});
}
}
items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
items.truncate(crate::models::MAX_MEDIA_LIST);
items
}
fn default_ping() -> u64 {
crate::models::DEFAULT_PING_SEC
}
#[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
}
#[derive(Clone, Serialize, Deserialize)]
pub struct IdentityRecord {
pub cert_hex: String,
pub key_hex: String,
pub fingerprint: String,
}
#[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")
}
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)
}
+276
View File
@@ -0,0 +1,276 @@
use crate::{
models::{fingerprint, PeerDevice},
storage::IdentityRecord,
};
use parking_lot::RwLock;
use rcgen::{generate_simple_self_signed, CertifiedKey};
use rustls::{
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
crypto::{verify_tls13_signature, CryptoProvider},
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime},
server::danger::{ClientCertVerified, ClientCertVerifier},
ClientConfig, DigitallySignedStruct, DistinguishedName, Error as TlsError, ServerConfig, SignatureScheme,
};
use std::{
collections::HashMap,
path::Path,
sync::{Arc, OnceLock},
time::Duration,
};
type Peers = Arc<RwLock<HashMap<String, PeerDevice>>>;
pub fn init_crypto() {
let _ = provider();
}
fn provider() -> Arc<CryptoProvider> {
static P: OnceLock<Arc<CryptoProvider>> = OnceLock::new();
P.get_or_init(|| {
let p = rustls::crypto::ring::default_provider();
let _ = p.clone().install_default();
Arc::new(p)
})
.clone()
}
// ───────────────────────── Идентичность ─────────────────────────
pub struct Identity {
pub cert_der: Vec<u8>,
pub key_der: Vec<u8>,
pub fingerprint: String,
}
pub fn identity_from_record(r: &IdentityRecord) -> Result<Identity, String> {
let cert_der = hex::decode(&r.cert_hex).map_err(|_| "identity.cert_hex: неверный hex".to_string())?;
let key_der = hex::decode(&r.key_hex).map_err(|_| "identity.key_hex: неверный hex".to_string())?;
let fp = fingerprint(&cert_der);
if fp != r.fingerprint {
return Err("identity.fingerprint не совпадает с сертификатом (данные изменены или повреждены)".into());
}
Ok(Identity {
cert_der,
key_der,
fingerprint: fp,
})
}
pub fn identity_to_record(id: &Identity) -> IdentityRecord {
IdentityRecord {
cert_hex: hex::encode(&id.cert_der),
key_hex: hex::encode(&id.key_der),
fingerprint: id.fingerprint.clone(),
}
}
pub fn read_identity_file(path: &Path) -> Result<Identity, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("чтение {}: {}", path.display(), e))?;
let rec: IdentityRecord =
serde_json::from_str(&content).map_err(|e| format!("файл {} повреждён: {}", path.display(), e))?;
identity_from_record(&rec).map_err(|e| format!("{}: {}", path.display(), e))
}
pub fn generate_identity() -> Result<Identity, String> {
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();
Ok(Identity {
fingerprint: fingerprint(&cert_der),
cert_der,
key_der: key_pair.serialize_der(),
})
}
fn key_of(der: &[u8]) -> PrivateKeyDer<'static> {
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(der.to_vec()))
}
// ───────────────────────── Сервер: mTLS ─────────────────────────
#[derive(Debug)]
struct AnyClientCert;
impl ClientCertVerifier for AnyClientCert {
fn root_hint_subjects(&self) -> &[DistinguishedName] {
&[]
}
fn client_auth_mandatory(&self) -> bool {
true
}
fn verify_client_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_now: UnixTime,
) -> Result<ClientCertVerified, TlsError> {
Ok(ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_m: &[u8],
_c: &CertificateDer<'_>,
_d: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Err(TlsError::General("tls12-disabled".into()))
}
fn verify_tls13_signature(
&self,
m: &[u8],
c: &CertificateDer<'_>,
d: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
provider().signature_verification_algorithms.supported_schemes()
}
}
pub fn server_config(cert_der: &[u8], key_der: &[u8]) -> Result<Arc<ServerConfig>, String> {
let mut cfg = ServerConfig::builder_with_provider(provider())
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(|e| e.to_string())?
.with_client_cert_verifier(Arc::new(AnyClientCert))
.with_single_cert(vec![CertificateDer::from(cert_der.to_vec())], key_of(key_der))
.map_err(|e| format!("сертификат/ключ не подходят друг другу: {e}"))?;
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(cfg))
}
// ───────────────────────── Клиенты ─────────────────────────
#[derive(Debug)]
struct PinVerifier {
expected: String,
peers: Peers,
}
impl ServerCertVerifier for PinVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
let fp = fingerprint(end_entity.as_ref());
if fp != self.expected {
return Err(TlsError::General("peer-mismatch".into()));
}
let trusted = self.peers.read().get(&fp).map_or(false, |p| p.verified);
if trusted {
Ok(ServerCertVerified::assertion())
} else {
Err(TlsError::General("peer-not-trusted".into()))
}
}
fn verify_tls12_signature(
&self,
_m: &[u8],
_c: &CertificateDer<'_>,
_d: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Err(TlsError::General("tls12-disabled".into()))
}
fn verify_tls13_signature(
&self,
m: &[u8],
c: &CertificateDer<'_>,
d: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
provider().signature_verification_algorithms.supported_schemes()
}
}
#[derive(Debug)]
struct AnyServerCert;
impl ServerCertVerifier for AnyServerCert {
fn verify_server_cert(
&self,
_e: &CertificateDer<'_>,
_i: &[CertificateDer<'_>],
_s: &ServerName<'_>,
_o: &[u8],
_n: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_m: &[u8],
_c: &CertificateDer<'_>,
_d: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Err(TlsError::General("tls12-disabled".into()))
}
fn verify_tls13_signature(
&self,
m: &[u8],
c: &CertificateDer<'_>,
d: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
provider().signature_verification_algorithms.supported_schemes()
}
}
fn client_config(
verifier: Arc<dyn ServerCertVerifier>,
cert: &[u8],
key: &[u8],
) -> Result<ClientConfig, String> {
let mut cfg = ClientConfig::builder_with_provider(provider())
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(|e| e.to_string())?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_client_auth_cert(vec![CertificateDer::from(cert.to_vec())], key_of(key))
.map_err(|e| e.to_string())?;
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(cfg)
}
fn base_builder(cfg: ClientConfig) -> reqwest::ClientBuilder {
reqwest::Client::builder()
.use_preconfigured_tls(cfg)
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.pool_max_idle_per_host(0)
.connect_timeout(Duration::from_secs(2))
}
pub fn pinned_client(
expected_fp: String,
peers: Peers,
cert: &[u8],
key: &[u8],
streaming: bool,
) -> Result<reqwest::Client, String> {
let cfg = client_config(
Arc::new(PinVerifier {
expected: expected_fp,
peers,
}),
cert,
key,
)?;
let mut b = base_builder(cfg);
if !streaming {
b = b.timeout(Duration::from_secs(12));
}
b.build().map_err(|e| e.to_string())
}
pub fn probe_client(cert: &[u8], key: &[u8]) -> Result<reqwest::Client, String> {
let cfg = client_config(Arc::new(AnyServerCert), cert, key)?;
base_builder(cfg)
.tls_info(true)
.timeout(Duration::from_secs(3))
.build()
.map_err(|e| e.to_string())
}
+200
View File
@@ -0,0 +1,200 @@
//! Общая логика исходящих передач: используется Web UI, CLI и Android.
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,
"Не удалось установить защищённое соединение (узел недоступен или сертификат не совпал)",
))
}
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;
}
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,
)),
}
}
+302
View File
@@ -0,0 +1,302 @@
use crate::{models::*, net, storage, transfer};
use axum::{
body::Body,
extract::{Path, Request, State},
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Response},
Json,
};
use futures_util::StreamExt;
use std::{
io,
net::IpAddr,
sync::{atomic::Ordering, Arc},
};
use tokio_util::io::ReaderStream;
const INDEX_HTML: &str = include_str!("static/index.html");
const APP_JS: &str = include_str!("static/app.js");
const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; \
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
// ───────────────────────── Защита UI (анти-CSRF / DNS-rebinding без токена) ─────────────────────────
pub struct UiGuard {
pub hosts: Vec<String>,
}
fn with_security_headers(mut resp: Response) -> Response {
let items: [(HeaderName, &'static str); 5] = [
(header::CONTENT_SECURITY_POLICY, CSP),
(HeaderName::from_static("x-content-type-options"), "nosniff"),
(header::X_FRAME_OPTIONS, "DENY"),
(header::REFERRER_POLICY, "no-referrer"),
(header::CACHE_CONTROL, "no-store"),
];
let h = resp.headers_mut();
for (k, v) in items {
h.entry(k).or_insert(HeaderValue::from_static(v)); // не перетираем заголовки обработчика
}
resp
}
/// 1) Host только 127.0.0.1/localhost:порт (анти-DNS-rebinding)
/// 2) POST только с Origin нашего же локального хоста (анти-CSRF)
pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -> Response {
let headers = req.headers();
let host_ok = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map_or(false, |h| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(h)));
if !host_ok {
return StatusCode::FORBIDDEN.into_response();
}
if !matches!(*req.method(), Method::GET | Method::HEAD) {
let origin_ok = headers
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.and_then(|o| o.strip_prefix("http://"))
.map_or(false, |o| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(o)));
if !origin_ok {
return StatusCode::FORBIDDEN.into_response();
}
}
with_security_headers(next.run(req).await)
}
// ───────────────────────── Статика и состояние ─────────────────────────
pub async fn ui_index() -> Html<&'static str> {
Html(INDEX_HTML)
}
pub async fn ui_js() -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], APP_JS)
}
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();
peers.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.cmp(&b.name)));
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
let transfers: Vec<TransferProgress> = state.transfers.read().clone();
let scan = state.scan_status.read().clone();
Json(serde_json::json!({
"device_name": state.device_name,
"my_ip": state.current_ip(),
"p2p_port": state.p2p_port,
"my_fingerprint": state.my_fingerprint,
"my_pin": short_id(&state.my_fingerprint),
"media_dir": state.media_dir.display().to_string(),
"ping_interval_sec": state.ping_interval_sec.load(Ordering::Relaxed),
"last_ping": state.last_ping.load(Ordering::Relaxed),
"max_ping_fails": MAX_PING_FAILS,
"now": now_sec(),
"ping_min": MIN_PING_SEC,
"ping_max": MAX_PING_SEC,
"peers": peers,
"texts": texts,
"media": media,
"transfers": transfers,
"scan": scan,
}))
}
// ───────────────────────── Устройства ─────────────────────────
pub async fn ui_add_manual_peer(
State(state): State<Arc<AppState>>,
Json(req): Json<UiManualPeerRequest>,
) -> Result<StatusCode, AppError> {
let ip: IpAddr = req
.ip
.trim()
.parse()
.map_err(|_| AppError::new(StatusCode::BAD_REQUEST, "Некорректный IP-адрес"))?;
match ip {
IpAddr::V4(v4) if is_lan_v4(v4) => {}
_ => return Err(AppError::new(StatusCode::BAD_REQUEST, "Разрешены только локальные IPv4-адреса")),
}
let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port);
net::probe_and_register(&state, ip, port)
.await
.map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Узел {}: {}", ip, e)))?;
Ok(StatusCode::OK)
}
pub async fn ui_scan_range(
State(state): State<Arc<AppState>>,
Json(req): Json<UiScanRangeRequest>,
) -> 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 my_ip = state.current_ip();
let (label, ips) =
parse_ip_range(&req.range, &my_ip).map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
let total = ips.len();
{
// проверка и установка флага — атомарно, под одним write-замком
let mut s = state.scan_status.write();
if s.active {
return Err(AppError::new(StatusCode::CONFLICT, "Сканирование уже выполняется"));
}
*s = ScanProgress {
active: true,
range: label.clone(),
scanned: 0,
total,
found: 0,
};
}
state.add_log(format!("Запущено сканирование {} ({} адресов, порт {})", label, total, port));
let st = state.clone();
let worker = tokio::spawn(net::run_scan(state.clone(), ips, port));
tokio::spawn(async move {
let _ = worker.await; // даже при панике воркера флаг active будет сброшен
let found = {
let mut s = st.scan_status.write();
s.active = false;
s.found
};
st.add_log(format!("Сканирование {} завершено. Найдено устройств: {}", label, found));
});
Ok(StatusCode::OK)
}
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
net::ping_all_peers(&state, true).await;
StatusCode::OK
}
pub async fn ui_ping_one_peer(
State(state): State<Arc<AppState>>,
Json(req): Json<UiPingPeerRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let online = net::ping_peer_by_id(&state, &req.peer_id)
.await
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
Ok(Json(serde_json::json!({ "online": online })))
}
pub async fn ui_trust_peer(
State(state): State<Arc<AppState>>,
Json(req): Json<UiTrustRequest>,
) -> Result<StatusCode, AppError> {
let msg = {
let mut peers = state.peers.write();
let p = peers
.get_mut(&req.peer_id)
.ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Устройство не найдено"))?;
p.verified = req.trust;
if req.trust {
p.fail_count = 0;
format!("Устройство '{}' [{}] ЗАВЕРЕНО", p.name, p.pin_code)
} else {
format!("Доверие устройству '{}' [{}] ОТОЗВАНО", p.name, p.pin_code)
}
};
state.persist_trust();
state.add_log(msg);
// сообщаем собеседнику сразу: он увидит запрос на сверку (или отзыв) и должен подтвердить у себя
let (st, id) = (state.clone(), req.peer_id.clone());
tokio::spawn(async move { net::push_trust(&st, &id).await });
Ok(StatusCode::OK)
}
pub async fn ui_set_settings(
State(state): State<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)
}
pub async fn ui_clear_ram(State(state): State<Arc<AppState>>) -> StatusCode {
state.ram_texts.write().clear();
state.add_log("Текстовый буфер в ОЗУ очищен");
StatusCode::OK
}
// ───────────────────────── Отправка (логика — в transfer.rs, общая с CLI) ─────────────────────────
pub async fn ui_send_text(
State(state): State<Arc<AppState>>,
Json(req): Json<UiSendTextRequest>,
) -> Result<StatusCode, AppError> {
transfer::send_text(&state, &req.peer_id, req.text).await?;
Ok(StatusCode::OK)
}
/// Потоковая отправка: браузер → канал с backpressure → TLS → получатель. Память O(1).
pub async fn ui_send_media(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Body,
) -> Result<StatusCode, AppError> {
let peer_id = hdr(&headers, "x-target-id")
.ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))?
.to_string();
let name = pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin"));
let total: u64 = hdr(&headers, "content-length")
.and_then(|v| v.parse().ok())
.ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?;
let stream = body
.into_data_stream()
.map(|r| r.map_err(|_| io::Error::new(io::ErrorKind::Other, "client aborted")));
transfer::send_stream(&state, &peer_id, &name, total, stream).await?;
Ok(StatusCode::OK)
}
// ───────────────────────── Раздача принятых файлов ─────────────────────────
pub async fn ui_serve_saved_file(State(state): State<Arc<AppState>>, Path(filename): Path<String>) -> Response {
let not_found = || (StatusCode::NOT_FOUND, "Файл не найден").into_response();
if filename.is_empty()
|| filename.starts_with('.')
|| filename.contains(|c: char| matches!(c, '/' | '\\' | '\0'))
{
return not_found();
}
let path = state.media_dir.join(&filename);
let Ok(meta) = tokio::fs::metadata(&path).await else { return not_found() };
if !meta.is_file() {
return not_found();
}
let Ok(file) = tokio::fs::File::open(&path).await else { return not_found() };
let inline = storage::is_previewable(&filename);
// Content-Type определяем САМИ по расширению, а не по заявлению отправителя
let ctype = if inline { storage::mime_from_name(&filename) } else { "application/octet-stream" };
let disposition = format!(
"{}; filename*=UTF-8''{}",
if inline { "inline" } else { "attachment" },
pct_encode(&filename)
);
let mut resp = Response::new(Body::from_stream(ReaderStream::new(file)));
let h = resp.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));
h.insert(header::CONTENT_LENGTH, HeaderValue::from(meta.len()));
if let Ok(v) = HeaderValue::from_str(&disposition) {
h.insert(header::CONTENT_DISPOSITION, v);
}
h.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'none'; sandbox"),
);
h.insert(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
resp
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ramshare_ui"
version.workspace = true
edition.workspace = true
[[bin]]
name = "ramshare_ui"
path = "src/main.rs"
[dependencies]
ramshare-core = { path = "../core" }
tokio.workspace = true
tokio-util.workspace = true
tokio-rustls.workspace = true
axum.workspace = true
serde_json.workspace = true
+482
View File
@@ -0,0 +1,482 @@
//! Консольный режим: те же возможности, что и в веб-интерфейсе, без запуска сервера.
use axum::http::StatusCode;
use ramshare_core::{models::*, net, transfer};
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,
pub wait: u64,
pub scan: Option<String>,
pub yes: bool,
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 fmt_ago(ts: u64, now: u64) -> String {
if ts == 0 {
return "никогда".into();
}
let d = now.saturating_sub(ts);
if d < 3 {
"сейчас".into()
} else if d < 60 {
format!("{} с назад", d)
} else if d < 3600 {
format!("{} мин назад", d / 60)
} else if d < 86400 {
format!("{} ч назад", d / 3600)
} else {
format!("{} дн назад", d / 86400)
}
}
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;
}
let now = now_sec();
let last_p = state.last_ping.load(Ordering::Relaxed);
eprintln!("Последний опрос (ping): {}", fmt_ago(last_p, now));
if v.is_empty() {
println!("Устройства не найдены.");
return;
}
println!(
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
"СТАТУС", "БЫЛ ОНЛАЙН", "ПОСЛ. ПИНГ", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID"
);
for p in v {
let addr = p
.ips
.first()
.map(|ip| format!("{}:{}", ip, p.port))
.unwrap_or_else(|| "—".into());
println!(
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
if p.online { "● online" } else { "○ offline" },
fmt_ago(p.last_seen, now),
fmt_ago(p.last_ping, now),
trust_label(&p),
trunc(&p.name, 20),
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),
}
}
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)
}
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(())
}
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, true).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, true).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, true).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;
x.fail_count = 0;
}
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() {
state.persist_trust();
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() {
state.persist_trust();
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)?;
state.persist_trust();
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) => {
state.persist_trust();
println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size));
}
Err(e) => return Err(format!("{}: {}", name, app_err(e))),
}
}
Ok(())
}
+383
View File
@@ -0,0 +1,383 @@
mod cli;
use axum::{
http::header,
response::{Html, IntoResponse},
routing::get,
Router,
};
use ramshare_core::{api, init_app_state, models::*, net, p2p, storage, tls};
use std::{
net::Ipv4Addr,
path::PathBuf,
sync::{atomic::Ordering, Arc},
time::Duration,
};
const INDEX_HTML: &str = include_str!("static/index.html");
const APP_JS: &str = include_str!("static/app.js");
async fn ui_index() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn ui_js() -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], APP_JS)
}
const USAGE: &str = r#"ramshare_ui — обмен текстом и файлами в локальной сети (TLS 1.3, mTLS, пиннинг сертификатов)
Использование: ramshare_ui [параметры] [команда]
Команды:
serve запустить узел и веб-интерфейс (команда по умолчанию)
scan [диапазон] просканировать сеть и показать найденные устройства
(all, 192.168.1.1-254, 10.0.0.0/24, список через запятую)
peers показать, кто онлайн (известные + UDP-обнаружение)
config показать путь и значения config.json (создаёт файл при первом запуске)
trust <устройство> сверить код и заверить устройство (сверка взаимная: второе
устройство получит запрос и тоже должно подтвердить код)
untrust <устройство> отозвать доверие
send-text <устройство> <текст…|-> отправить текст ("-" — читать из stdin)
send-file <устройство> <файл…> отправить один или несколько файлов
<устройство> — имя, IP[:порт], префикс ID (≥4 hex) или полный отпечаток
Параметры:
--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 эта справка
"#;
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 {
name: Option<String>,
p2p_port: u16,
ui_port: u16,
ip: Option<Ipv4Addr>,
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) -> ! {
eprintln!("Ошибка: {}", msg);
std::process::exit(1);
}
fn default_device_name() -> String {
let n = std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.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 {
name: None,
p2p_port: 8443,
ui_port: 8080,
ip: None,
data_dir: None,
ping_interval: None,
json: false,
wait: 4,
confirm_wait: 0,
scan: None,
yes: false,
no_ui: false,
command: Command::Serve,
};
let mut positional: Vec<String> = Vec::new();
let mut only_pos = false;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
if !only_pos && a == "--" {
only_pos = true;
continue;
}
if only_pos || (!a.starts_with("--") && a != "-h" && a != "-y") {
positional.push(a);
continue;
}
let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag));
match a.as_str() {
"--name" => cfg.name = Some(clean_label(&val("--name")?, 48)),
"--p2p-port" => {
cfg.p2p_port = val("--p2p-port")?
.parse()
.map_err(|_| "Некорректный --p2p-port".to_string())?
}
"--ui-port" => {
cfg.ui_port = val("--ui-port")?
.parse()
.map_err(|_| "Некорректный --ui-port".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")?)),
"--ping-interval" => {
let s: u64 = val("--ping-interval")?
.parse()
.map_err(|_| "Некорректный --ping-interval".to_string())?;
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&s) {
return Err(format!("--ping-interval: допустимо {}–{} секунд", MIN_PING_SEC, MAX_PING_SEC));
}
cfg.ping_interval = Some(s);
}
"--wait" => {
let w: u64 = val("--wait")?.parse().map_err(|_| "Некорректный --wait".to_string())?;
if !(1..=60).contains(&w) {
return Err("--wait: допустимо 1–60 секунд".into());
}
cfg.wait = w;
}
"--scan" => cfg.scan = Some(val("--scan")?),
"--confirm-wait" => {
let w: u64 = val("--confirm-wait")?
.parse()
.map_err(|_| "Некорректный --confirm-wait".to_string())?;
if w > 600 {
return Err("--confirm-wait: допустимо 0–600 секунд".into());
}
cfg.confirm_wait = w;
}
"--json" => cfg.json = true,
"--no-ui" => cfg.no_ui = true,
"-y" | "--yes" => cfg.yes = true,
"-h" | "--help" => {
println!("{}", USAGE);
std::process::exit(0);
}
other => return Err(format!("Неизвестный параметр: {}\n\n{}", other, USAGE)),
}
}
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());
}
if cfg.p2p_port == 0 || cfg.ui_port == 0 {
return Err("Порт не может быть 0".into());
}
Ok(cfg)
}
async fn run_serve(cfg: &Config, state: Arc<AppState>, bind_ip: Ipv4Addr) {
let server_cfg = tls::server_config(&state.cert_der, &state.key_der).unwrap_or_else(|e| fatal(&e));
let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg);
let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port))
.await
.unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e)));
let ui_listener = if cfg.no_ui {
None
} else {
Some(
tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port))
.await
.unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e))),
)
};
state.add_log(format!(
"Узел '{}' запущен на {}:{} (ID: {})",
state.device_name,
state.current_ip(),
cfg.p2p_port,
short_id(&state.my_fingerprint)
));
net::start_discovery(state.clone());
{
let st = state.clone();
tokio::spawn(async move {
net::ping_all_peers(&st, false).await;
st.maintenance();
loop {
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, false).await;
st.maintenance();
}
});
}
println!("==============================================================");
println!(" Устройство: {}", state.device_name);
println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port);
println!(" Адрес в сети: {}", state.current_ip());
println!(" ID устройства: {}", short_id(&state.my_fingerprint));
println!(" Папка config: {}", state.data_dir.display());
println!(" Файл настроек: {}", state.data_dir.join("config.json").display());
println!(" Принятые файлы: {}", state.media_dir.display());
println!(
" Опрос устройств: каждые {} с (до {} попыток при недоступности)",
state.ping_interval_sec.load(Ordering::Relaxed),
MAX_PING_FAILS
);
println!("--------------------------------------------------------------");
let ui_task = match ui_listener {
Some(listener) => {
println!(" ОТКРОЙТЕ ВЕБ-ИНТЕРФЕЙС:");
println!(" http://127.0.0.1:{}/", cfg.ui_port);
let web_assets = Router::new()
.route("/", get(ui_index))
.route("/app.js", get(ui_js));
let ui_router = api::control_router(web_assets, state.clone(), cfg.ui_port);
Some(tokio::spawn(async move { axum::serve(listener, ui_router).await }))
}
None => {
println!(" Режим без UI (--no-ui). Входящий текст печатается в терминал,");
println!(" файлы сохраняются в {}", state.media_dir.display());
None
}
};
println!("==============================================================");
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! {
_ = ui_wait => {}
r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r),
_ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"),
}
state.sync_trust_from_disk();
state.persist_trust();
storage::cleanup_partials(&state.media_dir);
}
#[tokio::main]
async fn main() {
let cfg = parse_args().unwrap_or_else(|e| fatal(&e));
let serving = matches!(cfg.command, Command::Serve);
let dirs = storage::resolve_dirs(cfg.data_dir.as_deref(), cfg.p2p_port).unwrap_or_else(|e| fatal(&e));
let (state, bind_ip) = init_app_state(
dirs.config,
dirs.media,
cfg.name.clone(),
&default_device_name(),
cfg.p2p_port,
cfg.ip,
cfg.ping_interval,
serving,
serving && cfg.no_ui,
)
.unwrap_or_else(|e| fatal(&e));
if serving {
run_serve(&cfg, state, bind_ip).await;
return;
}
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);
}
}
+486
View File
@@ -0,0 +1,486 @@
'use strict';
const $ = (id) => document.getElementById(id);
let statusTimer = null;
let lastData = null;
let knownPeersKey = '';
let lastSubnet = '';
let pendingTrustId = null;
function esc(v) {
return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[s]));
}
function showStatus(msg, isError = false) {
const el = $('status');
clearTimeout(statusTimer);
el.style.display = 'block';
el.style.background = isError ? 'rgba(239,68,68,.2)' : 'rgba(16,185,129,.2)';
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
el.textContent = msg;
statusTimer = setTimeout(() => { el.style.display = 'none'; }, 4500);
}
function formatBytes(b) {
if (b < 1024) return `${b} Б`;
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`;
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(2)} МБ`;
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
}
function formatTimeAgo(ts, nowSec) {
if (!ts) return 'никогда';
const now = nowSec || Math.floor(Date.now() / 1000);
const diff = Math.max(0, now - ts);
const dt = new Date(ts * 1000);
const timeStr = dt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (diff < 3) return `только что (${timeStr})`;
if (diff < 60) return `${diff} с назад (${timeStr})`;
if (diff < 3600) return `${Math.floor(diff / 60)} мин назад (${timeStr})`;
if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад (${timeStr})`;
const dateStr = dt.toLocaleDateString('ru-RU');
return `${dateStr} ${timeStr}`;
}
function setHtml(el, html) {
if (el._h !== html) { el._h = html; el.innerHTML = html; }
}
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: 'same-origin', ...opts });
if (!res.ok) throw new Error((await res.text()) || `Ошибка ${res.status}`);
return res;
}
function postJson(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function switchTab(tab) {
const isMain = tab === 'main';
$('view-main').style.display = isMain ? 'grid' : 'none';
$('view-trusted').style.display = isMain ? 'none' : 'block';
$('tab-btn-main').classList.toggle('active', isMain);
$('tab-btn-trusted').classList.toggle('active', !isMain);
}
function renderHeader(d) {
$('my-name').textContent = `Устройство: ${d.device_name}`;
$('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
$('my-pin').textContent = d.my_pin;
$('media-dir').textContent = d.media_dir;
const pingTxt = `Последний пинг: ${formatTimeAgo(d.last_ping, d.now)}`;
$('last-ping-info').textContent = pingTxt;
$('trusted-last-ping').textContent = pingTxt;
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 subnet = `${p[0]}.${p[1]}.${p[2]}`;
if (subnet !== lastSubnet) {
lastSubnet = subnet;
$('scan-range-input').value = `${subnet}.1-254`;
}
}
}
function renderScan(scan) {
const box = $('scan-progress-box');
if (scan && (scan.active || scan.total > 0)) {
box.style.display = 'block';
const pct = scan.total > 0 ? Math.round((scan.scanned / scan.total) * 100) : 0;
$('scan-label').textContent = scan.active
? `Сканирование ${scan.range}… (найдено: ${scan.found})`
: `Сканирование ${scan.range} завершено (найдено: ${scan.found})`;
$('scan-numbers').textContent = `${scan.scanned} / ${scan.total} (${pct}%)`;
$('scan-bar-fill').style.width = `${pct}%`;
$('btn-scan-range').disabled = scan.active;
$('btn-scan-all').disabled = scan.active;
$('btn-scan-all-trusted').disabled = scan.active;
}
}
function renderTransfers(list) {
setHtml($('transfers-list'), (list || []).map((t) => {
const dir = t.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Приём от';
const status = t.error ? '✗ Ошибка' : t.done ? '✓ Завершено' : `${t.percent}%`;
const note = t.error
? `<span style="color:#f87171;">${esc(t.error)}</span>`
: (t.done ? 'Готово' : 'Передача по TLS…');
return `
<div class="progress-wrap">
<div style="display:flex;justify-content:space-between;font-size:.83rem;font-weight:600;gap:8px;">
<span>${dir} ${esc(t.peer_name)}: <span style="color:#60a5fa;">${esc(t.filename)}</span></span>
<span>${esc(status)}</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${Number(t.percent)}%;"></div></div>
<div class="small muted" style="display:flex;justify-content:space-between;gap:8px;">
<span>${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)}</span>
<span>${note}</span>
</div>
</div>`;
}).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, nowSec, maxFails) {
const activePeers = peers.filter((p) => p.online);
const trustedPeers = peers.filter((p) => p.verified);
$('badge-active').textContent = String(activePeers.length);
$('badge-trusted').textContent = String(trustedPeers.length);
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);
const mainBox = $('peers-list');
if (!activePeers.length) {
setHtml(mainBox, '<div class="muted" style="font-size:.9rem;">Активных устройств сейчас нет. Нажмите «Вся подсеть», «Опросить всех известных» или перейдите во вкладку «Заверенные устройства».</div>');
} else {
setHtml(mainBox, activePeers.map((p) => {
const t = trustInfo(p);
const seenLabel = `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`;
const pingLabel = formatTimeAgo(p.last_ping, nowSec);
return `
<div class="peer-item${t.req ? ' request' : ''}">
<div style="min-width:0;">
<div style="font-weight:600;">
<span class="dot dot-on"></span>${esc(p.name)}
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
</div>
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
Статус: <span style="color:${t.color};">${esc(t.text)}</span><br>
Был онлайн: <span style="color:#cbd5e1;">${esc(seenLabel)}</span> | Пинг: <span style="color:#cbd5e1;">${esc(pingLabel)}</span>
</div>
</div>
<div class="peer-actions">
<button class="btn-sm" data-action="ping-one" data-id="${esc(p.id)}">Пинг</button>
<button class="btn-sm ${t.btnCls}" data-action="trust" data-id="${esc(p.id)}" data-trust="${t.trust}">
${esc(t.btn)}
</button>
</div>
</div>`;
}).join(''));
}
const trustedBox = $('trusted-peers-list');
if (!trustedPeers.length) {
setHtml(trustedBox, '<div class="muted" style="font-size:.9rem;">У вас пока нет заверенных устройств. Найдите устройство на главной вкладке и нажмите «Заверить».</div>');
} else {
setHtml(trustedBox, trustedPeers.map((p) => {
const t = trustInfo(p);
const seenLabel = p.online
? `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`
: formatTimeAgo(p.last_seen, nowSec);
const pingLabel = formatTimeAgo(p.last_ping, nowSec);
const limit = maxFails || 5;
const failNote = p.online
? '<span style="color:#34d399;">на связи (0 ошибок)</span>'
: p.fail_count >= limit
? `<span style="color:#f87171;">не ответил ${p.fail_count}/${limit} раз — автоопрос остановлен (нажмите «Пинг» или «Сканировать»)</span>`
: `<span style="color:#fbbf24;">оффлайн (попыток пинга: ${p.fail_count}/${limit})</span>`;
return `
<div class="peer-item">
<div style="min-width:0;">
<div style="font-weight:600;">
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
</div>
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b> | ID: ${esc(p.fingerprint.slice(0, 8).toUpperCase())}<br>
Доверие: <span style="color:${t.color};">${esc(t.text)}</span><br>
Был онлайн: <span style="color:#cbd5e1;">${esc(seenLabel)}</span> | Посл. пинг: <span style="color:#cbd5e1;">${esc(pingLabel)}</span><br>
Опрос: ${failNote}
</div>
</div>
<div class="peer-actions">
<button class="btn-sm" data-action="ping-one" data-id="${esc(p.id)}">Пинг</button>
<button class="btn-sm btn-red" data-action="trust" data-id="${esc(p.id)}" data-trust="0">Отозвать</button>
</div>
</div>`;
}).join(''));
}
const key = activePeers.map((p) => `${p.id}:${p.verified}:${p.trusts_me}:${p.ips[0] || ''}:${p.name}`).join('|');
if (key !== knownPeersKey) {
knownPeersKey = key;
const sel = $('target-peer');
const prev = sel.value;
sel.innerHTML = '<option value="">-- Выберите активное устройство --</option>' + activePeers.map((p) => {
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;
}
}
function renderTexts(texts) {
const box = $('ram-texts');
if (!texts.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Буфер ОЗУ пуст</div>');
setHtml(box, texts.map((t, i) => `
<div class="msg-item">
<div class="msg-meta">
<span>От: <b>${esc(t.sender)}</b></span>
<button class="btn-sm" data-action="copy" data-idx="${i}">Скопировать</button>
</div>
<div class="msg-text">${esc(t.text)}</div>
</div>`).join(''));
}
function renderMedia(media) {
const box = $('disk-media');
if (!media.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Нет сохранённых файлов</div>');
setHtml(box, media.map((m) => {
const url = `/ui/files/${encodeURIComponent(m.filename)}`;
return `
<div class="msg-item">
<div class="msg-meta"><span>От: <b>${esc(m.sender)}</b></span><span>${formatBytes(m.size)}</span></div>
<div style="font-weight:600;margin:2px 0;word-break:break-all;">
<a href="${url}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:none;">${esc(m.filename)} ↗</a>
</div>
${m.risky ? '<div class="warn">⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.</div>' : ''}
${m.previewable ? `<img src="${url}" class="preview-img" alt="">` : ''}
</div>`;
}).join(''));
}
async function fetchState() {
const d = await (await api('/ui/state')).json();
lastData = d;
renderHeader(d);
renderScan(d.scan);
renderTransfers(d.transfers);
renderPeers(d.peers, d.now, d.max_ping_fails);
renderTexts(d.texts);
renderMedia(d.media);
}
async function guarded(fn) {
try { await fn(); } catch (e) { showStatus(e.message || String(e), true); }
}
function scanIpRange(full) {
return guarded(async () => {
const range = full ? 'all' : $('scan-range-input').value.trim();
if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true);
await postJson('/ui/scan-range', { range });
showStatus('Сканирование запущено (автоопрос всех пиров возобновлён)…');
fetchState().catch(() => {});
});
}
function addManualPeer() {
return guarded(async () => {
const raw = $('manual-ip-input').value.trim();
if (!raw) return showStatus('Введите IP-адрес устройства', true);
let ip = raw, port = null;
const i = raw.lastIndexOf(':');
if (i > 0) { ip = raw.slice(0, i); port = parseInt(raw.slice(i + 1), 10) || null; }
showStatus(`Связь с узлом ${ip}…`);
await postJson('/ui/manual-peer', port ? { ip, port } : { ip });
$('manual-ip-input').value = '';
showStatus(`Узел ${ip} найден. Сверьте код и заверьте устройство.`);
fetchState().catch(() => {});
});
}
function saveSettings() {
return guarded(async () => {
const el = $('ping-interval-input');
const v = parseInt(el.value, 10);
const min = Number(el.min) || 10, max = Number(el.max) || 30;
if (!Number.isInteger(v) || v < min || v > max) return showStatus(`Интервал должен быть от ${min} до ${max} секунд`, true);
await postJson('/ui/settings', { ping_interval_sec: v });
showStatus(`Опрос устройств каждые ${v} с`);
el.blur();
fetchState().catch(() => {});
});
}
function pingPeers() {
return guarded(async () => {
$('btn-ping').disabled = true;
$('btn-ping-trusted').disabled = true;
try {
await api('/ui/ping', { method: 'POST' });
showStatus('Ручной опрос выполнен (счётчики попыток сброшены)');
await fetchState();
} finally {
$('btn-ping').disabled = false;
$('btn-ping-trusted').disabled = false;
}
});
}
function pingOnePeer(id, btn) {
return guarded(async () => {
if (btn) btn.disabled = true;
try {
const res = await (await postJson('/ui/ping-peer', { peer_id: id })).json();
const peer = lastData && lastData.peers.find((p) => p.id === id);
const name = peer ? peer.name : id.slice(0, 8);
if (res.online) {
showStatus(`✓ Устройство «${name}» онлайн!`);
} else {
showStatus(`Устройство «${name}» не ответило (автоопрос возобновлён на 5 попыток)`, true);
}
await fetchState();
} finally {
if (btn) btn.disabled = false;
}
});
}
function setTrust(id, trust) {
return guarded(async () => {
await postJson('/ui/trust', { peer_id: id, trust });
showStatus(trust ? 'Устройство заверено' : 'Доверие отозвано');
fetchState().catch(() => {});
});
}
function sendText() {
return guarded(async () => {
const peer_id = $('target-peer').value;
const input = $('text-input');
const text = input.value.trim();
if (!peer_id) return showStatus('Выберите активное устройство-получатель!', true);
if (!text) return showStatus('Введите текст для отправки!', true);
if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true);
await postJson('/ui/send-text', { peer_id, text });
input.value = '';
showStatus('Текст доставлен в ОЗУ получателя');
});
}
async function sendMedia() {
const peer_id = $('target-peer').value;
const fileInput = $('media-input');
const btn = $('send-media-btn');
if (!peer_id) return showStatus('Выберите активное устройство-получатель!', true);
if (!fileInput.files.length) return showStatus('Выберите файл!', true);
const file = fileInput.files[0];
btn.disabled = true;
try {
await api('/ui/send-media', {
method: 'POST',
headers: {
'X-Target-Id': peer_id,
'X-Filename': encodeURIComponent(file.name),
'Content-Type': 'application/octet-stream',
},
body: file,
});
fileInput.value = '';
showStatus(`Файл «${file.name}» передан`);
} catch (e) {
showStatus(e.message || String(e), true);
} finally {
btn.disabled = false;
fetchState().catch(() => {});
}
}
function clearRam() {
return guarded(async () => {
await api('/ui/clear-ram', { method: 'POST' });
showStatus('Буфер текстов в ОЗУ очищен');
fetchState().catch(() => {});
});
}
function handlePeerListClick(e) {
const pingBtn = e.target.closest('button[data-action="ping-one"]');
if (pingBtn) {
pingOnePeer(pingBtn.dataset.id, pingBtn);
return;
}
const btn = e.target.closest('button[data-action="trust"]');
if (!btn || !lastData) return;
const id = btn.dataset.id;
if (btn.dataset.trust === '0') return setTrust(id, false);
const peer = lastData.peers.find((p) => p.id === id);
if (!peer) return;
pendingTrustId = id;
$('td-name').textContent = peer.name;
$('td-code').textContent = peer.pin_code;
$('trust-dialog').showModal();
}
$('tab-btn-main').addEventListener('click', () => switchTab('main'));
$('tab-btn-trusted').addEventListener('click', () => switchTab('trusted'));
$('btn-ping').addEventListener('click', pingPeers);
$('btn-ping-trusted').addEventListener('click', pingPeers);
$('btn-save-settings').addEventListener('click', saveSettings);
$('ping-interval-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') saveSettings(); });
$('btn-manual').addEventListener('click', addManualPeer);
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
$('btn-scan-all-trusted').addEventListener('click', () => {
switchTab('main');
scanIpRange(true);
});
$('btn-send-text').addEventListener('click', sendText);
$('send-media-btn').addEventListener('click', sendMedia);
$('btn-clear-ram').addEventListener('click', clearRam);
$('text-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendText(); }
});
$('peers-list').addEventListener('click', handlePeerListClick);
$('trusted-peers-list').addEventListener('click', handlePeerListClick);
$('td-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); });
$('td-ok').addEventListener('click', () => {
const id = pendingTrustId;
pendingTrustId = null;
$('trust-dialog').close();
if (id) setTrust(id, true);
});
$('ram-texts').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action="copy"]');
if (!btn || !lastData) return;
const item = lastData.texts[Number(btn.dataset.idx)];
if (!item) return;
navigator.clipboard.writeText(item.text).then(
() => showStatus('Текст скопирован в буфер обмена'),
() => showStatus('Не удалось скопировать', true),
);
});
async function poll() {
try {
if (!document.hidden) await fetchState();
$('offline').style.display = 'none';
} catch (_) {
$('offline').style.display = 'block';
}
setTimeout(poll, document.hidden ? 2500 : 700);
}
poll();
+192
View File
@@ -0,0 +1,192 @@
<!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: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 800px) { .grid-2 { grid-template-columns: 1fr; } }
.header { 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.15rem; }
.tabs-bar { display: flex; gap: 10px; background: var(--card); border: 1px solid var(--border); padding: 8px; border-radius: 12px; flex-wrap: wrap; }
.tab-btn { background: transparent; color: var(--muted); border: 1px solid transparent; border-radius: 8px; padding: 9px 16px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,.04); }
.tab-btn.active { background: var(--accent); color: #fff; }
.badge { background: rgba(15,23,42,.55); padding: 2px 8px; border-radius: 999px; font-size: .78rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
h2 { margin: 0; font-size: 1.08rem; display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
.tag-ram { background: rgba(16,185,129,.2); color: #34d399; }
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
input[type="text"], input[type="number"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: .92rem; }
textarea { resize: vertical; min-height: 80px; }
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
button:hover { opacity: .9; }
button:disabled { opacity: .5; cursor: not-allowed; }
.btn-green { background: var(--green); }
.btn-red { background: var(--red); }
.btn-gray { background: #475569; }
.btn-sm { padding: 7px 12px; font-size: .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; }
.peer-actions { display: flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
.msg-item { flex-direction: column; align-items: flex-start; }
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: .8rem; color: var(--muted); }
.msg-text { font-size: .95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
.preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; }
.peer-item.request { border-color: #fb923c; background: rgba(251,146,60,.08); }
.warn { color: #fbbf24; font-size: .8rem; }
.muted { color: var(--muted); }
.small { font-size: .8rem; }
.mono { font-family: monospace; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot-on { background: #10b981; }
.dot-off { background: #64748b; }
.progress-wrap { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
.progress-bar-bg { width: 100%; height: 10px; background: #0b1120; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width .2s ease; }
.sub-box { background: rgba(15,23,42,.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.col { display: flex; flex-direction: column; gap: 8px; }
#status { padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
#offline { padding: 10px 16px; border-radius: 8px; display: none; background: rgba(239,68,68,.25); border: 1px solid var(--red); }
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 12px; padding: 22px; max-width: 460px; }
dialog::backdrop { background: rgba(0,0,0,.65); }
.sas { font-family: monospace; font-size: 1.5rem; font-weight: 700; letter-spacing: 2px; background: #1d4ed8; border-radius: 8px; padding: 12px; text-align: center; margin: 14px 0; word-break: break-all; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<div style="font-size:1.2rem;font-weight:700;" id="my-name">Загрузка...</div>
<div class="small muted" id="my-ip"></div>
</div>
<div>
<span class="small muted" style="margin-right:8px;">ID устройства:</span>
<span class="pin-badge" id="my-pin">--------</span>
</div>
</div>
<div class="tabs-bar">
<button class="tab-btn active" id="tab-btn-main">
<span>Главная (Активные в сети)</span>
<span class="badge" id="badge-active">0</span>
</button>
<button class="tab-btn" id="tab-btn-trusted">
<span>Заверенные устройства</span>
<span class="badge" id="badge-trusted">0</span>
</button>
</div>
<div id="offline">Нет связи с приложением. Проверьте, что оно запущено.</div>
<div id="status"></div>
<!-- ВКЛАДКА 1: ГЛАВНАЯ (ТОЛЬКО АКТИВНЫЕ) -->
<div id="view-main" class="grid-2">
<div class="card">
<h2>
<span>1. Активные устройства в сети</span>
<button class="btn-sm" id="btn-ping">Опросить всех известных</button>
</h2>
<div class="row" style="flex-wrap:nowrap;">
<input type="text" id="manual-ip-input" placeholder="Один IP[:порт] (напр. 192.168.1.55 или 192.168.1.55:8443)">
<button class="btn-sm" style="white-space:nowrap;" id="btn-manual">Найти IP</button>
</div>
<div class="sub-box">
<div class="small muted" style="font-weight:600;">Сканер диапазона IP-адресов (только локальные сети):</div>
<div class="row">
<input type="text" id="scan-range-input" style="flex:1;min-width:190px;" placeholder="Напр. 192.168.1.1-254, 10.0.0.0/24">
<button class="btn-sm" id="btn-scan-range">Сканировать диапазон</button>
<button class="btn-sm btn-green" id="btn-scan-all">Вся подсеть (.1–254)</button>
</div>
<div id="scan-progress-box" style="display:none;">
<div class="small muted" style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span id="scan-label">Сканирование...</span>
<span id="scan-numbers">0 / 0</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" id="scan-bar-fill" style="width:0%;"></div></div>
</div>
</div>
<div class="row" style="align-items:center;justify-content:space-between;">
<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:74px;">
<span class="small muted">сек (10–30)</span>
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
</div>
<div class="small muted" id="last-ping-info">Последний пинг: —</div>
</div>
<div id="peers-list" class="col"></div>
<div class="small muted">На главной показаны только устройства, которые сейчас онлайн. Если устройство не отвечает 5 раз подряд, его автоопрос прекращается (посмотреть все заверенные и пингануть вручную можно во вкладке «Заверенные устройства»).</div>
</div>
<div class="card">
<h2>2. Отправка данных</h2>
<label class="small muted">Активный получатель (должен быть заверен):</label>
<select id="target-peer"><option value="">-- Выберите активное устройство --</option></select>
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка, до 64 КБ)..."></textarea>
<button id="btn-send-text">Отправить текст в ОЗУ</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" id="send-media-btn">Отправить файл на диск получателя</button>
<div id="transfers-list" class="col"></div>
</div>
<div class="card">
<h2>
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
<button class="btn-red btn-sm" id="btn-clear-ram">Очистить ОЗУ</button>
</h2>
<div id="ram-texts" class="col" style="max-height:400px;overflow-y:auto;"></div>
</div>
<div class="card">
<h2>Входящие файлы <span class="tag tag-disk">НА ДИСКЕ</span></h2>
<div class="small muted mono" id="media-dir"></div>
<div id="disk-media" class="col" style="max-height:400px;overflow-y:auto;"></div>
</div>
</div>
<!-- ВКЛАДКА 2: ВСЕ ЗАВЕРЕННЫЕ ПИРЫ -->
<div id="view-trusted" style="display:none;">
<div class="card">
<h2>
<span>Все заверенные устройства (онлайн и оффлайн)</span>
<div class="row">
<button class="btn-sm" id="btn-ping-trusted">Пингануть все заверенные</button>
<button class="btn-sm btn-green" id="btn-scan-all-trusted">Сканировать всю подсеть</button>
</div>
</h2>
<div class="small muted" id="trusted-last-ping">Последний общий пинг: —</div>
<div id="trusted-peers-list" class="col"></div>
<div class="small muted">Если устройство недоступно, узел пробует пингануть его 5 раз. После 5 неудач устройство переводится в offline и больше не опрашивается автоматически, пока вы не нажмёте «Пинг» или «Сканировать всю подсеть».</div>
</div>
</div>
</div>
<dialog id="trust-dialog">
<h3 style="margin:0 0 8px;">Заверить устройство?</h3>
<div>«<b id="td-name"></b>»</div>
<div class="sas" id="td-code"></div>
<div class="small muted">Код должен <b>полностью совпадать</b> с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.<br><br>После вашего подтверждения то же самое будет запрошено на втором устройстве: доверие считается установленным, только когда код подтвердили <b>оба</b>.</div>
<div class="row" style="margin-top:16px;justify-content:flex-end;">
<button class="btn-gray" id="td-cancel">Отмена</button>
<button class="btn-green" id="td-ok">Коды совпадают — заверить</button>
</div>
</dialog>
<script src="/app.js"></script>
</body>
</html>