Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a95abf18b | ||
|
|
552c009120 |
No files matched your search
@@ -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: <FP_B> -------> |
|
||||||
|
| <--- Ответ: 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 <IPv4>` | Авто | Принудительная привязка к конкретному локальному адресу |
|
||||||
|
| `--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` и др.) помечаются в интерфейсе как потенциально опасные и не открываются автоматически.
|
||||||
+38
-8
@@ -46,6 +46,24 @@ fn fmt_bytes(b: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn app_err(e: AppError) -> String {
|
||||||
if e.0 == StatusCode::FORBIDDEN {
|
if e.0 == StatusCode::FORBIDDEN {
|
||||||
format!("{} (выполните: ramshare_ui trust <устройство>)", e.1)
|
format!("{} (выполните: ramshare_ui trust <устройство>)", e.1)
|
||||||
@@ -91,13 +109,16 @@ fn print_peers(state: &AppState, json: bool, only_online: bool) {
|
|||||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into()));
|
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into()));
|
||||||
return;
|
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() {
|
if v.is_empty() {
|
||||||
println!("Устройства не найдены.");
|
println!("Устройства не найдены.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"{:<9} {:<9} {:<24} {:<22} {:<24} {}",
|
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
|
||||||
"СТАТУС", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID"
|
"СТАТУС", "БЫЛ ОНЛАЙН", "ПОСЛ. ПИНГ", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID"
|
||||||
);
|
);
|
||||||
for p in v {
|
for p in v {
|
||||||
let addr = p
|
let addr = p
|
||||||
@@ -106,10 +127,12 @@ fn print_peers(state: &AppState, json: bool, only_online: bool) {
|
|||||||
.map(|ip| format!("{}:{}", ip, p.port))
|
.map(|ip| format!("{}:{}", ip, p.port))
|
||||||
.unwrap_or_else(|| "—".into());
|
.unwrap_or_else(|| "—".into());
|
||||||
println!(
|
println!(
|
||||||
"{:<9} {:<9} {:<24} {:<22} {:<24} {}",
|
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
|
||||||
if p.online { "● online" } else { "○ offline" },
|
if p.online { "● online" } else { "○ offline" },
|
||||||
|
fmt_ago(p.last_seen, now),
|
||||||
|
fmt_ago(p.last_ping, now),
|
||||||
trust_label(&p),
|
trust_label(&p),
|
||||||
trunc(&p.name, 24),
|
trunc(&p.name, 20),
|
||||||
addr,
|
addr,
|
||||||
p.pin_code,
|
p.pin_code,
|
||||||
short_id(&p.fingerprint)
|
short_id(&p.fingerprint)
|
||||||
@@ -211,7 +234,7 @@ async fn find_peer(
|
|||||||
) -> Result<PeerDevice, String> {
|
) -> Result<PeerDevice, String> {
|
||||||
let (host, port_opt) = split_sel(sel);
|
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 let Some(p) = resolve(state, &host)? {
|
||||||
if p.online || !need_online {
|
if p.online || !need_online {
|
||||||
return Ok(p);
|
return Ok(p);
|
||||||
@@ -261,14 +284,14 @@ async fn find_peer(
|
|||||||
|
|
||||||
// ───────────────────────── Команды ─────────────────────────
|
// ───────────────────────── Команды ─────────────────────────
|
||||||
pub async fn cmd_scan(state: &Arc<AppState>, range: &str, opts: &CliOpts) -> Result<(), String> {
|
pub async fn cmd_scan(state: &Arc<AppState>, 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?;
|
scan_with_progress(state, range).await?;
|
||||||
print_peers(state, opts.json, true);
|
print_peers(state, opts.json, true);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cmd_peers(state: &Arc<AppState>, opts: &CliOpts) -> Result<(), String> {
|
pub async fn cmd_peers(state: &Arc<AppState>, opts: &CliOpts) -> Result<(), String> {
|
||||||
net::ping_all_peers(state).await;
|
net::ping_all_peers(state, true).await;
|
||||||
ensure_discovery(state);
|
ensure_discovery(state);
|
||||||
if let Some(r) = &opts.scan {
|
if let Some(r) = &opts.scan {
|
||||||
scan_with_progress(state, r).await?;
|
scan_with_progress(state, r).await?;
|
||||||
@@ -303,6 +326,7 @@ pub async fn cmd_trust(state: &Arc<AppState>, sel: &str, opts: &CliOpts) -> Resu
|
|||||||
}
|
}
|
||||||
if let Some(x) = state.peers.write().get_mut(&p.id) {
|
if let Some(x) = state.peers.write().get_mut(&p.id) {
|
||||||
x.verified = true;
|
x.verified = true;
|
||||||
|
x.fail_count = 0;
|
||||||
}
|
}
|
||||||
state.persist_trust();
|
state.persist_trust();
|
||||||
println!("Вы подтвердили: {} [{}]", p.name, p.pin_code);
|
println!("Вы подтвердили: {} [{}]", p.name, p.pin_code);
|
||||||
@@ -320,6 +344,7 @@ pub async fn cmd_trust(state: &Arc<AppState>, sel: &str, opts: &CliOpts) -> Resu
|
|||||||
.map_or(false, |x| x.verified && x.trusts_me)
|
.map_or(false, |x| x.verified && x.trusts_me)
|
||||||
};
|
};
|
||||||
if mutual() {
|
if mutual() {
|
||||||
|
state.persist_trust();
|
||||||
println!("✓ Взаимная сверка завершена: {}", p.name);
|
println!("✓ Взаимная сверка завершена: {}", p.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -337,6 +362,7 @@ pub async fn cmd_trust(state: &Arc<AppState>, sel: &str, opts: &CliOpts) -> Resu
|
|||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
net::push_trust(state, &p.id).await;
|
net::push_trust(state, &p.id).await;
|
||||||
if mutual() {
|
if mutual() {
|
||||||
|
state.persist_trust();
|
||||||
println!("✓ Взаимная сверка завершена: {}", p.name);
|
println!("✓ Взаимная сверка завершена: {}", p.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -383,6 +409,7 @@ pub async fn cmd_send_text(
|
|||||||
}
|
}
|
||||||
let p = find_peer(state, sel, opts, true).await?;
|
let p = find_peer(state, sel, opts, true).await?;
|
||||||
let name = transfer::send_text(state, &p.id, text).await.map_err(app_err)?;
|
let name = transfer::send_text(state, &p.id, text).await.map_err(app_err)?;
|
||||||
|
state.persist_trust();
|
||||||
println!("✓ Текст доставлен в ОЗУ устройства «{}»", name);
|
println!("✓ Текст доставлен в ОЗУ устройства «{}»", name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -457,7 +484,10 @@ pub async fn cmd_send_files(
|
|||||||
eprint!("\r{}\r", " ".repeat(72));
|
eprint!("\r{}\r", " ".repeat(72));
|
||||||
}
|
}
|
||||||
match res {
|
match res {
|
||||||
Ok(peer) => println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size)),
|
Ok(peer) => {
|
||||||
|
state.persist_trust();
|
||||||
|
println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size));
|
||||||
|
}
|
||||||
Err(e) => return Err(format!("{}: {}", name, app_err(e))),
|
Err(e) => return Err(format!("{}: {}", name, app_err(e))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -282,6 +282,7 @@ fn build_state(cfg: &Config, serving: bool) -> Built {
|
|||||||
let port = if t.port == 0 { 8443 } else { t.port };
|
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);
|
let mut p = PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true);
|
||||||
p.trusts_me = t.trusts_me;
|
p.trusts_me = t.trusts_me;
|
||||||
|
p.last_seen = t.last_seen;
|
||||||
known.insert(t.fp.clone(), p);
|
known.insert(t.fp.clone(), p);
|
||||||
}
|
}
|
||||||
let peers = Arc::new(RwLock::new(known));
|
let peers = Arc::new(RwLock::new(known));
|
||||||
@@ -322,6 +323,7 @@ fn build_state(cfg: &Config, serving: bool) -> Built {
|
|||||||
trust_dirty: AtomicBool::new(false),
|
trust_dirty: AtomicBool::new(false),
|
||||||
probe_cooldown: PlMutex::new(HashMap::new()),
|
probe_cooldown: PlMutex::new(HashMap::new()),
|
||||||
ping_interval_sec: AtomicU64::new(ping_sec),
|
ping_interval_sec: AtomicU64::new(ping_sec),
|
||||||
|
last_ping: AtomicU64::new(0),
|
||||||
ping_notify: Notify::new(),
|
ping_notify: Notify::new(),
|
||||||
log_stdout: serving,
|
log_stdout: serving,
|
||||||
headless: serving && cfg.no_ui,
|
headless: serving && cfg.no_ui,
|
||||||
@@ -361,17 +363,19 @@ async fn run_serve(cfg: &Config, built: Built) {
|
|||||||
|
|
||||||
net::start_discovery(state.clone());
|
net::start_discovery(state.clone());
|
||||||
|
|
||||||
// периодический опрос + обслуживание; интервал меняется на лету (UI / config.json / --ping-interval)
|
// периодический опрос + обслуживание; после 5 неудач пир переводится в offline и больше не опрашивается автоматически
|
||||||
{
|
{
|
||||||
let st = state.clone();
|
let st = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
net::ping_all_peers(&st, false).await;
|
||||||
|
st.maintenance();
|
||||||
loop {
|
loop {
|
||||||
let secs = st.ping_interval_sec.load(Ordering::Relaxed);
|
let secs = st.ping_interval_sec.load(Ordering::Relaxed);
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = tokio::time::sleep(Duration::from_secs(secs)) => {}
|
_ = tokio::time::sleep(Duration::from_secs(secs)) => {}
|
||||||
_ = st.ping_notify.notified() => {}
|
_ = st.ping_notify.notified() => {}
|
||||||
}
|
}
|
||||||
net::ping_all_peers(&st).await;
|
net::ping_all_peers(&st, false).await;
|
||||||
st.maintenance();
|
st.maintenance();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -386,23 +390,22 @@ async fn run_serve(cfg: &Config, built: Built) {
|
|||||||
println!(" Файл настроек: {}", state.data_dir.join("config.json").display());
|
println!(" Файл настроек: {}", state.data_dir.join("config.json").display());
|
||||||
println!(" Принятые файлы: {}", state.media_dir.display());
|
println!(" Принятые файлы: {}", state.media_dir.display());
|
||||||
println!(
|
println!(
|
||||||
" Опрос устройств: каждые {} с (UI / config.json / --ping-interval)",
|
" Опрос устройств: каждые {} с (до {} попыток при недоступности)",
|
||||||
state.ping_interval_sec.load(Ordering::Relaxed)
|
state.ping_interval_sec.load(Ordering::Relaxed),
|
||||||
|
MAX_PING_FAILS
|
||||||
);
|
);
|
||||||
println!("--------------------------------------------------------------");
|
println!("--------------------------------------------------------------");
|
||||||
|
|
||||||
let ui_task = match ui_listener {
|
let ui_task = match ui_listener {
|
||||||
Some(listener) => {
|
Some(listener) => {
|
||||||
let guard = Arc::new(ui::UiGuard {
|
let guard = Arc::new(ui::UiGuard {
|
||||||
token: random_hex(16),
|
|
||||||
cookie_name: format!("rs_token_{}", cfg.ui_port),
|
|
||||||
hosts: vec![
|
hosts: vec![
|
||||||
format!("127.0.0.1:{}", cfg.ui_port),
|
format!("127.0.0.1:{}", cfg.ui_port),
|
||||||
format!("localhost:{}", cfg.ui_port),
|
format!("localhost:{}", cfg.ui_port),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
println!(" ОТКРОЙТЕ UI (ссылка содержит одноразовый токен сессии):");
|
println!(" ОТКРОЙТЕ ВЕБ-ИНТЕРФЕЙС:");
|
||||||
println!(" http://127.0.0.1:{}/?t={}", cfg.ui_port, guard.token);
|
println!(" http://127.0.0.1:{}/", cfg.ui_port);
|
||||||
|
|
||||||
let ui_router = Router::new()
|
let ui_router = Router::new()
|
||||||
.route("/", get(ui::ui_index))
|
.route("/", get(ui::ui_index))
|
||||||
@@ -411,6 +414,7 @@ async fn run_serve(cfg: &Config, built: Built) {
|
|||||||
.route("/ui/manual-peer", post(ui::ui_add_manual_peer))
|
.route("/ui/manual-peer", post(ui::ui_add_manual_peer))
|
||||||
.route("/ui/scan-range", post(ui::ui_scan_range))
|
.route("/ui/scan-range", post(ui::ui_scan_range))
|
||||||
.route("/ui/ping", post(ui::ui_ping_peers))
|
.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/trust", post(ui::ui_trust_peer))
|
||||||
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
||||||
.route("/ui/settings", post(ui::ui_set_settings))
|
.route("/ui/settings", post(ui::ui_set_settings))
|
||||||
|
|||||||
+32
-3
@@ -35,6 +35,7 @@ pub const DISCOVERY_PORT: u16 = 5354;
|
|||||||
pub const MIN_PING_SEC: u64 = 10;
|
pub const MIN_PING_SEC: u64 = 10;
|
||||||
pub const MAX_PING_SEC: u64 = 30;
|
pub const MAX_PING_SEC: u64 = 30;
|
||||||
pub const DEFAULT_PING_SEC: u64 = 15;
|
pub const DEFAULT_PING_SEC: u64 = 15;
|
||||||
|
pub const MAX_PING_FAILS: u8 = 5;
|
||||||
|
|
||||||
// ───────────────────────── Ошибки ─────────────────────────
|
// ───────────────────────── Ошибки ─────────────────────────
|
||||||
pub struct AppError(pub StatusCode, pub String);
|
pub struct AppError(pub StatusCode, pub String);
|
||||||
@@ -90,7 +91,12 @@ pub struct PeerDevice {
|
|||||||
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
||||||
pub trusts_me: bool,
|
pub trusts_me: bool,
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
|
/// Когда устройство последний раз отвечало по сети (unix timestamp, сек).
|
||||||
pub last_seen: u64,
|
pub last_seen: u64,
|
||||||
|
/// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек).
|
||||||
|
pub last_ping: u64,
|
||||||
|
/// Количество подряд неудачных попыток пинга (при достижении 5 автоопрос прекращается).
|
||||||
|
pub fail_count: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeerDevice {
|
impl PeerDevice {
|
||||||
@@ -106,6 +112,8 @@ impl PeerDevice {
|
|||||||
trusts_me: false,
|
trusts_me: false,
|
||||||
online: false,
|
online: false,
|
||||||
last_seen: 0,
|
last_seen: 0,
|
||||||
|
last_ping: 0,
|
||||||
|
fail_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +165,11 @@ pub struct UiTrustRequest {
|
|||||||
pub trust: bool,
|
pub trust: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiPingPeerRequest {
|
||||||
|
pub peer_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UiManualPeerRequest {
|
pub struct UiManualPeerRequest {
|
||||||
pub ip: String,
|
pub ip: String,
|
||||||
@@ -198,6 +211,8 @@ pub struct AppState {
|
|||||||
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
||||||
/// Интервал опроса известных пиров (сек), меняется на лету
|
/// Интервал опроса известных пиров (сек), меняется на лету
|
||||||
pub ping_interval_sec: AtomicU64,
|
pub ping_interval_sec: AtomicU64,
|
||||||
|
/// Время последнего глобального опроса устройств (unix timestamp, сек)
|
||||||
|
pub last_ping: AtomicU64,
|
||||||
pub ping_notify: Notify,
|
pub ping_notify: Notify,
|
||||||
/// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом)
|
/// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом)
|
||||||
pub log_stdout: bool,
|
pub log_stdout: bool,
|
||||||
@@ -303,6 +318,7 @@ impl AppState {
|
|||||||
ips: p.ips.clone(),
|
ips: p.ips.clone(),
|
||||||
port: p.port,
|
port: p.port,
|
||||||
trusts_me: p.trusts_me,
|
trusts_me: p.trusts_me,
|
||||||
|
last_seen: p.last_seen,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
match storage::save_trusted(&self.data_dir, &entries) {
|
match storage::save_trusted(&self.data_dir, &entries) {
|
||||||
@@ -366,7 +382,9 @@ impl AppState {
|
|||||||
p.name = name.clone();
|
p.name = name.clone();
|
||||||
p.port = port;
|
p.port = port;
|
||||||
p.online = true;
|
p.online = true;
|
||||||
|
p.fail_count = 0;
|
||||||
p.last_seen = now;
|
p.last_seen = now;
|
||||||
|
p.last_ping = now;
|
||||||
p.ips.retain(|x| x != &ip_s);
|
p.ips.retain(|x| x != &ip_s);
|
||||||
p.ips.insert(0, ip_s.clone());
|
p.ips.insert(0, ip_s.clone());
|
||||||
p.ips.truncate(MAX_PEER_IPS);
|
p.ips.truncate(MAX_PEER_IPS);
|
||||||
@@ -377,7 +395,9 @@ impl AppState {
|
|||||||
Entry::Vacant(v) => {
|
Entry::Vacant(v) => {
|
||||||
let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false);
|
let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false);
|
||||||
p.online = true;
|
p.online = true;
|
||||||
|
p.fail_count = 0;
|
||||||
p.last_seen = now;
|
p.last_seen = now;
|
||||||
|
p.last_ping = now;
|
||||||
v.insert(p);
|
v.insert(p);
|
||||||
created = true;
|
created = true;
|
||||||
}
|
}
|
||||||
@@ -405,11 +425,13 @@ impl AppState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ip_s = ip.to_string();
|
let ip_s = ip.to_string();
|
||||||
|
let now = now_sec();
|
||||||
let mut dirty = false;
|
let mut dirty = false;
|
||||||
if let Some(p) = self.peers.write().get_mut(fp) {
|
if let Some(p) = self.peers.write().get_mut(fp) {
|
||||||
let changed = p.ips.first() != Some(&ip_s);
|
let changed = p.ips.first() != Some(&ip_s);
|
||||||
p.online = true;
|
p.online = true;
|
||||||
p.last_seen = now_sec();
|
p.fail_count = 0;
|
||||||
|
p.last_seen = now;
|
||||||
if changed {
|
if changed {
|
||||||
p.ips.retain(|x| x != &ip_s);
|
p.ips.retain(|x| x != &ip_s);
|
||||||
p.ips.insert(0, ip_s);
|
p.ips.insert(0, ip_s);
|
||||||
@@ -424,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 не нужен.
|
/// Пир уже недавно виден по этому адресу и порту — повторный probe по beacon не нужен.
|
||||||
pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool {
|
pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool {
|
||||||
let ip_s = ip.to_string();
|
let ip_s = ip.to_string();
|
||||||
@@ -496,7 +526,6 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust).
|
/// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust).
|
||||||
/// Повреждённый файл игнорируется, а не «разжалует» всех пиров.
|
|
||||||
pub fn sync_trust_from_disk(&self) {
|
pub fn sync_trust_from_disk(&self) {
|
||||||
let path = self.data_dir.join("trusted.json");
|
let path = self.data_dir.join("trusted.json");
|
||||||
let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return };
|
let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return };
|
||||||
@@ -528,6 +557,7 @@ impl AppState {
|
|||||||
let port = if e.port == 0 { 8443 } else { e.port };
|
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);
|
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.trusts_me = e.trusts_me;
|
||||||
|
np.last_seen = e.last_seen;
|
||||||
peers.insert(e.fp.clone(), np);
|
peers.insert(e.fp.clone(), np);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,7 +611,6 @@ pub fn short_id(fp: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа.
|
/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа.
|
||||||
/// Подбор ключа «под префикс» не работает: код зависит от обоих сертификатов.
|
|
||||||
pub fn sas_code(a: &str, b: &str) -> String {
|
pub fn sas_code(a: &str, b: &str) -> String {
|
||||||
let (x, y) = if a <= b { (a, b) } else { (b, a) };
|
let (x, y) = if a <= b { (a, b) } else { (b, a) };
|
||||||
let mut h = Sha256::new();
|
let mut h = Sha256::new();
|
||||||
|
|||||||
+48
-12
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use socket2::{Domain, Protocol, Socket, Type};
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
use std::{
|
use std::{
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
||||||
sync::Arc,
|
sync::{atomic::Ordering, Arc},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tokio::{net::UdpSocket, sync::Semaphore};
|
use tokio::{net::UdpSocket, sync::Semaphore};
|
||||||
@@ -145,10 +145,6 @@ async fn probe_with_hint(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
|
||||||
probe_with_hint(state, ip, port, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
||||||
let ip_s = ip.to_string();
|
let ip_s = ip.to_string();
|
||||||
let had_ip = state
|
let had_ip = state
|
||||||
@@ -174,7 +170,12 @@ pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── Ping и синхронизация доверия ─────────────────────────
|
// ───────────────────────── Ping и синхронизация доверия ─────────────────────────
|
||||||
pub async fn ping_all_peers(state: &Arc<AppState>) {
|
/// `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 Ok(_guard) = state.ping_lock.try_lock() else {
|
let Ok(_guard) = state.ping_lock.try_lock() else {
|
||||||
return; // опрос уже идёт
|
return; // опрос уже идёт
|
||||||
};
|
};
|
||||||
@@ -185,35 +186,69 @@ pub async fn ping_all_peers(state: &Arc<AppState>) {
|
|||||||
.peers
|
.peers
|
||||||
.read()
|
.read()
|
||||||
.values()
|
.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))
|
.map(|p| (p.id.clone(), p.ips.clone(), p.port))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
futures_util::stream::iter(targets)
|
futures_util::stream::iter(targets)
|
||||||
.for_each_concurrent(8, |(id, ips, port)| async move {
|
.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;
|
.await;
|
||||||
|
|
||||||
|
state.last_ping.store(now_sec(), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) {
|
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) -> bool {
|
||||||
let parsed: Vec<IpAddr> = ips.iter().filter_map(|s| s.parse().ok()).collect();
|
let parsed: Vec<IpAddr> = ips.iter().filter_map(|s| s.parse().ok()).collect();
|
||||||
for ip in prioritize_ips(&parsed) {
|
for ip in prioritize_ips(&parsed) {
|
||||||
if let Ok(info) = probe_with_hint(state, ip, port, Some(id)).await {
|
if let Ok(info) = probe_with_hint(state, ip, port, Some(id)).await {
|
||||||
if info.fp == id {
|
if info.fp == id {
|
||||||
state.upsert_peer(&info.name, ip, port, id);
|
state.upsert_peer(&info.name, ip, port, id);
|
||||||
state.set_remote_trust(id, info.trusts_you);
|
state.set_remote_trust(id, info.trusts_you);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
// по этому адресу теперь другое устройство — игнорируем
|
// по этому адресу теперь другое устройство — игнорируем
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let now = now_sec();
|
||||||
|
let mut stopped_name: Option<String> = None;
|
||||||
|
{
|
||||||
let mut peers = state.peers.write();
|
let mut peers = state.peers.write();
|
||||||
if let Some(p) = peers.get_mut(id) {
|
if let Some(p) = peers.get_mut(id) {
|
||||||
if now_sec().saturating_sub(p.last_seen) > 15 {
|
p.last_ping = now;
|
||||||
p.online = false;
|
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`.
|
/// Мгновенно отправляет обновлённый статус доверия пиру и обновляет его `trusts_me`.
|
||||||
@@ -226,7 +261,7 @@ pub async fn push_trust(state: &AppState, id: &str) {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
ping_one(state, id, ips, port).await;
|
let _ = ping_one(state, id, ips, port).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Возвращает рабочий IP пира (для потоковой отправки файлов при возможной смене подсети).
|
/// Возвращает рабочий IP пира (для потоковой отправки файлов при возможной смене подсети).
|
||||||
@@ -256,6 +291,7 @@ pub async fn select_peer_ip(state: &AppState, id: &str) -> Option<(IpAddr, u16)>
|
|||||||
|
|
||||||
// ───────────────────────── Сканер ─────────────────────────
|
// ───────────────────────── Сканер ─────────────────────────
|
||||||
pub async fn run_scan(state: Arc<AppState>, ips: Vec<Ipv4Addr>, port: u16) {
|
pub async fn run_scan(state: Arc<AppState>, ips: Vec<Ipv4Addr>, port: u16) {
|
||||||
|
state.reset_all_ping_failures();
|
||||||
let st = &state;
|
let st = &state;
|
||||||
futures_util::stream::iter(ips)
|
futures_util::stream::iter(ips)
|
||||||
.for_each_concurrent(48, |ip| async move {
|
.for_each_concurrent(48, |ip| async move {
|
||||||
|
|||||||
+158
-35
@@ -28,6 +28,20 @@ function formatBytes(b) {
|
|||||||
return `${(b / 1024 ** 3).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/картинки)
|
// Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки)
|
||||||
function setHtml(el, html) {
|
function setHtml(el, html) {
|
||||||
if (el._h !== html) { el._h = html; el.innerHTML = html; }
|
if (el._h !== html) { el._h = html; el.innerHTML = html; }
|
||||||
@@ -47,16 +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) {
|
function renderHeader(d) {
|
||||||
$('my-name').textContent = `Устройство: ${d.device_name}`;
|
$('my-name').textContent = `Устройство: ${d.device_name}`;
|
||||||
$('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
|
$('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
|
||||||
$('my-pin').textContent = d.my_pin;
|
$('my-pin').textContent = d.my_pin;
|
||||||
$('media-dir').textContent = d.media_dir;
|
$('media-dir').textContent = d.media_dir;
|
||||||
|
const pingTxt = `Последний пинг: ${formatTimeAgo(d.last_ping, d.now)}`;
|
||||||
|
$('last-ping-info').textContent = pingTxt;
|
||||||
|
$('trusted-last-ping').textContent = pingTxt;
|
||||||
|
|
||||||
const pi = $('ping-interval-input');
|
const pi = $('ping-interval-input');
|
||||||
pi.min = d.ping_min;
|
pi.min = d.ping_min;
|
||||||
pi.max = d.ping_max;
|
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.')) {
|
if (d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) {
|
||||||
const p = d.my_ip.split('.');
|
const p = d.my_ip.split('.');
|
||||||
const subnet = `${p[0]}.${p[1]}.${p[2]}`;
|
const subnet = `${p[0]}.${p[1]}.${p[2]}`;
|
||||||
@@ -79,6 +106,7 @@ function renderScan(scan) {
|
|||||||
$('scan-bar-fill').style.width = `${pct}%`;
|
$('scan-bar-fill').style.width = `${pct}%`;
|
||||||
$('btn-scan-range').disabled = scan.active;
|
$('btn-scan-range').disabled = scan.active;
|
||||||
$('btn-scan-all').disabled = scan.active;
|
$('btn-scan-all').disabled = scan.active;
|
||||||
|
$('btn-scan-all-trusted').disabled = scan.active;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +141,12 @@ function trustInfo(p) {
|
|||||||
return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false };
|
return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPeers(peers) {
|
function renderPeers(peers, nowSec, maxFails) {
|
||||||
const box = $('peers-list');
|
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));
|
const pending = new Set(peers.filter((p) => !p.verified && p.trusts_me).map((p) => p.id));
|
||||||
@@ -126,36 +158,85 @@ function renderPeers(peers) {
|
|||||||
}
|
}
|
||||||
for (const id of [...notifiedRequests]) if (!pending.has(id)) notifiedRequests.delete(id);
|
for (const id of [...notifiedRequests]) if (!pending.has(id)) notifiedRequests.delete(id);
|
||||||
|
|
||||||
if (!peers.length) {
|
// 1) Главная вкладка: ТОЛЬКО активные (online) пиры
|
||||||
setHtml(box, '<div class="muted" style="font-size:.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.</div>');
|
const mainBox = $('peers-list');
|
||||||
|
if (!activePeers.length) {
|
||||||
|
setHtml(mainBox, '<div class="muted" style="font-size:.9rem;">Активных устройств сейчас нет. Нажмите «Вся подсеть», «Опросить всех известных» или перейдите во вкладку «Заверенные устройства».</div>');
|
||||||
} else {
|
} else {
|
||||||
setHtml(box, peers.map((p) => {
|
setHtml(mainBox, activePeers.map((p) => {
|
||||||
const t = trustInfo(p);
|
const t = trustInfo(p);
|
||||||
|
const seenLabel = `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`;
|
||||||
|
const pingLabel = formatTimeAgo(p.last_ping, nowSec);
|
||||||
return `
|
return `
|
||||||
<div class="peer-item${t.req ? ' request' : ''}">
|
<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="min-width:0;">
|
||||||
<div style="font-weight:600;">
|
<div style="font-weight:600;">
|
||||||
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
|
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
|
||||||
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
|
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
||||||
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
|
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b> | ID: ${esc(p.fingerprint.slice(0, 8).toUpperCase())}<br>
|
||||||
Статус: <span style="color:${t.color};">${esc(t.text)}</span>
|
Доверие: <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>
|
</div>
|
||||||
<button class="btn-sm ${t.btnCls}" data-action="trust" data-id="${esc(p.id)}" data-trust="${t.trust}">
|
<div class="peer-actions">
|
||||||
${esc(t.btn)}
|
<button class="btn-sm" data-action="ping-one" data-id="${esc(p.id)}">Пинг</button>
|
||||||
</button>
|
<button class="btn-sm btn-red" data-action="trust" data-id="${esc(p.id)}" data-trust="0">Отозвать</button>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join(''));
|
}).join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = peers.map((p) => `${p.id}:${p.verified}:${p.trusts_me}:${p.ips[0] || ''}:${p.name}`).join('|');
|
// Выпадающий список получателей: только активные (online) устройства
|
||||||
|
const key = activePeers.map((p) => `${p.id}:${p.verified}:${p.trusts_me}:${p.ips[0] || ''}:${p.name}`).join('|');
|
||||||
if (key !== knownPeersKey) {
|
if (key !== knownPeersKey) {
|
||||||
knownPeersKey = key;
|
knownPeersKey = key;
|
||||||
const sel = $('target-peer');
|
const sel = $('target-peer');
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
sel.innerHTML = '<option value="">-- Выберите устройство --</option>' + peers.map((p) => {
|
sel.innerHTML = '<option value="">-- Выберите активное устройство --</option>' + activePeers.map((p) => {
|
||||||
const mark = p.verified && p.trusts_me ? '✓' : p.verified ? '⏳ ждёт подтверждения' : p.trusts_me ? '🔔 запрос на сверку' : '🔒 не заверен';
|
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>`;
|
return `<option value="${esc(p.id)}">${esc(p.name)} (${esc(p.ips[0] || '—')}) ${mark}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -199,7 +280,7 @@ async function fetchState() {
|
|||||||
renderHeader(d);
|
renderHeader(d);
|
||||||
renderScan(d.scan);
|
renderScan(d.scan);
|
||||||
renderTransfers(d.transfers);
|
renderTransfers(d.transfers);
|
||||||
renderPeers(d.peers);
|
renderPeers(d.peers, d.now, d.max_ping_fails);
|
||||||
renderTexts(d.texts);
|
renderTexts(d.texts);
|
||||||
renderMedia(d.media);
|
renderMedia(d.media);
|
||||||
}
|
}
|
||||||
@@ -214,7 +295,7 @@ function scanIpRange(full) {
|
|||||||
const range = full ? 'all' : $('scan-range-input').value.trim();
|
const range = full ? 'all' : $('scan-range-input').value.trim();
|
||||||
if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true);
|
if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true);
|
||||||
await postJson('/ui/scan-range', { range });
|
await postJson('/ui/scan-range', { range });
|
||||||
showStatus('Сканирование запущено…');
|
showStatus('Сканирование запущено (автоопрос всех пиров возобновлён)…');
|
||||||
fetchState().catch(() => {});
|
fetchState().catch(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -249,9 +330,35 @@ function saveSettings() {
|
|||||||
|
|
||||||
function pingPeers() {
|
function pingPeers() {
|
||||||
return guarded(async () => {
|
return guarded(async () => {
|
||||||
|
$('btn-ping').disabled = true;
|
||||||
|
$('btn-ping-trusted').disabled = true;
|
||||||
|
try {
|
||||||
await api('/ui/ping', { method: 'POST' });
|
await api('/ui/ping', { method: 'POST' });
|
||||||
showStatus('Запущен опрос известных узлов');
|
showStatus('Ручной опрос выполнен (счётчики попыток сброшены)');
|
||||||
setTimeout(() => fetchState().catch(() => {}), 800);
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +375,7 @@ function sendText() {
|
|||||||
const peer_id = $('target-peer').value;
|
const peer_id = $('target-peer').value;
|
||||||
const input = $('text-input');
|
const input = $('text-input');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
if (!peer_id) return showStatus('Выберите активное устройство-получатель!', true);
|
||||||
if (!text) return showStatus('Введите текст для отправки!', true);
|
if (!text) return showStatus('Введите текст для отправки!', true);
|
||||||
if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true);
|
if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true);
|
||||||
await postJson('/ui/send-text', { peer_id, text });
|
await postJson('/ui/send-text', { peer_id, text });
|
||||||
@@ -281,7 +388,7 @@ async function sendMedia() {
|
|||||||
const peer_id = $('target-peer').value;
|
const peer_id = $('target-peer').value;
|
||||||
const fileInput = $('media-input');
|
const fileInput = $('media-input');
|
||||||
const btn = $('send-media-btn');
|
const btn = $('send-media-btn');
|
||||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
if (!peer_id) return showStatus('Выберите активное устройство-получатель!', true);
|
||||||
if (!fileInput.files.length) return showStatus('Выберите файл!', true);
|
if (!fileInput.files.length) return showStatus('Выберите файл!', true);
|
||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -313,22 +420,12 @@ function clearRam() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────── события (без inline-обработчиков: CSP script-src 'self') ─────────────
|
function handlePeerListClick(e) {
|
||||||
$('btn-ping').addEventListener('click', pingPeers);
|
const pingBtn = e.target.closest('button[data-action="ping-one"]');
|
||||||
$('btn-save-settings').addEventListener('click', saveSettings);
|
if (pingBtn) {
|
||||||
$('ping-interval-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') saveSettings(); });
|
pingOnePeer(pingBtn.dataset.id, pingBtn);
|
||||||
$('btn-manual').addEventListener('click', addManualPeer);
|
return;
|
||||||
$('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) => {
|
|
||||||
const btn = e.target.closest('button[data-action="trust"]');
|
const btn = e.target.closest('button[data-action="trust"]');
|
||||||
if (!btn || !lastData) return;
|
if (!btn || !lastData) return;
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
@@ -339,7 +436,33 @@ $('peers-list').addEventListener('click', (e) => {
|
|||||||
$('td-name').textContent = peer.name;
|
$('td-name').textContent = peer.name;
|
||||||
$('td-code').textContent = peer.pin_code;
|
$('td-code').textContent = peer.pin_code;
|
||||||
$('trust-dialog').showModal();
|
$('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-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); });
|
||||||
$('td-ok').addEventListener('click', () => {
|
$('td-ok').addEventListener('click', () => {
|
||||||
|
|||||||
+53
-14
@@ -8,12 +8,18 @@
|
|||||||
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
|
: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; }
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
body { margin: 0; padding: 20px; background: var(--bg); color: var(--text); }
|
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; }
|
.container { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
||||||
@media (max-width: 800px) { .container { grid-template-columns: 1fr; } }
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
.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; }
|
@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; }
|
.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; }
|
.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 { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
|
||||||
.tag-ram { background: rgba(16,185,129,.2); color: #34d399; }
|
.tag-ram { background: rgba(16,185,129,.2); color: #34d399; }
|
||||||
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
|
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
|
||||||
@@ -27,10 +33,10 @@
|
|||||||
.btn-gray { background: #475569; }
|
.btn-gray { background: #475569; }
|
||||||
.btn-sm { padding: 7px 12px; font-size: .82rem; }
|
.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-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-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-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; }
|
.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; }
|
.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); }
|
.peer-item.request { border-color: #fb923c; background: rgba(251,146,60,.08); }
|
||||||
.warn { color: #fbbf24; font-size: .8rem; }
|
.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; }
|
.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; }
|
.row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.col { display: flex; flex-direction: column; gap: 8px; }
|
.col { display: flex; flex-direction: column; gap: 8px; }
|
||||||
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
|
#status { 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); }
|
#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 { 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); }
|
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; }
|
.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,13 +72,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="offline">Нет связи с приложением. Проверьте, что оно запущено.</div>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<!-- ВКЛАДКА 1: ГЛАВНАЯ (ТОЛЬКО АКТИВНЫЕ) -->
|
||||||
|
<div id="view-main" class="grid-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
<span>1. Устройства в сети</span>
|
<span>1. Активные устройства в сети</span>
|
||||||
<button class="btn-sm" id="btn-ping">Опросить известных</button>
|
<button class="btn-sm" id="btn-ping">Опросить всех известных</button>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="row" style="flex-wrap:nowrap;">
|
<div class="row" style="flex-wrap:nowrap;">
|
||||||
@@ -96,21 +115,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="align-items:center;justify-content:space-between;">
|
||||||
<div class="row" style="align-items:center;">
|
<div class="row" style="align-items:center;">
|
||||||
<label class="small muted" for="ping-interval-input">Опрос известных устройств каждые</label>
|
<label class="small muted" for="ping-interval-input">Опрос каждые</label>
|
||||||
<input type="number" id="ping-interval-input" min="10" max="30" step="1" style="width:80px;">
|
<input type="number" id="ping-interval-input" min="10" max="30" step="1" style="width:74px;">
|
||||||
<span class="small muted">сек (10–30)</span>
|
<span class="small muted">сек (10–30)</span>
|
||||||
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
|
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="small muted" id="last-ping-info">Последний пинг: —</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="peers-list" class="col"></div>
|
<div id="peers-list" class="col"></div>
|
||||||
<div class="small muted">Сверка взаимная: когда вы подтверждаете код, второе устройство получает запрос и тоже должно сверить и подтвердить код у себя. Пока оба не подтвердили, передачи не проходят.</div>
|
<div class="small muted">На главной показаны только устройства, которые сейчас онлайн. Если устройство не отвечает 5 раз подряд, его автоопрос прекращается (посмотреть все заверенные и пингануть вручную можно во вкладке «Заверенные устройства»).</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>2. Отправка данных</h2>
|
<h2>2. Отправка данных</h2>
|
||||||
<label class="small muted">Получатель (должен быть заверен):</label>
|
<label class="small muted">Активный получатель (должен быть заверен):</label>
|
||||||
<select id="target-peer"><option value="">-- Выберите устройство --</option></select>
|
<select id="target-peer"><option value="">-- Выберите активное устройство --</option></select>
|
||||||
|
|
||||||
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка, до 64 КБ)..."></textarea>
|
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка, до 64 КБ)..."></textarea>
|
||||||
<button id="btn-send-text">Отправить текст в ОЗУ</button>
|
<button id="btn-send-text">Отправить текст в ОЗУ</button>
|
||||||
@@ -135,6 +157,23 @@
|
|||||||
<div class="small muted mono" id="media-dir"></div>
|
<div class="small muted mono" id="media-dir"></div>
|
||||||
<div id="disk-media" class="col" style="max-height:400px;overflow-y:auto;"></div>
|
<div id="disk-media" class="col" style="max-height:400px;overflow-y:auto;"></div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<dialog id="trust-dialog">
|
<dialog id="trust-dialog">
|
||||||
|
|||||||
+4
-5
@@ -18,6 +18,9 @@ pub struct TrustedEntry {
|
|||||||
/// последнее известное «собеседник подтвердил нас» (уточняется при каждом опросе)
|
/// последнее известное «собеседник подтвердил нас» (уточняется при каждом опросе)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trusts_me: bool,
|
pub trusts_me: bool,
|
||||||
|
/// когда устройство последний раз было онлайн (unix timestamp, сек)
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_seen: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Папка, где лежит исполняемый файл ramshare_ui.
|
/// Папка, где лежит исполняемый файл ramshare_ui.
|
||||||
@@ -87,9 +90,6 @@ fn migrate_legacy(config: &Path, p2p_port: u16) {
|
|||||||
/// Раскладка каталогов:
|
/// Раскладка каталогов:
|
||||||
/// <папка с ramshare_ui>/config/ — config.json, trusted.json
|
/// <папка с ramshare_ui>/config/ — config.json, trusted.json
|
||||||
/// <папка с ramshare_ui>/received_media/ — принятые файлы
|
/// <папка с ramshare_ui>/received_media/ — принятые файлы
|
||||||
/// `--data-dir X` заменяет «папку с ramshare_ui» на X. Для не-стандартного P2P-порта без --data-dir
|
|
||||||
/// каталоги получают суффикс (config-8444), чтобы экземпляры не делили одну идентичность.
|
|
||||||
/// Если папка программы недоступна для записи — запасной вариант: каталог данных ОС (с предупреждением).
|
|
||||||
pub fn resolve_dirs(base_override: Option<&Path>, p2p_port: u16) -> Result<Dirs, String> {
|
pub fn resolve_dirs(base_override: Option<&Path>, p2p_port: u16) -> Result<Dirs, String> {
|
||||||
if let Some(b) = base_override {
|
if let Some(b) = base_override {
|
||||||
let base = if b.is_absolute() {
|
let base = if b.is_absolute() {
|
||||||
@@ -331,8 +331,7 @@ pub fn save_config(dir: &Path, cfg: &Config) -> io::Result<()> {
|
|||||||
write_atomic(&config_path(dir), &json, true)
|
write_atomic(&config_path(dir), &json, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Меняет одно значение, сохраняя остальные (в т.ч. идентичность). Файл обязан существовать,
|
/// Меняет одно значение, сохраняя остальные (в т.ч. идентичность).
|
||||||
/// чтобы случайно не создать конфиг без идентичности.
|
|
||||||
pub fn update_config(dir: &Path, f: impl FnOnce(&mut Config)) -> io::Result<()> {
|
pub fn update_config(dir: &Path, f: impl FnOnce(&mut Config)) -> io::Result<()> {
|
||||||
let mut cfg = load_config(dir)
|
let mut cfg = load_config(dir)
|
||||||
.map_err(io::Error::other)?
|
.map_err(io::Error::other)?
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use std::{io, net::IpAddr, sync::Arc};
|
use std::{
|
||||||
|
io,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{atomic::Ordering, Arc},
|
||||||
|
};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||||
@@ -17,31 +21,11 @@ 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'; \
|
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'";
|
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||||
|
|
||||||
// ───────────────────────── Защита UI (CSRF / DNS-rebinding) ─────────────────────────
|
// ───────────────────────── Защита UI (анти-CSRF / DNS-rebinding без токена) ─────────────────────────
|
||||||
pub struct UiGuard {
|
pub struct UiGuard {
|
||||||
pub token: String,
|
|
||||||
pub cookie_name: String,
|
|
||||||
pub hosts: Vec<String>,
|
pub hosts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
|
||||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cookie_ok(headers: &HeaderMap, g: &UiGuard) -> bool {
|
|
||||||
headers
|
|
||||||
.get_all(header::COOKIE)
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.to_str().ok())
|
|
||||||
.flat_map(|s| s.split(';'))
|
|
||||||
.any(|kv| {
|
|
||||||
kv.trim()
|
|
||||||
.strip_prefix(g.cookie_name.as_str())
|
|
||||||
.and_then(|r| r.strip_prefix('='))
|
|
||||||
.map_or(false, |v| ct_eq(v.as_bytes(), g.token.as_bytes()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_security_headers(mut resp: Response) -> Response {
|
fn with_security_headers(mut resp: Response) -> Response {
|
||||||
let items: [(HeaderName, &'static str); 5] = [
|
let items: [(HeaderName, &'static str); 5] = [
|
||||||
(header::CONTENT_SECURITY_POLICY, CSP),
|
(header::CONTENT_SECURITY_POLICY, CSP),
|
||||||
@@ -57,9 +41,8 @@ fn with_security_headers(mut resp: Response) -> Response {
|
|||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1) Host только 127.0.0.1/localhost:порт (анти-rebinding)
|
/// 1) Host только 127.0.0.1/localhost:порт (анти-DNS-rebinding)
|
||||||
/// 2) POST только с Origin нашего же хоста (анти-CSRF)
|
/// 2) POST только с Origin нашего же локального хоста (анти-CSRF)
|
||||||
/// 3) Доступ по токену из консоли: /?t=… выставляет HttpOnly SameSite=Strict cookie
|
|
||||||
pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -> Response {
|
pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -> Response {
|
||||||
let headers = req.headers();
|
let headers = req.headers();
|
||||||
|
|
||||||
@@ -82,31 +65,6 @@ pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.uri().path() == "/" {
|
|
||||||
if let Some(q) = req.uri().query() {
|
|
||||||
let token_ok = q
|
|
||||||
.split('&')
|
|
||||||
.filter_map(|kv| kv.strip_prefix("t="))
|
|
||||||
.any(|t| ct_eq(t.as_bytes(), g.token.as_bytes()));
|
|
||||||
if token_ok {
|
|
||||||
let mut resp = StatusCode::SEE_OTHER.into_response();
|
|
||||||
resp.headers_mut().insert(header::LOCATION, HeaderValue::from_static("/"));
|
|
||||||
if let Ok(c) = HeaderValue::from_str(&format!(
|
|
||||||
"{}={}; Path=/; HttpOnly; SameSite=Strict",
|
|
||||||
g.cookie_name, g.token
|
|
||||||
)) {
|
|
||||||
resp.headers_mut().insert(header::SET_COOKIE, c);
|
|
||||||
}
|
|
||||||
return with_security_headers(resp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cookie_ok(headers, &g) {
|
|
||||||
return with_security_headers(
|
|
||||||
(StatusCode::FORBIDDEN, "Откройте адрес из консоли (с токеном ?t=…)").into_response(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
with_security_headers(next.run(req).await)
|
with_security_headers(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +82,7 @@ pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json
|
|||||||
state.update_my_ip(ip);
|
state.update_my_ip(ip);
|
||||||
}
|
}
|
||||||
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
||||||
peers.sort_by(|a, b| a.name.cmp(&b.name));
|
peers.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.cmp(&b.name)));
|
||||||
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
||||||
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
|
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
|
||||||
let transfers: Vec<TransferProgress> = state.transfers.read().clone();
|
let transfers: Vec<TransferProgress> = state.transfers.read().clone();
|
||||||
@@ -137,7 +95,10 @@ pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json
|
|||||||
"my_fingerprint": state.my_fingerprint,
|
"my_fingerprint": state.my_fingerprint,
|
||||||
"my_pin": short_id(&state.my_fingerprint),
|
"my_pin": short_id(&state.my_fingerprint),
|
||||||
"media_dir": state.media_dir.display().to_string(),
|
"media_dir": state.media_dir.display().to_string(),
|
||||||
"ping_interval_sec": state.ping_interval_sec.load(std::sync::atomic::Ordering::Relaxed),
|
"ping_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_min": MIN_PING_SEC,
|
||||||
"ping_max": MAX_PING_SEC,
|
"ping_max": MAX_PING_SEC,
|
||||||
"peers": peers,
|
"peers": peers,
|
||||||
@@ -213,10 +174,20 @@ pub async fn ui_scan_range(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
|
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
|
||||||
tokio::spawn(async move { net::ping_all_peers(&state).await });
|
net::ping_all_peers(&state, true).await;
|
||||||
StatusCode::OK
|
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(
|
pub async fn ui_trust_peer(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<UiTrustRequest>,
|
Json(req): Json<UiTrustRequest>,
|
||||||
@@ -228,6 +199,7 @@ pub async fn ui_trust_peer(
|
|||||||
.ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Устройство не найдено"))?;
|
.ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Устройство не найдено"))?;
|
||||||
p.verified = req.trust;
|
p.verified = req.trust;
|
||||||
if req.trust {
|
if req.trust {
|
||||||
|
p.fail_count = 0;
|
||||||
format!("Устройство '{}' [{}] ЗАВЕРЕНО", p.name, p.pin_code)
|
format!("Устройство '{}' [{}] ЗАВЕРЕНО", p.name, p.pin_code)
|
||||||
} else {
|
} else {
|
||||||
format!("Доверие устройству '{}' [{}] ОТОЗВАНО", p.name, p.pin_code)
|
format!("Доверие устройству '{}' [{}] ОТОЗВАНО", p.name, p.pin_code)
|
||||||
|
|||||||
Reference in new issue
Block a user