diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 103e35e..f88bb36 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1669,6 +1669,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 = "litemap" version = "0.8.2" @@ -1696,6 +1722,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "ssh2", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2045,6 +2072,18 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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" @@ -2837,6 +2876,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" @@ -3716,6 +3767,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 8413485..069b232 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,3 +17,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-dialog = "2.7.1" tauri-plugin-fs = "2.5.1" +ssh2 = "0.9" \ No newline at end of file diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index c80ee34..3537c56 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -1,16 +1,211 @@ +use std::process::Command; + #[tauri::command] pub async fn remove_known_host( ip: String, ) -> Result { if ip.trim().is_empty() { return Err( - "ip address cannot be empty" - .into(), + "ip address cannot be empty".into(), ); } - Ok(format!( - "removed stale known_hosts entry for {}", - ip, + let output = Command::new("ssh-keygen") + .args(["-R", &ip]) + .output() + .map_err(|error| { + format!( + "failed to run ssh-keygen: {}", + error, + ) + })?; + + if output.status.success() { + Ok(format!( + "removed stale known_hosts entry for {}", + ip, + )) + } else { + Err(String::from_utf8_lossy(&output.stderr) + .to_string()) + } +} + +#[tauri::command] +pub async fn probe_router_ssh( + ip: String, +) -> Result { + if ip.trim().is_empty() { + return Err( + "router IP is required".into(), + ); + } + + let output = Command::new("ssh") + .args([ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + "-o", + "StrictHostKeyChecking=accept-new", + &format!("root@{}", ip), + "ubus call system board", + ]) + .output() + .map_err(|error| { + format!( + "failed to run ssh command: {}", + error, + ) + })?; + + let stdout = + String::from_utf8_lossy(&output.stdout) + .to_string(); + + let stderr = + String::from_utf8_lossy(&output.stderr) + .to_string(); + + if output.status.success() { + return Ok(stdout); + } + + if stderr.contains( + "REMOTE HOST IDENTIFICATION HAS CHANGED", + ) { + return Err(format!( + "STALE_HOST_KEY: SSH host key mismatch for {}. Remove known_hosts entry and retry.", + ip, + )); + } + + if stderr.contains("Permission denied") + || stderr.contains("publickey") + || stderr.contains("password") + { + return Err(format!( + "AUTH_REQUIRED: Router is reachable at {}, but SSH requires password login. Try manually: ssh root@{} then run: ubus call system board", + ip, ip, + )); + } + + Err(format!( + "SSH_FAILED: {}\n{}", + stderr, stdout, )) +} + +use ssh2::Session; +use std::{ + io::Read, + net::{TcpStream, ToSocketAddrs}, + time::Duration, +}; + +#[tauri::command] +pub async fn inspect_router_with_password( + ip: String, + password: String, +) -> Result { + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } + + if password.trim().is_empty() { + return Err("router password is required".into()); + } + + let address = format!("{}:22", ip); + + let socket_address = address + .to_socket_addrs() + .map_err(|error| { + format!("failed to resolve router address: {}", error) + })? + .next() + .ok_or_else(|| { + format!("failed to resolve router address {}", address) + })?; + + let tcp = TcpStream::connect_timeout( + &socket_address, + Duration::from_secs(5), + ) + .map_err(|error| { + format!("failed to connect to SSH on {}: {}", ip, error) + })?; + + tcp.set_read_timeout(Some(Duration::from_secs(10))) + .map_err(|error| { + format!("failed to set SSH read timeout: {}", error) + })?; + + tcp.set_write_timeout(Some(Duration::from_secs(10))) + .map_err(|error| { + format!("failed to set SSH write timeout: {}", error) + })?; + + let mut session = Session::new() + .map_err(|error| { + format!("failed to create SSH session: {}", error) + })?; + + session.set_tcp_stream(tcp); + + session.handshake().map_err(|error| { + format!("SSH handshake failed for {}: {}", ip, error) + })?; + + session + .userauth_password("root", &password) + .map_err(|error| { + format!( + "SSH authentication failed for root@{}: {}", + ip, error + ) + })?; + + if !session.authenticated() { + return Err(format!( + "SSH authentication failed for root@{}", + ip + )); + } + + let mut channel = session.channel_session().map_err(|error| { + format!("failed to open SSH channel: {}", error) + })?; + + channel + .exec("ubus call system board") + .map_err(|error| { + format!("failed to run router inspection command: {}", error) + })?; + + let mut stdout = String::new(); + + channel + .read_to_string(&mut stdout) + .map_err(|error| { + format!("failed to read router inspection output: {}", error) + })?; + + channel.wait_close().map_err(|error| { + format!("failed to close SSH channel: {}", error) + })?; + + let exit_status = channel.exit_status().map_err(|error| { + format!("failed to read SSH command exit status: {}", error) + })?; + + if exit_status != 0 { + return Err(format!( + "router inspection command failed with exit code {}", + exit_status + )); + } + + Ok(stdout) } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80a942a..c16a9c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,7 +13,11 @@ use commands::{ verify_router, wait_for_ssh, }, - ssh::remove_known_host, + ssh::{ + inspect_router_with_password, + probe_router_ssh, + remove_known_host, + }, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -25,6 +29,8 @@ pub fn run() { read_text_file, ping_host, remove_known_host, + probe_router_ssh, + inspect_router_with_password, detect_router, upload_firmware, flash_router, diff --git a/src/components/provisioning/ProvisioningWizard.tsx b/src/components/provisioning/ProvisioningWizard.tsx index b5880f4..f012ae0 100644 --- a/src/components/provisioning/ProvisioningWizard.tsx +++ b/src/components/provisioning/ProvisioningWizard.tsx @@ -1,277 +1,462 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { + AlertTriangle, + CheckCircle2, + KeyRound, + Network, + Search, + ShieldAlert, + Terminal, + Wifi, +} from 'lucide-react'; + +import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; -import { Badge } from '@/components/ui/Badge'; -import { ProvisioningStepCard } from './ProvisioningStepCard'; import { addActivityLog } from '@/services/activityLogService'; -import { vpnApi } from '@/services/vpnApi'; +type DetectionStatus = + | 'idle' + | 'checking' + | 'reachable' + | 'ssh_ok' + | 'auth_required' + | 'stale_host_key' + | 'failed'; -import type { - ProvisioningState, - ProvisionMode, -} from '@/types/provisioning'; - -const states: ProvisioningState[] = [ - 'IDLE', - 'DETECT_ROUTER', - 'UPLOAD_FIRMWARE', - 'FLASHING', - 'WAITING_FOR_REBOOT', - 'WAITING_FOR_RECONNECT', - 'UPLOAD_PROVISIONING_BUNDLE', - 'RUN_PROVISIONING', - 'CAPTURE_PUBLIC_KEY', - 'REGISTER_PEER', - 'VERIFY', - 'COMPLETE', +const ROUTER_IPS = [ + '192.168.1.1', + '198.51.100.1', ]; -const waitMessage = - 'Router rebooting or network changed. Please reconnect/replug the Ethernet cable if needed. Waiting for router at 198.51.100.1...'; +function statusTone(status: DetectionStatus) { + if ( + status === 'reachable' || + status === 'ssh_ok' + ) { + return 'green'; + } -function buildRouterEnv(vpnIp: string) { - const routerIdParts = vpnIp.split('.'); - const routerId = routerIdParts[routerIdParts.length - 1]; + if ( + status === 'auth_required' || + status === 'stale_host_key' + ) { + return 'purple'; + } - return [ - `ROUTER_ID=${routerId}`, - `ROUTER_HOSTNAME=Litoral_Regas_${routerId}`, - `ROUTER_WG_IP=${vpnIp}`, - 'LAN_SUBNET=198.51.100.0/24', - 'ROUTER_LAN_IP=198.51.100.1', - 'CONTROLLER_IP=198.51.100.10', - 'PLC_IP=198.51.100.50', - 'OVERLAY_ROUTE=198.19.0.0/16', - 'ROUTER_PASSWORD=litoralr', - '', - ].join('\n'); + if (status === 'failed') { + return 'red'; + } + + return 'blue'; } export function ProvisioningWizard() { - const [mode, setMode] = - useState('new'); + const [selectedIp, setSelectedIp] = + useState(ROUTER_IPS[0]); - const [state, setState] = - useState('IDLE'); + const [status, setStatus] = + useState('idle'); + + const [routerInfo, setRouterInfo] = + useState(''); - const [vpnIp, setVpnIp] = useState(''); const [log, setLog] = useState([]); - const activeIndex = useMemo( - () => states.indexOf(state), - [state], - ); - - function addLog(message: string) { + function addLog( + message: string, + level: + | 'info' + | 'success' + | 'warning' + | 'error' = 'info', + ) { setLog((currentLog) => [ `${new Date().toLocaleTimeString()} ${message}`, ...currentLog, ]); addActivityLog({ - level: 'info', + level, source: 'desktop', - action: state, + action: 'ROUTER_DETECTION', message, - routerIp: - state.includes('RECONNECT') || - state.includes('PROVISION') - ? '198.51.100.1' - : '192.168.1.1', - vpnIp: vpnIp || undefined, + routerIp: selectedIp, }); } - async function runWorkflow() { + + async function detectRouter() { + setStatus('checking'); + setRouterInfo(''); + + addLog( + `Starting safe router detection at ${selectedIp}`, + ); + try { - setState('DETECT_ROUTER'); + await invoke('ping_host', { + ip: selectedIp, + }); addLog( - 'Removing stale known_hosts entries for 192.168.1.1 and 198.51.100.1', + `Router responded at ${selectedIp}`, + 'success', ); - await invoke('detect_router', { - ip: '192.168.1.1', - }); + try { + const info = await invoke( + 'probe_router_ssh', + { + ip: selectedIp, + }, + ); - const selectedVpnIp = - mode === 'new' - ? (await vpnApi.availableIp()).vpnIp - : vpnIp; + setRouterInfo(info); - setVpnIp(selectedVpnIp); - addLog(`Using WireGuard IP ${selectedVpnIp}`); + setStatus('ssh_ok'); - setState('UPLOAD_FIRMWARE'); + addLog( + `SSH key authentication succeeded at ${selectedIp}`, + 'success', + ); - await invoke('upload_firmware', { - ip: '192.168.1.1', - firmwarePath: - './firmware/openwrt-23.05-zbt-we826-16m.bin', - }); + return; + } catch (sshError) { + const sshMessage = String(sshError); - setState('FLASHING'); + if ( + sshMessage.includes( + 'STALE_HOST_KEY', + ) + ) { + setStatus('stale_host_key'); - await invoke('flash_router', { - ip: '192.168.1.1', - remoteFirmwarePath: '/tmp/firmware.bin', - }); + addLog( + `Stale SSH host key detected for ${selectedIp}`, + 'warning', + ); - setState('WAITING_FOR_REBOOT'); - addLog(waitMessage); + return; + } - setState('WAITING_FOR_RECONNECT'); + if ( + sshMessage.includes( + 'AUTH_REQUIRED', + ) + ) { + addLog( + `Router requires password authentication. Attempting automatic inspection...`, + 'warning', + ); - await invoke('wait_for_ssh', { - ip: '198.51.100.1', - }); + try { + const passwordInfo = + await invoke( + 'inspect_router_with_password', + { + ip: selectedIp, + password: 'litoralr', + }, + ); - setState('UPLOAD_PROVISIONING_BUNDLE'); + setRouterInfo(passwordInfo); - await invoke('upload_provisioning_bundle', { - ip: '198.51.100.1', - envContent: buildRouterEnv(selectedVpnIp), - scriptContent: - '#!/bin/sh\n# future production provision.sh\n', - }); + setStatus('ssh_ok'); - setState('RUN_PROVISIONING'); + addLog( + `Password SSH inspection succeeded at ${selectedIp}`, + 'success', + ); - await invoke('run_provisioning', { - ip: '198.51.100.1', - }); + return; + } catch (passwordError) { + setStatus('auth_required'); - setState('CAPTURE_PUBLIC_KEY'); + addLog( + `Password authentication failed: ${String( + passwordError, + )}`, + 'error', + ); - const publicKey = await invoke( - 'capture_wireguard_public_key', + return; + } + } + + throw sshError; + } + } catch (error) { + const message = String(error); + + setStatus('failed'); + + addLog( + `Router detection failed: ${message}`, + 'error', + ); + } + } + async function fixKnownHost() { + try { + addLog( + `Removing known_hosts entry for ${selectedIp}`, + ); + + const result = await invoke( + 'remove_known_host', { - ip: '198.51.100.1', + ip: selectedIp, }, ); - addLog( - `Captured public key ${publicKey.slice( - 0, - 12, - )}...`, - ); + addLog(result, 'success'); - setState('REGISTER_PEER'); - - await vpnApi.registerPeer({ - vpnIp: selectedVpnIp, - publicKey, - }); - - setState('VERIFY'); - - await invoke('verify_router', { - ip: '198.51.100.1', - }); - - setState('COMPLETE'); - - addLog( - 'Provisioning complete. LuCI is available over WireGuard.', - ); + setStatus('idle'); } catch (error) { - setState('ERROR'); - addLog(String(error)); + addLog(String(error), 'error'); } } return ( -
- -
-
-

