refactor code, ẩn thông tin ssh

This commit is contained in:
2026-03-09 11:59:55 +07:00
parent 9f6bd8c35a
commit 1297511122
13 changed files with 1043 additions and 504 deletions

141
core/api_flash.py Normal file
View File

@@ -0,0 +1,141 @@
"""
LuCI HTTP Firmware Flasher for OpenWrt devices.
Tự động hoá 3 bước flash qua web interface LuCI:
1. Login → lấy sysauth cookie + stok token
2. Upload firmware.bin (multipart)
3. Confirm (Proceed) → thiết bị flash và reboot
Tương thích:
- Barrier Breaker 14.07 (field: username/password)
- OpenWrt mới hơn (field: luci_username/luci_password)
"""
import re
import time
import os
from typing import Optional
import requests
def _extract_stok(text: str) -> Optional[str]:
"""Tách stok token từ URL hoặc body HTML. Trả về None nếu không tìm thấy."""
m = re.search(r";stok=([a-f0-9]+)", text)
return m.group(1) if m else None
def flash_device_api(ip, firmware_path, username="root", password="",
keep_settings=False, status_cb=None):
"""
Flash firmware lên thiết bị OpenWrt qua LuCI HTTP.
Args:
ip : địa chỉ IP thiết bị
firmware_path : đường dẫn file .bin trên máy tính
username : LuCI username (mặc định "root")
password : LuCI password (mặc định rỗng — Barrier Breaker không có pass)
keep_settings : True = giữ cấu hình cũ sau flash
status_cb : callback(str) cập nhật tiến độ lên UI
Returns:
"DONE" — flash thành công
"FAIL: …" — thất bại, kèm lý do
"""
base_url = f"http://{ip}"
login_url = f"{base_url}/cgi-bin/luci"
session = requests.Session()
try:
# ── STEP 1: Login ────────────────────────────────────────────
if status_cb:
status_cb("Logging in...")
# Phát hiện field name tương thích Barrier Breaker vs OpenWrt mới
try:
page_html = session.get(login_url, timeout=10).text
except Exception:
page_html = ""
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)"
# Lấy stok từ URL → body → redirect history
stok = (_extract_stok(resp.url)
or _extract_stok(resp.text)
or next(
(_extract_stok(h.headers.get("Location", ""))
for h in resp.history
if _extract_stok(h.headers.get("Location", ""))),
None
))
if not stok and "sysauth" not in str(session.cookies):
return "FAIL: Login failed — no session"
flash_url = (
f"{base_url}/cgi-bin/luci/;stok={stok}/admin/system/flashops"
if stok else
f"{base_url}/cgi-bin/luci/admin/system/flashops"
)
# ── STEP 2: Upload Firmware ──────────────────────────────────
if status_cb:
status_cb("Uploading firmware...")
filename = os.path.basename(firmware_path)
extra = {"keep": "on"} if keep_settings else {}
with open(firmware_path, "rb") as f:
resp = session.post(
flash_url,
data=extra,
files={"image": (filename, f, "application/octet-stream")},
timeout=300,
)
if resp.status_code != 200:
return f"FAIL: Upload HTTP {resp.status_code}"
body = resp.text.lower()
if "invalid image" in body or "bad image" in body:
return "FAIL: Invalid firmware image"
if "unsupported" in body or "not compatible" in body:
return "FAIL: Firmware not compatible"
if "verify" not in body or "proceed" not in body:
return ("FAIL: Upload ignored by server"
if 'name="image"' in resp.text
else "FAIL: Unexpected response after upload")
# ── STEP 3: Confirm (Proceed) ────────────────────────────────
if status_cb:
status_cb("Confirming (Proceed)...")
confirm = {"step": "2", "keep": "on" if keep_settings else ""}
try:
session.post(flash_url, data=confirm, timeout=300)
except (requests.exceptions.ConnectionError,
requests.exceptions.ReadTimeout):
pass # Đứt kết nối khi reboot = bình thường
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()

79
core/flash_new_worker.py Normal file
View File

