diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..1b757db --- /dev/null +++ b/Readme.md @@ -0,0 +1,232 @@ +# ramshare_ui + +Легковесная, автономная утилита для безопасного обмена текстом и файлами внутри локальных сетей (Wi-Fi, точки доступа со смартфонов, офисные LAN, домашние роутеры, VPN/Tailscale `100.64.0.0/10`). + +Приложение спроектировано для работы в условиях нестабильного окружения и роуминга устройств (ноутбуки, телефоны, сменяющие IP-адреса при переходе между сетями). Работает как в виде локального веб-интерфейса, так и через полноценный CLI без графической оболочки. + +--- + +## Ключевые особенности + +* **Криптографическая безопасность из коробки:** +* Соединения работают строго по **TLS 1.3** с обязательной взаимной аутентификацией (**mTLS**) через самоподписанные сертификаты. +* Пиннинг сертификатов: соединение устанавливается только с известным и явно подтверждённым отпечатком (SHA-256). +* **SAS-коды сверки (Short Authentication String):** 80-битный симметричный верификационный код вида `XXXX-XXXX-XXXX`, вычисляемый из отпечатков обоих узлов, предотвращает атаки типа Man-in-the-Middle (MitM). +* Двусторонняя верификация: передача данных блокируется до тех пор, пока **оба** участника не подтвердят код на своих экранах. + + +* **Приватность и потоковая передача:** +* **Текст принимается исключительно в ОЗУ** (до 64 КБ на сообщение, кольцевой буфер на 50 записей). При закрытии процесса или нажатии кнопки очистки текст бесследно исчезает. +* **Потоковая передача файлов с backpressure:** чтение и запись производятся чанками с константным потреблением памяти ($O(1)$ RAM), поддерживаются файлы объёмом до 2 ГБ. +* Файлы записываются во временные `.part_*` файлы с атомарным переименованием и очисткой битых передач по таймауту. + + +* **Адаптивность к смене сетей и роумингу:** +* Автоматическое отслеживание изменений сетевых интерфейсов и актуального IP-адреса без перезапуска. +* Приоритезация IP-адресов: узлы сначала опрашивают адреса из своей текущей `/24` подсети. +* Поддержка частных диапазонов RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), Link-Local (`169.254.0.0/16`) и CGNAT / Carrier VPN (`100.64.0.0/10`). + + +* **Энергоэффективный опрос (Smart Ping):** +* Если устройство перестаёт отвечать, система делает до **5 попыток** опроса, после чего переводит пир в `offline` и **полностью прекращает фоновый опрос**, экономя заряд батареи и сетевой трафик. +* Опрос возобновляется автоматически при ручном пинге, общем сканировании подсети или если пир сам пришлёт UDP-маяк/данные. + + +* **Автономное обнаружение:** +* Быстрое обнаружение через UDP-рассылку (порт `5354`) без зависимости от тяжелых сервисов mDNS / Avahi / Bonjour. +* Встроенный многопоточный сканер диапазонов IPv4 (CIDR, списки, диапазоны тире). + + +* **Интерфейс без внешних зависимостей:** +* Web UI встроен непосредственно в бинарный файл (zero-external-assets, никаких внешних CDN и шрифтов). +* Работает прямо на `[http://127.0.0.1:8080/](http://127.0.0.1:8080/)` без необходимости ввода одноразовых токенов. +* Защита от межсайтовых атак: валидация заголовков `Host` и `Origin` (защита от DNS-rebinding и CSRF), строгие CSP-заголовки. +* Разделение интерфейса на активные устройства и отдельную вкладку со всеми ранее заверенными пирами. + + + +--- + +## Архитектура сетевого взаимодействия + +``` + +-------------+ +-------------+ + | Node A | | Node B | + +-------------+ +-------------+ + | | + | --- 1. UDP Broadcast Beacon (порт 5354) ------------> | + | | + | <--- 2. HTTPS/TLS 1.3 Handshake (mTLS /p2p/info) ---> | + | (Обмен именами, портами и статусом доверия) | + | | + | === 3. Сверка SAS-кода пользователями на экранах === | + | | + | --- 4. Запрос доверия: x-node-trusts: -------> | + | <--- Ответ: trusts_you: true --------------------- | + | | + | [Взаимное доверие установлено (Status: mutual)] | + | | + | --- 5. POST /p2p/text (Только в ОЗУ) ---------------> | + | --- 6. POST /p2p/media (Поток на диск) -------------> | + +``` + +--- + +## Сборка и установка + +Требуется компилятор Rust версии **1.75+** (Cargo). + +```bash +# Клонирование репозитория +git clone https://github.com/your-username/ramshare_ui.git +cd ramshare_ui + +# Сборка оптимизированного релиза (LTO + Strip) +cargo build --release + +# Исполняемый файл появится по пути: +./target/release/ramshare_ui + +``` + +--- + +## Быстрый старт + +### 1. Запуск узла с веб-интерфейсом + +Запустите узел на обоих устройствах в сети: + +```bash +# Запуск с параметрами по умолчанию (P2P: 8443, UI: 8080) +ramshare_ui serve + +# Либо с явным указанием имени узла +ramshare_ui serve --name "Laptop-Work" + +``` + +После старта откройте в браузере: **`[http://127.0.0.1:8080](http://127.0.0.1:8080)`**. + +### 2. Запуск в headless-режиме (сервер/терминал) + +Если веб-интерфейс не нужен (например, на удалённом сервере или Raspberry Pi): + +```bash +ramshare_ui serve --no-ui --name "Home-Server" + +``` + +*Входящие текстовые сообщения будут выводиться прямо в stdout терминала, а принятые файлы сохраняться в папку `received_media/`.* + +--- + +## Использование через CLI + +Все операции можно выполнять через консоль без открытия веб-интерфейса. + +### Поиск устройств в сети + +```bash +# Показать устройства, которые видны прямо сейчас +ramshare_ui peers + +# Вывод списка в формате JSON +ramshare_ui peers --json + +# Сканирование текущей подсети (например, 192.168.1.1-254) +ramshare_ui scan + +# Сканирование произвольного диапазона +ramshare_ui scan 192.168.1.50-100 +ramshare_ui scan 10.0.0.0/24 + +``` + +### Заверение устройства (Trust) + +Для отправки данных устройства должны взаимно подтвердить SAS-код: + +```bash +# Заверить по имени, IP или префиксу ID +ramshare_ui trust Laptop + +# С ожиданием подтверждения с противоположной стороны (до 60 секунд) +ramshare_ui trust 192.168.1.45 --confirm-wait 60 + +# Отозвать доверие +ramshare_ui untrust Laptop + +``` + +### Отправка текста и файлов + +```bash +# Отправить текст в оперативную память устройства +ramshare_ui send-text Laptop "Привет! Пароль от архива: secret123" + +# Передача содержимого через конвейер (stdin) +cat dump.sql | ramshare_ui send-text Laptop - +echo "токен_авторизации" | ramshare_ui send-text 192.168.1.50 - + +# Отправка одного или нескольких файлов +ramshare_ui send-file Laptop ./document.pdf ./archive.tar.gz + +``` + +### Просмотр параметров узла + +```bash +ramshare_ui config + +``` + +--- + +## Структура каталогов + +По умолчанию приложение хранит данные рядом с бинарным файлом (или в пути, переданном через `--data-dir`): + +```text +. +├── ramshare_ui # Исполняемый файл +├── config/ # Каталог конфигурации +│ ├── config.json # Идентичность узла (приватный ключ, сертификат, имя) +│ └── trusted.json # База данных заверенных устройств и их адресов +└── received_media/ # Папка принятых файлов + ├── report.pdf + └── 1774500000_a1b2c3_photo.png + +``` + +*Файл `config.json` генерируется автоматически при первом запуске с правами доступа `0600` (Unix).* + +--- + +## Параметры командной строки + +| Параметр | По умолчанию | Описание | +| --- | --- | --- | +| `--name <имя>` | Имя хоста ОС | Имя устройства, отображаемое другим пирам в сети | +| `--p2p-port <порт>` | `8443` | Порт для HTTPS / mTLS соединений передачи данных | +| `--ui-port <порт>` | `8080` | Порт веб-интерфейса (привязывается строго к `127.0.0.1`) | +| `--ip ` | Авто | Принудительная привязка к конкретному локальному адресу | +| `--data-dir <путь>` | Рядом с бинарником | Базовая папка для каталогов `config/` и `received_media/` | +| `--ping-interval <с>` | `15` | Интервал периодического опроса устройств (от `10` до `30` с) | +| `--no-ui` | `false` | Запуск сервера без поднятия локального веб-сервера | +| `--wait <с>` | `4` | Время прослушивания UDP-маяков при выполнении CLI-команд | +| `--scan <диапазон>` | `None` | Автоматически сканировать указанную сеть, если пир не найден | +| `--confirm-wait <с>` | `0` | Время ожидания подтверждения со второго устройства при `trust` | +| `--json` | `false` | Вывод списков узлов (`peers`, `scan`) в формате JSON | +| `-y, --yes` | `false` | Пропуск интерактивного запроса подтверждения в CLI | +| `-h, --help` | — | Вывод справки по доступным командам | + +--- + +## Безопасность и модель угроз + +1. **Защита от прослушивания в открытых сетях:** Весь трафик шифруется TLS 1.3 с современными наборами шифров (AEAD). Провайдер сети или сосед по Wi-Fi не видят передаваемый контент или имена файлов. +2. **Защита от подмены (MitM):** Если злоумышленник пытается перехватить трафик и подменить самоподписанный сертификат на лету, у пользователей **не совпадут SAS-коды сверки**. При несовпадении кодов подтверждать соединение запрещено. +3. **Безопасность Web UI:** Сервер интерфейса слушает только `127.0.0.1`. При попытке внешнего сайта выполнить запрос в локальный браузер (CSRF / DNS-rebinding) запрос отклоняется благодаря проверке заголовков `Origin` и `Host`, а также строгой политике CSP (`frame-ancestors 'none'`, `default-src 'none'`). +4. **Защита файловой системы:** Имена входящих файлов нормализуются с вырезанием управляющих символов и попыток выхода из каталога (`../`). Файлы с исполняемыми расширениями (`.sh`, `.exe`, `.bat` и др.) помечаются в интерфейсе как потенциально опасные и не открываются автоматически. diff --git a/src/cli.rs b/src/cli.rs index bd603cd..8c209db 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -234,7 +234,7 @@ async fn find_peer( ) -> Result { let (host, port_opt) = split_sel(sel); - net::ping_all_peers(state).await; + net::ping_all_peers(state, true).await; if let Some(p) = resolve(state, &host)? { if p.online || !need_online { return Ok(p); @@ -284,14 +284,14 @@ async fn find_peer( // ───────────────────────── Команды ───────────────────────── pub async fn cmd_scan(state: &Arc, range: &str, opts: &CliOpts) -> Result<(), String> { - net::ping_all_peers(state).await; + 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, opts: &CliOpts) -> Result<(), String> { - net::ping_all_peers(state).await; + net::ping_all_peers(state, true).await; ensure_discovery(state); if let Some(r) = &opts.scan { scan_with_progress(state, r).await?; @@ -326,6 +326,7 @@ pub async fn cmd_trust(state: &Arc, sel: &str, opts: &CliOpts) -> Resu } 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); diff --git a/src/main.rs b/src/main.rs index eb5e720..389ffd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -363,11 +363,11 @@ async fn run_serve(cfg: &Config, built: Built) { net::start_discovery(state.clone()); - // периодический опрос + обслуживание; первый пинг выполняется сразу при старте + // периодический опрос + обслуживание; после 5 неудач пир переводится в offline и больше не опрашивается автоматически { let st = state.clone(); tokio::spawn(async move { - net::ping_all_peers(&st).await; + net::ping_all_peers(&st, false).await; st.maintenance(); loop { let secs = st.ping_interval_sec.load(Ordering::Relaxed); @@ -375,7 +375,7 @@ async fn run_serve(cfg: &Config, built: Built) { _ = tokio::time::sleep(Duration::from_secs(secs)) => {} _ = st.ping_notify.notified() => {} } - net::ping_all_peers(&st).await; + net::ping_all_peers(&st, false).await; st.maintenance(); } }); @@ -390,8 +390,9 @@ async fn run_serve(cfg: &Config, built: Built) { println!(" Файл настроек: {}", state.data_dir.join("config.json").display()); println!(" Принятые файлы: {}", state.media_dir.display()); println!( - " Опрос устройств: каждые {} с (UI / config.json / --ping-interval)", - state.ping_interval_sec.load(Ordering::Relaxed) + " Опрос устройств: каждые {} с (до {} попыток при недоступности)", + state.ping_interval_sec.load(Ordering::Relaxed), + MAX_PING_FAILS ); println!("--------------------------------------------------------------"); @@ -413,6 +414,7 @@ async fn run_serve(cfg: &Config, built: Built) { .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)) diff --git a/src/models.rs b/src/models.rs index d975353..0ffc1de 100644 --- a/src/models.rs +++ b/src/models.rs @@ -35,6 +35,7 @@ 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); @@ -94,6 +95,8 @@ pub struct PeerDevice { pub last_seen: u64, /// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек). pub last_ping: u64, + /// Количество подряд неудачных попыток пинга (при достижении 5 автоопрос прекращается). + pub fail_count: u8, } impl PeerDevice { @@ -110,6 +113,7 @@ impl PeerDevice { online: false, last_seen: 0, last_ping: 0, + fail_count: 0, } } } @@ -161,6 +165,11 @@ pub struct UiTrustRequest { pub trust: bool, } +#[derive(Deserialize)] +pub struct UiPingPeerRequest { + pub peer_id: String, +} + #[derive(Deserialize)] pub struct UiManualPeerRequest { pub ip: String, @@ -373,6 +382,7 @@ impl AppState { 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); @@ -385,6 +395,7 @@ impl AppState { 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); @@ -419,6 +430,7 @@ impl AppState { 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); @@ -434,6 +446,14 @@ impl AppState { } } + /// Сбрасывает счётчик неудачных пингов у всех известных устройств (вызывается при ручном опросе или сканировании). + 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(); diff --git a/src/net.rs b/src/net.rs index 8eb1418..f9ea766 100644 --- a/src/net.rs +++ b/src/net.rs @@ -145,10 +145,6 @@ async fn probe_with_hint( }) } -pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result { - probe_with_hint(state, ip, port, None).await -} - pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result { let ip_s = ip.to_string(); let had_ip = state @@ -174,7 +170,12 @@ pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Resu } // ───────────────────────── Ping и синхронизация доверия ───────────────────────── -pub async fn ping_all_peers(state: &Arc) { +/// `force = true`: ручной опрос (сбрасывает счётчик неудач `fail_count` и опрашивает всех). +/// `force = false`: фоновый автоопрос (пропускает устройства, не ответившие 5 раз подряд). +pub async fn ping_all_peers(state: &Arc, force: bool) { + if force { + state.reset_all_ping_failures(); + } let Ok(_guard) = state.ping_lock.try_lock() else { return; // опрос уже идёт }; @@ -185,39 +186,69 @@ pub async fn ping_all_peers(state: &Arc) { .peers .read() .values() - .filter(|p| !p.ips.is_empty()) + .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 { - ping_one(state, &id, ips, port).await; + 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, port: u16) { +async fn ping_one(state: &AppState, id: &str, ips: Vec, port: u16) -> bool { let parsed: Vec = ips.iter().filter_map(|s| s.parse().ok()).collect(); for ip in prioritize_ips(&parsed) { if let Ok(info) = probe_with_hint(state, ip, port, Some(id)).await { if info.fp == id { state.upsert_peer(&info.name, ip, port, id); state.set_remote_trust(id, info.trusts_you); - return; + return true; } // по этому адресу теперь другое устройство — игнорируем } } let now = now_sec(); - let mut peers = state.peers.write(); - if let Some(p) = peers.get_mut(id) { - p.last_ping = now; - if now.saturating_sub(p.last_seen) > 15 { + let mut stopped_name: Option = 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 { + 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`. @@ -230,7 +261,7 @@ pub async fn push_trust(state: &AppState, id: &str) { else { return; }; - ping_one(state, id, ips, port).await; + let _ = ping_one(state, id, ips, port).await; } /// Возвращает рабочий IP пира (для потоковой отправки файлов при возможной смене подсети). @@ -260,6 +291,7 @@ pub async fn select_peer_ip(state: &AppState, id: &str) -> Option<(IpAddr, u16)> // ───────────────────────── Сканер ───────────────────────── pub async fn run_scan(state: Arc, ips: Vec, port: u16) { + state.reset_all_ping_failures(); let st = &state; futures_util::stream::iter(ips) .for_each_concurrent(48, |ip| async move { diff --git a/src/static/app.js b/src/static/app.js index 4543207..d07d136 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -61,17 +61,29 @@ function postJson(path, 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; - $('last-ping-info').textContent = `Последний пинг: ${formatTimeAgo(d.last_ping, d.now)}`; + 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 (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]}`; @@ -94,6 +106,7 @@ function renderScan(scan) { $('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; } } @@ -128,8 +141,12 @@ function trustInfo(p) { return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false }; } -function renderPeers(peers, nowSec) { - const box = $('peers-list'); +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)); @@ -141,20 +158,20 @@ function renderPeers(peers, nowSec) { } for (const id of [...notifiedRequests]) if (!pending.has(id)) notifiedRequests.delete(id); - if (!peers.length) { - setHtml(box, '
Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.
'); + // 1) Главная вкладка: ТОЛЬКО активные (online) пиры + const mainBox = $('peers-list'); + if (!activePeers.length) { + setHtml(mainBox, '
Активных устройств сейчас нет. Нажмите «Вся подсеть», «Опросить всех известных» или перейдите во вкладку «Заверенные устройства».
'); } else { - setHtml(box, peers.map((p) => { + setHtml(mainBox, activePeers.map((p) => { const t = trustInfo(p); - const seenLabel = p.online - ? `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})` - : formatTimeAgo(p.last_seen, nowSec); + const seenLabel = `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`; const pingLabel = formatTimeAgo(p.last_ping, nowSec); return `
- ${esc(p.name)} + ${esc(p.name)} (${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})
@@ -163,19 +180,63 @@ function renderPeers(peers, nowSec) { Был онлайн: ${esc(seenLabel)} | Пинг: ${esc(pingLabel)}
- +
+ + +
`; }).join('')); } - const key = peers.map((p) => `${p.id}:${p.verified}:${p.trusts_me}:${p.ips[0] || ''}:${p.name}`).join('|'); + // 2) Вкладка «Заверенные устройства»: все verified пиры (и online, и offline) + const trustedBox = $('trusted-peers-list'); + if (!trustedPeers.length) { + setHtml(trustedBox, '
У вас пока нет заверенных устройств. Найдите устройство на главной вкладке и нажмите «Заверить».
'); + } 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 + ? 'на связи (0 ошибок)' + : p.fail_count >= limit + ? `не ответил ${p.fail_count}/${limit} раз — автоопрос остановлен (нажмите «Пинг» или «Сканировать»)` + : `оффлайн (попыток пинга: ${p.fail_count}/${limit})`; + + return ` +
+
+
+ ${esc(p.name)} + (${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)}) +
+
+ Код сверки: ${esc(p.pin_code)} | ID: ${esc(p.fingerprint.slice(0, 8).toUpperCase())}
+ Доверие: ${esc(t.text)}
+ Был онлайн: ${esc(seenLabel)} | Посл. пинг: ${esc(pingLabel)}
+ Опрос: ${failNote} +
+
+
+ + +
+
`; + }).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 = '' + peers.map((p) => { + sel.innerHTML = '' + activePeers.map((p) => { const mark = p.verified && p.trusts_me ? '✓' : p.verified ? '⏳ ждёт подтверждения' : p.trusts_me ? '🔔 запрос на сверку' : '🔒 не заверен'; return ``; }).join(''); @@ -219,7 +280,7 @@ async function fetchState() { renderHeader(d); renderScan(d.scan); renderTransfers(d.transfers); - renderPeers(d.peers, d.now); + renderPeers(d.peers, d.now, d.max_ping_fails); renderTexts(d.texts); renderMedia(d.media); } @@ -234,7 +295,7 @@ function scanIpRange(full) { 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('Сканирование запущено…'); + showStatus('Сканирование запущено (автоопрос всех пиров возобновлён)…'); fetchState().catch(() => {}); }); } @@ -270,12 +331,33 @@ function saveSettings() { function pingPeers() { return guarded(async () => { $('btn-ping').disabled = true; + $('btn-ping-trusted').disabled = true; try { await api('/ui/ping', { method: 'POST' }); - showStatus('Опрос известных узлов выполнен'); + 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; } }); } @@ -293,7 +375,7 @@ function sendText() { const peer_id = $('target-peer').value; const input = $('text-input'); const text = input.value.trim(); - if (!peer_id) return showStatus('Выберите устройство-получатель!', true); + 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 }); @@ -306,7 +388,7 @@ 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 (!peer_id) return showStatus('Выберите активное устройство-получатель!', true); if (!fileInput.files.length) return showStatus('Выберите файл!', true); const file = fileInput.files[0]; btn.disabled = true; @@ -338,22 +420,12 @@ function clearRam() { }); } -// ───────────── события (без inline-обработчиков: CSP script-src 'self') ───────────── -$('btn-ping').addEventListener('click', pingPeers); -$('btn-save-settings').addEventListener('click', saveSettings); -$('ping-interval-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') saveSettings(); }); -$('btn-manual').addEventListener('click', addManualPeer); -$('btn-scan-range').addEventListener('click', () => scanIpRange(false)); -$('btn-scan-all').addEventListener('click', () => scanIpRange(true)); -$('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', (e) => { +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; @@ -364,7 +436,33 @@ $('peers-list').addEventListener('click', (e) => { $('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', () => { diff --git a/src/static/index.html b/src/static/index.html index 7745015..e0ffe3a 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -8,12 +8,18 @@ :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: grid; grid-template-columns: 1fr 1fr; gap: 20px; } - @media (max-width: 800px) { .container { grid-template-columns: 1fr; } } - .header { grid-column: 1 / -1; background: var(--card); border: 1px solid var(--border); padding: 16px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; } + .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; } + 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; } @@ -27,10 +33,10 @@ .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; } - .path-box { font-family: monospace; font-size: .78rem; color: #93c5fd; background: rgba(59,130,246,.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; } .preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; } .peer-item.request { border-color: #fb923c; background: rgba(251,146,60,.08); } .warn { color: #fbbf24; font-size: .8rem; } @@ -46,8 +52,8 @@ .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 { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; } - #offline { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; background: rgba(239,68,68,.25); border: 1px solid var(--red); } + #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; } @@ -66,77 +72,107 @@ +
+ + +
+
Нет связи с приложением. Проверьте, что оно запущено.
-
-

- 1. Устройства в сети - -

+ +
+
+

+ 1. Активные устройства в сети + +

-
- - -
- -
-
Сканер диапазона IP-адресов (только локальные сети):
-
- - - +
+ +
-