add android icon

This commit is contained in:
netadmin committed 2026-11-02 22:13:43 +03:00
1 parent f22d954105
commit 55a69c743a
521 files changed
+8146 -14086

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
name = "ramshare-core"
version.workspace = true
edition.workspace = true
autobins = false
[lib]
name = "ramshare_core"
+42 -33
View File
@@ -1,5 +1,3 @@
//! Локальный HTTP API управления узлом (127.0.0.1): используется Web UI на ПК и Jetpack Compose на Android.
use crate::{models::*, net, storage, transfer};
use axum::{
body::Body,
@@ -25,7 +23,41 @@ pub struct UiGuard {
pub hosts: Vec<String>,
}
/// Создаёт базовый роутер `/ui/*` (без статических веб-файлов) с защитой Host/Origin.
pub fn state_snapshot_json(state: &AppState) -> 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();
let logs: Vec<String> = state.logs.read().iter().take(30).cloned().collect();
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.current_media_dir().display().to_string(),
"configured_media_dir": state.configured_media_dir.read().clone().unwrap_or_default(),
"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,
"logs": logs,
})
}
pub fn control_router(extra_routes: Router<Arc<AppState>>, state: Arc<AppState>, ui_port: u16) -> Router {
let guard = Arc::new(UiGuard {
hosts: vec![
@@ -93,35 +125,7 @@ pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -
}
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,
}))
Json(state_snapshot_json(&state))
}
pub async fn ui_add_manual_peer(
@@ -232,6 +236,11 @@ pub async fn ui_set_settings(
state
.set_ping_interval(req.ping_interval_sec)
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
if let Some(md) = req.media_dir.as_deref() {
state
.set_media_dir(Some(md))
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
}
Ok(StatusCode::OK)
}
@@ -276,7 +285,7 @@ pub async fn ui_serve_saved_file(State(state): State<Arc<AppState>>, Path(filena
{
return not_found();
}
let path = state.media_dir.join(&filename);
let path = state.current_media_dir().join(&filename);
let Ok(meta) = tokio::fs::metadata(&path).await else { return not_found() };
if !meta.is_file() {
return not_found();
+10 -1
View File
@@ -1,5 +1,5 @@
//! Единый файл конфигурации `<data-dir>/config.json`:
//! имя устройства, идентичность (сертификат + закрытый ключ) и интервал опроса.
//! имя устройства, идентичность (сертификат + закрытый ключ), интервал опроса и путь к `received_media`.
use crate::{
models::{clean_label, MAX_PING_SEC, MIN_PING_SEC},
@@ -12,6 +12,7 @@ pub struct Loaded {
pub identity: Identity,
pub device_name: String,
pub ping_interval_sec: u64,
pub configured_media_dir: Option<String>,
}
fn migrate_or_generate(data_dir: &Path, legacy_port: u16) -> Result<Identity, String> {
@@ -76,6 +77,13 @@ pub fn load_or_init(data_dir: &Path, legacy_port: u16, default_name: &str) -> Re
}
};
let configured_media_dir = cfg
.media_dir
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
if dirty {
storage::save_config(data_dir, &cfg)
.map_err(|e| format!("запись {}: {}", storage::config_path(data_dir).display(), e))?;
@@ -87,5 +95,6 @@ pub fn load_or_init(data_dir: &Path, legacy_port: u16, default_name: &str) -> Re
identity,
device_name,
ping_interval_sec,
configured_media_dir,
})
}
+13 -5
View File
@@ -12,7 +12,7 @@ use parking_lot::{Mutex as PlMutex, RwLock};
use std::{
collections::{HashMap, VecDeque},
net::{IpAddr, Ipv4Addr},
path::PathBuf,
path::Path,
sync::{
atomic::{AtomicBool, AtomicU64},
Arc,
@@ -22,8 +22,7 @@ use tokio::sync::{Mutex, Notify, Semaphore};
/// Единая точка инициализации состояния узла для Desktop, CLI и Android.
pub fn init_app_state(
data_dir: PathBuf,
media_dir: PathBuf,
base_override: Option<&Path>,
name_override: Option<String>,
default_name: &str,
p2p_port: u16,
@@ -42,11 +41,18 @@ pub fn init_app_state(
}
};
let data_dir = storage::resolve_config_dir(base_override, p2p_port)?;
let loaded = config::load_or_init(&data_dir, p2p_port, default_name)?;
let media_dir = storage::resolve_media_dir(
loaded.configured_media_dir.as_deref(),
base_override,
p2p_port,
)?;
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())
@@ -99,7 +105,9 @@ pub fn init_app_state(
cert_der: identity.cert_der,
key_der: identity.key_der,
data_dir,
media_dir,
base_override: base_override.map(Path::to_path_buf),
media_dir: RwLock::new(media_dir),
configured_media_dir: RwLock::new(loaded.configured_media_dir),
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
disk_media: RwLock::new(disk_media),
peers,
-498
View File
@@ -1,498 +0,0 @@
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);
}
}
+31 -24
View File
@@ -21,8 +21,8 @@ use std::{
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_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024;
pub const MAX_TEXT_BYTES: usize = 64 * 1024;
pub const MAX_RAM_TEXTS: usize = 50;
pub const MAX_LOGS: usize = 100;
@@ -44,7 +44,6 @@ 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())
@@ -84,18 +83,12 @@ pub struct PeerDevice {
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,
}
@@ -185,6 +178,8 @@ pub struct UiScanRangeRequest {
#[derive(Deserialize)]
pub struct UiSettingsRequest {
pub ping_interval_sec: u64,
#[serde(default)]
pub media_dir: Option<String>,
}
// ───────────────────────── Состояние ─────────────────────────
@@ -197,7 +192,9 @@ pub struct AppState {
pub cert_der: Vec<u8>,
pub key_der: Vec<u8>,
pub data_dir: PathBuf,
pub media_dir: PathBuf,
pub base_override: Option<PathBuf>,
pub media_dir: RwLock<PathBuf>,
pub configured_media_dir: RwLock<Option<String>>,
pub ram_texts: RwLock<VecDeque<TextItem>>,
pub disk_media: RwLock<VecDeque<MediaItem>>,
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
@@ -209,16 +206,11 @@ pub struct AppState {
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>>,
}
@@ -227,6 +219,10 @@ impl AppState {
self.my_ip.read().clone()
}
pub fn current_media_dir(&self) -> PathBuf {
self.media_dir.read().clone()
}
pub fn update_my_ip(&self, ip: Ipv4Addr) -> bool {
if self.fixed_ip {
return false;
@@ -342,7 +338,6 @@ impl AppState {
}
// ── пиры ──
/// Регистрирует/обновляет пира. Вызывать ТОЛЬКО с отпечатком, полученным из 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;
@@ -418,7 +413,6 @@ impl AppState {
created
}
/// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене).
pub fn touch_peer_ip(&self, fp: &str, ip: IpAddr) {
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
return;
@@ -445,7 +439,6 @@ impl AppState {
}
}
/// Сбрасывает счётчик неудачных пингов у всех известных устройств.
pub fn reset_all_ping_failures(&self) {
let mut peers = self.peers.write();
for p in peers.values_mut() {
@@ -453,7 +446,6 @@ impl AppState {
}
}
/// Пир уже недавно виден по этому адресу и порту — повторный 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| {
@@ -479,7 +471,6 @@ impl AppState {
}
}
/// Меняет интервал опроса: применяется сразу и сохраняется в 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));
@@ -493,7 +484,26 @@ impl AppState {
Ok(())
}
/// Бит «собеседник подтвердил сверку с нами».
/// Изменяет папку сохранения файлов (`media_dir`), создаёт её на диске и сохраняет в `config.json`.
/// Пустая строка сбрасывает путь к папке по умолчанию (`./received_media` там, где запущено приложение).
pub fn set_media_dir(&self, raw: Option<&str>) -> Result<PathBuf, String> {
let cleaned = raw.map(str::trim).filter(|s| !s.is_empty()).map(String::from);
let resolved = storage::resolve_media_dir(
cleaned.as_deref(),
self.base_override.as_deref(),
self.p2p_port,
)?;
*self.media_dir.write() = resolved.clone();
*self.configured_media_dir.write() = cleaned.clone();
*self.disk_media.write() = storage::scan_media_dir(&resolved).into_iter().collect();
if let Err(e) = storage::update_config(&self.data_dir, |c| c.media_dir = cleaned) {
self.add_log(format!("Не удалось сохранить media_dir в config.json: {}", e));
}
self.add_log(format!("Папка входящих файлов: {}", resolved.display()));
Ok(resolved)
}
pub fn set_remote_trust(&self, fp: &str, v: bool) {
let (msg, hint) = {
let mut peers = self.peers.write();
@@ -523,7 +533,6 @@ impl AppState {
}
}
/// Подхватывает изменения 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 };
@@ -608,7 +617,6 @@ 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();
@@ -661,7 +669,6 @@ 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)
+25 -16
View File
@@ -7,7 +7,11 @@ use std::{
sync::{atomic::Ordering, Arc},
time::{Duration, Instant},
};
use tokio::{net::UdpSocket, sync::Semaphore};
use tokio::{
net::{TcpListener, UdpSocket},
sync::Semaphore,
task::JoinHandle,
};
const MAX_PROBE_BODY: usize = 8 * 1024;
@@ -24,7 +28,7 @@ pub struct LanIf {
pub broadcast: Ipv4Addr,
}
// ───────────────────────── Интерфейсы ─────────────────────────
// ───────────────────────── Интерфейсы и сокеты ─────────────────────────
fn is_virtual_if(name: &str) -> bool {
let n = name.to_ascii_lowercase();
n.starts_with("docker")
@@ -48,8 +52,18 @@ fn detect_lan_ip_udp() -> Option<Ipv4Addr> {
None
}
/// Привязка P2P TCP-слушателя с SO_REUSEADDR (предотвращает ошибку занятого порта при перезапуске APK).
pub fn bind_p2p_listener(bind_ip: Ipv4Addr, port: u16) -> std::io::Result<TcpListener> {
let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
let addr = SocketAddr::new(IpAddr::V4(bind_ip), port);
socket.bind(&addr.into())?;
socket.listen(128)?;
TcpListener::from_std(socket.into())
}
/// Все локальные (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()
@@ -88,7 +102,6 @@ pub fn lan_interfaces() -> Vec<LanIf> {
list
}
/// Адрес основного локального интерфейса.
pub fn detect_lan_ip() -> Option<Ipv4Addr> {
if let Some(ip) = detect_lan_ip_udp() {
return Some(ip);
@@ -103,7 +116,6 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
.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 {
@@ -201,7 +213,6 @@ pub async fn probe_and_register_hint(
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);
@@ -211,8 +222,6 @@ pub async fn probe_and_register_hint(
}
// ───────────────────────── 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();
@@ -281,7 +290,6 @@ async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) -> bo
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();
@@ -297,7 +305,6 @@ pub async fn ping_peer_by_id(state: &AppState, id: &str) -> Result<bool, String>
Ok(ok)
}
/// Мгновенно отправляет обновлённый статус доверия пиру и обновляет его `trusts_me`.
pub async fn push_trust(state: &AppState, id: &str) {
let Some((ips, port)) = state
.peers
@@ -310,7 +317,6 @@ pub async fn push_trust(state: &AppState, id: &str) {
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();
@@ -382,11 +388,12 @@ fn bind_discovery_socket() -> std::io::Result<UdpSocket> {
UdpSocket::from_std(socket.into())
}
pub fn start_discovery(state: Arc<AppState>) {
pub fn start_discovery(state: Arc<AppState>) -> Vec<JoinHandle<()>> {
let serving = state.log_stdout;
let mut handles = Vec::with_capacity(2);
let tx = state.clone();
tokio::spawn(async move {
handles.push(tokio::spawn(async move {
let sock = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
@@ -431,9 +438,9 @@ pub fn start_discovery(state: Arc<AppState>) {
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
});
}));
tokio::spawn(async move {
handles.push(tokio::spawn(async move {
let socket = match bind_discovery_socket() {
Ok(s) => s,
Err(e) => {
@@ -496,5 +503,7 @@ pub fn start_discovery(state: Arc<AppState>) {
let _ = probe_and_register_hint(&st, ip, port, Some(&fp_hint)).await;
});
}
});
}));
handles
}
+5 -6
View File
@@ -20,7 +20,6 @@ use std::{
use tokio::{io::AsyncWriteExt, net::TcpListener, sync::Semaphore};
use tokio_rustls::TlsAcceptor;
/// Личность собеседника из TLS-рукопожатия (а не из заголовков!).
#[derive(Clone)]
pub struct PeerCtx {
pub fp: String,
@@ -78,7 +77,6 @@ pub async fn serve(listener: TcpListener, acceptor: TlsAcceptor, app: Router) {
}
}
// ───────────────────────── Handlers ─────────────────────────
async fn p2p_info(
State(state): State<Arc<AppState>>,
Extension(ctx): Extension<PeerCtx>,
@@ -189,11 +187,12 @@ async fn p2p_receive_media(
}
let safe_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")));
let media_dir = state.current_media_dir();
tokio::fs::create_dir_all(&state.media_dir)
tokio::fs::create_dir_all(&media_dir)
.await
.map_err(|e| AppError::internal("mkdir", e))?;
let tmp = state.media_dir.join(format!(".part_{}", random_hex(8)));
let tmp = media_dir.join(format!(".part_{}", random_hex(8)));
let mut guard = TempGuard {
path: tmp.clone(),
armed: true,
@@ -272,10 +271,10 @@ async fn p2p_receive_media(
}
drop(file);
let candidate = state.media_dir.join(&safe_name);
let candidate = 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);
let path = media_dir.join(&name);
(name, path)
} else {
(safe_name.clone(), candidate)
+55 -31
View File
@@ -43,16 +43,6 @@ fn os_data_dir(p2p_port: u16) -> PathBuf {
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;
@@ -76,49 +66,79 @@ fn migrate_legacy(config: &Path, p2p_port: u16) {
}
}
pub fn resolve_dirs(base_override: Option<&Path>, p2p_port: u16) -> Result<Dirs, String> {
/// Определяет папку `config/` (для `config.json` и `trusted.json`).
pub fn resolve_config_dir(base_override: Option<&Path>, p2p_port: u16) -> Result<PathBuf, 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);
let cfg_dir = base.join("config");
fs::create_dir_all(&cfg_dir).map_err(|e| format!("создание {}: {}", cfg_dir.display(), e))?;
return Ok(cfg_dir);
}
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) {
let cfg_dir = base.join(format!("config{}", suffix));
match fs::create_dir_all(&cfg_dir) {
Ok(()) => {
migrate_legacy(&d.config, p2p_port);
return Ok(d);
migrate_legacy(&cfg_dir, p2p_port);
return Ok(cfg_dir);
}
Err(e) => eprintln!(
"[WARN] не удалось создать {} ({}). Используется каталог данных ОС; \
чтобы выбрать место явно, задайте --data-dir.",
d.config.display(),
cfg_dir.display(),
e
),
}
}
let base = os_data_dir(p2p_port);
let d = Dirs {
config: base.join("config"),
media: base.join("received_media"),
let cfg_dir = base.join("config");
fs::create_dir_all(&cfg_dir).map_err(|e| format!("создание {}: {}", cfg_dir.display(), e))?;
migrate_legacy(&cfg_dir, p2p_port);
Ok(cfg_dir)
}
/// Определяет и создаёт папку `received_media`:
/// 1) Если в `config.json` задан `media_dir` — используется он.
/// 2) Иначе создаётся папка `received_media` там, где запущено приложение (`current_dir`),
/// либо внутри `base_override` (если задан `--data-dir` или на Android).
pub fn resolve_media_dir(
configured: Option<&str>,
base_override: Option<&Path>,
p2p_port: u16,
) -> Result<PathBuf, String> {
let launch_dir = match base_override {
Some(b) if b.is_absolute() => b.to_path_buf(),
Some(b) => std::env::current_dir().map_err(|e| e.to_string())?.join(b),
None => std::env::current_dir().map_err(|e| format!("не удалось определить текущую папку: {}", e))?,
};
make_dirs(&d).map_err(|e| format!("создание каталогов в {}: {}", base.display(), e))?;
migrate_legacy(&d.config, p2p_port);
Ok(d)
let dir = match configured.map(str::trim).filter(|s| !s.is_empty()) {
Some(custom) => {
let p = PathBuf::from(custom);
if p.is_absolute() {
p
} else {
launch_dir.join(p)
}
}
None => {
let folder = if p2p_port == 8443 || base_override.is_some() {
"received_media".to_string()
} else {
format!("received_media-{}", p2p_port)
};
launch_dir.join(folder)
}
};
fs::create_dir_all(&dir).map_err(|e| format!("создание папки файлов {}: {}", dir.display(), e))?;
Ok(dir)
}
pub fn write_atomic(path: &Path, data: &[u8], private: bool) -> io::Result<()> {
@@ -283,6 +303,8 @@ pub struct IdentityRecord {
pub fingerprint: String,
}
/// `<data-dir>/config.json`.
/// Если `media_dir` не указан (`null` или `""`), папка `received_media` создаётся там, где запущено приложение.
#[derive(Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
@@ -290,6 +312,8 @@ pub struct Config {
#[serde(default)]
pub ping_interval_sec: Option<u64>,
#[serde(default)]
pub media_dir: Option<String>,
#[serde(default)]
pub identity: Option<IdentityRecord>,
}