@@ -0,0 +1,79 @@
"""
Worker thread cho chế độ "Nạp Mới FW" (New Flash / Factory Reset).
Hỗ trợ 2 method:
- "api" : Flash qua LuCI HTTP (core/api_flash.py)
- "ssh" : Flash qua SSH/SCP (core/ssh_new_flash.py), có tuỳ chọn set_passwd
"""
from PyQt6.QtCore import QThread, pyqtSignal
from concurrent.futures import ThreadPoolExecutor
from core.api_flash import flash_device_api
from core.ssh_new_flash import flash_device_new_ssh
class NewFlashThread(QThread):
"""Flash firmware lên thiết bị OpenWrt vừa reset / chưa có pass."""
device_status = pyqtSignal(int, str) # index, status message
device_done = pyqtSignal(int, str) # index, result ("DONE" / "FAIL: ...")
all_done = pyqtSignal()
def __init__(self, devices, firmware_path, max_workers=10,
method="api",
ssh_user="root", ssh_password="admin123a",
ssh_backup_password="", set_passwd=True):
"""
Args:
devices : list[dict] — [{ip, mac, ...}, ...]
firmware_path : str — đường dẫn file firmware
max_workers : int — số thiết bị flash song song (0 = unlimited)
method : "api"|"ssh"
ssh_user : str — SSH username (chỉ dùng với method="ssh")
ssh_password : str — SSH password mới sẽ đặt / đăng nhập
ssh_backup_password : str — password dự phòng nếu login lần đầu thất bại
set_passwd : bool — True = gọi passwd trước khi flash (thiết bị vừa reset)
"""
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.ssh_backup_password = ssh_backup_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_new_ssh(
dev["ip"], self.firmware_path,
user=self.ssh_user,
password=self.ssh_password,
backup_password=self.ssh_backup_password,
set_passwd=self.set_passwd,
status_cb=on_status,
)
else:
result = flash_device_api(
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}")
workers = self.max_workers if self.max_workers > 0 else len(self.devices)
with ThreadPoolExecutor(max_workers=max(workers, 1)) as executor:
futures = [executor.submit(_flash_one, i, dev)
for i, dev in enumerate(self.devices)]
for f in futures:
f.result()
self.all_done.emit()

View File

@@ -0,0 +1,65 @@
"""
Worker thread cho chế độ "Update Firmware" (thiết bị đã cài sẵn OpenWrt).
Đặc điểm:
- Chỉ dùng SSH (không có LuCI API).
- Credentials cố định: root / admin123a (backup: admin).
- Không thực hiện bước đặt lại mật khẩu (set_passwd=False) vì thiết bị
đã có SSH đang chạy với pass đã biết.
"""
from PyQt6.QtCore import QThread, pyqtSignal
from concurrent.futures import ThreadPoolExecutor
from core.ssh_update_flash import flash_device_update_ssh
# Credentials mặc định cho Update Mode
_UPDATE_SSH_USER = "root"
_UPDATE_SSH_PASSWORD = "admin123a"
_UPDATE_SSH_BACKUP = "admin"
class UpdateFlashThread(QThread):
"""Cập nhật firmware lên thiết bị OpenWrt đang chạy qua SSH / sysupgrade."""
device_status = pyqtSignal(int, str) # index, status message
device_done = pyqtSignal(int, str) # index, result ("DONE" / "FAIL: ...")
all_done = pyqtSignal()
def __init__(self, devices, firmware_path, max_workers=10):
"""
Args:
devices : list[dict] — [{ip, mac, ...}, ...]
firmware_path : str — đường dẫn file firmware
max_workers : int — số thiết bị update song song (0 = unlimited)
"""
super().__init__()
self.devices = devices
self.firmware_path = firmware_path
self.max_workers = max_workers
def run(self):
def _update_one(i, dev):
try:
def on_status(msg):
self.device_status.emit(i, msg)
result = flash_device_update_ssh(
dev["ip"], self.firmware_path,
user=_UPDATE_SSH_USER,
password=_UPDATE_SSH_PASSWORD,
backup_password=_UPDATE_SSH_BACKUP,
status_cb=on_status,
)
self.device_done.emit(i, result)
except Exception as e:
self.device_done.emit(i, f"FAIL: {e}")
workers = self.max_workers if self.max_workers > 0 else len(self.devices)
with ThreadPoolExecutor(max_workers=max(workers, 1)) as executor:
futures = [executor.submit(_update_one, i, dev)
for i, dev in enumerate(self.devices)]
for f in futures:
f.result()
self.all_done.emit()

View File

