Refactor: Chia nho file main thao thu muc va don dep theo yeu cau

This commit is contained in:
2026-03-08 14:37:27 +07:00
parent ada3440ebc
commit 2c2a78d27c
13 changed files with 532 additions and 773 deletions

164
core/flasher.py Normal file
View 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
View 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
View 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
View 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()