adds old rsa support

This commit is contained in:
litoral05
2026-06-03 12:01:33 +01:00
parent 522f56b95f
commit dae218e1e9
3 changed files with 524 additions and 477 deletions
+292 -374
View File
@@ -1,5 +1,5 @@
use ssh2::Session; use ssh2::Session;
use std::process::Command; use std::process::{Command, Stdio};
use std::{ use std::{
fs::File, fs::File,
io::{Read, Write}, io::{Read, Write},
@@ -46,22 +46,30 @@ fn authenticate_router(session: &Session, password: &str) -> Result<(), String>
Err("SSH authentication failed for root".into()) Err("SSH authentication failed for root".into())
} }
fn system_ssh_base_args(connect_timeout: &str) -> Vec<String> {
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<String, String> { fn run_system_ssh(ip: &str, command: &str) -> Result<String, String> {
let target = format!("root@{}", ip); 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") let output = Command::new("ssh")
.args([ .args(args)
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=6",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=NUL",
&target,
command,
])
.output() .output()
.map_err(|error| format!("failed to run system ssh: {}", error))?; .map_err(|error| format!("failed to run system ssh: {}", error))?;
@@ -80,6 +88,196 @@ fn run_system_ssh(ip: &str, command: &str) -> Result<String, String> {
)) ))
} }
fn run_system_ssh_with_stdin(ip: &str, command: &str, input: &[u8]) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<Session, String> { fn open_router_session(ip: &str, password: &str) -> Result<Session, String> {
let address = format!("{}:22", ip); let address = format!("{}:22", ip);
@@ -214,15 +412,7 @@ pub async fn run_provisioning(ip: String, password: String) -> Result<String, St
return Err("router IP is required".into()); return Err("router IP is required".into());
} }
let command = "cd /tmp && ./provision.sh"; run_router_command(&ip, &password, "cd /tmp && ./provision.sh")
if password.trim().is_empty() {
return run_system_ssh(&ip, command);
}
let session = open_router_session(&ip, &password)?;
run_ssh_command(&session, command)
} }
#[tauri::command] #[tauri::command]
@@ -231,15 +421,7 @@ pub async fn capture_wireguard_public_key(ip: String, password: String) -> Resul
return Err("router IP is required".into()); return Err("router IP is required".into());
} }
let command = "wg show wg0 public-key"; let output = run_router_command(&ip, &password, "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 public_key = output.trim(); let public_key = output.trim();
if public_key.is_empty() { if public_key.is_empty() {
@@ -281,92 +463,17 @@ pub async fn upload_firmware_to_router(
return Err("router IP is required".into()); return Err("router IP is required".into());
} }
if password.trim().is_empty() { upload_file_to_router_dynamic(
let target = format!("root@{}:{}", ip, remote_firmware_path); &ip,
let local_firmware_path_string = local_firmware_path.to_string_lossy().to_string(); &password,
&local_firmware_path,
let output = Command::new("scp") remote_firmware_path,
.args([ 0o644,
"-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))?;
Ok(format!( Ok(format!(
"Firmware uploaded to {}:{} ({} bytes)", "Firmware uploaded to {}:{}",
ip, remote_firmware_path, firmware_size ip, remote_firmware_path
)) ))
} }
@@ -376,69 +483,40 @@ pub async fn flash_router_sysupgrade(ip: String, password: String) -> Result<Str
return Err("router IP is required".into()); return Err("router IP is required".into());
} }
let flash_command = "test -f /tmp/firmware.bin && sysupgrade -n /tmp/firmware.bin"; let flash_command = "test -f /tmp/firmware.bin && sysupgrade -F -n /tmp/firmware.bin";
let output = run_router_command(&ip, &password, flash_command);
if password.trim().is_empty() { match output {
let output = run_system_ssh(&ip, flash_command); Ok(output) => Ok(format!(
"sysupgrade command submitted on {}. {}",
return match output {
Ok(output) => 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. {}",
ip, output 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!( let lowered = error.to_lowercase();
"sysupgrade command submitted on {}. Router should reboot shortly. {}",
ip, output 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] #[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); let wait_between_attempts = Duration::from_secs(5);
for attempt in 1..=max_attempts { for attempt in 1..=max_attempts {
if password.trim().is_empty() { match run_router_command(&ip, &password, "ubus call system board") {
match run_system_ssh(&ip, "ubus call system board") { Ok(output) => {
Ok(output) => { return Ok(format!(
return Ok(format!( "Router reconnected after flash on attempt {}/{}.\n{}",
"Router reconnected after flash on attempt {}/{} using system ssh.\n{}", attempt, max_attempts, output
attempt, max_attempts, output ));
));
}
Err(_) => {
thread::sleep(wait_between_attempts);
continue;
}
} }
} Err(error) => {
if is_ssh_auth_failure(&error) {
match open_router_session(&ip, &password) { return Err(format!(
Ok(session) => match run_ssh_command(&session, "ubus call system board") { "AUTH_REQUIRED: Router is reachable at {} over SSH, but authentication failed. Enter the router password before continuing.",
Ok(output) => { ip
return Ok(format!(
"Router reconnected after flash on attempt {}/{}.\n{}",
attempt, max_attempts, output
)); ));
} }
Err(_) => thread::sleep(wait_between_attempts),
}, thread::sleep(wait_between_attempts);
Err(_) => thread::sleep(wait_between_attempts), }
} }
} }
@@ -492,12 +561,19 @@ pub async fn check_router_after_flash(ip: String, password: String) -> Result<St
return Err("router IP is required".into()); return Err("router IP is required".into());
} }
if password.trim().is_empty() { match run_router_command(&ip, &password, "ubus call system board") {
return run_system_ssh(&ip, "ubus call system board"); Ok(output) => 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] #[tauri::command]
@@ -515,64 +591,12 @@ pub async fn upload_provisioning_bundle(
let remote_script_path = "/tmp/provision.sh"; let remote_script_path = "/tmp/provision.sh";
let remote_env_path = "/tmp/router.env"; let remote_env_path = "/tmp/router.env";
if password.trim().is_empty() { upload_file_to_router_dynamic(&ip, &password, &local_script_path, remote_script_path, 0o755)?;
let script_target = format!("root@{}:{}", ip, remote_script_path); upload_string_to_router_dynamic(&ip, &password, &env_content, remote_env_path, 0o600)?;
let local_script_path_string = local_script_path.to_string_lossy().to_string(); run_router_command(&ip, &password, "chmod +x /tmp/provision.sh")?;
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")?;
Ok(format!("uploaded provision.sh and router.env to {}", ip)) Ok(format!("uploaded provision.sh and router.env to {}", ip))
} }
#[tauri::command] #[tauri::command]
pub async fn upload_udp2raw_setup_script( pub async fn upload_udp2raw_setup_script(
app: AppHandle, 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 local_script_path = resource_path(&app, "udp2raw/setup_udp2raw.sh")?;
let remote_script_path = "/tmp/setup_udp2raw.sh"; let remote_script_path = "/tmp/setup_udp2raw.sh";
if password.trim().is_empty() { upload_file_to_router_dynamic(&ip, &password, &local_script_path, remote_script_path, 0o755)?;
let target = format!("root@{}:{}", ip, remote_script_path); run_router_command(&ip, &password, "chmod +x /tmp/setup_udp2raw.sh")?;
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")?;
Ok(format!("uploaded setup_udp2raw.sh to {}", ip)) Ok(format!("uploaded setup_udp2raw.sh to {}", ip))
} }
@@ -640,15 +622,7 @@ pub async fn run_udp2raw_setup(ip: String, password: String) -> Result<String, S
return Err("router IP is required".into()); return Err("router IP is required".into());
} }
let command = "sh /tmp/setup_udp2raw.sh"; run_router_command(&ip, &password, "sh /tmp/setup_udp2raw.sh")
if password.trim().is_empty() {
return run_system_ssh(&ip, command);
}
let session = open_router_session(&ip, &password)?;
run_ssh_command(&session, command)
} }
#[tauri::command] #[tauri::command]
@@ -703,13 +677,7 @@ pub async fn check_udp2raw_router_status(ip: String, password: String) -> Result
wg show wg0 2>/dev/null | grep -A8 '^peer:' || echo "wg0 unavailable" wg show wg0 2>/dev/null | grep -A8 '^peer:' || echo "wg0 unavailable"
"#; "#;
if password.trim().is_empty() { run_router_command(&ip, &password, command)
return run_system_ssh(&ip, command);
}
let session = open_router_session(&ip, &password)?;
run_ssh_command(&session, command)
} }
#[tauri::command] #[tauri::command]
@@ -752,17 +720,11 @@ pub async fn test_udp2raw_tunnel(ip: String, password: String) -> Result<String,
echo "UDP2RAW tunnel test completed" echo "UDP2RAW tunnel test completed"
"#; "#;
if password.trim().is_empty() {
return run_system_ssh(&ip, command);
}
let mut last_error = String::new(); let mut last_error = String::new();
for attempt in 1..=5 { for attempt in 1..=5 {
match open_router_session(&ip, &password) { match run_router_command(&ip, &password, command) {
Ok(session) => { Ok(output) => return Ok(output),
return run_ssh_command(&session, command);
}
Err(error) => { Err(error) => {
last_error = format!("SSH attempt {}/5 failed: {}", attempt, error); last_error = format!("SSH attempt {}/5 failed: {}", attempt, error);
thread::sleep(Duration::from_secs(2)); 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 local_binary_path = resource_path(&app, "udp2raw/udp2raw")?;
let remote_binary_path = "/usr/bin/udp2raw"; let remote_binary_path = "/usr/bin/udp2raw";
if password.trim().is_empty() { upload_file_to_router_dynamic(&ip, &password, &local_binary_path, remote_binary_path, 0o755)?;
let target = format!("root@{}:{}", ip, remote_binary_path); run_router_command(
let local_binary_path_string = local_binary_path.to_string_lossy().to_string(); &ip,
&password,
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,
"chmod +x /usr/bin/udp2raw && ls -l /usr/bin/udp2raw", "chmod +x /usr/bin/udp2raw && ls -l /usr/bin/udp2raw",
)?; )?;
+64 -13
View File
@@ -49,6 +49,13 @@ pub async fn probe_router_ssh(
"ConnectTimeout=5", "ConnectTimeout=5",
"-o", "-o",
"StrictHostKeyChecking=accept-new", "StrictHostKeyChecking=accept-new",
// Legacy OpenWrt/Dropbear routers that only offer ssh-rsa.
"-o",
"HostKeyAlgorithms=+ssh-rsa",
"-o",
"PubkeyAcceptedAlgorithms=+ssh-rsa",
&format!("root@{}", ip), &format!("root@{}", ip),
"ubus call system board", "ubus call system board",
]) ])
@@ -97,6 +104,44 @@ pub async fn probe_router_ssh(
)) ))
} }
fn system_ssh_probe(ip: &str, command: &str) -> Result<String, String> {
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 ssh2::Session;
use std::{ use std::{
io::Read, io::Read,
@@ -158,20 +203,26 @@ pub async fn inspect_router_with_password(
format!("SSH handshake failed for {}: {}", ip, error) format!("SSH handshake failed for {}: {}", ip, error)
})?; })?;
session let auth_result = session.userauth_password("root", &password);
.userauth_password("root", &password)
.map_err(|error| {
format!(
"SSH authentication failed for root@{}: {}",
ip, error
)
})?;
if !session.authenticated() { if auth_result.is_err() || !session.authenticated() {
return Err(format!( let error = auth_result
"SSH authentication failed for root@{}", .err()
ip .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| { let mut channel = session.channel_session().map_err(|error| {
@@ -92,55 +92,55 @@ const workflowSteps: Array<{
description: string; description: string;
icon: typeof Search; icon: typeof Search;
}> = [ }> = [
{ {
id: 'DETECT_ROUTER', id: 'DETECT_ROUTER',
title: 'Detetar Router', title: 'Detetar Router',
description: 'Ping e inspeção do router via SSH.', description: 'Ping e inspeção do router via SSH.',
icon: Search, icon: Search,
}, },
{ {
id: 'UPLOAD_FIRMWARE', id: 'UPLOAD_FIRMWARE',
title: 'Enviar Firmware', title: 'Enviar Firmware',
description: 'Copiar imagem de firmware para /tmp.', description: 'Copiar imagem de firmware para /tmp.',
icon: UploadCloud, icon: UploadCloud,
}, },
{ {
id: 'FLASH_FIRMWARE', id: 'FLASH_FIRMWARE',
title: 'Gravar Firmware', title: 'Gravar Firmware',
description: 'Executar sysupgrade -n /tmp/firmware.bin.', description: 'Executar sysupgrade -n /tmp/firmware.bin.',
icon: Cpu, icon: Cpu,
}, },
{ {
id: 'WAIT_REBOOT', id: 'WAIT_REBOOT',
title: 'Aguardar Reinício', title: 'Aguardar Reinício',
description: 'Bloquear ações enquanto o router reinicia.', description: 'Bloquear ações enquanto o router reinicia.',
icon: RefreshCw, icon: RefreshCw,
}, },
{ {
id: 'RECONNECT_ROUTER', id: 'RECONNECT_ROUTER',
title: 'Reconectar Router', title: 'Reconectar Router',
description: 'Reconectar Ethernet e aguardar SSH.', description: 'Reconectar Ethernet e aguardar SSH.',
icon: PlugZap, icon: PlugZap,
}, },
{ {
id: 'UPLOAD_PROVISIONING', id: 'UPLOAD_PROVISIONING',
title: 'Enviar Pacote', title: 'Enviar Pacote',
description: 'Copiar router.env e provision.sh.', description: 'Copiar router.env e provision.sh.',
icon: FileUp, icon: FileUp,
}, },
{ {
id: 'RUN_PROVISIONING', id: 'RUN_PROVISIONING',
title: 'Executar Provisionamento', title: 'Executar Provisionamento',
description: 'Executar script de configuração no router.', description: 'Executar script de configuração no router.',
icon: Terminal, icon: Terminal,
}, },
{ {
id: 'REGISTER_PEER', id: 'REGISTER_PEER',
title: 'Registar Peer VPS', title: 'Registar Peer VPS',
description: 'Aplicar peer WireGuard na VPS.', description: 'Aplicar peer WireGuard na VPS.',
icon: Network, icon: Network,
}, },
]; ];
const scrollClass = 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'; '[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] = const [setupCompleteOpen, setSetupCompleteOpen] =
useState(false); useState(false);
const effectivePassword = const effectivePassword = customPassword.trim() || routerPassword;
customIp.trim() &&
selectedIp === customIp.trim()
? customPassword
: routerPassword;
const provisioningStarted = const provisioningStarted =
activeStep !== 'DETECT_ROUTER' || activeStep !== 'DETECT_ROUTER' ||
@@ -598,6 +594,17 @@ export function ProvisioningWizard() {
'AUTH_REQUIRED', '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( addLog(
'O router requer autenticação por palavra-passe. A tentar inspeção automática...', 'O router requer autenticação por palavra-passe. A tentar inspeção automática...',
'warning', 'warning',
@@ -798,11 +805,30 @@ export function ProvisioningWizard() {
return; return;
} catch (error) { } catch (error) {
const message = String(error);
const lowered = message.toLowerCase();
addLog( addLog(
`Tentativa de reconexão ${attempt}/30 falhou: ${String(error)}`, `Tentativa de reconexão ${attempt}/30 falhou: ${message}`,
'warning', '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) => await new Promise((resolve) =>
window.setTimeout(resolve, 5000), window.setTimeout(resolve, 5000),
); );
@@ -1002,28 +1028,57 @@ export function ProvisioningWizard() {
flashCompletionHandledRef.current = false; flashCompletionHandledRef.current = false;
setFlashSecondsRemaining(FLASH_SECONDS);
setFlashOverlayOpen(true);
addLog( 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', 'warning',
); );
try { try {
await invoke<string>( await invoke<string>('flash_router_sysupgrade', {
'flash_router_sysupgrade', ip: selectedIp,
{ password: effectivePassword,
ip: selectedIp, });
password: effectivePassword,
},
);
addLog( 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', 'success',
); );
} catch (error) { } catch (error) {
const message = String(error); const message = String(error);
const loweredMessage = message.toLowerCase(); 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 ( if (
loweredMessage.includes('broken pipe') || loweredMessage.includes('broken pipe') ||
loweredMessage.includes('connection reset') || loweredMessage.includes('connection reset') ||
@@ -1038,35 +1093,61 @@ export function ProvisioningWizard() {
'O router aceitou o sysupgrade e desligou-se como esperado durante o flash.', 'O router aceitou o sysupgrade e desligou-se como esperado durante o flash.',
'warning', 'warning',
); );
} else {
setStatus('failed');
addLog(
`Falha no comando de flash antes do reinício: ${message}`,
'error',
);
return; return;
} }
}
setFlashSecondsRemaining(FLASH_SECONDS); setFlashOverlayOpen(false);
setFlashOverlayOpen(true); setStatus('failed');
addLog(
`Falha no comando de flash antes do reinício: ${message}`,
'error',
);
}
} }
async function prepareRouterEnv() { async function prepareRouterEnv() {
const timeoutMs = 15000;
try { try {
addLog( addLog(
'A pedir ao backend o próximo IP WireGuard disponível...', 'A pedir ao backend o próximo IP WireGuard disponível...',
); );
const response = await vpnApi.availableIp(); const timeoutPromise = new Promise<never>((_, 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; 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 vpnParts = vpnIp.split('.');
const routerId = const routerId =
vpnParts[vpnParts.length - 1] || ''; vpnParts[vpnParts.length - 1] || '';
if (!routerId) {
throw new Error(
`IP WireGuard inválido recebido do backend: ${vpnIp}`,
);
}
setRouterEnv((current) => ({ setRouterEnv((current) => ({
...current, ...current,
routerId, routerId,
@@ -1081,6 +1162,8 @@ export function ProvisioningWizard() {
setEnvModalOpen(true); setEnvModalOpen(true);
} catch (error) { } catch (error) {
setEnvModalOpen(false);
addLog( addLog(
`Falha ao obter IP WireGuard disponível: ${String(error)}`, `Falha ao obter IP WireGuard disponível: ${String(error)}`,
'error', 'error',
@@ -1175,11 +1258,10 @@ export function ProvisioningWizard() {
setRouterPassword(preset.password); setRouterPassword(preset.password);
setCustomIp(''); setCustomIp('');
}} }}
className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${ className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${active
active
? 'border-blue-500/40 bg-blue-500/10' ? '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]' : 'border-white/10 bg-white/[0.02] hover:border-blue-500/20 hover:bg-white/[0.04]'
}`} }`}
> >
<Network <Network
size={18} size={18}
@@ -1209,11 +1291,10 @@ export function ProvisioningWizard() {
})} })}
<div <div
className={`rounded-2xl border p-4 transition md:col-span-2 ${ className={`rounded-2xl border p-4 transition md:col-span-2 ${customIp.trim()
customIp.trim()
? 'border-blue-500/40 bg-blue-500/10' ? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02]' : 'border-white/10 bg-white/[0.02]'
}`} }`}
> >
<div className="mb-3 flex items-center gap-3"> <div className="mb-3 flex items-center gap-3">
<Network <Network
@@ -1507,11 +1588,10 @@ function ModeButton({
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${ className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${active
active
? 'border-blue-500/40 bg-blue-500/10' ? '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]' : 'border-white/10 bg-white/[0.02] hover:border-blue-500/20 hover:bg-white/[0.04]'
}`} }`}
> >
<p className="font-semibold text-white"> <p className="font-semibold text-white">
{title} {title}
@@ -1906,25 +1986,23 @@ function WorkflowStepCard({
return ( return (
<div <div
className={`rounded-2xl border p-3 transition ${ className={`rounded-2xl border p-3 transition ${done
done
? 'border-green-500/30 bg-green-500/10' ? 'border-green-500/30 bg-green-500/10'
: active : active
? 'border-blue-500/40 bg-blue-500/10' ? 'border-blue-500/40 bg-blue-500/10'
: blocked : blocked
? 'border-white/5 bg-white/[0.015] opacity-60' ? 'border-white/5 bg-white/[0.015] opacity-60'
: 'border-white/10 bg-white/[0.02]' : 'border-white/10 bg-white/[0.02]'
}`} }`}
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div <div
className={`rounded-xl p-2 ${ className={`rounded-xl p-2 ${done
done
? 'bg-green-500/10 text-green-300' ? 'bg-green-500/10 text-green-300'
: active : active
? 'bg-blue-500/10 text-blue-300' ? 'bg-blue-500/10 text-blue-300'
: 'bg-slate-900 text-slate-500' : 'bg-slate-900 text-slate-500'
}`} }`}
> >
<Icon size={16} /> <Icon size={16} />
</div> </div>