Improve router provisioning workflow

This commit is contained in:
litoral05
2026-05-11 15:59:28 +01:00
parent ec2727927b
commit c1e9aeb386
11 changed files with 2658 additions and 292 deletions
+1 -1
View File
@@ -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"
ssh2 = "0.9"
@@ -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 <<EOF
ROUTER_ID=$ROUTER_ID
HOSTNAME=$HOSTNAME
LAN_IP=$LAN_IP
WG_IP=$WG_IP/$WG_CIDR
PROVISIONED_AT=$(date || true)
EOF
touch /etc/litoral_provisioned
log "Restarting services"
service system reload
service uhttpd restart
service firewall restart
service network restart
log "Verification summary"
echo "Hostname: $(uci get system.@system[0].hostname)"
echo "LAN IP: $(uci get network.lan.ipaddr)"
echo "WG IP: $WG_IP/$WG_CIDR"
echo "Board: $BOARD"
echo "OpenWrt: $VERSION"
echo
echo "ROUTER PUBLIC KEY:"
echo "$ROUTER_PUBLIC_KEY"
echo
echo "Add this peer to the VPS:"
echo "[Peer]"
echo "PublicKey = $ROUTER_PUBLIC_KEY"
echo "AllowedIPs = $WG_IP/32"
echo
echo "Provisioning complete."
+665 -74
View File
@@ -1,4 +1,10 @@
use ssh2::Session;
use std::process::Command;
use std::{
fs::File,
io::{ Read, Write },
net::{ TcpStream, ToSocketAddrs },
path::Path,
thread,
time::Duration,
};
@@ -8,118 +14,703 @@ fn delay() {
}
#[tauri::command]
pub async fn detect_router(
ip: String,
) -> Result<bool, String> {
pub async fn detect_router(ip: String) -> Result<bool, String> {
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<String, String> {
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<String, String> {
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<Session, String> {
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<String, String> {
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<String, String> {
pub async fn upload_firmware(ip: String, firmware_path: String) -> Result<String, String> {
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<bool, String> {
pub async fn flash_router(ip: String, remote_firmware_path: String) -> Result<String, String> {
delay();
Ok(format!("sysupgrade started on {} with {}", ip, remote_firmware_path))
}
#[tauri::command]
pub async fn wait_for_ssh(ip: String) -> Result<bool, String> {
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<String, String> {
delay();
pub async fn run_provisioning(ip: String, password: String) -> Result<String, String> {
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<String, String> {
delay();
pub async fn capture_wireguard_public_key(ip: String, password: String) -> Result<String, String> {
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<String, String> {
delay();
Ok(
"MOCK_ROUTER_WIREGUARD_PUBLIC_KEY_BASE64="
.into(),
)
}
#[tauri::command]
pub async fn verify_router(
ip: String,
) -> Result<bool, String> {
pub async fn verify_router(ip: String) -> Result<bool, String> {
delay();
if ip == "198.51.100.1" {
Ok(true)
} else {
Err(
"router verification failed"
.into(),
)
Err("router verification failed".into())
}
}
}
#[tauri::command]
pub async fn upload_firmware_to_router(ip: String, password: String) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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))
}
+8
View File
@@ -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");