version a0.0.1

This commit is contained in:
netadmin committed 2026-10-22 20:45:13 +03:00
commit 9469ef10b6
19 files changed
+4281

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
mod mdns;
mod models;
mod p2p;
mod tls;
mod ui;
use axum::{
extract::DefaultBodyLimit,
routing::{get, post},
Router,
};
use axum_server::tls_rustls::RustlsConfig;
use local_ip_address::local_ip;
use models::{AppState, MAX_PAYLOAD_SIZE, MAX_RAM_TEXTS, MEDIA_DIR};
use std::{
collections::{HashMap, VecDeque},
net::SocketAddr,
sync::{Arc, RwLock},
};
#[tokio::main]
async fn main() {
// 0. Регистрация глобального CryptoProvider (ring) для rustls 0.23+
let _ = rustls::crypto::ring::default_provider().install_default();
let args: Vec<String> = std::env::args().collect();
// Значения по умолчанию
let mut device_name = "My-Device".to_string();
let mut p2p_port: u16 = 8443;
let mut ui_port: u16 = 8080;
let mut custom_ip: Option<String> = None;
// Парсинг как именованных флагов (--ip, --name), так и позиционных аргументов
let mut i = 1;
let mut positional_idx = 0;
while i < args.len() {
match args[i].as_str() {
"--ip" => {
if i + 1 < args.len() {
custom_ip = Some(args[i + 1].clone());
i += 1;
}
}
"--name" => {
if i + 1 < args.len() {
device_name = args[i + 1].clone();
i += 1;
}
}
"--p2p-port" => {
if i + 1 < args.len() {
if let Ok(p) = args[i + 1].parse() {
p2p_port = p;
}
i += 1;
}
}
"--ui-port" => {
if i + 1 < args.len() {
if let Ok(p) = args[i + 1].parse() {
ui_port = p;
}
i += 1;
}
}
arg if !arg.starts_with("--") => {
match positional_idx {
0 => device_name = arg.to_string(),
1 => {
if let Ok(p) = arg.parse() {
p2p_port = p;
}
}
2 => {
if let Ok(p) = arg.parse() {
ui_port = p;
}
}
3 => custom_ip = Some(arg.to_string()),
_ => {}
}
positional_idx += 1;
}
_ => {}
}
i += 1;
}
// Определение адреса привязки (bind_ip) и сетевого IP для сертификата/mDNS
let (bind_ip, my_ip) = match custom_ip {
Some(ip) => {
if ip == "0.0.0.0" {
let detected = local_ip()
.map(|addr| addr.to_string())
.unwrap_or_else(|_| "127.0.0.1".to_string());
("0.0.0.0".to_string(), detected)
} else {
(ip.clone(), ip)
}
}
None => {
let detected = local_ip().expect("Не удалось определить локальный IP").to_string();
("0.0.0.0".to_string(), detected)
}
};
tokio::fs::create_dir_all(MEDIA_DIR)
.await
.expect("Не удалось создать папку received_media на диске");
// 1. Генерация сертификата в ОЗУ под выбранный IP и настройка HTTPS-клиента
let (cert_der, key_der, my_fingerprint) = tls::generate_in_memory_cert(&my_ip);
let trusted_fingerprints = Arc::new(RwLock::new(HashMap::new()));
let http_client = tls::build_pinned_http_client(trusted_fingerprints.clone());
let state = Arc::new(AppState {
device_name: device_name.clone(),
my_ip: my_ip.clone(),
p2p_port,
my_fingerprint: my_fingerprint.clone(),
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
disk_media: RwLock::new(VecDeque::new()),
peers: RwLock::new(HashMap::new()),
trusted_fingerprints,
http_client,
});
// 2. Запуск mDNS-обнаружения
mdns::start_mdns_service(state.clone());
// 3. Запуск внешнего P2P HTTPS-сервера на указанном IP
let tls_config = RustlsConfig::from_der(vec![cert_der], key_der)
.await
.expect("Ошибка конфигурации TLS");
let p2p_router = Router::new()
.route("/p2p/text", post(p2p::p2p_receive_text))
.route("/p2p/media", post(p2p::p2p_receive_media))
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
.with_state(state.clone());
let p2p_addr: SocketAddr = format!("{}:{}", bind_ip, p2p_port)
.parse()
.unwrap_or_else(|_| panic!("Некорректный IP или порт: {}:{}", bind_ip, p2p_port));
tokio::spawn(async move {
axum_server::bind_rustls(p2p_addr, tls_config)
.serve(p2p_router.into_make_service())
.await
.expect("Ошибка P2P HTTPS-сервера");
});
// 4. Запуск локального UI-сервера (127.0.0.1)
let ui_router = Router::new()
.route("/", get(ui::ui_index))
.route("/ui/state", get(ui::ui_get_state))
.route("/ui/trust", post(ui::ui_trust_peer))
.route("/ui/clear-ram", post(ui::ui_clear_ram))
.route("/ui/send-text", post(ui::ui_send_text))
.route("/ui/send-media", post(ui::ui_send_media))
.route("/ui/files/:filename", get(ui::ui_serve_saved_file))
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
.with_state(state);
let ui_addr: SocketAddr = format!("127.0.0.1:{}", ui_port).parse().unwrap();
println!("==============================================================");
println!(" Устройство: {}", device_name);
println!(" P2P сокет (порт открыт на): https://{}:{}", bind_ip, p2p_port);
println!(" Сетевой IP (mDNS / TLS SAN): {}", my_ip);
println!(" КОД СВЕРКИ СЕРТИФИКАТА (PIN): [ {} ]", &my_fingerprint[..8].to_uppercase());
println!(" Папка для медиа на диске: ./{}/", MEDIA_DIR);
println!("--------------------------------------------------------------");
println!(" ОТКРОЙТЕ ИНТЕРФЕЙС В БРАУЗЕРЕ: http://127.0.0.1:{}", ui_port);
println!("==============================================================");
let listener = tokio::net::TcpListener::bind(ui_addr).await.unwrap();
axum::serve(listener, ui_router).await.unwrap();
}
+60
View File
@@ -0,0 +1,60 @@
use crate::models::{AppState, PeerNode, SERVICE_TYPE};
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::sync::Arc;
pub fn start_mdns_service(state: Arc<AppState>) {
let mdns = ServiceDaemon::new().expect("Не удалось запустить mDNS");
let instance_name = format!("{}_{}", state.device_name, &state.my_fingerprint[..6]);
let host_name = format!("{}.local.", instance_name);
let properties = [
("fp", state.my_fingerprint.as_str()),
("name", state.device_name.as_str()),
];
let service_info = ServiceInfo::new(
SERVICE_TYPE,
&instance_name,
&host_name,
&state.my_ip,
state.p2p_port,
&properties[..],
)
.expect("Ошибка создания mDNS записи");
mdns.register(service_info).expect("Ошибка регистрации mDNS");
let receiver = mdns.browse(SERVICE_TYPE).expect("Ошибка mDNS browse");
tokio::spawn(async move {
while let Ok(event) = receiver.recv_async().await {
if let ServiceEvent::ServiceResolved(info) = event {
let fp = info
.get_property_val_str("fp")
.unwrap_or_default()
.to_string();
let name = info
.get_property_val_str("name")
.unwrap_or("Unknown")
.to_string();
if fp == state.my_fingerprint || fp.len() < 8 {
continue;
}
if let Some(ip) = info.get_addresses().iter().next() {
let ip_str = ip.to_string();
let pin_code = fp[..8].to_uppercase();
let peer = PeerNode {
name,
ip: ip_str.clone(),
port: info.get_port(),
fingerprint: fp,
pin_code,
verified: false,
};
state.peers.write().unwrap().insert(ip_str, peer);
}
}
}
});
}
+123
View File
@@ -0,0 +1,123 @@
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, RwLock},
time::{SystemTime, UNIX_EPOCH},
};
/// Лимит одного передаваемого медиафайла (200 МБ)
pub const MAX_PAYLOAD_SIZE: usize = 200 * 1024 * 1024;
/// Максимум текстовых сообщений, хранимых в ОЗУ
pub const MAX_RAM_TEXTS: usize = 50;
/// Папка на диске для сохранения входящего медиаконтента
pub const MEDIA_DIR: &str = "received_media";
/// Тип сервиса для обнаружения через mDNS
pub const SERVICE_TYPE: &str = "_ramshare._tcp.local.";
/// Текстовое сообщение (хранится ТОЛЬКО в ОЗУ)
#[derive(Clone, Serialize, Deserialize)]
pub struct TextItem {
pub sender: String,
pub text: String,
pub timestamp: u64,
}
/// Метаданные медиафайла (сам файл сохраняется НА ДИСК)
#[derive(Clone, Serialize, Deserialize)]
pub struct MediaItem {
pub sender: String,
pub filename: String,
pub saved_path: String,
pub mime: String,
pub size: usize,
pub timestamp: u64,
}
/// Устройство в локальной сети, найденное через mDNS
#[derive(Clone, Serialize, Deserialize)]
pub struct PeerNode {
pub name: String,
pub ip: String,
pub port: u16,
pub fingerprint: String,
pub pin_code: String,
pub verified: bool,
}
/// Глобальное состояние приложения
pub struct AppState {
pub device_name: String,
pub my_ip: String,
pub p2p_port: u16,
pub my_fingerprint: String,
pub ram_texts: RwLock<VecDeque<TextItem>>,
pub disk_media: RwLock<VecDeque<MediaItem>>,
pub peers: RwLock<HashMap<String, PeerNode>>,
pub trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
pub http_client: reqwest::Client,
}
#[derive(Serialize, Deserialize)]
pub struct IncomingTextPayload {
pub sender: String,
pub text: String,
}
#[derive(Deserialize)]
pub struct UiSendTextRequest {
pub target_ip: String,
pub text: String,
}
#[derive(Deserialize)]
pub struct UiTrustRequest {
pub target_ip: String,
pub trust: bool,
}
pub fn now_sec() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Декодирование URL-encoded имени файла (для поддержки кириллицы и пробелов)
pub fn url_decode(input: &str) -> String {
let mut bytes = Vec::with_capacity(input.len());
let mut chars = input.as_bytes().iter().copied();
while let Some(b) = chars.next() {
if b == b'%' {
if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) {
if let Ok(decoded) = u8::from_str_radix(&format!("{}{}", c1 as char, c2 as char), 16)
{
bytes.push(decoded);
continue;
}
}
} else if b == b'+' {
bytes.push(b' ');
} else {
bytes.push(b);
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
/// Защита от Path Traversal при сохранении медиа на диск
pub fn sanitize_filename(name: &str) -> String {
let decoded = url_decode(name);
let clean: String = decoded
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
_ => c,
})
.collect();
let trimmed = clean.trim_matches('.').trim();
if trimmed.is_empty() {
"media_file.bin".to_string()
} else {
trimmed.to_string()
}
}
+81
View File
@@ -0,0 +1,81 @@
use crate::models::{
now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem,
MAX_RAM_TEXTS, MEDIA_DIR,
};
use axum::{
body::Bytes,
extract::State,
http::{header, HeaderMap, StatusCode},
Json,
};
use std::{path::PathBuf, sync::Arc};
/// Прием текста от другого устройства -> сохраняется СТРОГО В ОЗУ
pub async fn p2p_receive_text(
State(state): State<Arc<AppState>>,
Json(payload): Json<IncomingTextPayload>,
) -> StatusCode {
let mut ram_texts = state.ram_texts.write().unwrap();
if ram_texts.len() >= MAX_RAM_TEXTS {
ram_texts.pop_back();
}
ram_texts.push_front(TextItem {
sender: payload.sender,
text: payload.text,
timestamp: now_sec(),
});
StatusCode::OK
}
/// Прием медиаконтента от другого устройства -> сохраняется НА ДИСК
pub async fn p2p_receive_media(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, (StatusCode, String)> {
let sender = headers
.get("X-Sender")
.and_then(|v| v.to_str().ok())
.map(url_decode)
.unwrap_or_else(|| "Unknown".to_string());
let raw_filename = headers
.get("X-Filename")
.and_then(|v| v.to_str().ok())
.unwrap_or("file.bin");
let mime = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
tokio::fs::create_dir_all(MEDIA_DIR)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let safe_name = sanitize_filename(raw_filename);
let final_filename = format!("{}_{}", now_sec(), safe_name);
let file_path = PathBuf::from(MEDIA_DIR).join(&final_filename);
tokio::fs::write(&file_path, &body)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let abs_path = std::fs::canonicalize(&file_path)
.unwrap_or_else(|_| file_path.clone())
.display()
.to_string();
let mut media_list = state.disk_media.write().unwrap();
media_list.push_front(MediaItem {
sender,
filename: final_filename,
saved_path: abs_path,
mime,
size: body.len(),
timestamp: now_sec(),
});
Ok(StatusCode::OK)
}
+261
View File
@@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LAN Share — Текст в ОЗУ / Медиа на Диск</title>
<style>
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
body { margin: 0; padding: 20px; background: var(--bg); color: var(--text); }
.container { max-width: 1100px; 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; }
.pin-badge { background: #1d4ed8; padding: 6px 14px; border-radius: 8px; font-weight: 700; letter-spacing: 1px; font-family: monospace; font-size: 1.1rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
h2 { margin: 0; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; }
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
.tag-ram { background: rgba(16, 185, 129, 0.2); color: #34d399; }
.tag-disk { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 0.95rem; }
textarea { resize: vertical; min-height: 80px; }
button { background: var(--accent); color: white; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
button:hover { opacity: 0.9; }
button.btn-green { background: var(--green); }
button.btn-red { background: var(--red); }
button.btn-sm { padding: 6px 12px; font-size: 0.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; }
.msg-item { flex-direction: column; align-items: flex-start; }
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--muted); }
.msg-text { font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
.path-box { font-family: monospace; font-size: 0.78rem; color: #93c5fd; background: rgba(59,130,246,0.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; }
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<div style="font-size: 1.2rem; font-weight: 700;" id="my-name">Загрузка...</div>
<div style="font-size: 0.85rem; color: var(--muted);" id="my-ip"></div>
</div>
<div>
<span style="font-size: 0.85rem; color: var(--muted); margin-right: 8px;">Код сверки сертификата (PIN):</span>
<span class="pin-badge" id="my-pin">--------</span>
</div>
</div>
<div id="status"></div>
<div class="card">
<h2>1. Устройства в сети (mDNS) <span class="tag tag-disk">TLS Pinning</span></h2>
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
<div style="color:var(--muted); font-size:0.9rem;">Поиск устройств...</div>
</div>
</div>
<div class="card">
<h2>2. Отправка данных</h2>
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
<select id="target-peer"><option value="">-- Выберите устройство --</option></select>
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка)..."></textarea>
<button onclick="sendText()">Отправить текст в ОЗУ</button>
<hr style="border:0; border-top:1px solid var(--border); width:100%; margin: 4px 0;">
<input type="file" id="media-input">
<button class="btn-green" onclick="sendMedia()">Отправить медиа на диск получателя</button>
</div>
<div class="card">
<h2>
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
<button class="btn-red btn-sm" onclick="clearRam()">Очистить ОЗУ</button>
</h2>
<div id="ram-texts" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
</div>
<div class="card">
<h2>Входящие медиафайлы <span class="tag tag-disk">НА ДИСКЕ (./received_media)</span></h2>
<div id="disk-media" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
</div>
</div>
<script>
let knownPeersKey = "";
function showStatus(msg, isError = false) {
const el = document.getElementById('status');
el.style.display = 'block';
el.style.background = isError ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)';
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
el.textContent = msg;
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
async function fetchState() {
try {
const res = await fetch('/ui/state');
const data = await res.json();
document.getElementById('my-name').textContent = `Устройство: ${data.device_name}`;
document.getElementById('my-ip').textContent = `HTTPS P2P: ${data.my_ip}:${data.p2p_port} | SHA-256: ${data.my_fingerprint.slice(0, 24)}...`;
document.getElementById('my-pin').textContent = data.my_pin;
const peersContainer = document.getElementById('peers-list');
if (data.peers.length === 0) {
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства в локальной сети пока не обнаружены</div>';
} else {
peersContainer.innerHTML = data.peers.map(p => `
<div class="peer-item">
<div>
<div style="font-weight:600;">${escapeHtml(p.name)} <span style="color:var(--muted); font-size:0.85rem;">(${p.ip}:${p.port})</span></div>
<div style="font-size:0.8rem; color:var(--muted); font-family:monospace;">PIN: <b style="color:#fff;">[${p.pin_code}]</b> | Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}</div>
</div>
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}" onclick="toggleTrust('${p.ip}', ${!p.verified})">
${p.verified ? 'Отозвать' : 'Заверить'}
</button>
</div>
`).join('');
}
const currentSelectKey = data.peers.map(p => `${p.ip}:${p.verified}`).join('|');
if (currentSelectKey !== knownPeersKey) {
knownPeersKey = currentSelectKey;
const select = document.getElementById('target-peer');
const prevVal = select.value;
select.innerHTML = '<option value="">-- Выберите устройство --</option>' +
data.peers.map(p => `<option value="${p.ip}">${escapeHtml(p.name)} (${p.ip}) ${p.verified ? '✓' : '🔒 [Не заверен]'}</option>`).join('');
if (prevVal) select.value = prevVal;
}
const textsContainer = document.getElementById('ram-texts');
if (data.texts.length === 0) {
textsContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Буфер ОЗУ пуст</div>';
} else {
textsContainer.innerHTML = data.texts.map(t => `
<div class="msg-item">
<div class="msg-meta">
<span>От: <b>${escapeHtml(t.sender)}</b></span>
<button class="btn-sm" onclick="copyText(${JSON.stringify(t.text).replace(/"/g, '&quot;')})">Скопировать</button>
</div>
<div class="msg-text">${escapeHtml(t.text)}</div>
</div>
`).join('');
}
const mediaContainer = document.getElementById('disk-media');
if (data.media.length === 0) {
mediaContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Нет сохраненных медиафайлов</div>';
} else {
mediaContainer.innerHTML = data.media.map(m => {
const isImg = m.mime.startsWith('image/');
const fileUrl = `/ui/files/${encodeURIComponent(m.filename)}`;
return `
<div class="msg-item">
<div class="msg-meta">
<span>От: <b>${escapeHtml(m.sender)}</b></span>
<span>${(m.size / 1024).toFixed(1)} КБ</span>
</div>
<div style="font-weight:600; margin:2px 0;">
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
</div>
<div class="path-box">Диск: ${escapeHtml(m.saved_path)}</div>
${isImg ? `<img src="${fileUrl}" class="preview-img">` : ''}
</div>
`;
}).join('');
}
} catch (e) {}
}
async function toggleTrust(ip, trust) {
const res = await fetch('/ui/trust', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_ip: ip, trust })
});
if (res.ok) {
showStatus(trust ? `Сертификат узла ${ip} заверен в ОЗУ` : `Доверие узлу ${ip} отозвано`);
fetchState();
}
}
async function sendText() {
const target_ip = document.getElementById('target-peer').value;
const input = document.getElementById('text-input');
const text = input.value.trim();
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
if (!text) return showStatus('Введите текст для отправки!', true);
const res = await fetch('/ui/send-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_ip, text })
});
if (res.ok) {
input.value = '';
showStatus(`Текст доставлен в ОЗУ узла ${target_ip}`);
} else {
const err = await res.text();
showStatus(err, true);
}
}
async function sendMedia() {
const target_ip = document.getElementById('target-peer').value;
const fileInput = document.getElementById('media-input');
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
const file = fileInput.files[0];
showStatus(`Отправка файла "${file.name}"...`);
const res = await fetch('/ui/send-media', {
method: 'POST',
headers: {
'X-Target-Ip': target_ip,
'X-Filename': encodeURIComponent(file.name),
'Content-Type': file.type || 'application/octet-stream'
},
body: file
});
if (res.ok) {
fileInput.value = '';
showStatus(`Файл "${file.name}" сохранен на диске узла ${target_ip}`);
} else {
const err = await res.text();
showStatus(err, true);
}
}
async function clearRam() {
await fetch('/ui/clear-ram', { method: 'POST' });
fetchState();
showStatus('Буфер текстовых сообщений в ОЗУ очищен');
}
function copyText(text) {
navigator.clipboard.writeText(text);
showStatus('Текст скопирован в буфер обмена');
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[s]));
}
document.getElementById('text-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendText();
}
});
setInterval(fetchState, 1500);
fetchState();
</script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
use rcgen::{generate_simple_self_signed, CertifiedKey};
use rustls::{
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider},
pki_types::{CertificateDer, ServerName, UnixTime},
ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme,
};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, RwLock},
time::Duration,
};
#[derive(Debug)]
pub struct PinnedCertVerifier {
trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
provider: Arc<CryptoProvider>,
}
impl PinnedCertVerifier {
pub fn new(trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>) -> Self {
Self {
trusted_fingerprints,
provider: Arc::new(rustls::crypto::ring::default_provider()),
}
}
fn cert_fingerprint(cert: &CertificateDer<'_>) -> String {
let mut hasher = Sha256::new();
hasher.update(cert.as_ref());
hex::encode(hasher.finalize())
}
}
impl ServerCertVerifier for PinnedCertVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
let actual_fp = Self::cert_fingerprint(end_entity);
let host = match server_name {
ServerName::DnsName(name) => name.as_ref().to_string(),
ServerName::IpAddress(ip) => std::net::IpAddr::from(*ip).to_string(),
_ => return Err(TlsError::General("Неподдерживаемое имя узла".into())),
};
let trusted = self.trusted_fingerprints.read().unwrap();
match trusted.get(&host) {
Some(expected_fp) if expected_fp.eq_ignore_ascii_case(&actual_fp) => {
Ok(ServerCertVerified::assertion())
}
Some(_) => Err(TlsError::General(format!(
"ВНИМАНИЕ: Сертификат узла {} не совпадает с заверенным!",
host
))),
None => Err(TlsError::General(format!(
"Узел {} не заверен! Нажмите «Заверить» в списке устройств.",
host
))),
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
/// Генерирует самоподписанный сертификат и приватный ключ в ОЗУ
pub fn generate_in_memory_cert(ip: &str) -> (Vec<u8>, Vec<u8>, String) {
let san = vec!["localhost".to_string(), ip.to_string()];
let CertifiedKey { cert, key_pair } =
generate_simple_self_signed(san).expect("Ошибка генерации сертификата");
let cert_der = cert.der().to_vec();
let key_der = key_pair.serialize_der();
let mut hasher = Sha256::new();
hasher.update(&cert_der);
let fingerprint = hex::encode(hasher.finalize());
(cert_der, key_der, fingerprint)
}
/// Создает преднастроенный HTTPS-клиент с кастомным верификатором
pub fn build_pinned_http_client(
trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
) -> reqwest::Client {
let verifier = Arc::new(PinnedCertVerifier::new(trusted_fingerprints));
let rustls_client_config = ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
reqwest::Client::builder()
.use_preconfigured_tls(rustls_client_config)
.timeout(Duration::from_secs(30))
.build()
.expect("Ошибка создания HTTPS-клиента")
}
+165
View File
@@ -0,0 +1,165 @@
use crate::models::{
sanitize_filename, AppState, IncomingTextPayload, MediaItem, PeerNode, TextItem,
UiSendTextRequest, UiTrustRequest, MEDIA_DIR,
};
use axum::{
body::Bytes,
extract::{Path, State},
http::{header, HeaderMap, StatusCode},
response::{Html, IntoResponse},
Json,
};
use std::{path::PathBuf, sync::Arc};
/// Вшиваем внешний файл src/static/index.html в бинарник на этапе компиляции
const INDEX_HTML: &str = include_str!("static/index.html");
pub async fn ui_index() -> Html<&'static str> {
Html(INDEX_HTML)
}
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let trusted_map = state.trusted_fingerprints.read().unwrap();
let mut peers_list: Vec<PeerNode> = state
.peers
.read()
.unwrap()
.values()
.cloned()
.map(|mut p| {
p.verified = trusted_map
.get(&p.ip)
.map(|fp| fp.eq_ignore_ascii_case(&p.fingerprint))
.unwrap_or(false);
p
})
.collect();
peers_list.sort_by(|a, b| a.name.cmp(&b.name));
let texts: Vec<TextItem> = state.ram_texts.read().unwrap().iter().cloned().collect();
let media: Vec<MediaItem> = state.disk_media.read().unwrap().iter().cloned().collect();
Json(serde_json::json!({
"device_name": state.device_name,
"my_ip": state.my_ip,
"p2p_port": state.p2p_port,
"my_fingerprint": state.my_fingerprint,
"my_pin": state.my_fingerprint[..8].to_uppercase(),
"peers": peers_list,
"texts": texts,
"media": media
}))
}
pub async fn ui_trust_peer(
State(state): State<Arc<AppState>>,
Json(req): Json<UiTrustRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
let peers = state.peers.read().unwrap();
let peer = peers
.get(&req.target_ip)
.ok_or((StatusCode::NOT_FOUND, "Узел не найден".to_string()))?;
let mut trusted = state.trusted_fingerprints.write().unwrap();
if req.trust {
trusted.insert(peer.ip.clone(), peer.fingerprint.clone());
} else {
trusted.remove(&peer.ip);
}
Ok(StatusCode::OK)
}
pub async fn ui_clear_ram(State(state): State<Arc<AppState>>) -> StatusCode {
state.ram_texts.write().unwrap().clear();
StatusCode::OK
}
pub async fn ui_send_text(
State(state): State<Arc<AppState>>,
Json(req): Json<UiSendTextRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
let port = state
.peers
.read()
.unwrap()
.get(&req.target_ip)
.map(|p| p.port)
.unwrap_or(state.p2p_port);
let url = format!("https://{}:{}/p2p/text", req.target_ip, port);
let payload = IncomingTextPayload {
sender: state.device_name.clone(),
text: req.text,
};
let resp = state
.http_client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?;
if resp.status().is_success() {
Ok(StatusCode::OK)
} else {
Err((resp.status(), "Удаленный узел отклонил запрос".to_string()))
}
}
pub async fn ui_send_media(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, (StatusCode, String)> {
let target_ip = headers
.get("X-Target-Ip")
.and_then(|v| v.to_str().ok())
.ok_or((StatusCode::BAD_REQUEST, "Не указан IP получателя".to_string()))?;
let filename = headers
.get("X-Filename")
.and_then(|v| v.to_str().ok())
.unwrap_or("file.bin");
let mime = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream");
let port = state
.peers
.read()
.unwrap()
.get(target_ip)
.map(|p| p.port)
.unwrap_or(state.p2p_port);
let url = format!("https://{}:{}/p2p/media", target_ip, port);
let resp = state
.http_client
.post(&url)
.header("X-Sender", &state.device_name)
.header("X-Filename", filename)
.header(header::CONTENT_TYPE, mime)
.body(body)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?;
if resp.status().is_success() {
Ok(StatusCode::OK)
} else {
Err((resp.status(), "Удаленный узел отклонил медиафайл".to_string()))
}
}
pub async fn ui_serve_saved_file(Path(filename): Path<String>) -> impl IntoResponse {
let safe_name = sanitize_filename(&filename);
let path = PathBuf::from(MEDIA_DIR).join(&safe_name);
match tokio::fs::read(&path).await {
Ok(bytes) => (StatusCode::OK, bytes).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Файл не найден на диске").into_response(),
}
}