- Router Provisioning Wizard -

+
+
+
+

+ Provisioning +

-

- Validated baseline: OpenWrt - 23.05, ZBT-WE826 16M, - fw4/nftables, no opkg upgrade. -

-
- - - - {mode === 'reprovision' && ( - - setVpnIp(event.target.value) - } - /> - )} - - - - - {state} - +

+ Safe router detection and SSH + inspection. No flashing or + configuration changes are performed. +

- {(state === 'WAITING_FOR_REBOOT' || - state === - 'WAITING_FOR_RECONNECT') && ( -

- {waitMessage} -

- )} - - -
- {states.map((provisioningState, index) => ( - - ))} + + {status.replace(/_/g, ' ')} +
- -

- Technician Log -

+
+ +
+
+ +
-
-          {log.join('\n')}
+            
+

+ Safe Router Detection +

+ +

+ Check reachability and attempt + read-only SSH inspection. +

+
+
+ +
+ {ROUTER_IPS.map((ip) => { + const active = selectedIp === ip; + + return ( + + ); + })} +
+ +
+
+
+ +
+ +
+

+ Detection Target +

+ +

+ root@{selectedIp} +

+ +

+ Already provisioned routers + use password{' '} + + litoralr + + . +

+
+ + + {status.replace(/_/g, ' ')} + +
+
+ +
+ + + +
+ + {(status === 'auth_required' || + status === 'stale_host_key') && ( +
+
+ + +
+

+ Manual SSH may be required +

+ +

+ Open a terminal and run: +

+ +
+                      ssh root@{selectedIp}
+                    
+ +

+ Password for provisioned + routers: +

+ +
+                      litoralr
+                    
+ +

+ Once connected, run this + read-only command: +

+ +
+                      ubus call system board
+                    
+
+
+
+ )} +
+ +
+ +
+
+ +
+ +
+

+ Current Safety Scope +

+ +

+ This screen is read-only. +

+
+
+ +
+

✓ Ping reachability check

+

✓ SSH host key detection

+

✓ Read-only router inspection

+

✕ No firmware flashing

+

✕ No config upload

+

✕ No password changes

+
+
+ + +
+
+ +
+ +
+

+ Router Info +

+ +

+ Output from ubus system board. +

+
+
+ +
+              {routerInfo ||
+                'No router information captured yet.'}
+            
+
+
+
+ + +

+ Technician Log +

+ +
+          {log.join('\n') ||
+            'No provisioning activity yet.'}