From dae218e1e911a18d13351eaa47ad14e9a0597e2a Mon Sep 17 00:00:00 2001 From: litoral05 Date: Wed, 3 Jun 2026 12:01:33 +0100 Subject: [PATCH] adds old rsa support --- src-tauri/src/commands/router.rs | 666 ++++++++---------- src-tauri/src/commands/ssh.rs | 77 +- .../provisioning/ProvisioningWizard.tsx | 258 ++++--- 3 files changed, 524 insertions(+), 477 deletions(-) diff --git a/src-tauri/src/commands/router.rs b/src-tauri/src/commands/router.rs index 6b98f86..8abd62d 100644 --- a/src-tauri/src/commands/router.rs +++ b/src-tauri/src/commands/router.rs @@ -1,5 +1,5 @@ use ssh2::Session; -use std::process::Command; +use std::process::{Command, Stdio}; use std::{ fs::File, io::{Read, Write}, @@ -46,22 +46,30 @@ fn authenticate_router(session: &Session, password: &str) -> Result<(), String> Err("SSH authentication failed for root".into()) } +fn system_ssh_base_args(connect_timeout: &str) -> Vec { + vec![ + "-o".into(), + "BatchMode=yes".into(), + "-o".into(), + format!("ConnectTimeout={}", connect_timeout), + "-o".into(), + "StrictHostKeyChecking=accept-new".into(), + "-o".into(), + "HostKeyAlgorithms=+ssh-rsa".into(), + "-o".into(), + "PubkeyAcceptedAlgorithms=+ssh-rsa".into(), + ] +} + fn run_system_ssh(ip: &str, command: &str) -> Result { let target = format!("root@{}", ip); + let mut args = system_ssh_base_args("6"); + + args.push(target); + args.push(command.to_string()); let output = Command::new("ssh") - .args([ - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=6", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=NUL", - &target, - command, - ]) + .args(args) .output() .map_err(|error| format!("failed to run system ssh: {}", error))?; @@ -80,6 +88,196 @@ fn run_system_ssh(ip: &str, command: &str) -> Result { )) } +fn run_system_ssh_with_stdin(ip: &str, command: &str, input: &[u8]) -> Result { + let target = format!("root@{}", ip); + let mut args = system_ssh_base_args("10"); + + args.push(target); + args.push(command.to_string()); + + let mut child = Command::new("ssh") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to start system ssh: {}", error))?; + + { + let stdin = child + .stdin + .as_mut() + .ok_or_else(|| "failed to open ssh stdin".to_string())?; + + stdin + .write_all(input) + .map_err(|error| format!("failed to write ssh stdin: {}", error))?; + } + + let output = child + .wait_with_output() + .map_err(|error| format!("failed waiting for system ssh: {}", error))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + return Ok(format!("{}{}", stdout, stderr)); + } + + Err(format!( + "system ssh stdin command failed with status {:?}:\n{}\n{}", + output.status.code(), + stderr, + stdout + )) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn upload_bytes_via_system_ssh( + ip: &str, + content: &[u8], + remote_path: &str, + mode: i32, +) -> Result { + let remote_path_quoted = shell_quote(remote_path); + let command = format!( + "cat > {} && chmod {:o} {}", + remote_path_quoted, mode, remote_path_quoted + ); + + run_system_ssh_with_stdin(ip, &command, content) +} + +fn upload_file_via_system_ssh( + ip: &str, + local_path: &Path, + remote_path: &str, + mode: i32, +) -> Result { + let mut local_file = File::open(local_path).map_err(|error| { + format!( + "failed to open local file {}: {}", + local_path.display(), + error + ) + })?; + + let mut buffer = Vec::new(); + + local_file + .read_to_end(&mut buffer) + .map_err(|error| format!("failed to read local file {}: {}", local_path.display(), error))?; + + upload_bytes_via_system_ssh(ip, &buffer, remote_path, mode) +} + +fn run_router_command(ip: &str, password: &str, command: &str) -> Result { + if !password.trim().is_empty() { + match open_router_session(ip, password) { + Ok(session) => return run_ssh_command(&session, command), + Err(error) => { + if !is_ssh_auth_failure(&error) { + return Err(error); + } + + // The OpenSSH client may have a usable agent/key even when libssh2 cannot use it + // on Windows. Fall back to system ssh for key-based auth. + return run_system_ssh(ip, command).map_err(|system_error| { + format!( + "password/libssh2 auth failed: {}; system ssh fallback failed: {}", + error, system_error + ) + }); + } + } + } + + run_system_ssh(ip, command) +} + +fn upload_file_to_router_dynamic( + ip: &str, + password: &str, + local_path: &Path, + remote_path: &str, + mode: i32, +) -> Result { + if !password.trim().is_empty() { + match open_router_session(ip, password) { + Ok(session) => { + scp_file_from_disk(&session, local_path.to_string_lossy().as_ref(), remote_path, mode)?; + return Ok(format!("uploaded {} to {}", local_path.display(), remote_path)); + } + Err(error) => { + if !is_ssh_auth_failure(&error) { + return Err(error); + } + + // Fall back to OpenSSH agent/key auth and stdin upload. This also avoids Windows + // scp parsing \\?\C:\... as a remote hostname. + return upload_file_via_system_ssh(ip, local_path, remote_path, mode).map(|_| { + format!( + "uploaded {} to {} using system ssh fallback", + local_path.display(), + remote_path + ) + }); + } + } + } + + upload_file_via_system_ssh(ip, local_path, remote_path, mode).map(|_| { + format!( + "uploaded {} to {} using system ssh", + local_path.display(), + remote_path + ) + }) +} + +fn upload_string_to_router_dynamic( + ip: &str, + password: &str, + content: &str, + remote_path: &str, + mode: i32, +) -> Result { + if !password.trim().is_empty() { + match open_router_session(ip, password) { + Ok(session) => { + scp_string(&session, content, remote_path, mode)?; + return Ok(format!("uploaded string to {}", remote_path)); + } + Err(error) => { + if !is_ssh_auth_failure(&error) { + return Err(error); + } + + return upload_bytes_via_system_ssh(ip, content.as_bytes(), remote_path, mode).map(|_| { + format!("uploaded string to {} using system ssh fallback", remote_path) + }); + } + } + } + + upload_bytes_via_system_ssh(ip, content.as_bytes(), remote_path, mode).map(|_| { + format!("uploaded string to {} using system ssh", remote_path) + }) +} + + +fn is_ssh_auth_failure(error: &str) -> bool { + let lowered = error.to_lowercase(); + + lowered.contains("permission denied") + || lowered.contains("publickey,password") + || lowered.contains("authentication failed") +} + fn open_router_session(ip: &str, password: &str) -> Result { let address = format!("{}:22", ip); @@ -214,15 +412,7 @@ pub async fn run_provisioning(ip: String, password: String) -> Result Resul return Err("router IP is required".into()); } - let command = "wg show wg0 public-key"; - - let output = if password.trim().is_empty() { - run_system_ssh(&ip, command)? - } else { - let session = open_router_session(&ip, &password)?; - run_ssh_command(&session, command)? - }; - + let output = run_router_command(&ip, &password, "wg show wg0 public-key")?; let public_key = output.trim(); if public_key.is_empty() { @@ -281,92 +463,17 @@ pub async fn upload_firmware_to_router( return Err("router IP is required".into()); } - if password.trim().is_empty() { - let target = format!("root@{}:{}", ip, remote_firmware_path); - let local_firmware_path_string = local_firmware_path.to_string_lossy().to_string(); - - let output = Command::new("scp") - .args([ - "-O", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=10", - "-o", - "StrictHostKeyChecking=accept-new", - &local_firmware_path_string, - &target, - ]) - .output() - .map_err(|error| format!("failed to run system scp: {}", error))?; - - if output.status.success() { - return Ok(format!( - "Firmware uploaded to {}:{} using system scp", - ip, remote_firmware_path - )); - } - - return Err(format!( - "system scp failed:\n{}\n{}", - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - )); - } - - let session = open_router_session(&ip, &password)?; - - let mut local_file = File::open(&local_firmware_path).map_err(|error| { - format!( - "failed to open local firmware file {}: {}", - local_firmware_path.display(), - error - ) - })?; - - let firmware_size = local_file - .metadata() - .map_err(|error| format!("failed to read firmware metadata: {}", error))? - .len(); - - let mut buffer = Vec::new(); - - local_file - .read_to_end(&mut buffer) - .map_err(|error| format!("failed to read firmware file: {}", error))?; - - let mut remote_file = session - .scp_send(Path::new(remote_firmware_path), 0o644, firmware_size, None) - .map_err(|error| { - format!( - "failed to start SCP upload to {}: {}", - remote_firmware_path, error - ) - })?; - - remote_file - .write_all(&buffer) - .map_err(|error| format!("failed to upload firmware via SCP: {}", error))?; - - remote_file - .send_eof() - .map_err(|error| format!("failed to send SCP EOF: {}", error))?; - - remote_file - .wait_eof() - .map_err(|error| format!("failed waiting for SCP EOF: {}", error))?; - - remote_file - .close() - .map_err(|error| format!("failed closing SCP channel: {}", error))?; - - remote_file - .wait_close() - .map_err(|error| format!("failed waiting for SCP close: {}", error))?; + upload_file_to_router_dynamic( + &ip, + &password, + &local_firmware_path, + remote_firmware_path, + 0o644, + )?; Ok(format!( - "Firmware uploaded to {}:{} ({} bytes)", - ip, remote_firmware_path, firmware_size + "Firmware uploaded to {}:{}", + ip, remote_firmware_path )) } @@ -376,69 +483,40 @@ pub async fn flash_router_sysupgrade(ip: String, password: String) -> Result Ok(format!( - "sysupgrade command submitted on {} using system ssh. {}", - ip, output - )), - Err(error) => { - let lowered = error.to_lowercase(); - - if lowered.contains("commencing upgrade") - || lowered.contains("closing all shell sessions") - || lowered.contains("connection failed") - || lowered.contains("connection reset") - || lowered.contains("broken pipe") - || lowered.contains("closed") - || lowered.contains("disconnect") - || lowered.contains("sysupgrade") - { - Ok(format!( - "sysupgrade started on {}; SSH disconnected as expected.", - ip - )) - } else { - Err(error) - } - } - }; - } - - let session = open_router_session(&ip, &password)?; - - let mut channel = session - .channel_session() - .map_err(|error| format!("failed to open SSH channel: {}", error))?; - - channel - .exec(flash_command) - .map_err(|error| format!("failed to start sysupgrade: {}", error))?; - - let mut output = String::new(); - let _ = channel.read_to_string(&mut output); - let _ = channel.wait_close(); - - let lowered = output.to_lowercase(); - - if lowered.contains("commencing upgrade") - || lowered.contains("closing all shell sessions") - || lowered.contains("sysupgrade") - { - return Ok(format!( - "sysupgrade started on {}; router should reboot shortly. {}", + match output { + Ok(output) => Ok(format!( + "sysupgrade command submitted on {}. {}", ip, output - )); - } + )), + Err(error) => { + if is_ssh_auth_failure(&error) { + return Err(format!( + "AUTH_REQUIRED: Firmware was uploaded, but sysupgrade was NOT started on {} because SSH authentication failed. Enter the router password and run flash again.", + ip + )); + } - Ok(format!( - "sysupgrade command submitted on {}. Router should reboot shortly. {}", - ip, output - )) + let lowered = error.to_lowercase(); + + if lowered.contains("commencing upgrade") + || lowered.contains("closing all shell sessions") + || lowered.contains("connection reset") + || lowered.contains("broken pipe") + || lowered.contains("disconnect") + || lowered.contains("sysupgrade") + { + Ok(format!( + "sysupgrade started on {}; SSH disconnected as expected.", + ip + )) + } else { + Err(error) + } + } + } } #[tauri::command] @@ -451,32 +529,23 @@ pub async fn reconnect_router_after_flash(ip: String, password: String) -> Resul let wait_between_attempts = Duration::from_secs(5); for attempt in 1..=max_attempts { - if password.trim().is_empty() { - match run_system_ssh(&ip, "ubus call system board") { - Ok(output) => { - return Ok(format!( - "Router reconnected after flash on attempt {}/{} using system ssh.\n{}", - attempt, max_attempts, output - )); - } - Err(_) => { - thread::sleep(wait_between_attempts); - continue; - } + match run_router_command(&ip, &password, "ubus call system board") { + Ok(output) => { + return Ok(format!( + "Router reconnected after flash on attempt {}/{}.\n{}", + attempt, max_attempts, output + )); } - } - - match open_router_session(&ip, &password) { - Ok(session) => match run_ssh_command(&session, "ubus call system board") { - Ok(output) => { - return Ok(format!( - "Router reconnected after flash on attempt {}/{}.\n{}", - attempt, max_attempts, output + Err(error) => { + if is_ssh_auth_failure(&error) { + return Err(format!( + "AUTH_REQUIRED: Router is reachable at {} over SSH, but authentication failed. Enter the router password before continuing.", + ip )); } - Err(_) => thread::sleep(wait_between_attempts), - }, - Err(_) => thread::sleep(wait_between_attempts), + + thread::sleep(wait_between_attempts); + } } } @@ -492,12 +561,19 @@ pub async fn check_router_after_flash(ip: String, password: String) -> Result Ok(output), + Err(error) => { + if is_ssh_auth_failure(&error) { + Err(format!( + "AUTH_REQUIRED: Router is reachable at {} over SSH, but authentication failed. Enter the router password before continuing.", + ip + )) + } else { + Err(error) + } + } } - - let session = open_router_session(&ip, &password)?; - run_ssh_command(&session, "ubus call system board") } #[tauri::command] @@ -515,64 +591,12 @@ pub async fn upload_provisioning_bundle( let remote_script_path = "/tmp/provision.sh"; let remote_env_path = "/tmp/router.env"; - if password.trim().is_empty() { - let script_target = format!("root@{}:{}", ip, remote_script_path); - let local_script_path_string = local_script_path.to_string_lossy().to_string(); - - let script_upload = Command::new("scp") - .args([ - "-O", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=10", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=NUL", - &local_script_path_string, - &script_target, - ]) - .output() - .map_err(|error| format!("failed to run scp for provision.sh: {}", error))?; - - if !script_upload.status.success() { - return Err(format!( - "failed to upload provision.sh:\n{}\n{}", - String::from_utf8_lossy(&script_upload.stderr), - String::from_utf8_lossy(&script_upload.stdout) - )); - } - - let env_command = format!( - "cat > {} <<'EOF'\n{}\nEOF\nchmod +x {}", - remote_env_path, env_content, remote_script_path - ); - - run_system_ssh(&ip, &env_command)?; - - return Ok(format!( - "uploaded provision.sh and router.env to {} using system ssh/scp", - ip - )); - } - - let session = open_router_session(&ip, &password)?; - - scp_file_from_disk( - &session, - local_script_path.to_string_lossy().as_ref(), - remote_script_path, - 0o755, - )?; - - scp_string(&session, &env_content, remote_env_path, 0o600)?; - - run_ssh_command(&session, "chmod +x /tmp/provision.sh")?; + upload_file_to_router_dynamic(&ip, &password, &local_script_path, remote_script_path, 0o755)?; + upload_string_to_router_dynamic(&ip, &password, &env_content, remote_env_path, 0o600)?; + run_router_command(&ip, &password, "chmod +x /tmp/provision.sh")?; Ok(format!("uploaded provision.sh and router.env to {}", ip)) } - #[tauri::command] pub async fn upload_udp2raw_setup_script( app: AppHandle, @@ -586,50 +610,8 @@ pub async fn upload_udp2raw_setup_script( let local_script_path = resource_path(&app, "udp2raw/setup_udp2raw.sh")?; let remote_script_path = "/tmp/setup_udp2raw.sh"; - if password.trim().is_empty() { - let target = format!("root@{}:{}", ip, remote_script_path); - let local_script_path_string = local_script_path.to_string_lossy().to_string(); - - let output = Command::new("scp") - .args([ - "-O", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=10", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=NUL", - &local_script_path_string, - &target, - ]) - .output() - .map_err(|error| format!("failed to run scp for setup_udp2raw.sh: {}", error))?; - - if !output.status.success() { - return Err(format!( - "failed to upload setup_udp2raw.sh:\n{}\n{}", - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - )); - } - - run_system_ssh(&ip, "chmod +x /tmp/setup_udp2raw.sh")?; - - return Ok(format!("uploaded setup_udp2raw.sh to {}", ip)); - } - - let session = open_router_session(&ip, &password)?; - - scp_file_from_disk( - &session, - local_script_path.to_string_lossy().as_ref(), - remote_script_path, - 0o755, - )?; - - run_ssh_command(&session, "chmod +x /tmp/setup_udp2raw.sh")?; + upload_file_to_router_dynamic(&ip, &password, &local_script_path, remote_script_path, 0o755)?; + run_router_command(&ip, &password, "chmod +x /tmp/setup_udp2raw.sh")?; Ok(format!("uploaded setup_udp2raw.sh to {}", ip)) } @@ -640,15 +622,7 @@ pub async fn run_udp2raw_setup(ip: String, password: String) -> Result Result wg show wg0 2>/dev/null | grep -A8 '^peer:' || echo "wg0 unavailable" "#; - if password.trim().is_empty() { - return run_system_ssh(&ip, command); - } - - let session = open_router_session(&ip, &password)?; - - run_ssh_command(&session, command) + run_router_command(&ip, &password, command) } #[tauri::command] @@ -752,17 +720,11 @@ pub async fn test_udp2raw_tunnel(ip: String, password: String) -> Result { - return run_ssh_command(&session, command); - } + match run_router_command(&ip, &password, command) { + Ok(output) => return Ok(output), Err(error) => { last_error = format!("SSH attempt {}/5 failed: {}", attempt, error); thread::sleep(Duration::from_secs(2)); @@ -786,54 +748,10 @@ pub async fn upload_udp2raw_binary( let local_binary_path = resource_path(&app, "udp2raw/udp2raw")?; let remote_binary_path = "/usr/bin/udp2raw"; - if password.trim().is_empty() { - let target = format!("root@{}:{}", ip, remote_binary_path); - let local_binary_path_string = local_binary_path.to_string_lossy().to_string(); - - let output = Command::new("scp") - .args([ - "-O", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=10", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=NUL", - &local_binary_path_string, - &target, - ]) - .output() - .map_err(|error| format!("failed to run scp for udp2raw binary: {}", error))?; - - if !output.status.success() { - return Err(format!( - "failed to upload udp2raw binary:\n{}\n{}", - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - )); - } - - run_system_ssh( - &ip, - "chmod +x /usr/bin/udp2raw && /usr/bin/udp2raw --help >/dev/null 2>&1 || true", - )?; - - return Ok("uploaded udp2raw binary to /usr/bin/udp2raw".into()); - } - - let session = open_router_session(&ip, &password)?; - - scp_file_from_disk( - &session, - local_binary_path.to_string_lossy().as_ref(), - remote_binary_path, - 0o755, - )?; - - run_ssh_command( - &session, + upload_file_to_router_dynamic(&ip, &password, &local_binary_path, remote_binary_path, 0o755)?; + run_router_command( + &ip, + &password, "chmod +x /usr/bin/udp2raw && ls -l /usr/bin/udp2raw", )?; diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index 3537c56..7af8301 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -49,6 +49,13 @@ pub async fn probe_router_ssh( "ConnectTimeout=5", "-o", "StrictHostKeyChecking=accept-new", + + // Legacy OpenWrt/Dropbear routers that only offer ssh-rsa. + "-o", + "HostKeyAlgorithms=+ssh-rsa", + "-o", + "PubkeyAcceptedAlgorithms=+ssh-rsa", + &format!("root@{}", ip), "ubus call system board", ]) @@ -97,6 +104,44 @@ pub async fn probe_router_ssh( )) } +fn system_ssh_probe(ip: &str, command: &str) -> Result { + let output = Command::new("ssh") + .args([ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "HostKeyAlgorithms=+ssh-rsa", + "-o", + "PubkeyAcceptedAlgorithms=+ssh-rsa", + &format!("root@{}", ip), + command, + ]) + .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() { + Ok(stdout) + } else { + Err(format!("SSH_FAILED: {}\n{}", stderr, stdout)) + } +} + +fn is_ssh_auth_failure(error: &str) -> bool { + let lowered = error.to_lowercase(); + + lowered.contains("permission denied") + || lowered.contains("publickey,password") + || lowered.contains("authentication failed") +} + + use ssh2::Session; use std::{ io::Read, @@ -158,20 +203,26 @@ pub async fn inspect_router_with_password( format!("SSH handshake failed for {}: {}", ip, error) })?; - session - .userauth_password("root", &password) - .map_err(|error| { - format!( - "SSH authentication failed for root@{}: {}", - ip, error - ) - })?; + let auth_result = session.userauth_password("root", &password); - if !session.authenticated() { - return Err(format!( - "SSH authentication failed for root@{}", - ip - )); + if auth_result.is_err() || !session.authenticated() { + let error = auth_result + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| format!("SSH authentication failed for root@{}", ip)); + + // If password auth fails but the local OpenSSH agent has a working key, + // still allow detection to succeed. This handles Windows/libssh2 agent gaps. + return system_ssh_probe(&ip, "ubus call system board").map_err(|system_error| { + if is_ssh_auth_failure(&system_error) { + format!( + "SSH authentication failed for root@{}: {}; system ssh fallback also failed: {}", + ip, error, system_error + ) + } else { + system_error + } + }); } let mut channel = session.channel_session().map_err(|error| { diff --git a/src/components/provisioning/ProvisioningWizard.tsx b/src/components/provisioning/ProvisioningWizard.tsx index ca3c8dd..543dc10 100644 --- a/src/components/provisioning/ProvisioningWizard.tsx +++ b/src/components/provisioning/ProvisioningWizard.tsx @@ -92,55 +92,55 @@ const workflowSteps: Array<{ description: string; icon: typeof Search; }> = [ - { - id: 'DETECT_ROUTER', - title: 'Detetar Router', - description: 'Ping e inspeção do router via SSH.', - icon: Search, - }, - { - id: 'UPLOAD_FIRMWARE', - title: 'Enviar Firmware', - description: 'Copiar imagem de firmware para /tmp.', - icon: UploadCloud, - }, - { - id: 'FLASH_FIRMWARE', - title: 'Gravar Firmware', - description: 'Executar sysupgrade -n /tmp/firmware.bin.', - icon: Cpu, - }, - { - id: 'WAIT_REBOOT', - title: 'Aguardar Reinício', - description: 'Bloquear ações enquanto o router reinicia.', - icon: RefreshCw, - }, - { - id: 'RECONNECT_ROUTER', - title: 'Reconectar Router', - description: 'Reconectar Ethernet e aguardar SSH.', - icon: PlugZap, - }, - { - id: 'UPLOAD_PROVISIONING', - title: 'Enviar Pacote', - description: 'Copiar router.env e provision.sh.', - icon: FileUp, - }, - { - id: 'RUN_PROVISIONING', - title: 'Executar Provisionamento', - description: 'Executar script de configuração no router.', - icon: Terminal, - }, - { - id: 'REGISTER_PEER', - title: 'Registar Peer VPS', - description: 'Aplicar peer WireGuard na VPS.', - icon: Network, - }, -]; + { + id: 'DETECT_ROUTER', + title: 'Detetar Router', + description: 'Ping e inspeção do router via SSH.', + icon: Search, + }, + { + id: 'UPLOAD_FIRMWARE', + title: 'Enviar Firmware', + description: 'Copiar imagem de firmware para /tmp.', + icon: UploadCloud, + }, + { + id: 'FLASH_FIRMWARE', + title: 'Gravar Firmware', + description: 'Executar sysupgrade -n /tmp/firmware.bin.', + icon: Cpu, + }, + { + id: 'WAIT_REBOOT', + title: 'Aguardar Reinício', + description: 'Bloquear ações enquanto o router reinicia.', + icon: RefreshCw, + }, + { + id: 'RECONNECT_ROUTER', + title: 'Reconectar Router', + description: 'Reconectar Ethernet e aguardar SSH.', + icon: PlugZap, + }, + { + id: 'UPLOAD_PROVISIONING', + title: 'Enviar Pacote', + description: 'Copiar router.env e provision.sh.', + icon: FileUp, + }, + { + id: 'RUN_PROVISIONING', + title: 'Executar Provisionamento', + description: 'Executar script de configuração no router.', + icon: Terminal, + }, + { + id: 'REGISTER_PEER', + title: 'Registar Peer VPS', + description: 'Aplicar peer WireGuard na VPS.', + icon: Network, + }, + ]; const scrollClass = '[scrollbar-width:thin] [scrollbar-color:rgba(59,130,246,0.45)_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-blue-500/30 hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/50'; @@ -291,11 +291,7 @@ export function ProvisioningWizard() { const [setupCompleteOpen, setSetupCompleteOpen] = useState(false); - const effectivePassword = - customIp.trim() && - selectedIp === customIp.trim() - ? customPassword - : routerPassword; + const effectivePassword = customPassword.trim() || routerPassword; const provisioningStarted = activeStep !== 'DETECT_ROUTER' || @@ -598,6 +594,17 @@ export function ProvisioningWizard() { 'AUTH_REQUIRED', ) ) { + if (!effectivePassword.trim()) { + setStatus('auth_required'); + + addLog( + 'O router requer palavra-passe SSH. Introduza a palavra-passe no campo "Acesso Personalizado ao Router" e volte a detetar.', + 'warning', + ); + + return; + } + addLog( 'O router requer autenticação por palavra-passe. A tentar inspeção automática...', 'warning', @@ -798,11 +805,30 @@ export function ProvisioningWizard() { return; } catch (error) { + const message = String(error); + const lowered = message.toLowerCase(); + addLog( - `Tentativa de reconexão ${attempt}/30 falhou: ${String(error)}`, + `Tentativa de reconexão ${attempt}/30 falhou: ${message}`, 'warning', ); + if ( + lowered.includes('auth_required') || + lowered.includes('permission denied') || + lowered.includes('publickey,password') + ) { + setStatus('auth_required'); + setIsReconnecting(false); + + addLog( + 'O router está acessível por SSH, mas requer palavra-passe. Introduza a palavra-passe e carregue em Continuar novamente.', + 'error', + ); + + return; + } + await new Promise((resolve) => window.setTimeout(resolve, 5000), ); @@ -1002,28 +1028,57 @@ export function ProvisioningWizard() { flashCompletionHandledRef.current = false; + setFlashSecondsRemaining(FLASH_SECONDS); + setFlashOverlayOpen(true); + addLog( - 'A iniciar gravação do firmware com sysupgrade -n /tmp/firmware.bin', + 'A iniciar gravação do firmware com sysupgrade -F -n /tmp/firmware.bin', 'warning', ); try { - await invoke( - 'flash_router_sysupgrade', - { - ip: selectedIp, - password: effectivePassword, - }, - ); + await invoke('flash_router_sysupgrade', { + ip: selectedIp, + password: effectivePassword, + }); addLog( - 'Comando de flash submetido. A sessão SSH pode desligar; a entrar na janela de espera protegida.', + 'Comando de flash submetido. A sessão SSH pode desligar; a janela de espera protegida já está ativa.', 'success', ); } catch (error) { const message = String(error); const loweredMessage = message.toLowerCase(); + if ( + loweredMessage.includes('auth_required') || + loweredMessage.includes('permission denied') || + loweredMessage.includes('publickey,password') + ) { + setFlashOverlayOpen(false); + setStatus('auth_required'); + + addLog(`Flash não iniciado: ${message}`, 'error'); + + return; + } + + if ( + loweredMessage.includes('device') && + loweredMessage.includes('not supported') && + loweredMessage.includes('image') + ) { + setFlashOverlayOpen(false); + setStatus('failed'); + + addLog( + `Flash bloqueado por incompatibilidade de imagem: ${message}`, + 'error', + ); + + return; + } + if ( loweredMessage.includes('broken pipe') || loweredMessage.includes('connection reset') || @@ -1038,35 +1093,61 @@ export function ProvisioningWizard() { 'O router aceitou o sysupgrade e desligou-se como esperado durante o flash.', 'warning', ); - } else { - setStatus('failed'); - - addLog( - `Falha no comando de flash antes do reinício: ${message}`, - 'error', - ); return; } - } - setFlashSecondsRemaining(FLASH_SECONDS); - setFlashOverlayOpen(true); + setFlashOverlayOpen(false); + setStatus('failed'); + + addLog( + `Falha no comando de flash antes do reinício: ${message}`, + 'error', + ); + } } async function prepareRouterEnv() { + const timeoutMs = 15000; + try { addLog( 'A pedir ao backend o próximo IP WireGuard disponível...', ); - const response = await vpnApi.availableIp(); + const timeoutPromise = new Promise((_, reject) => { + window.setTimeout(() => { + reject( + new Error( + `TIMEOUT: o backend não respondeu ao pedido de próximo IP WireGuard em ${timeoutMs / 1000}s`, + ), + ); + }, timeoutMs); + }); + + const response = await Promise.race([ + vpnApi.availableIp(), + timeoutPromise, + ]); + const vpnIp = response.vpnIp; + if (!vpnIp || typeof vpnIp !== 'string') { + throw new Error( + `Resposta inválida do backend ao pedir IP WireGuard: ${JSON.stringify(response)}`, + ); + } + const vpnParts = vpnIp.split('.'); const routerId = vpnParts[vpnParts.length - 1] || ''; + if (!routerId) { + throw new Error( + `IP WireGuard inválido recebido do backend: ${vpnIp}`, + ); + } + setRouterEnv((current) => ({ ...current, routerId, @@ -1081,6 +1162,8 @@ export function ProvisioningWizard() { setEnvModalOpen(true); } catch (error) { + setEnvModalOpen(false); + addLog( `Falha ao obter IP WireGuard disponível: ${String(error)}`, 'error', @@ -1175,11 +1258,10 @@ export function ProvisioningWizard() { setRouterPassword(preset.password); setCustomIp(''); }} - className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${ - active + className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${active ? 'border-blue-500/40 bg-blue-500/10' : 'border-white/10 bg-white/[0.02] hover:border-blue-500/20 hover:bg-white/[0.04]' - }`} + }`} >

{title} @@ -1906,25 +1986,23 @@ function WorkflowStepCard({ return (