diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 069b232..51bee79 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,4 +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 +ssh2 = "0.9" diff --git a/src-tauri/resources/firmware/openwrt-23.05.5-zbt-we826-16m-litoral-golden-sysupgrade.bin b/src-tauri/resources/firmware/openwrt-23.05.5-zbt-we826-16m-litoral-golden-sysupgrade.bin new file mode 100644 index 0000000..8c847aa Binary files /dev/null and b/src-tauri/resources/firmware/openwrt-23.05.5-zbt-we826-16m-litoral-golden-sysupgrade.bin differ diff --git a/src-tauri/resources/provisioning/provision.sh b/src-tauri/resources/provisioning/provision.sh new file mode 100644 index 0000000..1ddda5a --- /dev/null +++ b/src-tauri/resources/provisioning/provision.sh @@ -0,0 +1,216 @@ +#!/bin/sh +set -eu + +ENV_FILE="${1:-/tmp/router.env}" + +log() { + echo + echo "==> $1" +} + +die() { + echo "ERROR: $1" + exit 1 +} + +require_var() { + eval "v=\${$1:-}" + [ -n "$v" ] || die "Missing variable: $1" +} + +backup_configs() { + mkdir -p /root/litoral-backups + TS="$(date +%Y%m%d-%H%M%S || echo unknown)" + cp /etc/config/network "/root/litoral-backups/network.$TS.bak" || true + cp /etc/config/firewall "/root/litoral-backups/firewall.$TS.bak" || true + cp /etc/config/system "/root/litoral-backups/system.$TS.bak" || true + cp /etc/config/uhttpd "/root/litoral-backups/uhttpd.$TS.bak" || true +} + +[ -f "$ENV_FILE" ] || die "Missing env file: $ENV_FILE" +. "$ENV_FILE" + +require_var ROUTER_ID +require_var HOSTNAME +require_var LAN_IP +require_var LAN_NETMASK +require_var WG_IP +require_var WG_CIDR +require_var WG_SERVER_HOST +require_var WG_SERVER_PORT +require_var WG_SERVER_PUBLIC_KEY +require_var CONTROLLER_IP +require_var PLC_IP +require_var ROOT_PASSWORD + +log "Starting Litoral_Regas production provisioning" + +log "Verifying board and firmware" +BOARD="$(ubus call system board | jsonfilter -e '@.board_name')" +VERSION="$(ubus call system board | jsonfilter -e '@.release.version')" + +[ "$BOARD" = "zbtlink,zbt-we826-16m" ] || die "Wrong board: $BOARD" + +case "$VERSION" in + 23.05.*) echo "OpenWrt version OK: $VERSION" ;; + *) die "Wrong OpenWrt version: $VERSION" ;; +esac + +log "Creating config backups" +backup_configs + +log "Setting hostname" +uci set system.@system[0].hostname="$HOSTNAME" +uci commit system + +log "Setting root password" +printf "%s\n%s\n" "$ROOT_PASSWORD" "$ROOT_PASSWORD" | passwd root + +log "Configuring LAN" +uci set network.lan.ipaddr="$LAN_IP" +uci set network.lan.netmask="$LAN_NETMASK" +uci commit network + +log "Preparing WireGuard keys" +mkdir -p /etc/wireguard +chmod 700 /etc/wireguard + +if [ ! -f /etc/wireguard/privatekey ]; then + umask 077 + wg genkey > /etc/wireguard/privatekey + cat /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey +fi + +ROUTER_PRIVATE_KEY="$(cat /etc/wireguard/privatekey)" +ROUTER_PUBLIC_KEY="$(cat /etc/wireguard/publickey)" + +log "Configuring WireGuard" +uci -q delete network.wg0 || true +uci -q delete network.wgserver || true + +uci set network.wg0="interface" +uci set network.wg0.proto="wireguard" +uci set network.wg0.private_key="$ROUTER_PRIVATE_KEY" +uci add_list network.wg0.addresses="$WG_IP/$WG_CIDR" + +uci set network.wgserver="wireguard_wg0" +uci set network.wgserver.description="Litoral_Regas_VPS" +uci set network.wgserver.public_key="$WG_SERVER_PUBLIC_KEY" +uci set network.wgserver.endpoint_host="$WG_SERVER_HOST" +uci set network.wgserver.endpoint_port="$WG_SERVER_PORT" +uci set network.wgserver.persistent_keepalive="25" +uci set network.wgserver.route_allowed_ips="1" +uci add_list network.wgserver.allowed_ips="198.19.0.0/16" + +uci commit network + +log "Configuring LuCI over WireGuard" +uci set uhttpd.main.rfc1918_filter="0" +uci commit uhttpd + +log "Configuring firewall zones and forwarding" + +uci -q delete firewall.vpn || true +uci -q delete firewall.vpn_lan || true +uci -q delete firewall.lan_vpn || true + +uci set firewall.vpn="zone" +uci set firewall.vpn.name="vpn" +uci set firewall.vpn.input="ACCEPT" +uci set firewall.vpn.output="ACCEPT" +uci set firewall.vpn.forward="ACCEPT" +uci add_list firewall.vpn.network="wg0" + +uci set firewall.vpn_lan="forwarding" +uci set firewall.vpn_lan.src="vpn" +uci set firewall.vpn_lan.dest="lan" + +uci set firewall.lan_vpn="forwarding" +uci set firewall.lan_vpn.src="lan" +uci set firewall.lan_vpn.dest="vpn" + +log "Configuring DNAT rules" + +uci -q delete firewall.dnat_controller_vnc || true +uci -q delete firewall.dnat_controller_runtime || true +uci -q delete firewall.dnat_controller_http || true +uci -q delete firewall.dnat_plc_http || true + +uci set firewall.dnat_controller_vnc="redirect" +uci set firewall.dnat_controller_vnc.name="DNAT_Controller_VNC_5900" +uci set firewall.dnat_controller_vnc.src="vpn" +uci set firewall.dnat_controller_vnc.dest="lan" +uci set firewall.dnat_controller_vnc.proto="tcp" +uci set firewall.dnat_controller_vnc.src_dport="5900" +uci set firewall.dnat_controller_vnc.dest_ip="$CONTROLLER_IP" +uci set firewall.dnat_controller_vnc.dest_port="5900" +uci set firewall.dnat_controller_vnc.target="DNAT" + +uci set firewall.dnat_controller_runtime="redirect" +uci set firewall.dnat_controller_runtime.name="DNAT_Controller_20248_to_20249" +uci set firewall.dnat_controller_runtime.src="vpn" +uci set firewall.dnat_controller_runtime.dest="lan" +uci set firewall.dnat_controller_runtime.proto="tcp" +uci set firewall.dnat_controller_runtime.src_dport="20248" +uci set firewall.dnat_controller_runtime.dest_ip="$CONTROLLER_IP" +uci set firewall.dnat_controller_runtime.dest_port="20249" +uci set firewall.dnat_controller_runtime.target="DNAT" + +uci set firewall.dnat_controller_http="redirect" +uci set firewall.dnat_controller_http.name="DNAT_Controller_HTTP_8000" +uci set firewall.dnat_controller_http.src="vpn" +uci set firewall.dnat_controller_http.dest="lan" +uci set firewall.dnat_controller_http.proto="tcp" +uci set firewall.dnat_controller_http.src_dport="8000" +uci set firewall.dnat_controller_http.dest_ip="$CONTROLLER_IP" +uci set firewall.dnat_controller_http.dest_port="8000" +uci set firewall.dnat_controller_http.target="DNAT" + +uci set firewall.dnat_plc_http="redirect" +uci set firewall.dnat_plc_http.name="DNAT_PLC_HTTP_81" +uci set firewall.dnat_plc_http.src="vpn" +uci set firewall.dnat_plc_http.dest="lan" +uci set firewall.dnat_plc_http.proto="tcp" +uci set firewall.dnat_plc_http.src_dport="81" +uci set firewall.dnat_plc_http.dest_ip="$PLC_IP" +uci set firewall.dnat_plc_http.dest_port="81" +uci set firewall.dnat_plc_http.target="DNAT" + +uci commit firewall + +log "Writing provisioning markers" +cat > /etc/litoral-router < Result { +pub async fn detect_router(ip: String) -> Result { delay(); if ip.trim().is_empty() { - return Err( - "router IP is required".into(), - ); + return Err("router IP is required".into()); } Ok(true) } -#[tauri::command] -pub async fn upload_firmware( - ip: String, - firmware_path: String, -) -> Result { - delay(); +fn authenticate_router(session: &Session, password: &str) -> Result<(), String> { + if !password.trim().is_empty() { + if session.userauth_password("root", password).is_ok() && session.authenticated() { + return Ok(()); + } + } - Ok(format!( - "uploaded {} to {}:/tmp/firmware.bin", - firmware_path, ip, - )) + if session.userauth_agent("root").is_ok() && session.authenticated() { + return Ok(()); + } + + Err("SSH authentication failed for root".into()) +} + +fn run_system_ssh(ip: &str, command: &str) -> Result { + let target = format!("root@{}", ip); + + let output = Command::new("ssh") + .args([ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=6", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=NUL", + &target, + command, + ]) + .output() + .map_err(|error| format!("failed to run 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 failed with status {:?}:\n{}\n{}", output.status.code(), stderr, stdout) + ) +} + +fn open_router_session(ip: &str, password: &str) -> Result { + 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(8)).map_err(|error| + format!("failed to connect to SSH on {}: {}", ip, error) + )?; + + let _ = tcp.set_read_timeout(Some(Duration::from_secs(30))); + let _ = tcp.set_write_timeout(Some(Duration::from_secs(30))); + + 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))?; + + authenticate_router(&session, password).map_err(|error| format!("root@{}: {}", ip, error))?; + + Ok(session) +} + +fn scp_file_from_disk( + session: &Session, + local_path: &str, + remote_path: &str, + mode: i32 +) -> Result<(), String> { + let mut local_file = File::open(local_path).map_err(|error| + format!("failed to open {}: {}", local_path, error) + )?; + + let file_size = local_file + .metadata() + .map_err(|error| format!("failed to read metadata for {}: {}", local_path, error))? + .len(); + + let mut buffer = Vec::new(); + + local_file + .read_to_end(&mut buffer) + .map_err(|error| format!("failed to read {}: {}", local_path, error))?; + + let mut remote_file = session + .scp_send(Path::new(remote_path), mode, file_size, None) + .map_err(|error| format!("failed to start SCP upload to {}: {}", remote_path, error))?; + + remote_file + .write_all(&buffer) + .map_err(|error| format!("failed to write {}: {}", remote_path, error))?; + + remote_file.send_eof().ok(); + remote_file.wait_eof().ok(); + remote_file.close().ok(); + remote_file.wait_close().ok(); + + Ok(()) +} + +fn scp_string( + session: &Session, + content: &str, + remote_path: &str, + mode: i32 +) -> Result<(), String> { + let bytes = content.as_bytes(); + + let mut remote_file = session + .scp_send(Path::new(remote_path), mode, bytes.len() as u64, None) + .map_err(|error| format!("failed to start SCP upload to {}: {}", remote_path, error))?; + + remote_file + .write_all(bytes) + .map_err(|error| format!("failed to write {}: {}", remote_path, error))?; + + remote_file.send_eof().ok(); + remote_file.wait_eof().ok(); + remote_file.close().ok(); + remote_file.wait_close().ok(); + + Ok(()) +} + +fn run_ssh_command(session: &Session, command: &str) -> Result { + let mut channel = session + .channel_session() + .map_err(|error| format!("failed to open SSH channel: {}", error))?; + + channel + .exec(command) + .map_err(|error| format!("failed to run command `{}`: {}", command, error))?; + + let mut output = String::new(); + + let _ = channel.read_to_string(&mut output); + let _ = channel.wait_close(); + + Ok(output) } #[tauri::command] -pub async fn flash_router( - ip: String, - remote_firmware_path: String, -) -> Result { +pub async fn upload_firmware(ip: String, firmware_path: String) -> Result { delay(); - Ok(format!( - "sysupgrade started on {} with {}", - ip, remote_firmware_path, - )) + Ok(format!("uploaded {} to {}:/tmp/firmware.bin", firmware_path, ip)) } #[tauri::command] -pub async fn wait_for_ssh( - ip: String, -) -> Result { +pub async fn flash_router(ip: String, remote_firmware_path: String) -> Result { + delay(); + + Ok(format!("sysupgrade started on {} with {}", ip, remote_firmware_path)) +} + +#[tauri::command] +pub async fn wait_for_ssh(ip: String) -> Result { for _ in 0..3 { delay(); } - if ip == "198.51.100.1" - || ip == "192.168.1.1" - { + if ip == "198.51.100.1" || ip == "192.168.1.1" { Ok(true) } else { - Err(format!( - "SSH timeout waiting for {}", - ip, - )) + Err(format!("SSH timeout waiting for {}", ip)) } } #[tauri::command] -pub async fn upload_provisioning_bundle( - ip: String, - env_content: String, - script_content: String, -) -> Result { - delay(); +pub async fn run_provisioning(ip: String, password: String) -> Result { + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } - Ok(format!( - "uploaded router.env ({} bytes) and provision.sh ({} bytes) to {}", - env_content.len(), - script_content.len(), - ip, - )) + let command = "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] -pub async fn run_provisioning( - ip: String, -) -> Result { - delay(); +pub async fn capture_wireguard_public_key(ip: String, password: String) -> Result { + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } - Ok(format!( - "provision.sh completed on {}; fw4/nftables, wg0, DNAT, LuCI over WireGuard configured", - ip, - )) + 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 public_key = output.trim(); + + if public_key.is_empty() { + return Err("WireGuard public key was empty".into()); + } + + if !public_key.ends_with('=') || public_key.len() < 40 { + return Err(format!("unexpected WireGuard public key output: {}", public_key)); + } + + Ok(public_key.to_string()) } #[tauri::command] -pub async fn capture_wireguard_public_key( - _ip: String, -) -> Result { - delay(); - - Ok( - "MOCK_ROUTER_WIREGUARD_PUBLIC_KEY_BASE64=" - .into(), - ) -} - -#[tauri::command] -pub async fn verify_router( - ip: String, -) -> Result { +pub async fn verify_router(ip: String) -> Result { delay(); if ip == "198.51.100.1" { Ok(true) } else { - Err( - "router verification failed" - .into(), - ) + Err("router verification failed".into()) } -} \ No newline at end of file +} + +#[tauri::command] +pub async fn upload_firmware_to_router(ip: String, password: String) -> Result { + let local_firmware_path = + "resources/firmware/openwrt-23.05.5-zbt-we826-16m-litoral-golden-sysupgrade.bin"; + + let remote_firmware_path = "/tmp/firmware.bin"; + + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } + + /* + * FACTORY / FRESH OPENWRT PATH + * Uses system scp when no password is set. + */ + + if password.trim().is_empty() { + let target = format!("root@{}:{}", ip, remote_firmware_path); + + let output = std::process::Command + ::new("scp") + .args([ + "-O", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "StrictHostKeyChecking=accept-new", + local_firmware_path, + &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) + ) + ); + } + + /* + * PASSWORD SSH PATH + */ + + 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(8)).map_err(|error| { + format!("failed to connect to SSH on {}: {}", ip, error) + })?; + + tcp + .set_read_timeout(Some(Duration::from_secs(30))) + .map_err(|error| { format!("failed to set read timeout: {}", error) })?; + + tcp + .set_write_timeout(Some(Duration::from_secs(30))) + .map_err(|error| { format!("failed to set 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 local_file = File::open(local_firmware_path).map_err(|error| { + format!("failed to open local firmware file {}: {}", local_firmware_path, 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!("Firmware uploaded to {}:{} ({} bytes)", ip, remote_firmware_path, firmware_size)) +} + +#[tauri::command] +pub async fn flash_router_sysupgrade(ip: String, password: String) -> Result { + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } + + let flash_command = "test -f /tmp/firmware.bin && sysupgrade -n /tmp/firmware.bin"; + + if password.trim().is_empty() { + let output = run_system_ssh(&ip, flash_command); + + 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 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(8)).map_err(|error| { + format!("failed to connect to SSH on {}: {}", ip, error) + })?; + + tcp.set_read_timeout(Some(Duration::from_secs(8))).ok(); + tcp.set_write_timeout(Some(Duration::from_secs(8))).ok(); + + 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) })?; + + authenticate_router(&session, &password).map_err(|error| { + format!("root@{}: {}", ip, error) + })?; + + 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) + ); + } + + Ok(format!("sysupgrade command submitted on {}. Router should reboot shortly. {}", ip, output)) +} + +#[tauri::command] +pub async fn reconnect_router_after_flash(ip: String, password: String) -> Result { + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } + + let max_attempts = 30; + 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; + } + } + } + + let address = format!("{}:22", ip); + + let socket_address = match address.to_socket_addrs() { + Ok(mut addresses) => + match addresses.next() { + Some(socket_address) => socket_address, + None => { + thread::sleep(wait_between_attempts); + continue; + } + } + Err(_) => { + thread::sleep(wait_between_attempts); + continue; + } + }; + + let tcp = match TcpStream::connect_timeout(&socket_address, Duration::from_secs(4)) { + Ok(tcp) => tcp, + Err(_) => { + thread::sleep(wait_between_attempts); + continue; + } + }; + + let _ = tcp.set_read_timeout(Some(Duration::from_secs(8))); + let _ = tcp.set_write_timeout(Some(Duration::from_secs(8))); + + let mut session = match Session::new() { + Ok(session) => session, + Err(_) => { + thread::sleep(wait_between_attempts); + continue; + } + }; + + session.set_tcp_stream(tcp); + + if session.handshake().is_err() { + thread::sleep(wait_between_attempts); + continue; + } + + if authenticate_router(&session, &password).is_err() { + thread::sleep(wait_between_attempts); + continue; + } + + let mut channel = match session.channel_session() { + Ok(channel) => channel, + Err(_) => { + thread::sleep(wait_between_attempts); + continue; + } + }; + + if channel.exec("ubus call system board").is_err() { + thread::sleep(wait_between_attempts); + continue; + } + + let mut output = String::new(); + + if channel.read_to_string(&mut output).is_err() { + thread::sleep(wait_between_attempts); + continue; + } + + let _ = channel.wait_close(); + + return Ok( + format!( + "Router reconnected after flash on attempt {}/{}.\n{}", + attempt, + max_attempts, + output + ) + ); + } + + Err( + format!( + "Router did not become reachable over SSH at {} after {} attempts. Replug Ethernet and retry.", + ip, + max_attempts + ) + ) +} + +#[tauri::command] +pub async fn check_router_after_flash(ip: String, password: String) -> Result { + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } + + if password.trim().is_empty() { + return run_system_ssh(&ip, "ubus call system board"); + } + + 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(4)).map_err(|error| + format!("SSH not ready at {}: {}", ip, error) + )?; + + let _ = tcp.set_read_timeout(Some(Duration::from_secs(8))); + let _ = tcp.set_write_timeout(Some(Duration::from_secs(8))); + + 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: {}", error))?; + + authenticate_router(&session, &password).map_err(|error| format!("root@{}: {}", ip, error))?; + + 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 ubus: {}", error))?; + + let mut output = String::new(); + + channel + .read_to_string(&mut output) + .map_err(|error| format!("failed to read router info: {}", error))?; + + let _ = channel.wait_close(); + + Ok(output) +} + +#[tauri::command] +pub async fn upload_provisioning_bundle( + ip: String, + password: String, + env_content: String +) -> Result { + if ip.trim().is_empty() { + return Err("router IP is required".into()); + } + + let local_script_path = "resources/provisioning/provision.sh"; + 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 script_upload = Command::new("scp") + .args([ + "-O", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=NUL", + local_script_path, + &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, 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)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c16a9c1..816714f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,11 +7,15 @@ use commands::{ capture_wireguard_public_key, detect_router, flash_router, + flash_router_sysupgrade, run_provisioning, upload_firmware, + upload_firmware_to_router, upload_provisioning_bundle, + reconnect_router_after_flash, verify_router, wait_for_ssh, + check_router_after_flash }, ssh::{ inspect_router_with_password, @@ -33,12 +37,16 @@ pub fn run() { inspect_router_with_password, detect_router, upload_firmware, + upload_firmware_to_router, flash_router, + flash_router_sysupgrade, + reconnect_router_after_flash, wait_for_ssh, upload_provisioning_bundle, run_provisioning, capture_wireguard_public_key, verify_router, + check_router_after_flash ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/app/routes.tsx b/src/app/routes.tsx index 8f8a9e8..b9546d1 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -8,13 +8,9 @@ import { import { TopBar } from '@/components/layout/TopBar'; import { MetricCard } from '@/components/dashboard/MetricCard'; -import { ProvisioningWorkflow } from '@/components/dashboard/ProvisioningWorkflow'; import { IpPoolChart } from '@/components/dashboard/IpPoolChart'; import { NetworkTrafficChart } from '@/components/dashboard/NetworkTrafficChart'; -import { VpnPeersTable } from '@/components/vpn/VpnPeersTable'; -import { IpManagementPanel } from '@/components/vpn/IpManagementPanel'; - import { ProvisioningWizard } from '@/components/provisioning/ProvisioningWizard'; import { BackendSettings } from '@/components/settings/BackendSettings'; import { ActivityLogs } from '@/components/activity/ActivityLogs'; @@ -40,8 +36,6 @@ export function DashboardRoute() { useEffect(() => { async function loadDashboard() { try { - setError(''); - const [ healthResponse, usedIpsResponse, @@ -52,7 +46,11 @@ export function DashboardRoute() { setHealth(healthResponse); setUsedIps(usedIpsResponse); + setError(''); } catch (err) { + setHealth(null); + setUsedIps(null); + setError(String(err)); } } @@ -84,15 +82,21 @@ export function DashboardRoute() { ).toFixed(2); }, [usedCount, ipPoolTotal]); - const vpnHealthy = - health?.wireGuardRunning ?? false; - const backendHealthy = - health?.backend ?? !error; + health?.backend === true; + + const vpnHealthy = + health?.wireGuardRunning === true; + + const dashboardReady = + !error && + Boolean(health) && + backendHealthy && + vpnHealthy; return (
- + {error && (
@@ -146,7 +150,7 @@ export function DashboardRoute() { />
-
+
@@ -157,10 +161,6 @@ export function DashboardRoute() { total={ipPoolTotal} />
- -
- -
); diff --git a/src/components/dashboard/IpPoolChart.tsx b/src/components/dashboard/IpPoolChart.tsx index 664b916..5452ea8 100644 --- a/src/components/dashboard/IpPoolChart.tsx +++ b/src/components/dashboard/IpPoolChart.tsx @@ -16,15 +16,15 @@ export function IpPoolChart({ used, total, }: IpPoolChartProps) { - const available = Math.max( - total - used, - 0, - ); + const available = Math.max(total - used, 0); const percentage = total > 0 - ? ((used / total) * 100).toFixed(2) - : '0.00'; + ? (used / total) * 100 + : 0; + + const displayPercentage = + percentage.toFixed(2); const data = [ { @@ -38,23 +38,28 @@ export function IpPoolChart({ ]; return ( - -

- IP Pool Usage -

+ +
+

+ IP Pool Usage +

-
-
- +

+ WireGuard allocation capacity +

+
+ +
+
+ {data.map((_, index) => ( @@ -70,26 +75,54 @@ export function IpPoolChart({ + +
+

+ {displayPercentage}% +

+ +

+ pool used +

+
-
-

- +

+
+

+ Used IPs +

+ +

{used} - {' '} - used IPs -

+

+
-

- +

+

+ Available IPs +

+ +

{available} - {' '} - available -

+

+
-

- {percentage}% -

+
+
+ Capacity + {used} / {total} +
+ +
+
+
+
diff --git a/src/components/dashboard/NetworkTrafficChart.tsx b/src/components/dashboard/NetworkTrafficChart.tsx index 19e40a6..a930c04 100644 --- a/src/components/dashboard/NetworkTrafficChart.tsx +++ b/src/components/dashboard/NetworkTrafficChart.tsx @@ -103,7 +103,7 @@ export function NetworkTrafficChart() {
)} -
+
@@ -26,8 +32,12 @@ export function TopBar() {
- - All Systems Operational + + {healthy + ? 'All Systems Operational' + : 'System Issues Detected'}
diff --git a/src/components/provisioning/ProvisioningWizard.tsx b/src/components/provisioning/ProvisioningWizard.tsx index f012ae0..dc78f46 100644 --- a/src/components/provisioning/ProvisioningWizard.tsx +++ b/src/components/provisioning/ProvisioningWizard.tsx @@ -1,15 +1,25 @@ -import { useState } from 'react'; +import { + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { invoke } from '@tauri-apps/api/core'; import { AlertTriangle, CheckCircle2, - KeyRound, + Cpu, + FileUp, Network, + PlugZap, + RefreshCw, + Rocket, Search, ShieldAlert, Terminal, - Wifi, + UploadCloud, + Wifi } from 'lucide-react'; import { Badge } from '@/components/ui/Badge'; @@ -17,6 +27,27 @@ import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; import { addActivityLog } from '@/services/activityLogService'; +import { vpnApi } from '@/services/vpnApi'; + +type ProvisioningMode = + | 'full' + | 'provision_only'; + +type WorkflowStep = + | 'DETECT_ROUTER' + | 'UPLOAD_FIRMWARE' + | 'FLASH_FIRMWARE' + | 'WAIT_REBOOT' + | 'RECONNECT_ROUTER' + | 'UPLOAD_PROVISIONING' + | 'RUN_PROVISIONING' + | 'REGISTER_PEER'; + +type StepStatus = + | 'pending' + | 'active' + | 'done' + | 'blocked'; type DetectionStatus = | 'idle' @@ -25,13 +56,95 @@ type DetectionStatus = | 'ssh_ok' | 'auth_required' | 'stale_host_key' - | 'failed'; + | 'failed' + | 'flashing' + | 'waiting_reboot' + | 'reconnecting'; -const ROUTER_IPS = [ - '192.168.1.1', - '198.51.100.1', +type RouterEnvForm = { + routerId: string; + hostname: string; + wgIp: string; + rootPassword: string; +}; + +const routerPresets = [ + { + ip: '192.168.1.1', + label: 'Factory/default', + password: '', + }, + { + ip: '198.51.100.1', + label: 'Provisioned LAN', + password: 'litoralr', + }, ]; +const FLASH_SECONDS = 300; +const PROVISION_SECONDS = 90; + +const stopPhrase = 'STOP PROVISIONING'; + +const workflowSteps: Array<{ + id: WorkflowStep; + title: string; + description: string; + icon: typeof Search; +}> = [ + { + id: 'DETECT_ROUTER', + title: 'Detect Router', + description: 'Ping and inspect router over SSH.', + icon: Search, + }, + { + id: 'UPLOAD_FIRMWARE', + title: 'Upload Firmware', + description: 'Copy firmware image to /tmp.', + icon: UploadCloud, + }, + { + id: 'FLASH_FIRMWARE', + title: 'Flash Firmware', + description: 'Run sysupgrade -n /tmp/firmware.bin.', + icon: Cpu, + }, + { + id: 'WAIT_REBOOT', + title: 'Wait for Reboot', + description: 'Block actions while router restarts.', + icon: RefreshCw, + }, + { + id: 'RECONNECT_ROUTER', + title: 'Reconnect Router', + description: 'Reconnect Ethernet and wait for SSH.', + icon: PlugZap, + }, + { + id: 'UPLOAD_PROVISIONING', + title: 'Upload Bundle', + description: 'Copy router.env and provision.sh.', + icon: FileUp, + }, + { + id: 'RUN_PROVISIONING', + title: 'Run Provisioning', + description: 'Execute router-side setup script.', + icon: Terminal, + }, + { + id: 'REGISTER_PEER', + title: 'Register VPS Peer', + description: 'Apply WireGuard peer on 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'; + function statusTone(status: DetectionStatus) { if ( status === 'reachable' || @@ -42,7 +155,10 @@ function statusTone(status: DetectionStatus) { if ( status === 'auth_required' || - status === 'stale_host_key' + status === 'stale_host_key' || + status === 'flashing' || + status === 'waiting_reboot' || + status === 'reconnecting' ) { return 'purple'; } @@ -54,18 +170,190 @@ function statusTone(status: DetectionStatus) { return 'blue'; } +function statusLabel(status: DetectionStatus) { + return status.replace(/_/g, ' '); +} + +function formatSeconds(totalSeconds: number) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function defaultRouterEnv(): RouterEnvForm { + return { + routerId: '', + hostname: '', + wgIp: '', + rootPassword: 'litoralr', + }; +} + +function buildRouterEnv(form: RouterEnvForm) { + return [ + `ROUTER_ID=${form.routerId}`, + `HOSTNAME=${form.hostname}`, + '', + 'LAN_IP=198.51.100.1', + 'LAN_NETMASK=255.255.255.0', + '', + `WG_IP=${form.wgIp}`, + 'WG_CIDR=32', + 'WG_SERVER_HOST=146.59.230.190', + 'WG_SERVER_PORT=4999', + 'WG_SERVER_PUBLIC_KEY=eSe9hH/X1tcAqGo09hDHvOXmy6o6NPk5sPwCzMXE7kc=', + '', + 'CONTROLLER_IP=198.51.100.10', + 'PLC_IP=198.51.100.50', + '', + `ROOT_PASSWORD=${form.rootPassword.trim() || 'litoralr'}`, + '', + ].join('\n'); +} + export function ProvisioningWizard() { + const [provisioningMode, setProvisioningMode] = + useState('full'); + const [selectedIp, setSelectedIp] = - useState(ROUTER_IPS[0]); + useState(routerPresets[0].ip); + + const [customIp, setCustomIp] = useState(''); + const [routerPassword, setRouterPassword] = + useState(routerPresets[0].password); + const [customPassword, setCustomPassword] = + useState(''); const [status, setStatus] = useState('idle'); + const [activeStep, setActiveStep] = + useState('DETECT_ROUTER'); + + const [completedSteps, setCompletedSteps] = + useState([]); + const [routerInfo, setRouterInfo] = useState(''); const [log, setLog] = useState([]); + const [confirmFlashOpen, setConfirmFlashOpen] = + useState(false); + + const [flashOverlayOpen, setFlashOverlayOpen] = + useState(false); + + const [flashSecondsRemaining, setFlashSecondsRemaining] = + useState(FLASH_SECONDS); + + const [isReconnecting, setIsReconnecting] = + useState(false); + + const [envModalOpen, setEnvModalOpen] = + useState(false); + + const [routerEnv, setRouterEnv] = + useState(defaultRouterEnv()); + + const [stopConfirmOpen, setStopConfirmOpen] = + useState(false); + + const [stopConfirmText, setStopConfirmText] = + useState(''); + + const flashCompletionHandledRef = + useRef(false); + + const [provisionOverlayOpen, setProvisionOverlayOpen] = + useState(false); + + const [provisionSecondsRemaining, setProvisionSecondsRemaining] = + useState(PROVISION_SECONDS); + + + const [isActionRunning, setIsActionRunning] = + useState(false); + + const [setupCompleteOpen, setSetupCompleteOpen] = + useState(false); + + const effectivePassword = + customIp.trim() && + selectedIp === customIp.trim() + ? customPassword + : routerPassword; + + const provisioningStarted = + activeStep !== 'DETECT_ROUTER' || + completedSteps.length > 0 || + status === 'flashing' || + status === 'waiting_reboot' || + status === 'reconnecting'; + + const controlsLocked = + provisioningStarted || + isReconnecting || + isActionRunning; + + const canContinue = + status === 'ssh_ok' || + completedSteps.includes('UPLOAD_FIRMWARE') || + activeStep === 'UPLOAD_PROVISIONING' || + activeStep === 'RUN_PROVISIONING' || + activeStep === 'REGISTER_PEER'; + + const visibleWorkflowSteps = useMemo(() => { + if (provisioningMode === 'full') { + return workflowSteps; + } + + return workflowSteps.filter( + (step) => + ![ + 'UPLOAD_FIRMWARE', + 'FLASH_FIRMWARE', + 'WAIT_REBOOT', + 'RECONNECT_ROUTER', + ].includes(step.id), + ); + }, [provisioningMode]); + + const flashProgress = + ((FLASH_SECONDS - flashSecondsRemaining) / + FLASH_SECONDS) * + 100; + + const workflowState = useMemo(() => { + return visibleWorkflowSteps.map((step) => { + let stepStatus: StepStatus = 'pending'; + + if (completedSteps.includes(step.id)) { + stepStatus = 'done'; + } else if (step.id === activeStep) { + stepStatus = 'active'; + } + + if ( + !canContinue && + step.id !== 'DETECT_ROUTER' + ) { + stepStatus = 'blocked'; + } + + return { + ...step, + status: stepStatus, + }; + }); + }, [ + activeStep, + completedSteps, + canContinue, + visibleWorkflowSteps, + ]); + function addLog( message: string, level: @@ -82,13 +370,158 @@ export function ProvisioningWizard() { addActivityLog({ level, source: 'desktop', - action: 'ROUTER_DETECTION', + action: activeStep, message, routerIp: selectedIp, }); } + function resetProvisioning(shouldLog = true) { + setProvisioningMode('full'); + setSelectedIp(routerPresets[0].ip); + setRouterPassword(routerPresets[0].password); + setCustomIp(''); + setCustomPassword(''); + + setActiveStep('DETECT_ROUTER'); + setCompletedSteps([]); + setStatus('idle'); + setRouterInfo(''); + setIsReconnecting(false); + setIsActionRunning(false); + + setFlashOverlayOpen(false); + setConfirmFlashOpen(false); + setProvisionOverlayOpen(false); + setEnvModalOpen(false); + setStopConfirmOpen(false); + setStopConfirmText(''); + setSetupCompleteOpen(false); + + setRouterEnv(defaultRouterEnv()); + + flashCompletionHandledRef.current = false; + + if (shouldLog) { + addLog( + 'Provisioning flow was reset.', + 'warning', + ); + } + } + + function finishProvisioning() { + resetProvisioning(false); + + setLog((currentLog) => [ + `${new Date().toLocaleTimeString()} Router setup completed successfully. Ready for next router.`, + ...currentLog, + ]); + } + + useEffect(() => { + if (!flashOverlayOpen) { + return; + } + + const intervalId = window.setInterval(() => { + setFlashSecondsRemaining((current) => { + if (current <= 1) { + window.clearInterval(intervalId); + + if (!flashCompletionHandledRef.current) { + flashCompletionHandledRef.current = true; + + setFlashOverlayOpen(false); + setStatus('waiting_reboot'); + + setCompletedSteps((currentSteps) => [ + ...new Set([ + ...currentSteps, + 'FLASH_FIRMWARE' as WorkflowStep, + 'WAIT_REBOOT' as WorkflowStep, + ]), + ]); + + setSelectedIp(routerPresets[0].ip); + setRouterPassword(routerPresets[0].password); + setCustomIp(''); + setCustomPassword(''); + + setActiveStep('RECONNECT_ROUTER'); + + addLog( + 'Flash wait window completed. Reconnect or replug Ethernet if needed, then continue with reconnect checks.', + 'success', + ); + } + + return 0; + } + + return current - 1; + }); + }, 1000); + + return () => { + window.clearInterval(intervalId); + }; + }, [flashOverlayOpen]); + + useEffect(() => { + if (!provisionOverlayOpen) { + return; + } + + const intervalId = window.setInterval(() => { + setProvisionSecondsRemaining((current) => { + if (current <= 1) { + window.clearInterval(intervalId); + + setProvisionOverlayOpen(false); + + setSelectedIp(routerPresets[1].ip); + setRouterPassword(routerPresets[1].password); + setCustomIp(''); + setCustomPassword(''); + + setActiveStep('REGISTER_PEER'); + + addLog( + 'Provisioning wait completed. Replug Ethernet now, wait a few seconds, then continue to verify WireGuard and register peer.', + 'warning', + ); + + return 0; + } + + return current - 1; + }); + }, 1000); + + return () => { + window.clearInterval(intervalId); + }; + }, [provisionOverlayOpen]); + async function detectRouter() { + if (controlsLocked) { + addLog( + 'Router detection is locked while provisioning is active.', + 'warning', + ); + return; + } + + if (!selectedIp.trim()) { + addLog( + 'Router detection blocked: no IP selected', + 'warning', + ); + return; + } + + setActiveStep('DETECT_ROUTER'); setStatus('checking'); setRouterInfo(''); @@ -115,8 +548,14 @@ export function ProvisioningWizard() { ); setRouterInfo(info); - setStatus('ssh_ok'); + setCompletedSteps(['DETECT_ROUTER']); + + setActiveStep( + provisioningMode === 'full' + ? 'UPLOAD_FIRMWARE' + : 'UPLOAD_PROVISIONING', + ); addLog( `SSH key authentication succeeded at ${selectedIp}`, @@ -148,7 +587,7 @@ export function ProvisioningWizard() { ) ) { addLog( - `Router requires password authentication. Attempting automatic inspection...`, + 'Router requires password authentication. Attempting automatic inspection...', 'warning', ); @@ -158,13 +597,21 @@ export function ProvisioningWizard() { 'inspect_router_with_password', { ip: selectedIp, - password: 'litoralr', + password: effectivePassword, }, ); setRouterInfo(passwordInfo); - setStatus('ssh_ok'); + setCompletedSteps([ + 'DETECT_ROUTER', + ]); + + setActiveStep( + provisioningMode === 'full' + ? 'UPLOAD_FIRMWARE' + : 'UPLOAD_PROVISIONING', + ); addLog( `Password SSH inspection succeeded at ${selectedIp}`, @@ -199,7 +646,24 @@ export function ProvisioningWizard() { ); } } + async function fixKnownHost() { + if (controlsLocked) { + addLog( + 'Known host cleanup is locked while provisioning is active.', + 'warning', + ); + return; + } + + if (!selectedIp.trim()) { + addLog( + 'Cannot remove known_hosts entry: no IP selected', + 'warning', + ); + return; + } + try { addLog( `Removing known_hosts entry for ${selectedIp}`, @@ -213,67 +677,524 @@ export function ProvisioningWizard() { ); addLog(result, 'success'); - setStatus('idle'); + setCompletedSteps([]); + setActiveStep('DETECT_ROUTER'); } catch (error) { addLog(String(error), 'error'); } } + async function continueProvisioning() { + if (isActionRunning || isReconnecting) { + return; + } + + setIsActionRunning(true); + + try { + await continueProvisioningInner(); + } finally { + setIsActionRunning(false); + } + } + + async function continueProvisioningInner() { + if (activeStep === 'UPLOAD_FIRMWARE') { + try { + addLog( + `Uploading firmware to ${selectedIp}:/tmp/firmware.bin`, + ); + + const result = await invoke( + 'upload_firmware_to_router', + { + ip: selectedIp, + password: effectivePassword, + }, + ); + + addLog(result, 'success'); + + setCompletedSteps((currentSteps) => [ + ...new Set([ + ...currentSteps, + 'UPLOAD_FIRMWARE' as WorkflowStep, + ]), + ]); + + setActiveStep('FLASH_FIRMWARE'); + + return; + } catch (error) { + addLog( + `Firmware upload failed: ${String(error)}`, + 'error', + ); + + return; + } + } + + if (activeStep === 'FLASH_FIRMWARE') { + setConfirmFlashOpen(true); + return; + } + + if (activeStep === 'RECONNECT_ROUTER') { + if (isReconnecting) { + return; + } + + setIsReconnecting(true); + setStatus('reconnecting'); + + addLog( + `Checking router SSH after flash at ${selectedIp}`, + 'warning', + ); + + for (let attempt = 1; attempt <= 30; attempt += 1) { + try { + addLog(`Reconnect attempt ${attempt}/30`); + + const info = await invoke( + 'check_router_after_flash', + { + ip: selectedIp, + password: effectivePassword, + }, + ); + + setRouterInfo(info); + + addLog( + `Router SSH reconnected after flash at ${selectedIp}`, + 'success', + ); + + setCompletedSteps((currentSteps) => [ + ...new Set([ + ...currentSteps, + 'RECONNECT_ROUTER' as WorkflowStep, + ]), + ]); + + setStatus('ssh_ok'); + setActiveStep('UPLOAD_PROVISIONING'); + setIsReconnecting(false); + + return; + } catch (error) { + addLog( + `Reconnect attempt ${attempt}/30 failed: ${String(error)}`, + 'warning', + ); + + await new Promise((resolve) => + window.setTimeout(resolve, 5000), + ); + } + } + + setStatus('failed'); + setIsReconnecting(false); + + addLog( + 'Router reconnect failed after 30 attempts. Replug Ethernet and retry.', + 'error', + ); + + return; + } + + if (activeStep === 'UPLOAD_PROVISIONING') { + await prepareRouterEnv(); + return; + } + + if (activeStep === 'RUN_PROVISIONING') { + setProvisionSecondsRemaining(PROVISION_SECONDS); + setProvisionOverlayOpen(true); + + try { + addLog( + `Running provisioning script on ${selectedIp}`, + 'warning', + ); + + const result = await invoke( + 'run_provisioning', + { + ip: selectedIp, + password: effectivePassword, + }, + ); + + addLog(result, 'success'); + } catch (error) { + const message = String(error); + const lowered = message.toLowerCase(); + + const expectedProvisionDisconnect = + lowered.includes('connection reset') || + lowered.includes('client_loop') || + lowered.includes('failed to connect to ubus') || + lowered.includes('disconnect') || + lowered.includes('broken pipe') || + lowered.includes('restart') || + lowered.includes('radio0'); + + if (expectedProvisionDisconnect) { + addLog( + 'Provisioning script reached network/service restart and SSH disconnected as expected.', + 'warning', + ); + + addLog(message, 'info'); + } else { + setProvisionOverlayOpen(false); + + addLog( + `Provisioning script failed: ${message}`, + 'error', + ); + + return; + } + } + + setCompletedSteps((currentSteps) => [ + ...new Set([ + ...currentSteps, + 'RUN_PROVISIONING' as WorkflowStep, + ]), + ]); + + setActiveStep('REGISTER_PEER'); + + return; + } + + if (activeStep === 'REGISTER_PEER') { + try { + addLog( + `Capturing router WireGuard public key from ${routerPresets[1].ip}`, + 'warning', + ); + + const publicKey = await invoke( + 'capture_wireguard_public_key', + { + ip: routerPresets[1].ip, + password: routerEnv.rootPassword.trim() || routerPresets[1].password, + }, + ); + + addLog( + `Captured router public key ${publicKey.slice(0, 12)}...`, + 'success', + ); + + await vpnApi.registerPeer({ + vpnIp: routerEnv.wgIp, + publicKey, + }); + + addLog( + `Registered VPS peer ${routerEnv.wgIp}`, + 'success', + ); + + setCompletedSteps((currentSteps) => [ + ...new Set([ + ...currentSteps, + 'REGISTER_PEER' as WorkflowStep, + ]), + ]); + + setStatus('ssh_ok'); + setSetupCompleteOpen(true); + + return; + } catch (error) { + addLog( + `Peer registration failed: ${String(error)}`, + 'error', + ); + + return; + } + } + + addLog( + `Step ${activeStep} is not wired yet.`, + 'warning', + ); + } + + async function uploadProvisioningBundle() { + if ( + !routerEnv.routerId.trim() || + !routerEnv.hostname.trim() || + !routerEnv.wgIp.trim() + ) { + addLog( + 'router.env is missing router ID, hostname, or WireGuard IP.', + 'error', + ); + return; + } + + try { + const envContent = buildRouterEnv(routerEnv); + + addLog( + `Uploading router.env and provision.sh to ${selectedIp}`, + 'warning', + ); + + const result = await invoke( + 'upload_provisioning_bundle', + { + ip: selectedIp, + password: effectivePassword, + envContent, + }, + ); + + addLog(result, 'success'); + + setEnvModalOpen(false); + + setCompletedSteps((currentSteps) => [ + ...new Set([ + ...currentSteps, + 'UPLOAD_PROVISIONING' as WorkflowStep, + ]), + ]); + + setActiveStep('RUN_PROVISIONING'); + } catch (error) { + addLog( + `Provisioning bundle upload failed: ${String(error)}`, + 'error', + ); + } + } + + async function confirmAndFlashRouter() { + setConfirmFlashOpen(false); + setStatus('flashing'); + setActiveStep('FLASH_FIRMWARE'); + + flashCompletionHandledRef.current = false; + + addLog( + 'Starting firmware flash with sysupgrade -n /tmp/firmware.bin', + 'warning', + ); + + try { + await invoke( + 'flash_router_sysupgrade', + { + ip: selectedIp, + password: effectivePassword, + }, + ); + + addLog( + 'Flash command submitted. SSH session may disconnect; entering protected wait window.', + 'success', + ); + } catch (error) { + const message = String(error); + const loweredMessage = message.toLowerCase(); + + if ( + loweredMessage.includes('broken pipe') || + loweredMessage.includes('connection reset') || + loweredMessage.includes('channel') || + loweredMessage.includes('disconnect') || + loweredMessage.includes('commencing upgrade') || + loweredMessage.includes('closing all shell sessions') || + loweredMessage.includes('connection failed') || + loweredMessage.includes('sysupgrade') + ) { + addLog( + 'Router accepted sysupgrade and disconnected as expected during flash.', + 'warning', + ); + } else { + setStatus('failed'); + + addLog( + `Flash command failed before reboot: ${message}`, + 'error', + ); + + return; + } + } + + setFlashSecondsRemaining(FLASH_SECONDS); + setFlashOverlayOpen(true); + } + + async function prepareRouterEnv() { + try { + addLog( + 'Requesting next available WireGuard IP from backend...', + ); + + const response = await vpnApi.availableIp(); + const vpnIp = response.vpnIp; + + const vpnParts = vpnIp.split('.'); + const routerId = + vpnParts[vpnParts.length - 1] || ''; + + setRouterEnv((current) => ({ + ...current, + routerId, + hostname: `Litoral_Regas_${routerId}`, + wgIp: vpnIp, + })); + + addLog( + `Reserved next available WireGuard IP candidate: ${vpnIp}`, + 'success', + ); + + setEnvModalOpen(true); + } catch (error) { + addLog( + `Failed to get available WireGuard IP: ${String(error)}`, + 'error', + ); + } + } + return (
-
+

Provisioning

- Safe router detection and SSH - inspection. No flashing or - configuration changes are performed. + Guided router provisioning from + detection to VPS peer registration.

- - {status.replace(/_/g, ' ')} - +
+ {provisioningStarted && ( + + )} + + + {statusLabel(status)} + +
- -
-
- + +
+
+
+ +
+ +
+

+ Router Connection +

+ +

+ Select target and validate SSH + before provisioning. +

+
-
-

- Safe Router Detection -

+
+ -

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

+
-
-
- {ROUTER_IPS.map((ip) => { - const active = selectedIp === ip; +
+ {routerPresets.map((preset) => { + const active = + selectedIp === preset.ip && + customIp.trim() === ''; - return ( - - ); - })} -
+

+ {preset.label} +

-
-
-
- -
+

+ Password:{' '} + + {preset.password || 'empty'} + +

+ + ); + })} -
-

- 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 +

+ Custom Router Access

-

- Open a terminal and run: +

+ Use this for non-standard router IPs + or passwords.

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

- Password for provisioned - routers: -

+ { + const value = + event.target.value; -
-                      litoralr
-                    
+ setCustomIp(value); + setSelectedIp(value.trim()); + }} + onFocus={() => { + setSelectedIp(customIp.trim()); + }} + placeholder="Example: 192.168.8.1" + className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 font-mono text-sm text-white outline-none transition focus:border-blue-500/40 disabled:cursor-not-allowed disabled:opacity-60" + /> +
-

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

+
+ -
-                      ubus call system board
-                    
+ + setCustomPassword( + event.target.value, + ) + } + onFocus={() => { + if (customIp.trim()) { + setSelectedIp( + customIp.trim(), + ); + } + }} + placeholder="Leave empty if no password" + className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 font-mono text-sm text-white outline-none transition focus:border-blue-500/40 disabled:cursor-not-allowed disabled:opacity-60" + />
- )} +
+ +
+
+
+ +
+ +
+

+ Detection Target +

+ +

+ root@{selectedIp || '—'} +

+
+ + + {statusLabel(status)} + +
+
+ +
+ + + +
+
-
- +
+
-
- +
+

- Current Safety Scope + Provisioning Flow

- This screen is read-only. + Full router onboarding sequence.

-
-

✓ Ping reachability check

-

✓ SSH host key detection

-

✓ Read-only router inspection

-

✕ No firmware flashing

-

✕ No config upload

-

✕ No password changes

+
+ {workflowState.map((step) => ( + + ))} +
+ +
+
+
- +
+
@@ -436,29 +1422,551 @@ export function ProvisioningWizard() {

- Output from ubus system board. + ubus system board output.

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

+ +

Technician Log

-
-          {log.join('\n') ||
-            'No provisioning activity yet.'}
+        
+          {log.join('\n') || 'No provisioning activity yet.'}
         
+ + {confirmFlashOpen && ( + setConfirmFlashOpen(false)} + onConfirm={confirmAndFlashRouter} + /> + )} + + {flashOverlayOpen && ( + + )} + + {provisionOverlayOpen && ( + + )} + + {envModalOpen && ( + setEnvModalOpen(false)} + onUpload={uploadProvisioningBundle} + /> + )} + + {stopConfirmOpen && ( + { + setStopConfirmOpen(false); + setStopConfirmText(''); + }} + onConfirm={() => resetProvisioning(true)} + canConfirm={stopConfirmText === stopPhrase} + /> + )} + + {setupCompleteOpen && ( + + )} +

+ ); +} + +function RouterEnvModal({ + routerEnv, + setRouterEnv, + onCancel, + onUpload, +}: { + routerEnv: RouterEnvForm; + setRouterEnv: React.Dispatch< + React.SetStateAction + >; + onCancel: () => void; + onUpload: () => void; +}) { + function updateField( + field: keyof RouterEnvForm, + value: string, + ) { + setRouterEnv((current) => ({ + ...current, + [field]: value, + })); + } + + return ( +
+
+
+
+ +
+ +
+

+ Router Environment +

+ +

+ Values used to generate router.env before upload. +

+
+
+ +
+ updateField('routerId', value)} + placeholder="203" + /> + + updateField('hostname', value)} + placeholder="Litoral_Regas_203" + /> + + updateField('wgIp', value)} + placeholder="198.19.1.203" + /> + + updateField('rootPassword', value)} + placeholder="litoralr" + type="password" + /> +
+ +
+

+ Static router.env values +

+ +
+

LAN_IP=198.51.100.1

+

LAN_NETMASK=255.255.255.0

+

WG_CIDR=32

+

WG_SERVER_HOST=146.59.230.190

+

WG_SERVER_PORT=4999

+

CONTROLLER_IP=198.51.100.10

+

PLC_IP=198.51.100.50

+

+ WG_SERVER_PUBLIC_KEY=eSe9hH/X1tcAqGo09hDHvOXmy6o6NPk5sPwCzMXE7kc= +

+
+
+ +
+ + + +
+
+
+ ); +} + +function EnvInput({ + label, + value, + onChange, + placeholder, + type = 'text', +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: string; +}) { + return ( +
+ + + onChange(event.target.value)} + placeholder={placeholder} + className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 font-mono text-sm text-white outline-none transition focus:border-blue-500/40" + /> +
+ ); +} + +function ConfirmFlashModal({ + selectedIp, + onCancel, + onConfirm, +}: { + selectedIp: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
+
+
+ +
+ +
+

+ Confirm Firmware Flash +

+ +

+ This will run{' '} + + sysupgrade -n /tmp/firmware.bin + {' '} + on router{' '} + + {selectedIp} + + . The SSH session will drop and the router may be unreachable for several minutes. +

+ +
+ Do not unplug router power. Do not close this application. +
+ +
+ + + +
+
+
+
+
+ ); +} + +function FlashOverlay({ + flashSecondsRemaining, + flashProgress, +}: { + flashSecondsRemaining: number; + flashProgress: number; +}) { + return ( +
+
+
+
+ +
+ +
+

+ Flashing router firmware +

+ +

+ The router is applying firmware and rebooting. The SSH session will be unavailable during this window. +

+ +
+
+ + Protected wait window + + + + {formatSeconds(flashSecondsRemaining)} + +
+ +
+
+
+
+ +
+ Do not unplug power. Do not close this app. Wait for this screen to finish before touching the Ethernet cable. +
+
+
+
+
+ ); +} + +function ProvisionOverlay({ + provisionSecondsRemaining, + provisionProgress, +}: { + provisionSecondsRemaining: number; + provisionProgress: number; +}) { + return ( +
+
+
+
+ +
+ +
+

+ Applying router provisioning +

+ +

+ The router is changing LAN, firewall, WireGuard, LuCI, + and root password settings. SSH or Ethernet may drop during this step. +

+ +
+
+ + Protected provisioning window + + + + {formatSeconds(provisionSecondsRemaining)} + +
+ +
+
+
+
+ +
+ Do not unplug power. When this timer finishes, unplug and replug Ethernet, + then continue to verify WireGuard and register the VPS peer. +
+
+
+
+
+ ); +} + +function StopProvisioningModal({ + value, + onChange, + onCancel, + onConfirm, + canConfirm, +}: { + value: string; + onChange: (value: string) => void; + onCancel: () => void; + onConfirm: () => void; + canConfirm: boolean; +}) { + return ( +
+
+
+
+ +
+ +
+

+ Stop Provisioning? +

+ +

+ Stopping in the middle of provisioning can leave the router in a partial or broken state. Only continue if you know the current router state is safe. +

+ +

+ Type{' '} + + {stopPhrase} + {' '} + to confirm. +

+ + onChange(event.target.value)} + className="mt-3 w-full rounded-xl border border-red-500/20 bg-slate-950 px-3 py-2 font-mono text-sm text-white outline-none focus:border-red-400/50" + /> + +
+ + + +
+
+
+
+
+ ); +} + +function WorkflowStepCard({ + title, + description, + icon: Icon, + status, +}: { + title: string; + description: string; + icon: typeof Search; + status: StepStatus; +}) { + const active = status === 'active'; + const done = status === 'done'; + const blocked = status === 'blocked'; + + return ( +
+
+
+ +
+ +
+

+ {title} +

+ +

+ {description} +

+
+
+
+ ); +} + +function SetupCompleteModal({ + routerId, + hostname, + wgIp, + onClose, +}: { + routerId: string; + hostname: string; + wgIp: string; + onClose: () => void; +}) { + return ( +
+
+
+
+ +
+ +
+

+ Router setup completed +

+ +

+ Router provisioning finished successfully and the VPS WireGuard peer was registered. +

+ +
+

ROUTER_ID={routerId}

+

HOSTNAME={hostname}

+

WG_IP={wgIp}

+
+ +
+ +
+
+
+
); } \ No newline at end of file diff --git a/src/components/settings/BackendSettings.tsx b/src/components/settings/BackendSettings.tsx index 9f4b5c6..1424360 100644 --- a/src/components/settings/BackendSettings.tsx +++ b/src/components/settings/BackendSettings.tsx @@ -24,7 +24,7 @@ const DEFAULT_ROUTER_IP = '198.51.100.1'; const DEFAULT_CONTROLLER_IP = '198.51.100.10'; const DEFAULT_PLC_IP = '198.51.100.50'; const DEFAULT_FIRMWARE = - 'openwrt-23.05-zbt-we826-16m.bin'; + 'openwrt-23.05.5-zbt-we826-16m-litoral-golden-sysupgrade.bin'; export function BackendSettings() { const [settings, setSettings] =