@@ -1,164 +0,0 @@
"""
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()

228
core/ssh_new_flash.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Luồng SSH cho chế độ "Nạp Mới FW" (Factory Reset / Raw device).
Đặc điểm:
- Thiết bị vừa reset cứng → Telnet port 23 mở, SSH chưa có pass.
- Bước 0 (tuỳ chọn): đặt password qua Telnet → SSH (set_passwd=True).
- Bước 14: kết nối SSH, upload SCP, verify, sync + sysupgrade.
Public API:
set_device_password(ip, user, old_password, new_password, status_cb)
flash_device_new_ssh(ip, firmware_path, user, password,
backup_password, set_passwd, status_cb)
"""
import socket
import time
import random
import paramiko
from core.ssh_utils import (
_create_ssh_client,
_upload_firmware,
_verify_firmware,
_sync_and_sysupgrade,
)
# ──────────────────────────────────────────────────
# Telnet client thủ công (telnetlib bị xoá Python 3.13+)
# ──────────────────────────────────────────────────
class _SimpleTelnet:
"""Minimal Telnet client dùng raw socket."""
def __init__(self, host, port=23, timeout=10):
self._sock = socket.create_connection((host, port), timeout=timeout)
def read_very_eager(self):
try:
self._sock.setblocking(False)
data = b""
while True:
try:
chunk = self._sock.recv(4096)
if not chunk:
break
data += chunk
except (BlockingIOError, OSError):
break
self._sock.setblocking(True)
return data
except Exception:
return b""
def write(self, data: bytes):
self._sock.sendall(data)
def close(self):
try:
self._sock.close()
except Exception:
pass
# ──────────────────────────────────────────────────
# Đặt mật khẩu thiết bị (Telnet → SSH fallback)
# ──────────────────────────────────────────────────
def set_device_password(ip, user="root", old_password="",
new_password="admin123a", status_cb=None):
"""
Đặt mật khẩu thiết bị OpenWrt.
Thứ tự thử:
1. Telnet port 23 — thiết bị vừa reset (chưa có pass, SSH chưa mở)
2. SSH — thiết bị cũ có SSH nhưng cần đổi pass
Returns:
"DONE" — thành công
"FAIL: …" — thất bại sau tất cả các retry
"""
# Jitter để không hammering khi chạy song song nhiều thiết bị
time.sleep(random.uniform(0.1, 1.5))
# ── 1. Thử Telnet ──────────────────────────────────────────────
if status_cb:
status_cb("Checking Telnet port for raw device...")
try:
tn = _SimpleTelnet(ip, timeout=5)
if status_cb:
status_cb("Telnet connected! Setting password...")
time.sleep(1)
tn.read_very_eager() # Flush banner OpenWrt
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)
tn.write(b"exit\n"); time.sleep(0.5)
tn.close()
if status_cb:
status_cb("Password set via Telnet. Waiting for SSH to start...")
time.sleep(3) # Chờ Dropbear (SSH daemon) khởi động
return "DONE"
except Exception:
# Telnet không truy cập được → thử SSH bên dưới
pass
# ── 2. Fallback SSH ─────────────────────────────────────────────
if status_cb:
status_cb("Connecting SSH for password update...")
last_err = None
for attempt in range(3):
try:
client = _create_ssh_client(ip, user, old_password, timeout=10)
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()
client.close()
if status_cb:
status_cb("Password set ✓")
return "DONE"
except paramiko.AuthenticationException as e:
return f"FAIL: Cannot connect SSH (Old pass incorrect?) — {e}"
except Exception as e:
last_err = e
try:
client.close()
except Exception:
pass
time.sleep(2)
return f"FAIL: Cannot connect SSH after 3 attempts — {last_err}"
# ──────────────────────────────────────────────────
# Flash firmware — Nạp Mới (Factory Reset)
# ──────────────────────────────────────────────────
def flash_device_new_ssh(ip, firmware_path, user="root", password="admin123a",
backup_password="", set_passwd=False, status_cb=None):
"""
Flash firmware lên thiết bị OpenWrt vừa reset / chưa có mật khẩu.
Luồng:
0. (Tuỳ chọn) Đặt mật khẩu qua Telnet / SSH
1. Kết nối SSH
2. Upload firmware qua SCP lên /tmp/
3. Verify file tồn tại
4. sync + sysupgrade
Args:
ip : địa chỉ IP thiết bị
firmware_path : đường dẫn file .bin trên máy tính
user : SSH username (mặc định "root")
password : mật khẩu SSH (hoặc mật khẩu mới sẽ đặt)
backup_password : mật khẩu dự phòng nếu password chính không vào được
set_passwd : True = chạy bước đặt mật khẩu trước khi flash
status_cb : callback(str) để cập nhật trạng thái lên UI
Returns:
"DONE" — flash thành công
"FAIL: …" — thất bại, kèm lý do
"""
# ── STEP 0: Đặt mật khẩu (tuỳ chọn) ──────────────────────────
if set_passwd:
result = set_device_password(ip, user, "", password, status_cb)
if result.startswith("FAIL"):
# Thử với backup_password nếu có
if backup_password:
result = set_device_password(ip, user, backup_password, password, status_cb)
# Thử xem password đã được đặt chưa (idempotent)
if result.startswith("FAIL"):
result = set_device_password(ip, user, password, password, status_cb)
if result.startswith("FAIL"):
return result
else:
time.sleep(random.uniform(0.1, 1.5)) # Jitter khi không set_passwd
# ── STEP 1: Kết nối 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}"
# ── STEP 24: Upload → Verify → sysupgrade ─────────────────────
try:
remote_path = _upload_firmware(client, firmware_path, status_cb)
_verify_firmware(client, remote_path, status_cb)
return _sync_and_sysupgrade(client, remote_path, status_cb)
except RuntimeError as e:
return f"FAIL: {e}"
except Exception as e:
return f"FAIL: {e}"
finally:
try:
client.close()
except Exception:
pass

