a.0.0.6
This commit is contained in:
1 parent
5d01dfb63a
commit
b0d63c9f0f
20 files changed
+1857
-1417
No files matched your search
+188
-18
@@ -13,12 +13,12 @@ use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tokio::sync::{Mutex, Notify, Semaphore};
|
||||
|
||||
// ───────────────────────── Лимиты ─────────────────────────
|
||||
pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // один файл
|
||||
@@ -32,6 +32,9 @@ pub const MAX_TRANSFERS: usize = 20;
|
||||
pub const MAX_INCOMING: usize = 4;
|
||||
pub const MAX_MEDIA_LIST: usize = 500;
|
||||
pub const DISCOVERY_PORT: u16 = 5354;
|
||||
pub const MIN_PING_SEC: u64 = 10;
|
||||
pub const MAX_PING_SEC: u64 = 30;
|
||||
pub const DEFAULT_PING_SEC: u64 = 15;
|
||||
|
||||
// ───────────────────────── Ошибки ─────────────────────────
|
||||
pub struct AppError(pub StatusCode, pub String);
|
||||
@@ -82,7 +85,10 @@ pub struct PeerDevice {
|
||||
pub fingerprint: String,
|
||||
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
|
||||
pub pin_code: String,
|
||||
/// Мы сверили код и подтвердили это устройство (наше решение; по нему принимаем/отправляем).
|
||||
pub verified: bool,
|
||||
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
||||
pub trusts_me: bool,
|
||||
pub online: bool,
|
||||
pub last_seen: u64,
|
||||
}
|
||||
@@ -97,6 +103,7 @@ impl PeerDevice {
|
||||
fingerprint: fp.to_string(),
|
||||
pin_code: sas_code(my_fp, fp),
|
||||
verified,
|
||||
trusts_me: false,
|
||||
online: false,
|
||||
last_seen: 0,
|
||||
}
|
||||
@@ -162,10 +169,16 @@ pub struct UiScanRangeRequest {
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UiSettingsRequest {
|
||||
pub ping_interval_sec: u64,
|
||||
}
|
||||
|
||||
// ───────────────────────── Состояние ─────────────────────────
|
||||
pub struct AppState {
|
||||
pub device_name: String,
|
||||
pub my_ip: String,
|
||||
pub my_ip: RwLock<String>,
|
||||
pub fixed_ip: bool,
|
||||
pub p2p_port: u16,
|
||||
pub my_fingerprint: String,
|
||||
pub cert_der: Vec<u8>,
|
||||
@@ -182,10 +195,37 @@ pub struct AppState {
|
||||
pub incoming_slots: Arc<Semaphore>,
|
||||
pub ping_lock: Mutex<()>,
|
||||
pub trust_dirty: AtomicBool,
|
||||
pub probe_cooldown: PlMutex<HashMap<IpAddr, Instant>>,
|
||||
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
||||
/// Интервал опроса известных пиров (сек), меняется на лету
|
||||
pub ping_interval_sec: AtomicU64,
|
||||
pub ping_notify: Notify,
|
||||
/// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом)
|
||||
pub log_stdout: bool,
|
||||
/// true в `serve --no-ui`: входящий текст печатается в терминал
|
||||
pub headless: bool,
|
||||
/// mtime trusted.json, чтобы подхватывать правки из CLI (trust/untrust) работающим узлом
|
||||
pub trust_mtime: PlMutex<Option<SystemTime>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
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>) {
|
||||
// защита от log-injection: управляющие символы в именах и т.п.
|
||||
let text: String = msg
|
||||
@@ -193,7 +233,9 @@ impl AppState {
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
println!("[LOG] {}", text);
|
||||
if self.log_stdout {
|
||||
println!("[LOG] {}", text);
|
||||
}
|
||||
let mut logs = self.logs.write();
|
||||
if logs.len() >= MAX_LOGS {
|
||||
logs.pop_back();
|
||||
@@ -260,10 +302,16 @@ impl AppState {
|
||||
name: p.name.clone(),
|
||||
ips: p.ips.clone(),
|
||||
port: p.port,
|
||||
trusts_me: p.trusts_me,
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = storage::save_trusted(&self.data_dir, &entries) {
|
||||
self.add_log(format!("Не удалось сохранить список доверенных: {}", e));
|
||||
match storage::save_trusted(&self.data_dir, &entries) {
|
||||
Ok(()) => {
|
||||
if let Ok(mt) = std::fs::metadata(self.data_dir.join("trusted.json")).and_then(|m| m.modified()) {
|
||||
*self.trust_mtime.lock() = Some(mt);
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_log(format!("Не удалось сохранить список доверенных: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +362,7 @@ impl AppState {
|
||||
match peers.entry(fp.to_string()) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let p = e.get_mut();
|
||||
let changed = p.ips.first() != Some(&ip_s) || p.port != port;
|
||||
let changed = p.ips.first() != Some(&ip_s) || p.port != port || p.name != name;
|
||||
p.name = name.clone();
|
||||
p.port = port;
|
||||
p.online = true;
|
||||
@@ -351,32 +399,141 @@ impl AppState {
|
||||
created
|
||||
}
|
||||
|
||||
/// Пир уже недавно виден по этому адресу — повторный probe по beacon не нужен.
|
||||
pub fn is_fresh(&self, fp: &str, ip: IpAddr) -> bool {
|
||||
/// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене).
|
||||
pub fn touch_peer_ip(&self, fp: &str, ip: IpAddr) {
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
|
||||
return;
|
||||
}
|
||||
let ip_s = ip.to_string();
|
||||
let mut dirty = false;
|
||||
if let Some(p) = self.peers.write().get_mut(fp) {
|
||||
let changed = p.ips.first() != Some(&ip_s);
|
||||
p.online = true;
|
||||
p.last_seen = now_sec();
|
||||
if changed {
|
||||
p.ips.retain(|x| x != &ip_s);
|
||||
p.ips.insert(0, ip_s);
|
||||
p.ips.truncate(MAX_PEER_IPS);
|
||||
if p.verified {
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if dirty {
|
||||
self.trust_dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Пир уже недавно виден по этому адресу и порту — повторный probe по beacon не нужен.
|
||||
pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool {
|
||||
let ip_s = ip.to_string();
|
||||
self.peers.read().get(fp).map_or(false, |p| {
|
||||
p.online
|
||||
&& p.port == port
|
||||
&& p.ips.first().map(String::as_str) == Some(ip_s.as_str())
|
||||
&& now_sec().saturating_sub(p.last_seen) < 10
|
||||
})
|
||||
}
|
||||
|
||||
pub fn probe_allowed(&self, ip: IpAddr) -> bool {
|
||||
pub fn probe_allowed(&self, ip: IpAddr, port: u16) -> bool {
|
||||
let mut m = self.probe_cooldown.lock();
|
||||
let now = Instant::now();
|
||||
if m.len() > 512 {
|
||||
m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(30));
|
||||
m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(15));
|
||||
}
|
||||
match m.get(&ip) {
|
||||
Some(t) if now.duration_since(*t) < Duration::from_secs(10) => false,
|
||||
match m.get(&(ip, port)) {
|
||||
Some(t) if now.duration_since(*t) < Duration::from_secs(4) => false,
|
||||
_ => {
|
||||
m.insert(ip, now);
|
||||
m.insert((ip, port), now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Меняет интервал опроса: применяется сразу и сохраняется в config.json.
|
||||
pub fn set_ping_interval(&self, sec: u64) -> Result<(), String> {
|
||||
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&sec) {
|
||||
return Err(format!("Интервал должен быть от {} до {} секунд", MIN_PING_SEC, MAX_PING_SEC));
|
||||
}
|
||||
self.ping_interval_sec.store(sec, Ordering::Relaxed);
|
||||
self.ping_notify.notify_one(); // будим цикл опроса, чтобы он взял новое значение
|
||||
if let Err(e) = storage::update_config(&self.data_dir, |c| c.ping_interval_sec = Some(sec)) {
|
||||
self.add_log(format!("Не удалось сохранить config.json: {}", e));
|
||||
}
|
||||
self.add_log(format!("Интервал опроса устройств: {} с", sec));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Бит «собеседник подтвердил сверку с нами». Источник — только аутентифицированный (mTLS)
|
||||
/// канал: заголовок/ответ /p2p/info от владельца отпечатка.
|
||||
pub fn set_remote_trust(&self, fp: &str, v: bool) {
|
||||
let (msg, hint) = {
|
||||
let mut peers = self.peers.write();
|
||||
let Some(p) = peers.get_mut(fp) else { return };
|
||||
if p.trusts_me == v {
|
||||
return;
|
||||
}
|
||||
p.trusts_me = v;
|
||||
if v && !p.verified {
|
||||
(
|
||||
format!(
|
||||
"«{}» запрашивает взаимную сверку. Код сверки: {} — сравните его на экране устройства и подтвердите",
|
||||
p.name, p.pin_code
|
||||
),
|
||||
Some(short_id(fp)),
|
||||
)
|
||||
} else if v {
|
||||
(format!("«{}» подтвердил сверку — доверие взаимное", p.name), None)
|
||||
} else {
|
||||
(format!("«{}» отозвал доверие к вашему устройству", p.name), None)
|
||||
}
|
||||
};
|
||||
self.trust_dirty.store(true, Ordering::Relaxed);
|
||||
self.add_log(msg);
|
||||
if let (true, Some(id)) = (self.headless, hint) {
|
||||
println!(" → подтвердить: ramshare_ui trust {}", id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust).
|
||||
/// Повреждённый файл игнорируется, а не «разжалует» всех пиров.
|
||||
pub fn sync_trust_from_disk(&self) {
|
||||
let path = self.data_dir.join("trusted.json");
|
||||
let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return };
|
||||
{
|
||||
let mut last = self.trust_mtime.lock();
|
||||
if *last == Some(mt) {
|
||||
return;
|
||||
}
|
||||
*last = Some(mt);
|
||||
}
|
||||
let Some(entries) = storage::read_trusted_strict(&self.data_dir) else { return };
|
||||
let mut peers = self.peers.write();
|
||||
for p in peers.values_mut() {
|
||||
p.verified = entries.iter().any(|e| e.fp == p.id);
|
||||
}
|
||||
for e in entries {
|
||||
if !is_valid_fp(&e.fp) || e.fp == self.my_fingerprint || peers.contains_key(&e.fp) {
|
||||
continue;
|
||||
}
|
||||
if peers.len() >= MAX_PEERS {
|
||||
break;
|
||||
}
|
||||
let ips: Vec<String> = e
|
||||
.ips
|
||||
.into_iter()
|
||||
.filter(|s| s.parse::<IpAddr>().is_ok())
|
||||
.take(MAX_PEER_IPS)
|
||||
.collect();
|
||||
let port = if e.port == 0 { 8443 } else { e.port };
|
||||
let mut np = PeerDevice::new(&e.fp, clean_label(&e.name, 48), ips, port, &self.my_fingerprint, true);
|
||||
np.trusts_me = e.trusts_me;
|
||||
peers.insert(e.fp.clone(), np);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn maintenance(&self) {
|
||||
self.sync_trust_from_disk();
|
||||
let now = now_sec();
|
||||
self.peers
|
||||
.write()
|
||||
@@ -460,6 +617,13 @@ pub fn clean_label(s: &str, max_chars: usize) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Для печати чужого текста в терминал: убирает управляющие/escape-символы (кроме \n и \t).
|
||||
pub fn strip_ctrl(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_control() && c != '\n' && c != '\t' { '\u{fffd}' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pct_decode(s: &str) -> String {
|
||||
percent_decode_str(s).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
@@ -472,8 +636,10 @@ pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
h.get(name).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// Локальные IPv4: RFC 1918 (10/8, 172.16/12, 192.168/16), Link-Local (169.254/16) и CGNAT/VPN (100.64/10).
|
||||
pub fn is_lan_v4(ip: Ipv4Addr) -> bool {
|
||||
ip.is_private() || ip.is_link_local()
|
||||
let o = ip.octets();
|
||||
ip.is_private() || ip.is_link_local() || (o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000)
|
||||
}
|
||||
|
||||
/// Принимает уже раскодированное имя. Ничего не декодирует.
|
||||
@@ -505,11 +671,15 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
}
|
||||
|
||||
/// Диапазоны: "all", "192.168.1.1-254", "192.168.1.10-192.168.1.80", "192.168.1.0/24" (маски /22–/32),
|
||||
/// одиночные адреса и списки через запятую. Только локальные (private/link-local) IPv4.
|
||||
/// одиночные адреса и списки через запятую. Только локальные IPv4.
|
||||
pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec<Ipv4Addr>), String> {
|
||||
const MAX_TOTAL: usize = 1024;
|
||||
let raw = input.trim();
|
||||
let mine: Option<Ipv4Addr> = my_ip.parse().ok();
|
||||
let mine: Option<Ipv4Addr> = my_ip
|
||||
.parse::<Ipv4Addr>()
|
||||
.ok()
|
||||
.filter(|ip| is_lan_v4(*ip))
|
||||
.or_else(crate::net::detect_lan_ip);
|
||||
|
||||
let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") {
|
||||
let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?;
|
||||
|
||||
Reference in new issue
Block a user