From 11dbaca04c1612720396c9b501282d6f7edf645a Mon Sep 17 00:00:00 2001 From: litoral05 Date: Wed, 6 May 2026 16:05:27 +0100 Subject: [PATCH] Add OpenWRT SSH provisioning and readiness workflow --- src-tauri/src/lib.rs | 3 +- src-tauri/src/openwrt.rs | 209 ++++++++++++++++++++++++++ src/App.css | 77 +++++++++- src/App.tsx | 2 +- src/pages/OpenWrtConfigPage.tsx | 257 ++++++++++++++++++++++++++++++-- 5 files changed, 533 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d307cec..ebae293 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,8 @@ pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ openwrt::detect_openwrt_router, - openwrt::test_openwrt_ssh + openwrt::test_openwrt_ssh, + openwrt::prepare_openwrt_router ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/openwrt.rs b/src-tauri/src/openwrt.rs index 23a5dfc..4940df9 100644 --- a/src-tauri/src/openwrt.rs +++ b/src-tauri/src/openwrt.rs @@ -43,6 +43,15 @@ pub struct OpenWrtReadiness { free_memory: Option, } +#[derive(Serialize)] +pub struct OpenWrtPrepareResult { + success: bool, + hostname_updated: bool, + password_updated: bool, + message: String, + logs: String, +} + #[tauri::command] pub fn detect_openwrt_router() -> OpenWrtDetectionResult { let candidates = vec![detect_default_gateway(), Some("192.168.1.1".to_string())]; @@ -169,6 +178,127 @@ pub fn test_openwrt_ssh(host: String, username: String, password: String) -> Ope } } +#[tauri::command] +pub fn prepare_openwrt_router( + host: String, + username: String, + password: String, + target_hostname: String, + new_password: String +) -> OpenWrtPrepareResult { + if target_hostname.trim().is_empty() { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: "Hostname inválido.".to_string(), + logs: "".to_string(), + }; + } + + if new_password.trim().is_empty() { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: "Password root inválida.".to_string(), + logs: "".to_string(), + }; + } + + if password.trim().is_empty() { + return prepare_openwrt_router_with_system_ssh( + host, + username, + target_hostname, + new_password + ); + } + + let address = format!("{}:22", host); + + let socket = match address.parse::() { + Ok(socket) => socket, + Err(err) => { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: format!("Endereço inválido: {}", err), + logs: "".to_string(), + }; + } + }; + + let tcp = match TcpStream::connect_timeout(&socket, Duration::from_secs(5)) { + Ok(stream) => stream, + Err(err) => { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: format!("Falha ao ligar por TCP/SSH: {}", err), + logs: "".to_string(), + }; + } + }; + + let mut session = match Session::new() { + Ok(session) => session, + Err(err) => { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: format!("Falha ao criar sessão SSH: {}", err), + logs: "".to_string(), + }; + } + }; + + session.set_tcp_stream(tcp); + + if let Err(err) = session.handshake() { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: format!("Falha no handshake SSH: {}", err), + logs: "".to_string(), + }; + } + + if let Err(err) = session.userauth_password(&username, &password) { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: format!("Falha na autenticação SSH: {}", err), + logs: "".to_string(), + }; + } + + let command = build_prepare_command(&target_hostname, &new_password); + + let logs = run_ssh_command(&session, &command).unwrap_or_default(); + + let hostname_updated = logs.contains("HOSTNAME_UPDATED"); + let password_updated = logs.contains("PASSWORD_UPDATED"); + let success = hostname_updated && password_updated; + + OpenWrtPrepareResult { + success, + hostname_updated, + password_updated, + message: if success { + "Router preparado com sucesso.".to_string() + } else { + "Preparação do router incompleta. Verifique os logs.".to_string() + }, + logs, + } +} + fn tcp_port_open(host: &str, port: u16) -> bool { let address = format!("{}:{}", host, port); @@ -436,3 +566,82 @@ fn extract_prefixed_value(content: &str, prefix: &str) -> Option { .map(|line| line.trim_start_matches(prefix).trim().to_string()) .filter(|value| !value.is_empty()) } + +fn prepare_openwrt_router_with_system_ssh( + host: String, + username: String, + target_hostname: String, + new_password: String +) -> OpenWrtPrepareResult { + let target = format!("{}@{}", username, host); + let command = build_prepare_command(&target_hostname, &new_password); + + let output = Command::new("ssh") + .args([ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=NUL", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + &target, + &command, + ]) + .output(); + + let output = match output { + Ok(output) => output, + Err(err) => { + return OpenWrtPrepareResult { + success: false, + hostname_updated: false, + password_updated: false, + message: format!("Falha ao executar ssh local: {}", err), + logs: "".to_string(), + }; + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let logs = format!("{}\n{}", stdout, stderr); + + let hostname_updated = logs.contains("HOSTNAME_UPDATED"); + let password_updated = logs.contains("PASSWORD_UPDATED"); + let success = output.status.success() && hostname_updated && password_updated; + + OpenWrtPrepareResult { + success, + hostname_updated, + password_updated, + message: if success { + "Router preparado com sucesso.".to_string() + } else { + "Preparação do router incompleta. Verifique os logs.".to_string() + }, + logs, + } +} + +fn build_prepare_command(target_hostname: &str, new_password: &str) -> String { + let safe_hostname = shell_escape_single_quotes(target_hostname); + let safe_password = shell_escape_single_quotes(new_password); + + format!( + "set -e; \ + uci set system.@system[0].hostname='{hostname}'; \ + uci commit system; \ + /etc/init.d/system reload >/dev/null 2>&1 || true; \ + echo HOSTNAME_UPDATED; \ + printf '%s\\n%s\\n' '{password}' '{password}' | passwd root; \ + echo PASSWORD_UPDATED", + hostname = safe_hostname, + password = safe_password + ) +} + +fn shell_escape_single_quotes(value: &str) -> String { + value.replace('\'', "'\\''") +} diff --git a/src/App.css b/src/App.css index e2bde20..35777a6 100644 --- a/src/App.css +++ b/src/App.css @@ -1381,7 +1381,7 @@ td .small-action { .openwrt-form { display: grid; grid-template-columns: 1fr 1fr 1fr auto; - align-items: end; + align-items: start; gap: 14px; } @@ -1422,4 +1422,79 @@ td .small-action { .openwrt-grid .card-header { margin-bottom: 22px; +} + +.openwrt-prepare-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.openwrt-prepare-form label { + display: grid; + gap: 8px; + color: #374151; + font-size: 13px; + font-weight: 800; +} + +.openwrt-prepare-form input, +.openwrt-prepare-form select { + height: 44px; + border: 1px solid #edf1f7; + border-radius: 14px; + padding: 0 14px; + background: #f8fafc; + color: #111827; + font-weight: 700; +} + +.openwrt-prepare-form input:read-only { + color: #64748b; +} + +.openwrt-form .form-error { + color: #dc2626; + margin-top: 4px; + display: block; + line-height: 1.3; +} + +.openwrt-button-wrap { + display: flex; + align-items: flex-start; + padding-top: 28px; +} + +.openwrt-form label, +.openwrt-prepare-form label { + align-self: start; +} + +.openwrt-form .form-error, +.openwrt-prepare-form .form-error { + color: #dc2626 !important; + font-size: 12px; + font-weight: 800; + margin-top: 4px; + display: block; + line-height: 1.3; +} + +.openwrt-form input:focus, +.openwrt-prepare-form input:focus, +.openwrt-prepare-form .custom-select-button:focus { + outline: 0 !important; + background: white; + border-color: #5da8ff !important; + box-shadow: 0 0 0 4px rgba(93, 168, 255, 0.1) !important; +} + +.status-note + .log-box { + margin-top: 14px; +} + +.detail-list + .status-note { + margin-top: 14px; } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 7e85b11..3bead93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -408,7 +408,7 @@ function App() { )} {page === "openwrt" && ( - + )} diff --git a/src/pages/OpenWrtConfigPage.tsx b/src/pages/OpenWrtConfigPage.tsx index 826342a..92850d4 100644 --- a/src/pages/OpenWrtConfigPage.tsx +++ b/src/pages/OpenWrtConfigPage.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; +import { ChevronDown } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; +import type { RouterItem } from "../types/router"; import { AlertTriangle, CheckCircle2, @@ -17,6 +19,14 @@ type DetectionResult = { message: string; }; +type PrepareResult = { + success: boolean; + hostname_updated: boolean; + password_updated: boolean; + message: string; + logs: string; +}; + type CompatibilityResult = { compatible: boolean; version: string | null; @@ -44,7 +54,11 @@ type SshResult = { message: string; }; -export function OpenWrtConfigPage() { +type Props = { + routers: RouterItem[]; +}; + +export function OpenWrtConfigPage({ routers }: Props) { const [detecting, setDetecting] = useState(false); const [connecting, setConnecting] = useState(false); const [detection, setDetection] = useState(null); @@ -53,6 +67,13 @@ export function OpenWrtConfigPage() { const [host, setHost] = useState(""); const [username, setUsername] = useState("root"); const [password, setPassword] = useState(""); + const [selectedRouterId, setSelectedRouterId] = useState(""); + const [newRootPassword, setNewRootPassword] = useState(""); + const [confirmRootPassword, setConfirmRootPassword] = useState(""); + const [routerDropdownOpen, setRouterDropdownOpen] = useState(false); + + const [preparing, setPreparing] = useState(false); + const [prepareResult, setPrepareResult] = useState(null); const compatibility = sshResult?.compatibility; @@ -73,6 +94,46 @@ export function OpenWrtConfigPage() { } } + const sshAuthFailed = + sshResult && + !sshResult.connected && + sshResult.message.toLowerCase().includes("permission denied"); + + async function prepareRouter() { + if (!selectedRouter || !targetHostname) return; + + try { + setPreparing(true); + setPrepareResult(null); + + const result = await invoke("prepare_openwrt_router", { + host, + username, + password, + targetHostname, + newPassword: newRootPassword, + }); + + setPrepareResult(result); + + if (result.success) { + setPassword(newRootPassword); + setNewRootPassword(""); + setConfirmRootPassword(""); + + const refreshed = await invoke("test_openwrt_ssh", { + host, + username, + password: newRootPassword, + }); + + setSshResult(refreshed); + } + } finally { + setPreparing(false); + } + } + async function connectSsh() { try { setConnecting(true); @@ -89,6 +150,14 @@ export function OpenWrtConfigPage() { } } + const selectedRouter = routers.find((router) => router.id === selectedRouterId); + + const vpnIpParts = selectedRouter?.vpnIp?.split(".") ?? []; + + const targetHostname = selectedRouter?.vpnIp + ? `Litoral_Regas_${vpnIpParts[vpnIpParts.length - 1]}` + : ""; + return (
@@ -161,19 +230,31 @@ export function OpenWrtConfigPage() { setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + + if (sshResult && !sshResult.connected) { + setSshResult(null); + } + }} placeholder="Password SSH" /> + {sshAuthFailed && ( + + Password SSH inválida ou em falta. Introduza uma password válida. + + )} - - +
+ +
@@ -199,7 +280,11 @@ export function OpenWrtConfigPage() { {!sshResult.connected && (
-

{sshResult.message}

+

+ {sshAuthFailed + ? "Não foi possível autenticar. Verifique a password SSH." + : sshResult.message} +

)} @@ -311,8 +396,156 @@ export function OpenWrtConfigPage() { )} + + {sshResult?.connected && sshResult.readiness && ( +
+
+
+

Preparação do Router

+

Associe este router físico a um router da plataforma

+
+
+ +
+ + + + + + + +
+ + {selectedRouter && ( +
+ + + +
+ )} + +
+ +
+
+ )} + + {prepareResult && ( +
+
+
+

Resultado da Preparação

+

Estado da configuração inicial do router

+
+ + + {prepareResult.success ? "Sucesso" : "Falhou"} + +
+ +
+ + + +
+ +
+ {prepareResult.success ? : } +

{prepareResult.message}

+
+ + {prepareResult.logs && ( +
+
{prepareResult.logs}
+
+ )} +
+ )}
+ ); }