92
core/ssh_update_flash.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Luồng SSH cho chế độ "Update Firmware" (thiết bị đã cài sẵn OpenWrt).
Đặc điểm:
- Thiết bị đang chạy bình thường, SSH đã mở sẵn với pass đã biết.
- Không có bước Telnet / đặt lại mật khẩu.
- Kết nối trực tiếp → upload SCP → verify → sync + sysupgrade.
Public API:
flash_device_update_ssh(ip, firmware_path, user, password,
backup_password, status_cb)
"""
import time
import paramiko
from core.ssh_utils import (
_create_ssh_client,
_upload_firmware,
_verify_firmware,
_sync_and_sysupgrade,
)
def flash_device_update_ssh(ip, firmware_path, user="root",
password="admin123a", backup_password="",
status_cb=None):
"""
Cập nhật firmware lên thiết bị OpenWrt đang chạy qua SSH / sysupgrade.
Luồng:
1. Kết nối SSH (thử password, nếu lỗi auth thử backup_password)
2. Upload firmware qua SCP lên /tmp/
3. Verify file tồn tại
4. sync + sysupgrade
Args:
ip : địa chỉ IP thiết bị
firmware_path : đường dẫn file .bin trên máy tính
user : SSH username (mặc định "root")
password : SSH password chính
backup_password : SSH password dự phòng nếu password chính sai
status_cb : callback(str) để cập nhật trạng thái lên UI
Returns:
"DONE" — update thành công
"FAIL: …" — thất bại, kèm lý do
"""
# ── STEP 1: Kết nối SSH ─────────────────────────────────────────
if status_cb:
status_cb("Connecting SSH...")
client = None
# Thử password chính trước
try:
client = _create_ssh_client(ip, user, password)
except paramiko.AuthenticationException:
# Thử backup_password nếu có
if backup_password:
try:
client = _create_ssh_client(ip, user, backup_password)
except paramiko.AuthenticationException:
return "FAIL: SSH authentication failed (wrong password)"
except paramiko.SSHException as e:
return f"FAIL: SSH error — {e}"
except Exception as e:
return f"FAIL: Cannot connect — {e}"
else:
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}"
# ── STEP 24: Upload → Verify → sysupgrade ─────────────────────
try:
remote_path = _upload_firmware(client, firmware_path, status_cb)
_verify_firmware(client, remote_path, status_cb)
return _sync_and_sysupgrade(client, remote_path, status_cb)
except RuntimeError as e:
return f"FAIL: {e}"
except Exception as e:
return f"FAIL: {e}"
finally:
try:
client.close()
except Exception:
pass

120
core/ssh_utils.py Normal file
View File

@@ -0,0 +1,120 @@
"""
SSH/SCP helper functions dùng chung cho cả 2 luồng flash.
Không chứa logic nghiệp vụ — chỉ là transport layer:
_create_ssh_client() — kết nối SSH với retry
_upload_firmware() — upload file qua SCP lên /tmp/
_verify_firmware() — kiểm tra file tồn tại trên device
_sync_and_sysupgrade() — sync + sysupgrade, trả về "DONE" / "FAIL: ..."
"""
import os
import time
import paramiko
from scp import SCPClient
def _create_ssh_client(ip, user, password, timeout=15):
"""Tạo SSH client với AutoAddPolicy, có retry 3 lần."""
last_err = None
for attempt in range(3):
try:
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
except paramiko.AuthenticationException:
# Sai password — retry không giúp ích gì
raise
except Exception as e:
last_err = e
if attempt < 2:
time.sleep(1)
raise last_err
def _upload_firmware(client, firmware_path, status_cb=None):
"""
Upload firmware qua SCP lên /tmp/<filename>.
Trả về remote_path khi thành công, raise RuntimeError nếu thất bại.
"""
if status_cb:
status_cb("Uploading firmware via SCP...")
filename = os.path.basename(firmware_path)
remote_path = f"/tmp/{filename}"
last_err = None
for attempt in range(3):
try:
scp = SCPClient(client.get_transport(), socket_timeout=350)
scp.put(firmware_path, remote_path)
scp.close()
time.sleep(2)
return remote_path
except Exception as e:
last_err = e
time.sleep(1.5)
raise RuntimeError(f"SCP upload failed (Check Network) — {last_err}")
def _verify_firmware(client, remote_path, status_cb=None):
"""
Xác nhận file firmware tồn tại trên thiết bị.
Raise RuntimeError nếu file không có.
"""
if status_cb:
status_cb("Verifying firmware...")
_, stdout, _ = client.exec_command(
f"test -f {remote_path} && ls -lh {remote_path}", timeout=10
)
output = stdout.read().decode("utf-8", errors="ignore").strip()
if not output:
raise RuntimeError("Firmware file not found on device after upload")
def _sync_and_sysupgrade(client, remote_path, status_cb=None):
"""
Sync filesystem rồi chạy sysupgrade.
Trả về "DONE" khi thành công, "FAIL: ..." khi sysupgrade lỗi sớm.
Connection drop trong lúc sysupgrade = thành công (thiết bị đang reboot).
"""
if status_cb:
status_cb("Syncing filesystem...")
client.exec_command("sync", timeout=10)
time.sleep(2)
if status_cb:
status_cb("Flashing firmware (sysupgrade)...")
try:
# -F: bỏ qua kiểm tra metadata (cần cho uImage)
# -v: verbose -n: không giữ cấu hình cũ
_, stdout, _ = client.exec_command(
f"sysupgrade -F -v -n {remote_path} > /tmp/sysup.log 2>&1"
)
# Chờ tối đa 4 giây — nếu exit quá sớm thì coi như lỗi
time.sleep(4)
if stdout.channel.exit_status_ready():
exit_code = stdout.channel.recv_exit_status()
_, 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 "
f"(Code {exit_code}). Details:\n{err_msg}"
)
except Exception:
# Connection drop = device đang reboot = thành công
pass
if status_cb:
status_cb("Rebooting...")
time.sleep(3)
return "DONE"

View File

@@ -1,18 +1,28 @@
"""
Workers module — chứa các QThread dùng cho tác vụ nền.
Scan:
ScanThread — quét mạng LAN tìm thiết bị OpenWrt.
Flash (tách thành 2 module riêng):
core.flash_new_worker → NewFlashThread (Nạp Mới FW)
core.flash_update_worker → UpdateFlashThread (Update FW)
"""
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)
"""Quét mạng LAN trong background thread để không đóng băng UI."""
finished = pyqtSignal(list)
error = pyqtSignal(str)
scan_progress = pyqtSignal(int, int) # done, total (ping sweep)
stage = pyqtSignal(str) # current scan phase
stage = pyqtSignal(str) # tên giai đoạn hiện tại
def __init__(self, network):
super().__init__()
@@ -29,7 +39,8 @@ class ScanThread(QThread):
results = scan_network(self.network,
progress_cb=on_ping_progress,
stage_cb=on_stage)
# Resolve hostnames in parallel
# Resolve hostname song song
self.stage.emit("hostname")
with ThreadPoolExecutor(max_workers=50) as executor:
future_to_dev = {
@@ -42,61 +53,7 @@ class ScanThread(QThread):
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",
ssh_backup_password="", 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.ssh_backup_password = ssh_backup_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,
backup_password=self.ssh_backup_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()