refactor code, ẩn thông tin ssh
This commit is contained in:
141
core/api_flash.py
Normal file
141
core/api_flash.py
Normal 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
79
core/flash_new_worker.py
Normal 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()
|
||||
65
core/flash_update_worker.py
Normal file
65
core/flash_update_worker.py
Normal 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()
|
||||
164
core/flasher.py
164
core/flasher.py
@@ -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
228
core/ssh_new_flash.py
Normal 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 1–4: 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 2–4: 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
92
core/ssh_update_flash.py
Normal 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 2–4: 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
120
core/ssh_utils.py
Normal 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"
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user