Refactor: Chia nho file main thao thu muc va don dep theo yeu cau
This commit is contained in:
164
core/flasher.py
Normal file
164
core/flasher.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
OpenWrt LuCI Firmware Flasher (Barrier Breaker 14.07)
|
||||
|
||||
Automates the 3-step firmware flash process via LuCI web interface:
|
||||
|
||||
Step 1: POST /cgi-bin/luci → username=root&password= → get sysauth cookie + stok
|
||||
Step 2: POST /cgi-bin/luci/;stok=XXX/admin/system/flashops
|
||||
→ multipart: image=firmware.bin → get verification page
|
||||
Step 3: POST /cgi-bin/luci/;stok=XXX/admin/system/flashops
|
||||
→ step=2&keep= → device flashes and reboots
|
||||
"""
|
||||
|
||||
import requests
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
|
||||
|
||||
def flash_device(ip, firmware_path, username="root", password="",
|
||||
keep_settings=False, status_cb=None):
|
||||
"""
|
||||
Flash firmware to an OpenWrt device via LuCI.
|
||||
|
||||
Returns:
|
||||
"DONE" on success, "FAIL: reason" on error
|
||||
"""
|
||||
base_url = f"http://{ip}"
|
||||
session = requests.Session()
|
||||
|
||||
try:
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 1: Login
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Logging in...")
|
||||
|
||||
# GET login page to detect form field names
|
||||
login_url = f"{base_url}/cgi-bin/luci"
|
||||
try:
|
||||
get_resp = session.get(login_url, timeout=10)
|
||||
page_html = get_resp.text
|
||||
except Exception:
|
||||
page_html = ""
|
||||
|
||||
# Barrier Breaker uses "username"/"password"
|
||||
# Newer LuCI uses "luci_username"/"luci_password"
|
||||
if 'name="luci_username"' in page_html:
|
||||
login_data = {"luci_username": username, "luci_password": password}
|
||||
else:
|
||||
login_data = {"username": username, "password": password}
|
||||
|
||||
resp = session.post(login_url, data=login_data,
|
||||
timeout=10, allow_redirects=True)
|
||||
|
||||
if resp.status_code == 403:
|
||||
return "FAIL: Login denied (403)"
|
||||
|
||||
# Extract stok from response body or URL
|
||||
stok = None
|
||||
for source in [resp.url, resp.text]:
|
||||
match = re.search(r";stok=([a-f0-9]+)", source)
|
||||
if match:
|
||||
stok = match.group(1)
|
||||
break
|
||||
|
||||
# Also check redirect history
|
||||
if not stok:
|
||||
for hist in resp.history:
|
||||
match = re.search(r";stok=([a-f0-9]+)",
|
||||
hist.headers.get("Location", ""))
|
||||
if match:
|
||||
stok = match.group(1)
|
||||
break
|
||||
|
||||
# Verify login succeeded
|
||||
has_cookie = "sysauth" in str(session.cookies)
|
||||
if not stok and not has_cookie:
|
||||
return "FAIL: Login failed — no session"
|
||||
|
||||
# Build flash URL with stok
|
||||
if stok:
|
||||
flash_url = f"{base_url}/cgi-bin/luci/;stok={stok}/admin/system/flashops"
|
||||
else:
|
||||
flash_url = f"{base_url}/cgi-bin/luci/admin/system/flashops"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 2: Upload firmware image
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Uploading firmware...")
|
||||
|
||||
filename = os.path.basename(firmware_path)
|
||||
with open(firmware_path, "rb") as f:
|
||||
# Don't send "keep" field = uncheck "Keep settings"
|
||||
# Send "keep": "on" only if keep_settings is True
|
||||
data = {}
|
||||
if keep_settings:
|
||||
data["keep"] = "on"
|
||||
|
||||
resp = session.post(
|
||||
flash_url,
|
||||
data=data,
|
||||
files={"image": (filename, f, "application/octet-stream")},
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return f"FAIL: Upload HTTP {resp.status_code}"
|
||||
|
||||
resp_lower = resp.text.lower()
|
||||
|
||||
# Check for errors
|
||||
if "invalid image" in resp_lower or "bad image" in resp_lower:
|
||||
return "FAIL: Invalid firmware image"
|
||||
if "unsupported" in resp_lower or "not compatible" in resp_lower:
|
||||
return "FAIL: Firmware not compatible"
|
||||
|
||||
# Verify we got the "Flash Firmware - Verify" page
|
||||
if "verify" not in resp_lower or "proceed" not in resp_lower:
|
||||
# Still on flash form = upload was ignored
|
||||
if 'name="image"' in resp.text:
|
||||
return "FAIL: Upload ignored by server"
|
||||
return "FAIL: Unexpected response after upload"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 3: Click "Proceed" to start flash
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Confirming (Proceed)...")
|
||||
|
||||
# The Proceed form from the verification page:
|
||||
# <input type="hidden" name="step" value="2" />
|
||||
# <input type="hidden" name="keep" value="" />
|
||||
confirm_data = {
|
||||
"step": "2",
|
||||
"keep": "",
|
||||
}
|
||||
|
||||
if keep_settings:
|
||||
confirm_data["keep"] = "on"
|
||||
|
||||
try:
|
||||
session.post(flash_url, data=confirm_data, timeout=300)
|
||||
except requests.exceptions.ConnectionError:
|
||||
# Device rebooting — this is expected and means SUCCESS
|
||||
pass
|
||||
except requests.exceptions.ReadTimeout:
|
||||
# Also expected during reboot
|
||||
pass
|
||||
|
||||
if status_cb:
|
||||
status_cb("Rebooting...")
|
||||
time.sleep(3)
|
||||
|
||||
return "DONE"
|
||||
|
||||
except requests.ConnectionError:
|
||||
return "FAIL: Cannot connect"
|
||||
except requests.Timeout:
|
||||
return "DONE (rebooting)"
|
||||
except Exception as e:
|
||||
return f"FAIL: {e}"
|
||||
finally:
|
||||
session.close()
|
||||
224
core/scanner.py
Normal file
224
core/scanner.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import ipaddress
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# Windows: prevent subprocess from opening visible console windows
|
||||
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
||||
|
||||
|
||||
def _scan_with_scapy(network):
|
||||
"""Scan using scapy (requires root/sudo, and Npcap on Windows)."""
|
||||
from scapy.all import ARP, Ether, srp
|
||||
|
||||
arp = ARP(pdst=str(network))
|
||||
ether = Ether(dst="ff:ff:ff:ff:ff:ff")
|
||||
|
||||
packet = ether / arp
|
||||
|
||||
result = srp(packet, timeout=3, verbose=0)[0]
|
||||
|
||||
devices = []
|
||||
|
||||
for sent, received in result:
|
||||
devices.append({
|
||||
"ip": received.psrc,
|
||||
"mac": received.hwsrc
|
||||
})
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def _ping_one(ip, is_win):
|
||||
"""Ping a single IP to populate ARP table."""
|
||||
try:
|
||||
if is_win:
|
||||
subprocess.run(
|
||||
["ping", "-n", "1", "-w", "600", str(ip)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=3,
|
||||
creationflags=_NO_WINDOW
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
["ping", "-c", "1", "-W", "1", str(ip)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=3
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ping_sweep(network, progress_cb=None):
|
||||
"""Ping all IPs in network concurrently to populate ARP table.
|
||||
Calls progress_cb(done, total) after each ping completes if provided.
|
||||
"""
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
|
||||
# Only ping sweep for /24 or smaller to avoid flooding
|
||||
if net.num_addresses > 256:
|
||||
return
|
||||
|
||||
is_win = sys.platform == "win32"
|
||||
hosts = list(net.hosts())
|
||||
total = len(hosts)
|
||||
done_count = [0]
|
||||
|
||||
def _ping_and_track(ip):
|
||||
_ping_one(ip, is_win)
|
||||
done_count[0] += 1
|
||||
if progress_cb:
|
||||
progress_cb(done_count[0], total)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=50) as executor:
|
||||
futures = [executor.submit(_ping_and_track, ip) for ip in hosts]
|
||||
for f in as_completed(futures):
|
||||
pass
|
||||
|
||||
|
||||
def _scan_with_arp_table(network):
|
||||
"""Fallback: read ARP table using system 'arp -a' (no root needed).
|
||||
Supports both macOS and Windows output formats.
|
||||
"""
|
||||
# Ping sweep first to populate ARP table with active devices
|
||||
_ping_sweep(network)
|
||||
|
||||
# Brief pause to let the OS finalize ARP cache entries
|
||||
time.sleep(1)
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["arp", "-a"], text=True, creationflags=_NO_WINDOW
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
devices = []
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Windows format:
|
||||
# 192.168.4.1 cc-2d-21-a5-85-b0 dynamic
|
||||
pattern = re.compile(
|
||||
r"(\d+\.\d+\.\d+\.\d+)\s+"
|
||||
r"([0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-"
|
||||
r"[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2})\s+"
|
||||
r"(dynamic|static)",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
for line in output.splitlines():
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
ip_str = m.group(1)
|
||||
# Convert Windows MAC format (cc-2d-21-a5-85-b0) to standard (cc:2d:21:a5:85:b0)
|
||||
mac = m.group(2).replace("-", ":")
|
||||
if mac.upper() != "FF:FF:FF:FF:FF:FF":
|
||||
try:
|
||||
if ipaddress.ip_address(ip_str) in net:
|
||||
devices.append({"ip": ip_str, "mac": mac})
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# macOS/Linux format:
|
||||
# ? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0
|
||||
pattern = re.compile(
|
||||
r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-fA-F:]+)"
|
||||
)
|
||||
|
||||
for line in output.splitlines():
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
ip_str, mac = m.group(1), m.group(2)
|
||||
if mac.lower() != "(incomplete)" and mac != "ff:ff:ff:ff:ff:ff":
|
||||
try:
|
||||
if ipaddress.ip_address(ip_str) in net:
|
||||
devices.append({"ip": ip_str, "mac": mac})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def scan_network(network, progress_cb=None, stage_cb=None):
|
||||
"""Scan network: ping sweep first, then merge scapy ARP + arp table."""
|
||||
# Phase 1: Ping sweep — wake up devices and populate ARP cache
|
||||
if stage_cb:
|
||||
stage_cb("ping")
|
||||
_ping_sweep(network, progress_cb)
|
||||
time.sleep(1)
|
||||
|
||||
# Collect results from both methods and merge by IP
|
||||
seen = {} # ip -> device dict
|
||||
|
||||
# Phase 2: ARP table (populated by ping sweep above)
|
||||
if stage_cb:
|
||||
stage_cb("arp")
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["arp", "-a"], text=True, creationflags=_NO_WINDOW
|
||||
)
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
if sys.platform == "win32":
|
||||
pattern = re.compile(
|
||||
r"(\d+\.\d+\.\d+\.\d+)\s+"
|
||||
r"([0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-"
|
||||
r"[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2})\s+"
|
||||
r"(dynamic|static)",
|
||||
re.IGNORECASE
|
||||
)
|
||||
for line in output.splitlines():
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
ip_str = m.group(1)
|
||||
mac = m.group(2).replace("-", ":")
|
||||
if mac.upper() != "FF:FF:FF:FF:FF:FF":
|
||||
try:
|
||||
if ipaddress.ip_address(ip_str) in net:
|
||||
seen[ip_str] = {"ip": ip_str, "mac": mac}
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
pattern = re.compile(
|
||||
r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-fA-F:]+)"
|
||||
)
|
||||
for line in output.splitlines():
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
ip_str, mac = m.group(1), m.group(2)
|
||||
if mac.lower() not in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
|
||||
try:
|
||||
if ipaddress.ip_address(ip_str) in net:
|
||||
seen[ip_str] = {"ip": ip_str, "mac": mac}
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Phase 3: scapy ARP scan (if Npcap available) — fills in any gaps
|
||||
if stage_cb:
|
||||
stage_cb("scapy")
|
||||
try:
|
||||
import io, os
|
||||
_stderr = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
from scapy.all import ARP, Ether, srp
|
||||
finally:
|
||||
sys.stderr = _stderr
|
||||
|
||||
arp = ARP(pdst=str(network))
|
||||
ether = Ether(dst="ff:ff:ff:ff:ff:ff")
|
||||
result = srp(ether / arp, timeout=2, verbose=0)[0]
|
||||
for sent, received in result:
|
||||
ip = received.psrc
|
||||
if ip not in seen:
|
||||
seen[ip] = {"ip": ip, "mac": received.hwsrc}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return sorted(seen.values(), key=lambda d: ipaddress.ip_address(d["ip"]))
|
||||
241
core/ssh_flasher.py
Normal file
241
core/ssh_flasher.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
SSH-based Firmware Flasher for OpenWrt Devices
|
||||
|
||||
Replicates the flash.ps1 logic using Python paramiko/scp:
|
||||
1. Auto-accept SSH host key
|
||||
2. Set device password via `passwd` command
|
||||
3. Upload firmware via SCP to /tmp/
|
||||
4. Verify, sync, and flash via sysupgrade
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import paramiko
|
||||
from scp import SCPClient
|
||||
|
||||
|
||||
def _create_ssh_client(ip, user, password, timeout=10):
|
||||
"""Create an SSH client with auto-accept host key policy."""
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(ip, username=user, password=password, timeout=timeout,
|
||||
look_for_keys=False, allow_agent=False)
|
||||
return client
|
||||
|
||||
|
||||
import telnetlib
|
||||
|
||||
def set_device_password(ip, user="root", old_password="", new_password="admin123a",
|
||||
status_cb=None):
|
||||
"""
|
||||
Set device password via Telnet (if raw/reset) or SSH.
|
||||
"""
|
||||
if status_cb:
|
||||
status_cb("Checking Telnet port for raw device...")
|
||||
|
||||
# 1. Thử Telnet trước (OpenWrt mặc định mở Telnet 23 và cấm SSH Root khi chưa có Pass)
|
||||
try:
|
||||
tn = telnetlib.Telnet(ip, timeout=5)
|
||||
# Nếu vô được Telnet tức là thiết bị vừa Reset cứng chưa có pass
|
||||
if status_cb:
|
||||
status_cb("Telnet connected! Setting password...")
|
||||
|
||||
# Đợi logo OpenWrt và prompt "root@OpenWrt:/# "
|
||||
time.sleep(1)
|
||||
tn.read_very_eager()
|
||||
|
||||
# Gửi lệnh đổi pass
|
||||
tn.write(b"passwd\n")
|
||||
time.sleep(1)
|
||||
tn.write(new_password.encode('ascii') + b"\n")
|
||||
time.sleep(1)
|
||||
tn.write(new_password.encode('ascii') + b"\n")
|
||||
time.sleep(1)
|
||||
|
||||
# Thoát telnet
|
||||
tn.write(b"exit\n")
|
||||
time.sleep(0.5)
|
||||
tn.close()
|
||||
|
||||
# Chờ 3 giây để OpenWrt kịp đóng Telnet và nổ tiến trình Dropbear (SSH Server)
|
||||
if status_cb:
|
||||
status_cb("Password set via Telnet. Waiting for SSH to start...")
|
||||
time.sleep(3)
|
||||
return "DONE"
|
||||
|
||||
except ConnectionRefusedError:
|
||||
# Port 23 đóng -> Tức là thiết bị đã có Pass và đã bật SSH, chuyển qua luồng mồi mật khẩu cũ
|
||||
pass
|
||||
except Exception as e:
|
||||
# Các lỗi timeout khác có thể do ping không tới
|
||||
return f"FAIL: Telnet check -> {e}"
|
||||
|
||||
# 2. Rơi xuống luồng SSH nếu thiết bị cũ (cổng Telnet đóng)
|
||||
if status_cb:
|
||||
status_cb("Connecting SSH for password update...")
|
||||
|
||||
try:
|
||||
client = _create_ssh_client(ip, user, old_password, timeout=5)
|
||||
except Exception as e:
|
||||
return f"FAIL: Cannot connect SSH (Old pass incorrect?) — {e}"
|
||||
|
||||
try:
|
||||
if status_cb:
|
||||
status_cb("Setting password via SSH...")
|
||||
|
||||
shell = client.invoke_shell()
|
||||
time.sleep(1)
|
||||
if shell.recv_ready():
|
||||
shell.recv(65535)
|
||||
|
||||
shell.send("passwd\n")
|
||||
time.sleep(2)
|
||||
shell.send(f"{new_password}\n")
|
||||
time.sleep(1)
|
||||
shell.send(f"{new_password}\n")
|
||||
time.sleep(2)
|
||||
|
||||
if shell.recv_ready():
|
||||
shell.recv(65535)
|
||||
|
||||
shell.send("exit\n")
|
||||
time.sleep(0.5)
|
||||
shell.close()
|
||||
|
||||
if status_cb:
|
||||
status_cb("Password set ✓")
|
||||
return "DONE"
|
||||
|
||||
except Exception as e:
|
||||
return f"FAIL: {e}"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def flash_device_ssh(ip, firmware_path, user="root", password="admin123a",
|
||||
set_passwd=False, status_cb=None):
|
||||
"""
|
||||
Flash firmware to an OpenWrt device via SSH/SCP.
|
||||
|
||||
Steps (mirroring flash.ps1):
|
||||
1. (Optional) Set device password via passwd
|
||||
2. Upload firmware via SCP to /tmp/
|
||||
3. Verify uploaded file
|
||||
4. Sync filesystem
|
||||
5. Execute sysupgrade
|
||||
|
||||
Returns:
|
||||
"DONE" on success, "FAIL: reason" on error
|
||||
"""
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 0: Set password (optional)
|
||||
# ═══════════════════════════════════════════
|
||||
if set_passwd:
|
||||
result = set_device_password(ip, user, "", password, status_cb)
|
||||
if result.startswith("FAIL"):
|
||||
# Try with current password as old password
|
||||
result = set_device_password(ip, user, password, password, status_cb)
|
||||
if result.startswith("FAIL"):
|
||||
return result
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 1: Connect SSH
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Connecting SSH...")
|
||||
|
||||
try:
|
||||
client = _create_ssh_client(ip, user, password)
|
||||
except paramiko.AuthenticationException:
|
||||
return "FAIL: SSH authentication failed"
|
||||
except paramiko.SSHException as e:
|
||||
return f"FAIL: SSH error — {e}"
|
||||
except Exception as e:
|
||||
return f"FAIL: Cannot connect — {e}"
|
||||
|
||||
try:
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 2: Upload firmware via SCP
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Uploading firmware via SCP...")
|
||||
|
||||
filename = os.path.basename(firmware_path)
|
||||
remote_path = f"/tmp/{filename}"
|
||||
|
||||
try:
|
||||
scp_client = SCPClient(client.get_transport(), socket_timeout=300)
|
||||
scp_client.put(firmware_path, remote_path)
|
||||
scp_client.close()
|
||||
except Exception as e:
|
||||
return f"FAIL: SCP upload failed — {e}"
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 3: Verify firmware uploaded
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Verifying firmware...")
|
||||
|
||||
stdin, stdout, stderr = client.exec_command(
|
||||
f"test -f {remote_path} && ls -lh {remote_path}",
|
||||
timeout=10
|
||||
)
|
||||
verify_output = stdout.read().decode("utf-8", errors="ignore").strip()
|
||||
verify_err = stderr.read().decode("utf-8", errors="ignore").strip()
|
||||
|
||||
if not verify_output:
|
||||
return f"FAIL: Firmware file not found on device after upload"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 4: Sync filesystem
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Syncing filesystem...")
|
||||
|
||||
client.exec_command("sync", timeout=10)
|
||||
time.sleep(2)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STEP 5: Flash firmware (sysupgrade)
|
||||
# ═══════════════════════════════════════════
|
||||
if status_cb:
|
||||
status_cb("Flashing firmware (sysupgrade)...")
|
||||
|
||||
try:
|
||||
# Capture output by redirecting to a file in /tmp first, or read from stdout
|
||||
# Use -F to force upgrade and bypass "Image metadata not present" error for uImage files
|
||||
stdin, stdout, stderr = client.exec_command(f"sysupgrade -F -v -n {remote_path} > /tmp/sysup.log 2>&1")
|
||||
|
||||
# Wait up to 4 seconds to see if it immediately fails
|
||||
# Sysupgrade takes time and normally drops the connection, so if it finishes in < 4s, it's an error.
|
||||
time.sleep(4)
|
||||
|
||||
if stdout.channel.exit_status_ready():
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
|
||||
# Fetch the error log
|
||||
_, log_out, _ = client.exec_command("cat /tmp/sysup.log")
|
||||
err_msg = log_out.read().decode("utf-8", errors="ignore").strip()
|
||||
|
||||
return f"FAIL: sysupgrade terminated early (Code {exit_code}). Details:\n{err_msg}"
|
||||
|
||||
except Exception:
|
||||
# Connection drop during sysupgrade is exactly what we expect if it succeeds
|
||||
pass
|
||||
|
||||
if status_cb:
|
||||
status_cb("Rebooting...")
|
||||
time.sleep(3)
|
||||
|
||||
return "DONE"
|
||||
|
||||
except Exception as e:
|
||||
return f"FAIL: {e}"
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
100
core/workers.py
Normal file
100
core/workers.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# We need these imports inside workers since they use them
|
||||
from core.scanner import scan_network
|
||||
from core.flasher import flash_device
|
||||
from core.ssh_flasher import flash_device_ssh
|
||||
from utils.network import _resolve_hostname
|
||||
|
||||
class ScanThread(QThread):
|
||||
"""Run network scan in a background thread so UI doesn't freeze."""
|
||||
finished = pyqtSignal(list)
|
||||
error = pyqtSignal(str)
|
||||
scan_progress = pyqtSignal(int, int) # done, total (ping sweep)
|
||||
stage = pyqtSignal(str) # current scan phase
|
||||
|
||||
def __init__(self, network):
|
||||
super().__init__()
|
||||
self.network = network
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
def on_ping_progress(done, total):
|
||||
self.scan_progress.emit(done, total)
|
||||
|
||||
def on_stage(s):
|
||||
self.stage.emit(s)
|
||||
|
||||
results = scan_network(self.network,
|
||||
progress_cb=on_ping_progress,
|
||||
stage_cb=on_stage)
|
||||
# Resolve hostnames in parallel
|
||||
self.stage.emit("hostname")
|
||||
with ThreadPoolExecutor(max_workers=50) as executor:
|
||||
future_to_dev = {
|
||||
executor.submit(_resolve_hostname, d["ip"]): d
|
||||
for d in results
|
||||
}
|
||||
for future in as_completed(future_to_dev):
|
||||
dev = future_to_dev[future]
|
||||
try:
|
||||
dev["name"] = future.result(timeout=3)
|
||||
except Exception:
|
||||
dev["name"] = ""
|
||||
self.finished.emit(results)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class FlashThread(QThread):
|
||||
"""Run firmware flash in background so UI stays responsive."""
|
||||
device_status = pyqtSignal(int, str) # index, status message
|
||||
device_done = pyqtSignal(int, str) # index, result
|
||||
all_done = pyqtSignal()
|
||||
|
||||
def __init__(self, devices, firmware_path, max_workers=10,
|
||||
method="api", ssh_user="root", ssh_password="admin123a",
|
||||
set_passwd=False):
|
||||
super().__init__()
|
||||
self.devices = devices
|
||||
self.firmware_path = firmware_path
|
||||
self.max_workers = max_workers
|
||||
self.method = method
|
||||
self.ssh_user = ssh_user
|
||||
self.ssh_password = ssh_password
|
||||
self.set_passwd = set_passwd
|
||||
|
||||
def run(self):
|
||||
def _flash_one(i, dev):
|
||||
try:
|
||||
def on_status(msg):
|
||||
self.device_status.emit(i, msg)
|
||||
|
||||
if self.method == "ssh":
|
||||
result = flash_device_ssh(
|
||||
dev["ip"], self.firmware_path,
|
||||
user=self.ssh_user,
|
||||
password=self.ssh_password,
|
||||
set_passwd=self.set_passwd,
|
||||
status_cb=on_status
|
||||
)
|
||||
else:
|
||||
result = flash_device(
|
||||
dev["ip"], self.firmware_path,
|
||||
status_cb=on_status
|
||||
)
|
||||
self.device_done.emit(i, result)
|
||||
except Exception as e:
|
||||
self.device_done.emit(i, f"FAIL: {e}")
|
||||
|
||||
# Use configured max_workers (0 = unlimited = one per device)
|
||||
workers = self.max_workers if self.max_workers > 0 else len(self.devices)
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = []
|
||||
for i, dev in enumerate(self.devices):
|
||||
futures.append(executor.submit(_flash_one, i, dev))
|
||||
for f in futures:
|
||||
f.result()
|
||||
|
||||
self.all_done.emit()
|
||||
Reference in New Issue
Block a user