diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b6c983e..2627e61 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1928,6 +1928,32 @@ dependencies = [ "libc", ] +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1961,6 +1987,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "ssh2", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2323,6 +2350,18 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3242,6 +3281,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libssh2-sys", + "parking_lot", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4179,6 +4230,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7e1a7dc..5704214 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-dialog = "2.7.1" tauri-plugin-fs = "2.5.1" - +ssh2 = "0.9" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d0188b..d307cec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,13 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +mod openwrt; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) + .invoke_handler(tauri::generate_handler![ + openwrt::detect_openwrt_router, + openwrt::test_openwrt_ssh + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } \ No newline at end of file diff --git a/src-tauri/src/openwrt.rs b/src-tauri/src/openwrt.rs new file mode 100644 index 0000000..23a5dfc --- /dev/null +++ b/src-tauri/src/openwrt.rs @@ -0,0 +1,438 @@ +use serde::Serialize; +use ssh2::Session; +use std::net::{ SocketAddr, TcpStream }; +use std::process::Command; +use std::time::Duration; + +#[derive(Serialize)] +pub struct OpenWrtDetectionResult { + detected: bool, + ip: Option, + method: String, + ssh_reachable: bool, + message: String, +} + +#[derive(Serialize)] +pub struct OpenWrtCompatibility { + compatible: bool, + version: Option, + target: Option, + arch: Option, + reason: Option, +} + +#[derive(Serialize)] +pub struct OpenWrtSshResult { + connected: bool, + host: String, + hostname: Option, + openwrt_release: Option, + compatibility: Option, + readiness: Option, + message: String, +} + +#[derive(Serialize)] +pub struct OpenWrtReadiness { + internet_ok: bool, + dns_ok: bool, + opkg_ok: bool, + openvpn_installed: bool, + free_space: Option, + free_memory: Option, +} + +#[tauri::command] +pub fn detect_openwrt_router() -> OpenWrtDetectionResult { + let candidates = vec![detect_default_gateway(), Some("192.168.1.1".to_string())]; + + for candidate in candidates.into_iter().flatten() { + if tcp_port_open(&candidate, 22) { + return OpenWrtDetectionResult { + detected: true, + ip: Some(candidate), + method: "SSH_PORT_22".to_string(), + ssh_reachable: true, + message: "Router detectado com SSH disponível.".to_string(), + }; + } + } + + OpenWrtDetectionResult { + detected: false, + ip: None, + method: "NONE".to_string(), + ssh_reachable: false, + message: "Nenhum router OpenWRT detetado automaticamente.".to_string(), + } +} + +#[tauri::command] +pub fn test_openwrt_ssh(host: String, username: String, password: String) -> OpenWrtSshResult { + let address = format!("{}:22", host); + + let socket = match address.parse::() { + Ok(socket) => socket, + Err(err) => { + return OpenWrtSshResult { + connected: false, + host, + hostname: None, + openwrt_release: None, + compatibility: None, + readiness: None, + message: format!("Endereço inválido: {}", err), + }; + } + }; + + let tcp = match TcpStream::connect_timeout(&socket, Duration::from_secs(5)) { + Ok(stream) => stream, + Err(err) => { + return OpenWrtSshResult { + connected: false, + host, + hostname: None, + openwrt_release: None, + compatibility: None, + readiness: None, + message: format!("Falha ao ligar por TCP/SSH: {}", err), + }; + } + }; + + let mut session = match Session::new() { + Ok(session) => session, + Err(err) => { + return OpenWrtSshResult { + connected: false, + host, + hostname: None, + openwrt_release: None, + compatibility: None, + readiness: None, + message: format!("Falha ao criar sessão SSH: {}", err), + }; + } + }; + + session.set_tcp_stream(tcp); + + if let Err(err) = session.handshake() { + return OpenWrtSshResult { + connected: false, + host, + hostname: None, + openwrt_release: None, + compatibility: None, + readiness: None, + message: format!("Falha no handshake SSH: {}", err), + }; + } + + if password.trim().is_empty() { + return test_openwrt_ssh_with_system_ssh(host, username); + } + + if let Err(err) = session.userauth_password(&username, &password) { + return OpenWrtSshResult { + connected: false, + host, + hostname: None, + openwrt_release: None, + compatibility: None, + readiness: None, + message: format!("Falha na autenticação SSH: {}", err), + }; + } + + let hostname = run_ssh_command( + &session, + "uci get system.@system[0].hostname 2>/dev/null || hostname" + ); + + let release = run_ssh_command(&session, "cat /etc/openwrt_release 2>/dev/null"); + + let compatibility = release.as_ref().map(|content| build_compatibility(content)); + + let readiness = Some(build_readiness(&session)); + + OpenWrtSshResult { + connected: true, + host, + hostname, + openwrt_release: release, + compatibility, + readiness, + message: "Ligação SSH estabelecida com sucesso.".to_string(), + } +} + +fn tcp_port_open(host: &str, port: u16) -> bool { + let address = format!("{}:{}", host, port); + + match address.parse::() { + Ok(socket) => TcpStream::connect_timeout(&socket, Duration::from_secs(2)).is_ok(), + Err(_) => false, + } +} + +fn run_ssh_command(session: &Session, command: &str) -> Option { + let mut channel = session.channel_session().ok()?; + channel.exec(command).ok()?; + + let mut output = String::new(); + std::io::Read::read_to_string(&mut channel, &mut output).ok()?; + + channel.wait_close().ok()?; + + let trimmed = output.trim().to_string(); + + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn detect_default_gateway() -> Option { + #[cfg(target_os = "windows")] + { + let output = Command::new("cmd").args(["/C", "ipconfig"]).output().ok()?; + + let text = String::from_utf8_lossy(&output.stdout); + + for line in text.lines() { + if line.contains("Default Gateway") { + if let Some(ip) = line.split(':').nth(1) { + let ip = ip.trim(); + + if !ip.is_empty() && ip.contains('.') { + return Some(ip.to_string()); + } + } + } + } + } + + None +} + +fn test_openwrt_ssh_with_system_ssh(host: String, username: String) -> OpenWrtSshResult { + let target = format!("{}@{}", username, host); + + let output = Command::new("ssh") + .args([ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=NUL", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + &target, + "uci get system.@system[0].hostname 2>/dev/null || hostname; \ + echo '---OPENWRT_RELEASE---'; \ + cat /etc/openwrt_release 2>/dev/null; \ + echo '---READINESS---'; \ + ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 && echo INTERNET_OK || echo INTERNET_FAIL; \ + nslookup openwrt.org >/dev/null 2>&1 && echo DNS_OK || echo DNS_FAIL; \ + command -v opkg >/dev/null 2>&1 && echo OPKG_OK || echo OPKG_FAIL; \ + command -v openvpn >/dev/null 2>&1 && echo OPENVPN_OK || echo OPENVPN_FAIL; \ + echo FREE_SPACE=$(df -h /overlay 2>/dev/null | awk 'NR==2 {print $4}'); \ + echo FREE_MEMORY=$(free -h 2>/dev/null | awk '/Mem:/ {print $4}')", + ]) + .output(); + + let output = match output { + Ok(output) => output, + Err(err) => { + return OpenWrtSshResult { + connected: false, + host, + hostname: None, + openwrt_release: None, + compatibility: None, + readiness: None, + message: format!("Falha ao executar ssh local: {}", err), + }; + } + }; + + if !output.status.success() { + return OpenWrtSshResult { + connected: false, + host, + hostname: None, + openwrt_release: None, + compatibility: None, + readiness: None, + message: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }; + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let parts: Vec<&str> = stdout.split("---OPENWRT_RELEASE---").collect(); + + let hostname = parts + .get(0) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let release_and_readiness = parts.get(1).copied().unwrap_or(""); + + let release_parts: Vec<&str> = release_and_readiness.split("---READINESS---").collect(); + + let openwrt_release = release_parts + .get(0) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let readiness_output = release_parts + .get(1) + .map(|value| value.trim().to_string()) + .unwrap_or_default(); + + let compatibility = openwrt_release.as_ref().map(|content| build_compatibility(content)); + + let readiness = Some(build_readiness_from_output(&readiness_output)); + OpenWrtSshResult { + connected: true, + host, + hostname, + openwrt_release, + compatibility, + readiness, + message: "Ligação SSH estabelecida com sucesso.".to_string(), + } +} + +fn build_compatibility(release_content: &str) -> OpenWrtCompatibility { + let version = extract_release_value(release_content, "DISTRIB_RELEASE"); + let target = extract_release_value(release_content, "DISTRIB_TARGET"); + let arch = extract_release_value(release_content, "DISTRIB_ARCH"); + + let compatible = version + .as_ref() + .map(|value| is_supported_openwrt_version(value)) + .unwrap_or(false); + + let reason = if compatible { + None + } else { + Some("Versão OpenWRT não suportada. É necessária a versão 21.02.2 ou superior.".to_string()) + }; + + OpenWrtCompatibility { + compatible, + version, + target, + arch, + reason, + } +} + +fn extract_release_value(content: &str, key: &str) -> Option { + content + .lines() + .find(|line| line.starts_with(key)) + .and_then(|line| line.split('=').nth(1)) + .map(|value| value.trim().trim_matches('\'').to_string()) + .filter(|value| !value.is_empty()) +} + +fn is_supported_openwrt_version(version: &str) -> bool { + let parts: Vec = version + .split('.') + .filter_map(|part| part.parse::().ok()) + .collect(); + + if parts.len() < 2 { + return false; + } + + let major = parts[0]; + let minor = parts[1]; + let patch = parts.get(2).copied().unwrap_or(0); + + if major > 21 { + return true; + } + + if major == 21 && minor > 2 { + return true; + } + + if major == 21 && minor == 2 && patch >= 2 { + return true; + } + + false +} + +fn build_readiness(session: &Session) -> OpenWrtReadiness { + let internet_ok = run_ssh_command( + session, + "ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 && echo OK || echo FAIL" + ) + .map(|value| value == "OK") + .unwrap_or(false); + + let dns_ok = run_ssh_command( + session, + "nslookup openwrt.org >/dev/null 2>&1 && echo OK || echo FAIL" + ) + .map(|value| value == "OK") + .unwrap_or(false); + + let opkg_ok = run_ssh_command( + session, + "command -v opkg >/dev/null 2>&1 && echo OK || echo FAIL" + ) + .map(|value| value == "OK") + .unwrap_or(false); + + let openvpn_installed = run_ssh_command( + session, + "command -v openvpn >/dev/null 2>&1 && echo OK || echo FAIL" + ) + .map(|value| value == "OK") + .unwrap_or(false); + + let free_space = run_ssh_command( + session, + "df -h /overlay 2>/dev/null | awk 'NR==2 {print $4}'" + ); + + let free_memory = run_ssh_command(session, "free -h 2>/dev/null | awk '/Mem:/ {print $4}'"); + + OpenWrtReadiness { + internet_ok, + dns_ok, + opkg_ok, + openvpn_installed, + free_space, + free_memory, + } +} + +fn build_readiness_from_output(output: &str) -> OpenWrtReadiness { + OpenWrtReadiness { + internet_ok: output.contains("INTERNET_OK"), + dns_ok: output.contains("DNS_OK"), + opkg_ok: output.contains("OPKG_OK"), + openvpn_installed: output.contains("OPENVPN_OK"), + free_space: extract_prefixed_value(output, "FREE_SPACE="), + free_memory: extract_prefixed_value(output, "FREE_MEMORY="), + } +} + +fn extract_prefixed_value(content: &str, prefix: &str) -> Option { + content + .lines() + .find(|line| line.starts_with(prefix)) + .map(|line| line.trim_start_matches(prefix).trim().to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/src/App.css b/src/App.css index 5c9073b..e2bde20 100644 --- a/src/App.css +++ b/src/App.css @@ -1376,4 +1376,50 @@ td .small-action { .settings-actions button:hover { background: white; transform: translateY(-1px); +} + +.openwrt-form { + display: grid; + grid-template-columns: 1fr 1fr 1fr auto; + align-items: end; + gap: 14px; +} + +.openwrt-form label { + display: grid; + gap: 8px; + color: #374151; + font-size: 13px; + font-weight: 800; +} + +.openwrt-form input { + height: 44px; + border: 1px solid #edf1f7; + border-radius: 14px; + padding: 0 14px; + background: #f8fafc; + color: #111827; + font-weight: 700; +} + +.openwrt-form input:focus { + outline: 0; + background: white; + border-color: #5da8ff; + box-shadow: 0 0 0 4px rgba(93, 168, 255, 0.1); +} + +.openwrt-grid { + display: grid; + gap: 24px; +} + +.openwrt-grid .dashboard-card { + padding: 26px; + border-radius: 24px; +} + +.openwrt-grid .card-header { + margin-bottom: 22px; } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 73a0108..7e85b11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import type { OpenVpnStatus } from "./types/openVpnStatus"; import type { OpenVpnHealthResponse } from "./types/openVpnHealthResponse"; import { VpsServerPage } from "./pages/VpsServerPage"; import { SettingsPage } from "./pages/SettingsPage"; +import { OpenWrtConfigPage } from "./pages/OpenWrtConfigPage"; import { availableSubnets, @@ -405,6 +406,10 @@ function App() { onRestartOpenVpn={() => setRestartOpenVpnConfirmOpen(true)} /> )} + + {page === "openwrt" && ( + + )} {createOpen && ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index c2c2ab0..28019e5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -59,11 +59,11 @@ export function Sidebar({ + + +
+
+
+
+

Deteção do Router

+

Procura pelo gateway local ou pelo IP padrão OpenWRT

+
+
+ +
+ + +

+ {detection + ? detection.message + : "Ainda não foi executada nenhuma deteção."} +

+
+ + {detection?.ip && ( +
+ + + +
+ )} +
+ +
+
+
+

Ligação SSH

+

Introduza as credenciais do router OpenWRT

+
+
+ +
+ + + + + + + +
+
+ + {sshResult && ( +
+
+
+

Resultado da Ligação

+

Estado da sessão SSH com o router

+
+
+ +
+ + + + +
+ + {!sshResult.connected && ( +
+ +

{sshResult.message}

+
+ )} +
+ )} + + {sshResult?.connected && compatibility && ( +
+
+
+

Compatibilidade OpenWRT

+

Validação da versão e arquitetura do firmware

+
+ + + {compatibility.compatible ? "Compatível" : "Não compatível"} + +
+ +
+ {compatibility.compatible ? ( + + ) : ( + + )} + +

+ {compatibility.compatible + ? "Este router cumpre os requisitos mínimos para configuração OpenVPN." + : compatibility.reason || + "Este router não cumpre os requisitos mínimos."} +

+
+ +
+ + + + + + + +
+ + {sshResult.openwrt_release && ( +
+
{sshResult.openwrt_release}
+
+ )} +
+ )} + + {sshResult?.connected && sshResult.readiness && ( +
+
+
+

Estado do Router

+

Verificações antes da configuração OpenVPN

+
+
+ +
+ + + + + + + + + + + +
+
+ )} +
+ + ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} \ No newline at end of file