Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 042c50536c | |||
| 19febeaf3f | |||
| 47f3320c3d | |||
| 56b688766e | |||
| 69b620f832 | |||
| a7c41a7235 | |||
| 594d13c0cc | |||
| 564b2567b6 | |||
| 24dc073b21 | |||
| 1297511122 | |||
| 9f6bd8c35a | |||
| a113bf56bd | |||
| ec78c984ad | |||
| 1c1fbb7f92 | |||
| ef363ac61d | |||
| f8ce6f5831 | |||
| 2c2a78d27c | |||
| ada3440ebc |
BIN
2023-11-03.webp
BIN
2023-11-03.webp
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
286
README.md
286
README.md
@@ -1,22 +1,44 @@
|
||||
# ⚡ IoT Firmware Loader (MiraV3)
|
||||
# ⚡ Mira Firmware Loader
|
||||
|
||||
Công cụ desktop dùng để **scan, phát hiện và flash firmware hàng loạt** cho các thiết bị OpenWrt (qua giao diện LuCI) trong mạng LAN.
|
||||
Công cụ desktop dùng để **scan, phát hiện và flash firmware hàng loạt** cho các thiết bị OpenWrt trong mạng LAN.
|
||||
Hỗ trợ nạp **thủ công** (chọn thiết bị → flash) và **tự động hóa** (scan → phát hiện → flash → retry).
|
||||
|
||||
> **Tech stack:** Python 3.9+ · PyQt6 · Scapy · Requests · PyInstaller
|
||||
> **Tech stack:** Python 3.9+ · PyQt6 · Paramiko/SCP · Scapy · Requests · PyInstaller
|
||||
> **Phiên bản:** `1.2.0`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Cấu trúc dự án
|
||||
|
||||
```
|
||||
iot_fw_loader/
|
||||
├── main.py # UI chính (PyQt6) + Điều phối luồng và xử lý đa luồng
|
||||
├── scanner.py # Quét thiết bị mạng đa lớp (Ping sweep + ARP + Scapy)
|
||||
├── flasher.py # Upload firmware và tự động hóa qua giao diện OpenWrt LuCI
|
||||
```text
|
||||
Mira_Firmware_Loader/
|
||||
├── main.py # UI chính (PyQt6) — App + AutoFlashWindow
|
||||
├── version.txt # Số phiên bản ứng dụng
|
||||
├── requirements.txt # Danh sách dependencies
|
||||
├── core/
|
||||
│ ├── scanner.py # Quét thiết bị mạng đa lớp (Ping sweep + ARP + Scapy)
|
||||
│ ├── workers.py # ScanThread — chạy scanner trong background thread
|
||||
│ ├── api_flash.py # Flash firmware qua LuCI HTTP API
|
||||
│ ├── ssh_utils.py # SSH/SCP transport helpers dùng chung
|
||||
│ ├── ssh_new_flash.py # Luồng SSH cho chế độ Nạp Mới (Telnet → set passwd → SSH)
|
||||
│ ├── ssh_update_flash.py # Luồng SSH cho chế độ Update (SSH trực tiếp)
|
||||
│ ├── flash_new_worker.py # NewFlashThread — QThread điều phối Nạp Mới FW
|
||||
│ ├── flash_update_worker.py # UpdateFlashThread — QThread điều phối Update FW
|
||||
│ └── auto_flash_worker.py # AutoFlashWorker — QThread tự động scan → flash → retry
|
||||
├── ui/
|
||||
│ ├── components.py # Custom Qt Widgets (CollapsibleGroupBox)
|
||||
│ └── styles.py # Stylesheet toàn ứng dụng (STYLE + AUTO_STYLE)
|
||||
├── utils/
|
||||
│ ├── network.py # Helper IP / network (get_local_ip, get_default_network)
|
||||
│ └── system.py # Lấy thông tin máy, resource path cho PyInstaller
|
||||
├── docs/
|
||||
│ ├── api_flash_docs.md # Tài liệu kỹ thuật LuCI API flash
|
||||
│ ├── load_fw_ssh_docs.md # Tài liệu kỹ thuật SSH flash (cả 2 luồng)
|
||||
│ ├── scanner_docs.md # Tài liệu kỹ thuật scanner
|
||||
│ └── auto_flash_docs.md # Tài liệu kỹ thuật tự động hóa nạp FW
|
||||
├── run.sh # Script khởi chạy (macOS/Linux)
|
||||
├── run.bat # Script khởi chạy (Windows)
|
||||
├── build_windows.bat # Script đóng gói thành file .exe độc lập (Windows)
|
||||
└── venv/ # Python virtual environment (tự tạo)
|
||||
└── build_windows.bat # Script đóng gói thành .exe (Windows, PyInstaller)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -25,11 +47,11 @@ iot_fw_loader/
|
||||
|
||||
### Yêu cầu
|
||||
|
||||
- Python 3.9+
|
||||
- Các thư viện: `PyQt6`, `scapy`, `requests`, `pyinstaller` (để build).
|
||||
- *Trên Windows:* Cần cài đặt [Npcap](https://npcap.com/) để `scapy` có thể quét ARP ở chế độ sâu (không bắt buộc, có fallback dự phòng).
|
||||
- Python **3.9+**
|
||||
- Thư viện: `PyQt6`, `scapy`, `requests`, `paramiko`, `scp`, `pyinstaller` (để build)
|
||||
- _Windows:_ Cài [Npcap](https://npcap.com/) để Scapy có thể gửi ARP broadcast (không bắt buộc — có fallback)
|
||||
|
||||
### Khởi chạy nhanh (Môi trường Dev)
|
||||
### Khởi chạy nhanh
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
@@ -45,47 +67,58 @@ run.bat
|
||||
|
||||
> Script tự tạo `venv` và cài dependencies nếu chưa có.
|
||||
|
||||
### 📦 Build ra file chạy độc lập (.exe) cho Windows
|
||||
Chạy script sau để tự động đóng gói ứng dụng thành 1 file `.exe` duy nhất (không cần cài Python trên máy đích):
|
||||
### 📦 Build file chạy độc lập (.exe) cho Windows
|
||||
|
||||
```bat
|
||||
build_windows.bat
|
||||
```
|
||||
|
||||
File nhận được sẽ nằm ở: `dist\IoT_Firmware_Loader.exe`.
|
||||
Output: `dist\Mira_Firmware_Loader.exe` — không cần cài Python trên máy đích.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Kiến trúc hệ thống
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ main.py (UI) │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌────────────────────────────┐ │
|
||||
│ │ Machine │ │ Firmware │ │ Network Scan (QThread) │ │
|
||||
│ │ Info │ │ Selector │ │ Tham số: Network (CIDR) │ │
|
||||
│ └───────────┘ └───────────┘ └────────┬───────────────────┘ │
|
||||
│ ┌────────────────────────────────────┼───────────────────┐ │
|
||||
│ │ Device Table │ │ │
|
||||
│ │ [ ] IP │ Name │ MAC │ Status │ │ │
|
||||
│ │ (Hỗ trợ lọc ẩn Gateway & Self) │ │ │
|
||||
│ └────────────────────────────────────┼───────────────────┘ │
|
||||
│ ┌────────────────────────────────────┼───────────────────┐ │
|
||||
│ │ Flash Controls + Progress Bar │ │ │
|
||||
│ │ (ThreadPoolExecutor) │ │ │
|
||||
│ └────────────────────────────────────┼───────────────────┘ │
|
||||
└───────────────────────────────────────┼─────────────────────┘
|
||||
│ │
|
||||
│ Machine Info │ Firmware Selector │ Network Scan │
|
||||
│ ───────────────────────────────────────────────────── │
|
||||
│ Device Table [ ] IP │ MAC │ Status │
|
||||
│ ───────────────────────────────────────────────────── │
|
||||
│ Flash Controls │
|
||||
│ Flash Mode: [ New Flash | Update Firmware ] │
|
||||
│ Method: [ API (LuCI) | SSH (paramiko) ] │
|
||||
│ Concurrent devices: [SpinBox] │
|
||||
│ [ ⚡ FLASH SELECTED DEVICES ] │
|
||||
│ ───────────────────────────────────────────────────── │
|
||||
│ [ 🤖 Tự động hóa nạp FW ] │
|
||||
└────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │
|
||||
┌─────▼─────┐ ┌─────▼─────┐
|
||||
│ scanner.py│ │ flasher.py│
|
||||
│ │ │ │
|
||||
│ 1. Ping │ │ LuCI HTTP │
|
||||
│ Sweep │ │ /cgi-bin/ │
|
||||
│ 2. arp -a │ │ luci │
|
||||
│ 3. Scapy │ │ │
|
||||
└───────────┘ └───────────┘
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
┌──────▼──────┐ ┌───────▼──────────┐ ┌───▼──────────────────┐
|
||||
│ scanner.py │ │ Flash Workers │ │ AutoFlashWorker │
|
||||
│ │ │ (thủ công) │ │ (tự động hóa) │
|
||||
│ 1. Ping │ │ │ │ │
|
||||
│ Sweep │ │ NewFlashThread │ │ Phase 1: Scan LAN │
|
||||
│ 2. arp -a │ │ ├─ api_flash │ │ (tối đa 15 lần) │
|
||||
│ 3. Scapy │ │ └─ ssh_new_flash│ │ Phase 2: Flash │
|
||||
│ ARP │ │ │ │ (auto-retry x3) │
|
||||
└─────────────┘ │ UpdateFlash │ │ ├─ api_flash │
|
||||
│ Thread │ │ └─ ssh_new_flash │
|
||||
│ └─ ssh_update │ └──────────────────────┘
|
||||
└──────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ ssh_utils.py │
|
||||
│ (Transport Layer) │
|
||||
│ _create_ssh_client │
|
||||
│ _upload_firmware │
|
||||
│ _verify_firmware │
|
||||
│ _sync_and_sysupgr │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
@@ -94,44 +127,161 @@ File nhận được sẽ nằm ở: `dist\IoT_Firmware_Loader.exe`.
|
||||
|
||||
### 1. Quét mạng (Network Scan)
|
||||
|
||||
Module `scanner.py` sử dụng chiến lược 3 lớp để đảm bảo phát hiện đa dạng các thiết bị mạng mà vẫn duy trì khả năng tránh spam/lag cho OS:
|
||||
`scanner.py` dùng chiến lược 3 lớp, đảm bảo phát hiện đầy đủ mà không cần quyền Root:
|
||||
|
||||
1. **Ping Sweep:** Gửi gói tin Ping song song (tối đa 50 threads) để đánh thức thiết bị và điền IP/MAC vào bảng ARP Cache của hệ điều hành.
|
||||
2. **ARP Table Fallback:** Đọc bảng ARP nội bộ của OS (`arp -a`) bằng Regex. Hoạt động đa nền tảng (Windows/macOS/Linux) mà không cần quyền Admin/Root.
|
||||
3. **Scapy ARP (Tính năng nâng cao):** Gửi các gói tin ARP Broadcast trực tiếp để đảm bảo bao phủ gap nếu có. Yêu cầu quyền Root trên Linux/macOS hoặc Npcap trên Windows.
|
||||
*Ngoài ra, công cụ tự động dò Hostname (`socket.gethostbyaddr`) song song để lấy tên thiết bị.*
|
||||
| Giai đoạn | Mô tả |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Ping Sweep** | Gửi ping đồng thời tới toàn bộ host trong dải `/24` (tất cả cùng lúc, không batching) để đánh thức thiết bị và điền ARP cache |
|
||||
| **ARP Table** | Đọc `arp -a` bằng regex, hỗ trợ cả định dạng Windows (`cc-2d-...`) và macOS/Linux (`aa:bb:...`) |
|
||||
| **Scapy ARP** | Chạy **song song** với ARP Table — gửi ARP broadcast để lấp khoảng trống. Yêu cầu Npcap (Windows) hoặc root (Linux); tự động bỏ qua nếu không khả dụng |
|
||||
|
||||
### 2. Giao diện thiết bị (Device Table)
|
||||
Kết quả được merge theo IP và sort tăng dần trước khi trả về UI.
|
||||
|
||||
- Thiết bị được thu thập sẽ hiện ra trên UI dạng bảng, với tính năng tuỳ chọn hiển thị (checkbox ẩn Gateway mạng hiện tại và chính máy tính đang quét phần mềm).
|
||||
- Trạng thái các luồng UI được tách rời bằng QThread + QPropertyAnimation cho hộp Collapsible nhằm tự động cuộn khi ẩn hiện nội dung, không làm treo ứng dụng.
|
||||
### 2. Bảng thiết bị (Device Table)
|
||||
|
||||
### 3. Nạp Firmware (OpenWrt Flasher)
|
||||
- Hiển thị cột: checkbox, IP, MAC, Status
|
||||
- Mặc định ẩn gateway và IP máy tính đang chạy (có thể bật "Show all")
|
||||
- Thiết bị đã flash trong session được đánh dấu "Already Flashed" và tự bỏ tick
|
||||
|
||||
Từ phiên bản hiện tại, phương thức ESP32 OTA không còn được áp dụng, thay vào đó module `flasher.py` tự động hóa quá trình update của router **OpenWrt (Barrier Breaker 14.07)**:
|
||||
### 3. Flash Firmware (Thủ công)
|
||||
|
||||
* **Bước 1:** Gửi `POST` chứa thông tin đăng nhập (username, password mặc định là root/trống, hoặc luci_username tuỳ thuộc firmware) để lấy session cookie `stok`.
|
||||
* **Bước 2:** Đẩy file firmware `*.bin` dạng `multipart/form-data` lên `/cgi-bin/luci/;stok=.../admin/system/flashops`.
|
||||
* **Bước 3:** Gửi lệnh tiếp tục (Kèm tuỳ chọn giữ cấu hình `keep=on` nếu có).
|
||||
* **Bước 4:** Thiết bị xác nhận và tự khởi động lại. Cập nhật Status trên Application.
|
||||
* 🛠 Hỗ trợ `ThreadPoolExecutor` để Flash đồng thời nhiều thiết bị, với lựa chọn số luồng đồng thời tuỳ chỉnh.
|
||||
Có 2 chế độ và 2 method, tổng cộng 3 luồng thực thi khác nhau:
|
||||
|
||||
#### Chế độ `New Flash` — dùng cho thiết bị vừa reset cứng
|
||||
|
||||
| Method | Luồng | Mô tả |
|
||||
| -------------- | ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **API (LuCI)** | `api_flash.py` | Đăng nhập LuCI → upload firmware → Proceed. Hỗ trợ Barrier Breaker 14.07 và OpenWrt mới hơn |
|
||||
| **SSH** | `ssh_new_flash.py` | Kết nối Telnet → đặt password mới → SSH → SCP upload → sysupgrade |
|
||||
|
||||
**Luồng SSH – New Flash chi tiết:**
|
||||
|
||||
1. Telnet port 23 (thiết bị vừa reset, chưa có pass)
|
||||
2. Đặt password `admin123a` qua lệnh `passwd`
|
||||
3. SSH vào với password vừa đặt
|
||||
4. SCP upload firmware lên `/tmp/`
|
||||
5. Verify file tồn tại
|
||||
6. `sync && sysupgrade -F -v -n` → thiết bị reboot (connection drop = DONE)
|
||||
|
||||
#### Chế độ `Update Firmware` — dùng cho thiết bị đang chạy OpenWrt
|
||||
|
||||
Luồng `ssh_update_flash.py`, SSH trực tiếp (không qua Telnet):
|
||||
|
||||
1. SSH kết nối với `root` / `admin123a` (fallback: `admin`)
|
||||
2. SCP upload firmware lên `/tmp/`
|
||||
3. Verify file tồn tại
|
||||
4. `sync && sysupgrade -F -v -n` → thiết bị reboot
|
||||
|
||||
> ⚠️ Update Mode hiển thị cảnh báo nếu IP thiết bị khác `192.168.11.102` và yêu cầu xác nhận.
|
||||
|
||||
### 4. 🤖 Tự động hóa nạp FW (MỚI)
|
||||
|
||||
Tính năng nạp FW hoàn toàn tự động — chỉ cần cấu hình và nhấn bắt đầu:
|
||||
|
||||
```
|
||||
Cấu hình → Auto Scan LAN → Phát hiện đủ thiết bị → Auto Flash → Auto Retry → Thông báo
|
||||
```
|
||||
|
||||
#### 4.1. Quy trình
|
||||
|
||||
| Phase | Mô tả |
|
||||
| --------------------- | ------------------------------------------------------------------------- |
|
||||
| **Phase 1: Scan LAN** | Scan mạng liên tục mỗi 5 giây, tối đa **15 lần**, cho đến khi đủ thiết bị |
|
||||
| **Phase 2: Flash** | Nạp FW song song qua ThreadPoolExecutor, tự động **retry tối đa 3 lần** |
|
||||
|
||||
#### 4.2. Cấu hình
|
||||
|
||||
| Tham số | Mô tả | Mặc định |
|
||||
| --------------- | --------------------------------------------- | ------------------- |
|
||||
| **Firmware** | File firmware (.bin/.hex/.uf2) | Lấy từ cửa sổ chính |
|
||||
| **Mạng** | Dải mạng LAN cần scan | Tự suy từ IP host |
|
||||
| **Số lượng** | Số thiết bị cần nạp | 5 |
|
||||
| **Phương thức** | API (LuCI) hoặc SSH | API (LuCI) |
|
||||
| **Song song** | Số thiết bị nạp cùng lúc (0 = không giới hạn) | 10 |
|
||||
|
||||
#### 4.3. Auto-Retry nạp FW
|
||||
|
||||
Khi một thiết bị nạp thất bại, hệ thống tự động retry:
|
||||
|
||||
```
|
||||
Lần 1 → FAIL: Connection timeout
|
||||
⚠️ Log cảnh báo, chờ 2 giây...
|
||||
Lần 2 → FAIL: Upload error
|
||||
⚠️ Log cảnh báo, chờ 2 giây...
|
||||
Lần 3 → DONE ✅ (hoặc ❌ báo lỗi sau 3 lần)
|
||||
```
|
||||
|
||||
- Tối đa **3 lần retry** mỗi thiết bị (`MAX_FLASH_RETRIES`)
|
||||
- Chờ **2 giây** giữa mỗi lần retry để thiết bị ổn định
|
||||
- Nếu hết 3 lần vẫn fail → đánh dấu ❌, tiếp tục thiết bị tiếp theo
|
||||
|
||||
#### 4.4. Scan Timeout
|
||||
|
||||
- Nếu scan **15 lần** mà chưa đủ thiết bị → dừng và hiện cảnh báo
|
||||
- Gợi ý kiểm tra: thiết bị đã bật chưa, dải mạng có đúng không
|
||||
|
||||
#### 4.5. Quy tắc bảo vệ IP `192.168.11.102`
|
||||
|
||||
| Chế độ | Được nạp 192.168.11.102? | Cơ chế |
|
||||
| ------------------------ | :----------------------: | ----------------------------------- |
|
||||
| **New Flash (thủ công)** | ❌ Không | Chặn trước khi flash, hiện cảnh báo |
|
||||
| **Update FW (thủ công)** | ✅ Có | Cho phép bình thường |
|
||||
| **Tự động hóa** | ❌ Không | Tự động lọc khỏi kết quả scan |
|
||||
|
||||
#### 4.6. Lịch sử nạp
|
||||
|
||||
Kết quả nạp được lưu ở 2 cấp:
|
||||
|
||||
| Nơi lưu | Phạm vi | Format |
|
||||
| ------------------------------- | ---------------------- | ------------------------------------ |
|
||||
| `AutoFlashWindow._auto_history` | Phiên tự động hiện tại | `list[(ip, mac, result, timestamp)]` |
|
||||
| `App.flashed_macs` | Toàn bộ session app | `dict{MAC: (ip, mac, result, ts)}` |
|
||||
|
||||
- Cả thành công ✅ lẫn thất bại ❌ đều được ghi lại
|
||||
- Nút "📋 Lịch sử nạp" hiển thị cùng format ở cả 2 cửa sổ: `[HH:MM:SS] ✅/❌ IP (MAC) — result`
|
||||
|
||||
### 5. Xử lý song song
|
||||
|
||||
| Tham số | Mô tả |
|
||||
| ---------------------- | ------------------------------------------------------------------------ |
|
||||
| **Concurrent devices** | Số thiết bị flash đồng thời (`ThreadPoolExecutor`). `0` = không giới hạn |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Cấu hình
|
||||
## ⚙️ Cấu hình mặc định
|
||||
|
||||
| Tham số | Mô tả |
|
||||
| ---------------------- | ------------------------------------------------------------------- |
|
||||
| **Network** | Dải mạng quét (vd: `192.168.1.0/24`). Tự động tính từ local IP app. |
|
||||
| **Show all** | Tuỳ chọn cho phép liệt kê cả Gateway IP máy chủ. |
|
||||
| **Concurrent devices** | Số luồng để đĩa flash song song. `0` = Không giới hạn. |
|
||||
| **Firmware filter** | Mặc định hiển thị và hỗ trợ `.bin`, `.hex`, `.uf2`. |
|
||||
| Tham số | Giá trị mặc định | Mô tả |
|
||||
| ---------------------- | ------------------------------------ | --------------------------------- |
|
||||
| **Network** | Tự suy ra từ local IP (`x.x.x.0/24`) | Dải mạng để quét |
|
||||
| **Flash Mode** | `New Flash` | Nạp mới hoặc Update |
|
||||
| **Method** | `API (LuCI)` | Phương thức flash cho New Flash |
|
||||
| **SSH User** | `root` | Hardcoded, không hiển thị trên UI |
|
||||
| **SSH Password** | `admin123a` | Hardcoded, không hiển thị trên UI |
|
||||
| **Concurrent devices** | `10` | Số luồng flash song song |
|
||||
| **Show all** | Tắt | Ẩn gateway và IP máy host |
|
||||
| **MAX_FLASH_RETRIES** | `3` | Số lần retry nạp FW (tự động) |
|
||||
| **MAX_SCAN_ROUNDS** | `15` | Số lần scan tối đa (tự động) |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Xử lý lỗi tổng hợp
|
||||
|
||||
| Tình huống | Hành vi |
|
||||
| -------------------------------- | --------------------------------------------------- |
|
||||
| Scan exception (network error) | Log lỗi, chờ 3s, scan lại (đếm vào MAX_SCAN_ROUNDS) |
|
||||
| Scan 15 lần chưa đủ thiết bị | Hiện popup cảnh báo, dừng tự động |
|
||||
| Flash thất bại lần 1–2 (tự động) | Log cảnh báo, chờ 2s, retry tự động |
|
||||
| Flash thất bại sau 3 lần retry | Log lỗi, đánh dấu ❌, tiếp tục device tiếp theo |
|
||||
| User nhấn DỪNG | Set stop flag, dừng scan/flash an toàn |
|
||||
| IP 192.168.11.102 + New Flash | Chặn ngay, hiện cảnh báo |
|
||||
| Chưa chọn firmware | Hiện popup cảnh báo, không cho bắt đầu |
|
||||
| Mạng không hợp lệ | Hiện popup cảnh báo, không cho bắt đầu |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Lưu ý bảo mật & Quyền
|
||||
|
||||
- **Quyền Scanner:** Ở chế độ quét Scapy (Sâu), cần khởi chạy bằng quyền Admin (Windows) hoặc Sudo (macOS/Linux), tuy nhiên App có thể chạy kể cả không có quyền Admin nhờ chiến lược Ping Sweep + `arp -a`.
|
||||
- **Ẩn Console (Window):** Khi gọi subproces (`ping`, `arp`), ứng dụng có tích hợp thuộc tính platform `CREATE_NO_WINDOW` để ngăn chặn các terminal đen giật nháy trên màn hình Windows.
|
||||
- **Bảo mật mạng:** Quá trình tải firmware lên thiết bị thông qua HTTP thuần, chỉ nên sử dụng ở mạng LAN nội bộ, tránh những mạng mở hoặc public để tránh lộ file firmware.
|
||||
- **LuCI Login:** API này nhắm thẳng vào quá trình đăng nhập qua form, nên sẽ tự động handle các router đang dùng OpenWrt Barrier Breaker mà không cần phải can thiệp tay.
|
||||
- **Scapy (chế độ sâu):** Cần Npcap (Windows) hoặc `sudo` (macOS/Linux). App vẫn hoạt động mà không cần quyền Admin nhờ fallback Ping Sweep + `arp -a`.
|
||||
- **CREATE_NO_WINDOW:** Khi gọi subprocess (`ping`, `arp`), ứng dụng dùng flag `CREATE_NO_WINDOW` trên Windows để ngăn cửa sổ console hiện ra.
|
||||
- **HTTP thuần:** Firmware được upload qua HTTP (không HTTPS). Chỉ dùng trong mạng LAN nội bộ, không nên dùng trên mạng mở.
|
||||
- **Credentials cố định:** SSH credentials (`root`/`admin123a`) được hardcode trong `flash_update_worker.py` và truyền từ `main.py`, không hiển thị trên UI.
|
||||
|
||||
@@ -11,6 +11,10 @@ echo.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Doc version tu file
|
||||
set /p APP_VERSION=<version.txt
|
||||
echo Building version: v%APP_VERSION%
|
||||
|
||||
REM 1. Tao venv neu chua co
|
||||
if not exist "venv" (
|
||||
echo [1/4] Creating virtual environment...
|
||||
@@ -22,14 +26,15 @@ call venv\Scripts\activate.bat
|
||||
|
||||
REM 3. Cai dependencies + PyInstaller
|
||||
echo [2/4] Installing dependencies...
|
||||
pip install PyQt6 scapy requests pyinstaller --quiet
|
||||
pip install -r requirements.txt pyinstaller --quiet
|
||||
|
||||
REM 4. Build .exe
|
||||
echo [3/4] Building executable...
|
||||
pyinstaller ^
|
||||
--name "MiraV3_Firmware_Loader" ^
|
||||
--name "Mira_Firmware_Loader_v%APP_VERSION%" ^
|
||||
--icon "icon.ico" ^
|
||||
--add-data "icon.ico;." ^
|
||||
--add-data "version.txt;." ^
|
||||
--onefile ^
|
||||
--windowed ^
|
||||
--noconfirm ^
|
||||
@@ -47,11 +52,11 @@ echo.
|
||||
echo [4/4] Build complete!
|
||||
echo.
|
||||
|
||||
if exist "dist\MiraV3_Firmware_Loader.exe" (
|
||||
echo ✅ SUCCESS: dist\MiraV3_Firmware_Loader.exe
|
||||
if exist "dist\Mira_Firmware_Loader_v%APP_VERSION%.exe" (
|
||||
echo SUCCESS: dist\Mira_Firmware_Loader_v%APP_VERSION%.exe
|
||||
echo.
|
||||
echo File size:
|
||||
for %%A in ("dist\MiraV3_Firmware_Loader.exe") do echo %%~zA bytes
|
||||
for %%A in ("dist\Mira_Firmware_Loader_v%APP_VERSION%.exe") do echo %%~zA bytes
|
||||
echo.
|
||||
echo Ban co the copy file .exe nay sang may khac va chay truc tiep.
|
||||
) else (
|
||||
|
||||
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()
|
||||
200
core/auto_flash_worker.py
Normal file
200
core/auto_flash_worker.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Worker thread cho chế độ "Tự động hóa nạp FW".
|
||||
|
||||
Flow:
|
||||
1. Scan mạng LAN liên tục (tối đa max_scan_rounds lần)
|
||||
2. Khi phát hiện đủ số thiết bị yêu cầu → bắt đầu nạp FW
|
||||
3. Nạp FW theo phương thức đã chọn (API / SSH), tự động retry nếu lỗi
|
||||
4. Thông báo khi hoàn thành
|
||||
"""
|
||||
|
||||
import time
|
||||
import ipaddress
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from core.scanner import scan_network
|
||||
from core.api_flash import flash_device_api
|
||||
from core.ssh_new_flash import flash_device_new_ssh
|
||||
|
||||
MAX_FLASH_RETRIES = 3 # Số lần retry nạp FW khi thất bại
|
||||
MAX_SCAN_ROUNDS = 15 # Số lần scan tối đa trước khi báo không đủ thiết bị
|
||||
|
||||
|
||||
class AutoFlashWorker(QThread):
|
||||
"""Tự động scan → flash khi đủ số lượng thiết bị."""
|
||||
|
||||
# Signals
|
||||
log_message = pyqtSignal(str) # log message cho UI
|
||||
scan_found = pyqtSignal(int) # số device tìm thấy trong lần scan hiện tại
|
||||
devices_ready = pyqtSignal(list) # danh sách devices sẵn sàng flash [{ip, mac}, ...]
|
||||
device_status = pyqtSignal(str, str) # ip, status message
|
||||
device_done = pyqtSignal(str, str, str) # ip, mac, result ("DONE"/"FAIL:...")
|
||||
flash_progress = pyqtSignal(int, int) # done_count, total
|
||||
all_done = pyqtSignal(int, int) # success_count, fail_count
|
||||
scan_timeout = pyqtSignal(int, int) # found_count, target_count — scan hết lần mà chưa đủ
|
||||
stopped = pyqtSignal() # khi dừng bởi user
|
||||
|
||||
def __init__(self, network, target_count, method, max_workers,
|
||||
firmware_path, local_ip="", gateway_ip="",
|
||||
ssh_user="root", ssh_password="admin123a",
|
||||
ssh_backup_password="admin123a", set_passwd=True):
|
||||
super().__init__()
|
||||
self.network = network
|
||||
self.target_count = target_count
|
||||
self.method = method
|
||||
self.max_workers = max_workers
|
||||
self.firmware_path = firmware_path
|
||||
self.local_ip = local_ip
|
||||
self.gateway_ip = gateway_ip
|
||||
self.ssh_user = ssh_user
|
||||
self.ssh_password = ssh_password
|
||||
self.ssh_backup_password = ssh_backup_password
|
||||
self.set_passwd = set_passwd
|
||||
self._stop_flag = False
|
||||
|
||||
def stop(self):
|
||||
self._stop_flag = True
|
||||
|
||||
def run(self):
|
||||
self.log_message.emit("🚀 Bắt đầu chế độ tự động hóa nạp FW...")
|
||||
self.log_message.emit(f" Mục tiêu: {self.target_count} thiết bị | Phương thức: {self.method.upper()} | Song song: {self.max_workers}")
|
||||
self.log_message.emit(f" Mạng: {self.network}")
|
||||
self.log_message.emit("")
|
||||
|
||||
# ── Phase 1: Scan liên tục cho đến khi đủ thiết bị (tối đa MAX_SCAN_ROUNDS lần) ──
|
||||
devices = []
|
||||
scan_round = 0
|
||||
excluded = {self.local_ip, self.gateway_ip}
|
||||
best_found = 0
|
||||
|
||||
while not self._stop_flag:
|
||||
scan_round += 1
|
||||
self.log_message.emit(f"🔍 Scan lần {scan_round}/{MAX_SCAN_ROUNDS}...")
|
||||
|
||||
try:
|
||||
results = scan_network(str(self.network))
|
||||
except Exception as e:
|
||||
self.log_message.emit(f"❌ Scan thất bại: {e}")
|
||||
if self._stop_flag:
|
||||
break
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
# Lọc bỏ gateway, local IP, và 192.168.11.102 (chỉ update mới được nạp)
|
||||
filtered = [d for d in results if d["ip"] not in excluded and d["ip"] != "192.168.11.102"]
|
||||
found_count = len(filtered)
|
||||
best_found = max(best_found, found_count)
|
||||
self.scan_found.emit(found_count)
|
||||
self.log_message.emit(f" Tìm thấy {found_count}/{self.target_count} thiết bị")
|
||||
|
||||
if found_count >= self.target_count:
|
||||
# Chỉ lấy đúng số lượng yêu cầu
|
||||
devices = filtered[:self.target_count]
|
||||
self.log_message.emit(f"✅ Đủ {self.target_count} thiết bị! Bắt đầu nạp FW...")
|
||||
self.log_message.emit("")
|
||||
break
|
||||
|
||||
if self._stop_flag:
|
||||
break
|
||||
|
||||
# Kiểm tra đã scan quá số lần tối đa
|
||||
if scan_round >= MAX_SCAN_ROUNDS:
|
||||
self.log_message.emit(f"⚠️ Đã scan {MAX_SCAN_ROUNDS} lần mà chỉ tìm thấy {best_found}/{self.target_count} thiết bị.")
|
||||
self.scan_timeout.emit(best_found, self.target_count)
|
||||
self.stopped.emit()
|
||||
return
|
||||
|
||||
self.log_message.emit(f" Chưa đủ, chờ 5 giây rồi scan lại...")
|
||||
# Chờ 5 giây nhưng check stop flag mỗi 0.5s
|
||||
for _ in range(10):
|
||||
if self._stop_flag:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
if self._stop_flag:
|
||||
self.log_message.emit("⛔ Đã dừng bởi người dùng.")
|
||||
self.stopped.emit()
|
||||
return
|
||||
|
||||
# ── Phase 2: Flash ──
|
||||
total = len(devices)
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
done_count = 0
|
||||
|
||||
self.flash_progress.emit(0, total)
|
||||
|
||||
# Gửi danh sách devices cho UI để populate bảng trước khi flash
|
||||
self.devices_ready.emit(devices)
|
||||
|
||||
# Log danh sách thiết bị
|
||||
for d in devices:
|
||||
self.log_message.emit(f" 📱 {d['ip']} ({d['mac']})")
|
||||
self.log_message.emit("")
|
||||
|
||||
def _flash_one(dev):
|
||||
nonlocal success_count, fail_count, done_count
|
||||
ip = dev["ip"]
|
||||
mac = dev.get("mac", "N/A")
|
||||
result = ""
|
||||
|
||||
for attempt in range(1, MAX_FLASH_RETRIES + 1):
|
||||
if self._stop_flag:
|
||||
result = "FAIL: Dừng bởi người dùng"
|
||||
break
|
||||
|
||||
if attempt > 1:
|
||||
self.log_message.emit(f"🔄 [{ip}] Retry lần {attempt}/{MAX_FLASH_RETRIES}...")
|
||||
self.device_status.emit(ip, f"Retry lần {attempt}/{MAX_FLASH_RETRIES}...")
|
||||
time.sleep(2) # chờ thiết bị ổn định trước khi retry
|
||||
|
||||
try:
|
||||
def on_status(msg):
|
||||
self.device_status.emit(ip, msg)
|
||||
|
||||
if self.method == "ssh":
|
||||
result = flash_device_new_ssh(
|
||||
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(
|
||||
ip, self.firmware_path,
|
||||
status_cb=on_status,
|
||||
)
|
||||
except Exception as e:
|
||||
result = f"FAIL: {e}"
|
||||
|
||||
if result.startswith("DONE"):
|
||||
break
|
||||
else:
|
||||
if attempt < MAX_FLASH_RETRIES:
|
||||
self.log_message.emit(f"⚠️ [{ip}] Lần {attempt} thất bại: {result}")
|
||||
else:
|
||||
self.log_message.emit(f"❌ [{ip}] Thất bại sau {MAX_FLASH_RETRIES} lần thử: {result}")
|
||||
|
||||
self.device_done.emit(ip, mac, result)
|
||||
|
||||
if result.startswith("DONE"):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
done_count += 1
|
||||
self.flash_progress.emit(done_count, total)
|
||||
|
||||
workers = self.max_workers if self.max_workers > 0 else total
|
||||
with ThreadPoolExecutor(max_workers=max(workers, 1)) as executor:
|
||||
futures = [executor.submit(_flash_one, dev) for dev in devices]
|
||||
for f in futures:
|
||||
f.result()
|
||||
if self._stop_flag:
|
||||
break
|
||||
|
||||
self.log_message.emit("")
|
||||
self.log_message.emit(f"🏁 Hoàn thành! Thành công: {success_count} | Thất bại: {fail_count}")
|
||||
self.all_done.emit(success_count, fail_count)
|
||||
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()
|
||||
155
core/scanner.py
Normal file
155
core/scanner.py
Normal file
@@ -0,0 +1,155 @@
|
||||
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 _ping_one(ip, is_win):
|
||||
"""Ping a single IP to populate ARP table."""
|
||||
try:
|
||||
if is_win:
|
||||
subprocess.run(
|
||||
["ping", "-n", "1", "-w", "300", str(ip)], # 300ms (was 600ms)
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
creationflags=_NO_WINDOW
|
||||
)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.run(
|
||||
["ping", "-c", "1", "-W", "500", str(ip)], # 500ms — macOS: -W unit là ms
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
["ping", "-c", "1", "-W", "1", str(ip)], # 1s — Linux: -W unit là giây
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ping_sweep(network, progress_cb=None):
|
||||
"""Ping tất cả host trong network đồng thời để điền ARP cache.
|
||||
Gọi progress_cb(done, total) sau mỗi ping nếu được cung cấp.
|
||||
"""
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
|
||||
# Chỉ ping sweep cho /24 hoặc nhỏ hơn
|
||||
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)
|
||||
|
||||
# Tất cả host cùng lúc — loại bỏ overhead batching (was max_workers=100)
|
||||
with ThreadPoolExecutor(max_workers=len(hosts)) as executor:
|
||||
futures = [executor.submit(_ping_and_track, ip) for ip in hosts]
|
||||
for f in as_completed(futures):
|
||||
pass
|
||||
|
||||
|
||||
def scan_network(network, progress_cb=None, stage_cb=None):
|
||||
"""Scan network: ping sweep → ARP table + Scapy song song."""
|
||||
# Phase 1: Ping sweep
|
||||
if stage_cb:
|
||||
stage_cb("ping")
|
||||
_ping_sweep(network, progress_cb)
|
||||
time.sleep(0.3) # Giảm từ 1s xuống 0.3s
|
||||
|
||||
# Phase 2 + 3: ARP table và Scapy chạy song song
|
||||
if stage_cb:
|
||||
stage_cb("arp")
|
||||
|
||||
def _collect_arp():
|
||||
result = {}
|
||||
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:
|
||||
result[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:
|
||||
result[ip_str] = {"ip": ip_str, "mac": mac}
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _collect_scapy():
|
||||
result = {}
|
||||
try:
|
||||
import io
|
||||
_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")
|
||||
raw = srp(ether / arp, timeout=1, verbose=0)[0] # Giảm từ 2s xuống 1s
|
||||
for _, received in raw:
|
||||
result[received.psrc] = {"ip": received.psrc, "mac": received.hwsrc}
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
# Chạy ARP read và Scapy đồng thời
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
f_arp = executor.submit(_collect_arp)
|
||||
f_scapy = executor.submit(_collect_scapy)
|
||||
seen = f_arp.result()
|
||||
# Scapy bổ sung những IP chưa có trong ARP table
|
||||
for ip, dev in f_scapy.result().items():
|
||||
if ip not in seen:
|
||||
seen[ip] = dev
|
||||
|
||||
return sorted(seen.values(), key=lambda d: ipaddress.ip_address(d["ip"]))
|
||||
313
core/ssh_flasher.py
Normal file
313
core/ssh_flasher.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
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
|
||||
import random
|
||||
|
||||
|
||||
def _create_ssh_client(ip, user, password, timeout=15):
|
||||
"""Create an SSH client with auto-accept host key policy, including retries."""
|
||||
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 Exception as e:
|
||||
if attempt == 2:
|
||||
raise e
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
import socket
|
||||
|
||||
|
||||
class _SimpleTelnet:
|
||||
"""Minimal Telnet client using raw sockets (replaces telnetlib removed in Python 3.13+)."""
|
||||
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):
|
||||
self._sock.sendall(data)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
# Jitter to avoid hammering the network with many concurrent connections
|
||||
time.sleep(random.uniform(0.1, 1.5))
|
||||
|
||||
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 = _SimpleTelnet(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 Exception as e:
|
||||
# Bất kỳ lỗi Telnet nào (ConnectionRefused, Timeout, BrokenPipe...)
|
||||
# đều có nghĩa là Telnet không truy cập được hoặc bị đóng ngang.
|
||||
# Chuyển qua luồng kết nối SSH.
|
||||
pass
|
||||
|
||||
# 2. Rơi xuống luồng SSH nếu thiết bị cũ (cổng Telnet đóng hoặc lỗi)
|
||||
if status_cb:
|
||||
status_cb("Connecting SSH for password update...")
|
||||
|
||||
last_error = 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:
|
||||
# Authentication means wrong password, retrying won't help here
|
||||
if 'client' in locals() and client:
|
||||
client.close()
|
||||
return f"FAIL: Cannot connect SSH (Old pass incorrect?) — {e}"
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if 'client' in locals() and client:
|
||||
try: client.close()
|
||||
except: pass
|
||||
# Errno 64 or 113 usually means 'Host is down' or 'No route to host'.
|
||||
# It can be a transient switch issue. Wait a bit and retry.
|
||||
time.sleep(2)
|
||||
|
||||
return f"FAIL: Cannot connect SSH after 3 attempts — {last_error}"
|
||||
|
||||
|
||||
def flash_device_ssh(ip, firmware_path, user="root", password="admin123a",
|
||||
backup_password="", 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 not set_passwd:
|
||||
# Jitter here if we skip set_passwd
|
||||
time.sleep(random.uniform(0.1, 1.5))
|
||||
|
||||
if set_passwd:
|
||||
result = set_device_password(ip, user, "", password, status_cb)
|
||||
if result.startswith("FAIL"):
|
||||
# Try with backup password if set
|
||||
if backup_password:
|
||||
result = set_device_password(ip, user, backup_password, password, status_cb)
|
||||
|
||||
# If still failing, try with current intended password just in case it was already set
|
||||
if result.startswith("FAIL"):
|
||||
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}"
|
||||
|
||||
upload_success = False
|
||||
last_error = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
scp_client = SCPClient(client.get_transport(), socket_timeout=350)
|
||||
scp_client.put(firmware_path, remote_path)
|
||||
scp_client.close()
|
||||
upload_success = True
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
time.sleep(1.5)
|
||||
|
||||
if not upload_success:
|
||||
return f"FAIL: SCP upload failed (Check Network) — {last_error}"
|
||||
|
||||
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
|
||||
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"
|
||||
43
core/workers.py
Normal file
43
core/workers.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
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 core.scanner import scan_network
|
||||
|
||||
|
||||
class ScanThread(QThread):
|
||||
"""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) # tên giai đoạn hiện tại
|
||||
|
||||
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)
|
||||
|
||||
self.finished.emit(results)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
205
debug_full.py
205
debug_full.py
@@ -1,205 +0,0 @@
|
||||
"""
|
||||
Debug Flash — chạy đầy đủ 3 bước flash và log chi tiết từng bước.
|
||||
|
||||
Usage:
|
||||
python debug_full.py <IP> <firmware.bin>
|
||||
python debug_full.py 192.168.11.17 V3.0.6p5.bin
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import re
|
||||
|
||||
|
||||
def debug_flash(ip, firmware_path, username="root", password=""):
|
||||
base_url = f"http://{ip}"
|
||||
session = requests.Session()
|
||||
|
||||
print(f"{'='*65}")
|
||||
print(f" Full Flash Debug — {ip}")
|
||||
print(f" Firmware: {os.path.basename(firmware_path)}")
|
||||
print(f" Size: {os.path.getsize(firmware_path) / 1024 / 1024:.2f} MB")
|
||||
print(f"{'='*65}")
|
||||
|
||||
# ═══════════════════════════════════
|
||||
# STEP 1: Login
|
||||
# ═══════════════════════════════════
|
||||
print(f"\n{'─'*65}")
|
||||
print(f" STEP 1: Login")
|
||||
print(f"{'─'*65}")
|
||||
|
||||
login_url = f"{base_url}/cgi-bin/luci"
|
||||
|
||||
print(f" → GET {login_url}")
|
||||
try:
|
||||
r = session.get(login_url, timeout=10)
|
||||
print(f" Status: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Cannot connect: {e}")
|
||||
return
|
||||
|
||||
# Detect form fields
|
||||
if 'name="luci_username"' in r.text:
|
||||
login_data = {"luci_username": username, "luci_password": password}
|
||||
print(f" Fields: luci_username / luci_password")
|
||||
else:
|
||||
login_data = {"username": username, "password": password}
|
||||
print(f" Fields: username / password")
|
||||
|
||||
print(f"\n → POST {login_url}")
|
||||
print(f" Data: {login_data}")
|
||||
resp = session.post(login_url, data=login_data,
|
||||
timeout=10, allow_redirects=True)
|
||||
|
||||
print(f" Status: {resp.status_code}")
|
||||
print(f" Cookies: {dict(session.cookies)}")
|
||||
|
||||
# Extract stok
|
||||
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
|
||||
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
|
||||
|
||||
print(f" stok: {stok}")
|
||||
has_cookie = "sysauth" in str(session.cookies)
|
||||
print(f" sysauth: {'✅ YES' if has_cookie else '❌ NO'}")
|
||||
|
||||
if not stok and not has_cookie:
|
||||
print(f"\n ❌ LOGIN FAILED — no stok, no sysauth cookie")
|
||||
return
|
||||
|
||||
print(f"\n ✅ LOGIN OK")
|
||||
|
||||
# Build flash URL
|
||||
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
|
||||
# ═══════════════════════════════════
|
||||
print(f"\n{'─'*65}")
|
||||
print(f" STEP 2: Upload firmware")
|
||||
print(f"{'─'*65}")
|
||||
|
||||
print(f" → POST {flash_url}")
|
||||
print(f" File field: image")
|
||||
print(f" File name: {os.path.basename(firmware_path)}")
|
||||
print(f" keep: NOT sent (unchecked)")
|
||||
print(f" Uploading...")
|
||||
|
||||
filename = os.path.basename(firmware_path)
|
||||
with open(firmware_path, "rb") as f:
|
||||
resp = session.post(
|
||||
flash_url,
|
||||
data={}, # No keep = uncheck Keep Settings
|
||||
files={"image": (filename, f, "application/octet-stream")},
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
print(f" Status: {resp.status_code}")
|
||||
print(f" URL: {resp.url}")
|
||||
|
||||
# Check response content
|
||||
resp_lower = resp.text.lower()
|
||||
has_verify = "verify" in resp_lower
|
||||
has_proceed = "proceed" in resp_lower
|
||||
has_checksum = "checksum" in resp_lower
|
||||
has_image_form = 'name="image"' in resp.text and 'type="file"' in resp.text
|
||||
|
||||
print(f"\n Response analysis:")
|
||||
print(f" Has 'verify': {'✅' if has_verify else '❌'}")
|
||||
print(f" Has 'proceed': {'✅' if has_proceed else '❌'}")
|
||||
print(f" Has 'checksum': {'✅' if has_checksum else '❌'}")
|
||||
print(f" Has flash form: {'⚠️ (upload ignored!)' if has_image_form else '✅ (not flash form)'}")
|
||||
|
||||
# Extract checksum from verification page
|
||||
checksum = re.search(r'Checksum:\s*<code>([a-f0-9]+)</code>', resp.text)
|
||||
size_info = re.search(r'Size:\s*([\d.]+\s*MB)', resp.text)
|
||||
if checksum:
|
||||
print(f" Checksum: {checksum.group(1)}")
|
||||
if size_info:
|
||||
print(f" Size: {size_info.group(1)}")
|
||||
|
||||
# Check for keep settings status
|
||||
if "will be erased" in resp.text:
|
||||
print(f" Config: Will be ERASED ✅ (keep=off)")
|
||||
elif "will be kept" in resp.text:
|
||||
print(f" Config: Will be KEPT ⚠️ (keep=on)")
|
||||
|
||||
if not has_verify or not has_proceed:
|
||||
print(f"\n ❌ Did NOT get verification page!")
|
||||
# Show cleaned response
|
||||
text = re.sub(r'<[^>]+>', ' ', resp.text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
print(f" Response: {text[:500]}")
|
||||
return
|
||||
|
||||
# Show the Proceed form
|
||||
print(f"\n Proceed form (from HTML):")
|
||||
forms = re.findall(r'<form[^>]*>(.*?)</form>', resp.text, re.DOTALL)
|
||||
for i, form_body in enumerate(forms):
|
||||
if 'value="Proceed"' in form_body or 'step' in form_body:
|
||||
inputs = re.findall(r'<input[^>]*/?\s*>', form_body)
|
||||
for inp in inputs:
|
||||
print(f" {inp.strip()}")
|
||||
|
||||
print(f"\n ✅ UPLOAD OK — Verification page received")
|
||||
|
||||
# ═══════════════════════════════════
|
||||
# STEP 3: Proceed (confirm flash)
|
||||
# ═══════════════════════════════════
|
||||
print(f"\n{'─'*65}")
|
||||
print(f" STEP 3: Proceed (confirm flash)")
|
||||
print(f"{'─'*65}")
|
||||
|
||||
confirm_data = {
|
||||
"step": "2",
|
||||
"keep": "",
|
||||
}
|
||||
|
||||
print(f" → POST {flash_url}")
|
||||
print(f" Data: {confirm_data}")
|
||||
|
||||
try:
|
||||
resp = session.post(flash_url, data=confirm_data, timeout=300)
|
||||
print(f" Status: {resp.status_code}")
|
||||
# Show cleaned response
|
||||
text = re.sub(r'<[^>]+>', ' ', resp.text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
print(f" Response: {text[:300]}")
|
||||
except requests.ConnectionError:
|
||||
print(f" ✅ Connection dropped — device is REBOOTING!")
|
||||
except requests.ReadTimeout:
|
||||
print(f" ✅ Timeout — device is REBOOTING!")
|
||||
|
||||
print(f"\n{'='*65}")
|
||||
print(f" 🎉 FLASH COMPLETE")
|
||||
print(f"{'='*65}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python debug_full.py <IP> <firmware.bin>")
|
||||
print("Example: python debug_full.py 192.168.11.17 V3.0.6p5.bin")
|
||||
sys.exit(1)
|
||||
|
||||
ip = sys.argv[1]
|
||||
fw = sys.argv[2]
|
||||
|
||||
if not os.path.exists(fw):
|
||||
print(f"❌ File not found: {fw}")
|
||||
sys.exit(1)
|
||||
|
||||
debug_flash(ip, fw)
|
||||
155
doc/FLASH_DOC.md
155
doc/FLASH_DOC.md
@@ -1,155 +0,0 @@
|
||||
# Tài liệu Flash Firmware — IoT Firmware Loader
|
||||
|
||||
## Tổng quan
|
||||
|
||||
Ứng dụng tự động hóa quá trình nạp firmware cho thiết bị **OpenWrt Barrier Breaker 14.07** thông qua giao diện web **LuCI**. Thay vì thao tác thủ công trên trình duyệt, ứng dụng thực hiện 3 bước HTTP tự động cho mỗi thiết bị.
|
||||
|
||||
---
|
||||
|
||||
## Luồng hoạt động
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Chọn Firmware .bin] --> B[Scan LAN]
|
||||
B --> C[Hiển thị danh sách thiết bị]
|
||||
C --> D{Chọn thiết bị ☑}
|
||||
D --> E[Nhấn Flash Selected Devices]
|
||||
E --> F[FlashThread chạy background]
|
||||
F --> G["ThreadPoolExecutor\n(max_workers = N)"]
|
||||
G --> H1[Device 1 → flash_device]
|
||||
G --> H2[Device 2 → flash_device]
|
||||
G --> H3[Device N → flash_device]
|
||||
H1 & H2 & H3 --> I[All Done → Thông báo]
|
||||
```
|
||||
|
||||
### Chi tiết `flash_device()` cho mỗi thiết bị
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["STEP 1: Login\nGET /cgi-bin/luci\nPOST username=root, password=empty"] --> C1{Thành công?}
|
||||
C1 -->|Có cookie sysauth + stok| S2
|
||||
C1 -->|403 Denied| F1["FAIL: Login denied (403)"]
|
||||
C1 -->|Không có session| F2["FAIL: Login failed — no session"]
|
||||
|
||||
S2["STEP 2: Upload Firmware\nPOST /flashops\nField: image=firmware.bin\nkeep=KHÔNG gửi (bỏ tích)"] --> C2{Response?}
|
||||
C2 -->|Trang Verify + Proceed| S3
|
||||
C2 -->|HTTP ≠ 200| F3["FAIL: Upload HTTP xxx"]
|
||||
C2 -->|invalid image| F4["FAIL: Invalid firmware image"]
|
||||
C2 -->|unsupported| F5["FAIL: Firmware not compatible"]
|
||||
C2 -->|Vẫn hiện form upload| F6["FAIL: Upload ignored by server"]
|
||||
|
||||
S3["STEP 3: Proceed\nPOST step=2, keep=empty\nXác nhận flash"] --> C3{Response?}
|
||||
C3 -->|200 Flashing...| R["DONE ✅\nThiết bị đang reboot"]
|
||||
C3 -->|Connection dropped| R
|
||||
C3 -->|Read timeout| R
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bảng Status
|
||||
|
||||
### Status trên cột "Status" trong bảng thiết bị
|
||||
|
||||
| Icon | Status | Điều kiện hiển thị |
|
||||
| ---- | ---------------------------------------- | ------------------------------------------------------------- |
|
||||
| — | `READY` | Sau khi scan, thiết bị chưa được flash |
|
||||
| ⏳ | `Logging in...` | Đang POST login vào LuCI |
|
||||
| ⏳ | `Uploading firmware...` | Đang upload file .bin (~30MB) lên thiết bị |
|
||||
| ⏳ | `Confirming (Proceed)...` | Đã upload xong, đang gửi lệnh xác nhận flash |
|
||||
| ⏳ | `Rebooting...` | Thiết bị đang reboot sau khi flash |
|
||||
| ✅ | `DONE` | Flash thành công, thiết bị đang khởi động lại |
|
||||
| ✅ | `DONE (rebooting)` | Flash thành công nhưng timeout khi chờ response (bình thường) |
|
||||
| ❌ | `FAIL: Cannot connect` | Không kết nối được tới thiết bị (sai IP, khác mạng) |
|
||||
| ❌ | `FAIL: Login denied (403)` | Thiết bị từ chối đăng nhập (sai mật khẩu) |
|
||||
| ❌ | `FAIL: Login failed — no session` | Login không trả về cookie hoặc token |
|
||||
| ❌ | `FAIL: Upload HTTP xxx` | Server trả mã lỗi HTTP khi upload |
|
||||
| ❌ | `FAIL: Invalid firmware image` | File firmware không hợp lệ |
|
||||
| ❌ | `FAIL: Firmware not compatible` | Firmware không tương thích với thiết bị |
|
||||
| ❌ | `FAIL: Upload ignored by server` | Server nhận file nhưng không xử lý (sai form field) |
|
||||
| ❌ | `FAIL: Unexpected response after upload` | Không nhận được trang xác nhận Verify |
|
||||
|
||||
---
|
||||
|
||||
## Chi tiết kỹ thuật HTTP
|
||||
|
||||
### Step 1: Login
|
||||
|
||||
```
|
||||
GET http://{IP}/cgi-bin/luci → Lấy trang login, phát hiện field name
|
||||
POST http://{IP}/cgi-bin/luci → Gửi username=root&password=
|
||||
```
|
||||
|
||||
| Kết quả | Điều kiện |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| **Thành công** | Response chứa `sysauth` cookie VÀ/HOẶC `stok` token trong URL/body |
|
||||
| **Thất bại** | HTTP 403, hoặc không có cookie/token |
|
||||
|
||||
**Field names tự động phát hiện:**
|
||||
|
||||
- OpenWrt Barrier Breaker 14.07: `username` / `password`
|
||||
- OpenWrt mới hơn: `luci_username` / `luci_password`
|
||||
|
||||
### Step 2: Upload Firmware
|
||||
|
||||
```
|
||||
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
Fields:
|
||||
image = [firmware.bin] (file upload)
|
||||
keep = (KHÔNG gửi) (bỏ tích Keep Settings)
|
||||
```
|
||||
|
||||
| Kết quả | Điều kiện |
|
||||
| -------------- | ----------------------------------------------------------------------- |
|
||||
| **Thành công** | Response chứa "Flash Firmware - Verify" + "Proceed" + checksum |
|
||||
| **Thất bại** | Response chứa "invalid image", "unsupported", hoặc vẫn hiện form upload |
|
||||
|
||||
### Step 3: Proceed (Xác nhận)
|
||||
|
||||
```
|
||||
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
|
||||
|
||||
Fields:
|
||||
step = 2
|
||||
keep = (empty string)
|
||||
```
|
||||
|
||||
| Kết quả | Điều kiện |
|
||||
| -------------- | ------------------------------------------------------------------------------- |
|
||||
| **Thành công** | Response "The system is flashing now" HOẶC connection bị ngắt (thiết bị reboot) |
|
||||
| **Thất bại** | Hiếm khi xảy ra — nếu đã qua Step 2 thì Step 3 gần như luôn thành công |
|
||||
|
||||
---
|
||||
|
||||
## Xử lý song song
|
||||
|
||||
```
|
||||
FlashThread (QThread - background)
|
||||
└── ThreadPoolExecutor (max_workers = N)
|
||||
├── Thread 1 → flash_device(IP_1)
|
||||
├── Thread 2 → flash_device(IP_2)
|
||||
├── ...
|
||||
└── Thread N → flash_device(IP_N)
|
||||
```
|
||||
|
||||
| Config | Giá trị | Ý nghĩa |
|
||||
| ------------------------- | --------- | ------------------------------ |
|
||||
| Concurrent devices = `10` | Mặc định | Flash 10 thiết bị song song |
|
||||
| Concurrent devices = `0` | Unlimited | Flash tất cả thiết bị cùng lúc |
|
||||
| Concurrent devices = `1` | Tuần tự | Flash từng thiết bị một |
|
||||
|
||||
**Mỗi thiết bị có session HTTP riêng** → không bị lẫn cookie/token giữa các thiết bị.
|
||||
|
||||
---
|
||||
|
||||
## Files liên quan
|
||||
|
||||
| File | Chức năng |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `flasher.py` | Logic flash 3 bước (login → upload → proceed) |
|
||||
| `main.py` | UI PyQt6, FlashThread, quản lý song song |
|
||||
| `debug_full.py` | Script debug — chạy 3 bước với log chi tiết |
|
||||
| `scanner.py` | Scan mạng LAN tìm thiết bị |
|
||||
|
||||
""", "Complexity": 3, "Description": "Created flash documentation file with workflow diagrams, status conditions, HTTP details, and parallel processing explanation.", "EmptyFile": false, "IsArtifact": false, "Overwrite": false, "TargetFile": "/Users/nguyennhatminh/Documents/file code/Smatec/iot_fw_loader/FLASH_DOC.md
|
||||
@@ -1,59 +0,0 @@
|
||||
# Tài liệu Kỹ thuật: Cơ chế Quét IP trên Mạng (Network Scanner)
|
||||
|
||||
## 1. Tổng quan
|
||||
Thành phần quét IP (IP Scanner) trong file `scanner.py` được thiết kế để dò tìm và liệt kê tất cả các thiết bị đang hoạt động trên một dải mạng (ví dụ: `192.168.1.0/24`). Nó theo dõi và trả về danh sách các đối tượng chứa địa chỉ **IP** và **MAC Address** của từng thiết bị.
|
||||
|
||||
Để đảm bảo tỷ lệ phát hiện cao nhất trên các hệ điều hành khác nhau (Windows, macOS, Linux), script sử dụng kết hợp hai phương pháp dò tìm:
|
||||
1. **Quét mồi bằng Ping (Ping Sweep) kết hợp đọc bảng ARP tĩnh của hệ điều hành.**
|
||||
2. **Quét ARP trực tiếp ở Tầng 2 (Layer 2) bằng thư viện `scapy`.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Luồng hoạt động chính (Hàm `scan_network`)
|
||||
|
||||
Đây là hàm được gọi trực tiếp khi muốn quét một mạng. Trình tự rà quét diễn ra qua các bước sau:
|
||||
|
||||
**Bước 1: "Đánh thức" các thiết bị (Ping Sweep)**
|
||||
- Hệ thống gọi hàm `_ping_sweep(network)` để gửi gói Ping (ICMP Echo Request) đồng loạt tới tất cả các IP có thể có trong mạng.
|
||||
- Mục đích của bước này là buộc các thiết bị phản hồi, từ đó hệ điều hành của máy đang chạy lệnh sẽ **tự động ghi nhận địa chỉ MAC** của các thiết bị đó vào bộ nhớ đệm ARP (ARP Cache).
|
||||
- Hệ thống tạm dừng 1 giây để đảm bảo hệ điều hành kịp lưu thông tin vào ARP Cache.
|
||||
|
||||
**Bước 2: Lấy dữ liệu từ bảng ARP của Hệ điều hành (Method 1)**
|
||||
- Thực thi lệnh hệ thống `arp -a` để đọc ARP Cache.
|
||||
- Kết quả được phân tích cú pháp (Regex) để trích xuất IP và MAC tương ứng, tương thích linh hoạt với cả đầu ra của Windows lẫn macOS/Linux.
|
||||
- Các thiết bị đọc được lưu vào danh sách nháp (biến `seen`).
|
||||
|
||||
**Bước 3: Quét sâu với Scapy (Method 2 - Dự phòng/Bổ sung)**
|
||||
- Script gọi thêm thư viện `scapy` để phát một thông điệp "ARP Who-has" tới địa chỉ MAC Broadcast (`ff:ff:ff:ff:ff:ff`).
|
||||
- Phương pháp này giúp phát hiện ra các thiết bị chặn Ping (chặn ICMP) ở Bước 1 nhưng buộc phải phản hồi gói ARP ở tầng Data Link.
|
||||
- Những thiết bị mới tìm thấy (nếu chưa có trong danh sách `seen` ở Bước 2) sẽ được bổ sung vào danh sách.
|
||||
|
||||
**Bước 4: Trả về kết quả**
|
||||
- Danh sách các thiết bị cuối cùng được sắp xếp từ nhỏ đến lớn dựa trên địa chỉ IP và trả về dưới dạng:
|
||||
`[{"ip": "192.168.1.2", "mac": "aa:bb:cc:dd:ee:ff"}, ...]`
|
||||
|
||||
---
|
||||
|
||||
## 3. Phân tích chi tiết các hàm hỗ trợ
|
||||
|
||||
### `_ping_sweep(network)` \& `_ping_one(ip, is_win)`
|
||||
- **Nhiệm vụ:** Quét Ping hàng loạt (Ping Sweep).
|
||||
- **Cách thức hoạt động:** Sử dụng `ThreadPoolExecutor` để chạy **tối đa 100 luồng (threads) song song**. Điều này giúp việc gửi Ping hàng loạt diễn ra cực kì nhanh chóng tính bằng giây thay vì phải đợi ping từng IP một.
|
||||
- **Biện pháp an toàn:** Script có cơ chế tự bảo vệ chặn flood mạng: nó sẽ **từ chối chạy Ping Sweep** nếu dải mạng (subnet) lớn hơn 256 địa chỉ IP (tức là chỉ chạy cho dải mạng từ `/24` trở xuống).
|
||||
|
||||
### `_scan_with_arp_table(network)`
|
||||
- **Nhiệm vụ:** Hàm chạy độc lập để quét tìm thiết bị mà **không cần thông qua đặc quyền Root/Administrator** (fallback method).
|
||||
- **Hỗ trợ Đa nền tảng:**
|
||||
- **Trên Windows (`win32`):** Chuẩn hóa định dạng chuẩn MAC từ gạch ngang sang dấu hai chấm (vd: từ `cc-2d-21...` sang `cc:2d:21...`). Nó dựa vào Regex nhận diện các dòng chuẩn xuất ra có chữ `dynamic` hoặc `static`.
|
||||
- **Trên macOS / Linux:** Dùng Regex đọc định dạng chuẩn riêng biệt ví dụ: `? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0`. Loại bỏ những địa chỉ lỗi bị đánh dấu là `(incomplete)`.
|
||||
|
||||
### `_scan_with_scapy(network)`
|
||||
- **Nhiệm vụ:** Công cụ quét cực mạnh ở Tầng 2 (Layer 2) của mô hình mạng OSI.
|
||||
- **Đặc thù:** Đòi hỏi người dùng phải cấp quyền Sudo/Root trên Linux/macOS hoặc phải cài đặt thư viện phần mềm **Npcap/WinPcap** trên Windows thì mới có thể sử dụng. Gửi gói `srp` bằng `scapy` với timeout là 3 giây để lấy về thông tin phần cứng đích thực.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tóm tắt Ưu & Nhược điểm của thiết kế này
|
||||
|
||||
- **Ưu điểm:** Khả năng dò tìm diện rộng rất cao vì sự kết hợp song song của hai phương pháp. Nếu thiết bị không có quyền Root/Sudo để chạy `scapy`, nó vẫn có thể tìm được ít nhất ~90% thiết bị trong mạng nhờ tính năng Ping Sweeping mạnh mẽ. Tính tương thích chéo OS (Windows/Mac/Linux) được xử lý rất tốt và gọn gàng qua Regex.
|
||||
- **Nhược điểm:** Tốn thêm 1 giây (hàm `time.sleep(1)`) và thêm vài giây timeout tổng cộng, do cần chờ để làm đầy bộ nhớ đệm ARP trước khi quét sâu. Nếu thiết bị đích cố tình tắt phản hồi ARP, thì vẫn có khả năng bị lọt dán mạng.
|
||||
123
docs/api_flash_docs.md
Normal file
123
docs/api_flash_docs.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Tài liệu Kỹ thuật: Flash Firmware qua LuCI API (`core/api_flash.py`)
|
||||
|
||||
Module `api_flash.py` tự động hoá quá trình nạp firmware cho thiết bị **OpenWrt** thông qua giao diện web **LuCI HTTP**. Được dùng trong chế độ **Nạp Mới FW** với method `"api"`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kiến Trúc — Vai Trò File
|
||||
|
||||
| File | Vai trò |
|
||||
| -------------------------- | ----------------------------------------------------------------------- |
|
||||
| `core/api_flash.py` | Logic flash 3 bước (login → upload → proceed) qua LuCI |
|
||||
| `core/flash_new_worker.py` | `NewFlashThread` — dispatch tới `flash_device_api()` khi `method="api"` |
|
||||
| `main.py` | UI PyQt6, chọn mode/method, truyền tham số vào worker |
|
||||
|
||||
---
|
||||
|
||||
## 2. Sơ Đồ Luồng
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[NewFlashThread - method=api] --> B[flash_device_api]
|
||||
|
||||
B --> S1["STEP 1: Login\nGET /cgi-bin/luci — phát hiện field name\nPOST username=root & password="]
|
||||
S1 --> C1{Thành công?}
|
||||
C1 -->|sysauth cookie + stok| S2
|
||||
C1 -->|HTTP 403| F1["FAIL: Login denied (403)"]
|
||||
C1 -->|Không có session| F2["FAIL: Login failed — no session"]
|
||||
|
||||
S2["STEP 2: Upload Firmware\nPOST /flashops\nmultipart: image=firmware.bin"] --> C2{Response?}
|
||||
C2 -->|Trang Verify + Proceed| S3
|
||||
C2 -->|HTTP ≠ 200| F3["FAIL: Upload HTTP xxx"]
|
||||
C2 -->|invalid image| F4["FAIL: Invalid firmware image"]
|
||||
C2 -->|unsupported| F5["FAIL: Firmware not compatible"]
|
||||
C2 -->|Vẫn hiện form upload| F6["FAIL: Upload ignored by server"]
|
||||
|
||||
S3["STEP 3: Proceed\nPOST step=2 & keep=empty"] --> C3{Response?}
|
||||
C3 -->|Connection dropped / Timeout| R["DONE ✅ — Device đang reboot"]
|
||||
C3 -->|200 OK| R
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Chi Tiết Kỹ Thuật HTTP
|
||||
|
||||
### Step 1 — Login
|
||||
|
||||
```
|
||||
GET http://{IP}/cgi-bin/luci → Lấy HTML login, phát hiện field name
|
||||
POST http://{IP}/cgi-bin/luci → Đăng nhập
|
||||
```
|
||||
|
||||
**Tự động phát hiện field name tương thích:**
|
||||
|
||||
| Phiên bản | Field |
|
||||
| --------------------- | --------------------------------- |
|
||||
| Barrier Breaker 14.07 | `username` / `password` |
|
||||
| OpenWrt mới hơn | `luci_username` / `luci_password` |
|
||||
|
||||
**Lấy session:** `stok` token được tìm tuần tự trong URL → body HTML → redirect history. Cookie `sysauth` là fallback nếu không có `stok`.
|
||||
|
||||
### Step 2 — Upload Firmware
|
||||
|
||||
```
|
||||
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
image = firmware.bin (file upload)
|
||||
keep = (không gửi) → bỏ tích "Keep Settings" = Clean Flash
|
||||
```
|
||||
|
||||
Thành công khi response trả về trang **"Flash Firmware - Verify"** chứa từ khoá `verify` + `proceed`.
|
||||
|
||||
### Step 3 — Confirm (Proceed)
|
||||
|
||||
```
|
||||
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
|
||||
|
||||
step = 2
|
||||
keep = (empty)
|
||||
```
|
||||
|
||||
**Đứt kết nối = Thành công:** Device bắt đầu flash → SSH/HTTP request bị drop là hành vi mong muốn. `ConnectionError` và `ReadTimeout` đều được bắt và trả về `"DONE"`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bảng Status UI
|
||||
|
||||
| Icon | Status | Điều kiện |
|
||||
| ---- | ---------------------------------------- | ------------------------------------------ |
|
||||
| ⏳ | `Logging in...` | Đang POST login vào LuCI |
|
||||
| ⏳ | `Uploading firmware...` | Đang upload file .bin |
|
||||
| ⏳ | `Confirming (Proceed)...` | Đang gửi lệnh xác nhận flash |
|
||||
| ⏳ | `Rebooting...` | Chờ device khởi động lại |
|
||||
| ✅ | `DONE` | Flash thành công |
|
||||
| ✅ | `DONE (rebooting)` | Flash thành công, timeout khi chờ response |
|
||||
| ❌ | `FAIL: Cannot connect` | Không kết nối được (sai IP / khác mạng) |
|
||||
| ❌ | `FAIL: Login denied (403)` | Sai mật khẩu LuCI |
|
||||
| ❌ | `FAIL: Login failed — no session` | Không có cookie/token sau login |
|
||||
| ❌ | `FAIL: Upload HTTP xxx` | Server trả lỗi HTTP khi upload |
|
||||
| ❌ | `FAIL: Invalid firmware image` | File firmware không hợp lệ |
|
||||
| ❌ | `FAIL: Firmware not compatible` | Firmware không tương thích thiết bị |
|
||||
| ❌ | `FAIL: Upload ignored by server` | Server không xử lý file (sai form field) |
|
||||
| ❌ | `FAIL: Unexpected response after upload` | Không nhận được trang Verify |
|
||||
|
||||
---
|
||||
|
||||
## 5. Xử Lý Song Song
|
||||
|
||||
```
|
||||
NewFlashThread (QThread)
|
||||
└── ThreadPoolExecutor (max_workers = N)
|
||||
├── Thread 1 → flash_device_api(IP_1)
|
||||
├── Thread 2 → flash_device_api(IP_2)
|
||||
└── Thread N → flash_device_api(IP_N)
|
||||
```
|
||||
|
||||
| Concurrent devices | Ý nghĩa |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `10` (mặc định) | Flash 10 thiết bị song song |
|
||||
| `0` | Unlimited — flash tất cả cùng lúc |
|
||||
| `1` | Tuần tự — từng thiết bị một |
|
||||
|
||||
Mỗi thiết bị có `requests.Session()` riêng — không bị lẫn cookie/token.
|
||||
271
docs/auto_flash_docs.md
Normal file
271
docs/auto_flash_docs.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Tài liệu Kỹ thuật: Tự động hóa nạp FW (`core/auto_flash_worker.py`)
|
||||
|
||||
Module **Tự động hóa nạp FW** tự động quét mạng LAN, phát hiện thiết bị đích, và nạp firmware hàng loạt mà không cần thao tác thủ công. Được thiết kế cho môi trường sản xuất cần nạp FW nhanh cho nhiều thiết bị OpenWrt cùng lúc.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kiến Trúc — Vai Trò File
|
||||
|
||||
| File | Vai trò |
|
||||
| --------------------------- | ------------------------------------------------------------------------ |
|
||||
| `core/auto_flash_worker.py` | `AutoFlashWorker` — QThread xử lý toàn bộ quy trình scan → flash tự động |
|
||||
| `core/scanner.py` | `scan_network()` — quét mạng LAN (ping sweep + ARP table + Scapy) |
|
||||
| `core/api_flash.py` | `flash_device_api()` — nạp FW qua LuCI HTTP API |
|
||||
| `core/ssh_new_flash.py` | `flash_device_new_ssh()` — nạp FW qua SSH (paramiko/scp) |
|
||||
| `main.py` | `AutoFlashWindow` — UI cửa sổ tự động hóa (PyQt6) |
|
||||
| `ui/styles.py` | `AUTO_STYLE` — stylesheet riêng cho cửa sổ tự động hóa (tím) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Sơ Đồ Luồng Tổng Quan
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[👤 Người dùng nhấn XÁC NHẬN & BẮT ĐẦU] --> B[AutoFlashWorker.run]
|
||||
|
||||
B --> P1["── Phase 1: Scan LAN ──"]
|
||||
P1 --> S1["scan_network(network)"]
|
||||
S1 --> F1{"Lọc bỏ:\n• Local IP\n• Gateway IP\n• 192.168.11.102"}
|
||||
F1 --> C1{Đủ số lượng\nthiết bị?}
|
||||
C1 -->|Có| P2["── Phase 2: Flash ──"]
|
||||
C1 -->|Chưa đủ| C2{Đã scan\n≥ 15 lần?}
|
||||
C2 -->|Chưa| W1["Chờ 5 giây\n→ scan lại"]
|
||||
W1 --> S1
|
||||
C2 -->|Rồi| T1["⚠️ scan_timeout\nThông báo lỗi"]
|
||||
|
||||
P2 --> D1["ThreadPoolExecutor\nNạp FW song song"]
|
||||
D1 --> D2["_flash_one(device)"]
|
||||
D2 --> D3{Kết quả?}
|
||||
D3 -->|DONE| D4["✅ Thành công"]
|
||||
D3 -->|FAIL| D5{Retry < 3?}
|
||||
D5 -->|Có| D6["🔄 Chờ 2s → Retry"]
|
||||
D6 --> D2
|
||||
D5 -->|Không| D7["❌ Thất bại\nsau 3 lần"]
|
||||
|
||||
D4 --> D8["Tổng hợp kết quả"]
|
||||
D7 --> D8
|
||||
D8 --> D9["🏁 all_done(success, fail)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Cấu Hình & Hằng Số
|
||||
|
||||
| Hằng số | Giá trị | Mô tả |
|
||||
| ------------------- | ------- | ------------------------------------------------------ |
|
||||
| `MAX_FLASH_RETRIES` | 3 | Số lần retry tối đa khi nạp FW thất bại cho 1 thiết bị |
|
||||
| `MAX_SCAN_ROUNDS` | 15 | Số lần scan LAN tối đa trước khi báo timeout |
|
||||
| Scan interval | 5 giây | Khoảng cách giữa các lần scan (check stop mỗi 0.5s) |
|
||||
| Retry delay | 2 giây | Thời gian chờ trước khi retry nạp FW |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tham Số Khởi Tạo Worker
|
||||
|
||||
```python
|
||||
AutoFlashWorker(
|
||||
network="192.168.11.0/24", # Dải mạng cần scan
|
||||
target_count=5, # Số lượng thiết bị cần nạp
|
||||
method="api", # "api" (LuCI) hoặc "ssh"
|
||||
max_workers=10, # Số luồng nạp song song (0 = không giới hạn)
|
||||
firmware_path="/path/fw.bin", # Đường dẫn file firmware
|
||||
local_ip="192.168.11.50", # IP máy host (sẽ bị loại khỏi scan)
|
||||
gateway_ip="192.168.11.1", # IP gateway (sẽ bị loại khỏi scan)
|
||||
ssh_user="root", # SSH username (mặc định: root)
|
||||
ssh_password="admin123a", # SSH password chính
|
||||
ssh_backup_password="admin123a", # SSH password dự phòng
|
||||
set_passwd=True, # Có đặt lại mật khẩu sau flash không
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Signal — Giao Tiếp Worker ↔ UI
|
||||
|
||||
| Signal | Kiểu dữ liệu | Khi nào emit |
|
||||
| ---------------- | --------------- | --------------------------------------------------- |
|
||||
| `log_message` | `str` | Mỗi dòng log (scan, flash, retry, kết quả) |
|
||||
| `scan_found` | `int` | Sau mỗi lần scan — số thiết bị tìm thấy |
|
||||
| `devices_ready` | `list[dict]` | Khi đủ thiết bị — danh sách `{ip, mac}` trước flash |
|
||||
| `device_status` | `str, str` | Cập nhật trạng thái real-time: `(ip, message)` |
|
||||
| `device_done` | `str, str, str` | Mỗi thiết bị xong: `(ip, mac, result)` |
|
||||
| `flash_progress` | `int, int` | Tiến trình: `(done_count, total)` |
|
||||
| `all_done` | `int, int` | Kết thúc: `(success_count, fail_count)` |
|
||||
| `scan_timeout` | `int, int` | Scan hết lần: `(best_found, target_count)` |
|
||||
| `stopped` | — | Khi worker dừng (bởi user hoặc timeout) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Chi Tiết Quy Trình
|
||||
|
||||
### 6.1. Phase 1 — Scan Mạng LAN
|
||||
|
||||
```
|
||||
Lần 1/15 → scan_network("192.168.11.0/24")
|
||||
→ Lọc bỏ: local_ip, gateway_ip, 192.168.11.102
|
||||
→ Tìm thấy 3/5 thiết bị → chưa đủ
|
||||
→ Chờ 5 giây...
|
||||
|
||||
Lần 2/15 → scan_network(...)
|
||||
→ Tìm thấy 5/5 thiết bị → ĐỦ!
|
||||
→ Chuyển sang Phase 2
|
||||
```
|
||||
|
||||
**Quy tắc lọc IP:**
|
||||
|
||||
- `local_ip` — IP của máy host (tránh nạp FW vào chính máy mình)
|
||||
- `gateway_ip` — IP của router/gateway
|
||||
- `192.168.11.102` — IP thiết bị đã cài FW sẵn, **chỉ được nạp ở chế độ Update FW**
|
||||
|
||||
**Timeout:**
|
||||
|
||||
- Sau **15 lần scan** mà chưa đủ thiết bị → emit `scan_timeout`
|
||||
- UI hiện popup cảnh báo kèm gợi ý kiểm tra:
|
||||
- Thiết bị đã bật và kết nối mạng chưa
|
||||
- Dải mạng có đúng không
|
||||
- Thử lại sau khi kiểm tra
|
||||
|
||||
### 6.2. Phase 2 — Nạp FW (có Auto-Retry)
|
||||
|
||||
Mỗi thiết bị được nạp trong `ThreadPoolExecutor` (chạy song song):
|
||||
|
||||
```
|
||||
[192.168.11.103] Lần 1 → flash_device_api(...) → FAIL: Connection timeout
|
||||
⚠️ Lần 1 thất bại
|
||||
Chờ 2 giây...
|
||||
[192.168.11.103] Lần 2 → flash_device_api(...) → FAIL: Upload error
|
||||
⚠️ Lần 2 thất bại
|
||||
Chờ 2 giây...
|
||||
[192.168.11.103] Lần 3 → flash_device_api(...) → DONE
|
||||
✅ Thành công (lần thứ 3)
|
||||
```
|
||||
|
||||
**Quy trình retry:**
|
||||
|
||||
1. Thực hiện nạp FW (API hoặc SSH)
|
||||
2. Nếu kết quả bắt đầu bằng `"DONE"` → thành công, dừng retry
|
||||
3. Nếu thất bại và còn lần retry → log cảnh báo, chờ 2 giây, thử lại
|
||||
4. Nếu thất bại sau 3 lần → log lỗi, báo kết quả `FAIL`
|
||||
|
||||
---
|
||||
|
||||
## 7. Giao Diện Cửa Sổ Tự Động (AutoFlashWindow)
|
||||
|
||||
### 7.1. Bố Cục
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 🤖 Tự động hóa nạp FW │
|
||||
├──────────────────────────────────────────┤
|
||||
│ ⚙️ Cấu hình nạp (thu gọn được) │
|
||||
│ FW: V3.0.6p5.bin │ Mạng: .11.0/24 │
|
||||
│ Số lượng: 5 │ API (LuCI) │ Song song:10│
|
||||
├──────────────────────────────────────────┤
|
||||
│ [▶ XÁC NHẬN & BẮT ĐẦU] [■ DỪNG] │
|
||||
│ 🔍 Đang scan: 3/5 thiết bị... ████░░ │
|
||||
├──────────────────────────────────────────┤
|
||||
│ 📋 Danh sách thiết bị │
|
||||
│ ┌───┬──────────────┬────────────┬──────┐ │
|
||||
│ │ # │ IP │ MAC │Kết quả│ │
|
||||
│ ├───┼──────────────┼────────────┼──────┤ │
|
||||
│ │ 1 │192.168.11.103│ AA:BB:CC.. │✅DONE│ │
|
||||
│ │ 2 │192.168.11.104│ DD:EE:FF.. │⏳... │ │
|
||||
│ │ 3 │192.168.11.105│ 11:22:33.. │🔄 R2 │ │
|
||||
│ └───┴──────────────┴────────────┴──────┘ │
|
||||
│ Tổng: 5 | Xong: 3 | ✅ 2 | ❌ 1 [📋Lịch sử]│
|
||||
├──────────────────────────────────────────┤
|
||||
│ 📝 Log (thu gọn được) │
|
||||
│ 🚀 Bắt đầu chế độ tự động hóa nạp FW...│
|
||||
│ 🔍 Scan lần 1/15... │
|
||||
│ Tìm thấy 5/5 thiết bị │
|
||||
│ ✅ Đủ 5 thiết bị! Bắt đầu nạp FW... │
|
||||
│ ⚠️ [192.168.11.105] Lần 1 thất bại... │
|
||||
│ 🔄 [192.168.11.105] Retry lần 2/3... │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2. Các Thành Phần UI
|
||||
|
||||
| Thành phần | Mô tả |
|
||||
| ------------------------- | ---------------------------------------------------------- |
|
||||
| **Cấu hình nạp** | CollapsibleGroupBox — chọn FW, mạng, số lượng, phương thức |
|
||||
| **Nút điều khiển** | XÁC NHẬN & BẮT ĐẦU / DỪNG — enable/disable theo trạng thái |
|
||||
| **Trạng thái + Progress** | Hiển thị inline trạng thái scan/flash + progress bar |
|
||||
| **Bảng thiết bị** | 4 cột: #, IP, MAC, Kết quả — cập nhật real-time |
|
||||
| **Tổng hợp + Lịch sử** | Bộ đếm ✅/❌ + nút "📋 Lịch sử nạp" xem chi tiết |
|
||||
| **Log** | CollapsibleGroupBox — log chi tiết toàn bộ quá trình |
|
||||
|
||||
---
|
||||
|
||||
## 8. Lịch Sử Nạp (Flash History)
|
||||
|
||||
Kết quả nạp được lưu ở **2 nơi** với cùng format:
|
||||
|
||||
| Nơi lưu | Phạm vi | Dữ liệu |
|
||||
| ------------------------------- | ---------------------- | ------------------------------------ |
|
||||
| `AutoFlashWindow._auto_history` | Phiên tự động hiện tại | `list[(ip, mac, result, timestamp)]` |
|
||||
| `App.flashed_macs` | Toàn bộ session app | `dict{MAC: (ip, mac, result, ts)}` |
|
||||
|
||||
- Cả thành công ✅ lẫn thất bại ❌ đều được ghi lại
|
||||
- Nút "📋 Lịch sử nạp" hiển thị danh sách với format: `[HH:MM:SS] ✅/❌ IP (MAC) — result`
|
||||
- Lịch sử `_auto_history` reset mỗi khi nhấn "XÁC NHẬN & BẮT ĐẦU" lần mới
|
||||
- Lịch sử `flashed_macs` tồn tại suốt phiên chạy app (cả manual và auto)
|
||||
|
||||
---
|
||||
|
||||
## 9. Quy Tắc Bảo Vệ IP `192.168.11.102`
|
||||
|
||||
| Chế độ | Được nạp 192.168.11.102? | Cơ chế |
|
||||
| ------------------------ | :----------------------: | --------------------------------------- |
|
||||
| **New Flash (thủ công)** | ❌ Không | Kiểm tra trước khi flash, hiện cảnh báo |
|
||||
| **Update FW (thủ công)** | ✅ Có | Cho phép bình thường |
|
||||
| **Tự động hóa** | ❌ Không | Lọc khỏi kết quả scan tự động |
|
||||
|
||||
---
|
||||
|
||||
## 10. Xử Lý Lỗi Tổng Hợp
|
||||
|
||||
| Tình huống | Hành vi |
|
||||
| ------------------------------ | --------------------------------------------------- |
|
||||
| Scan exception (network error) | Log lỗi, chờ 3s, scan lại (đếm vào MAX_SCAN_ROUNDS) |
|
||||
| Scan 15 lần chưa đủ thiết bị | Emit `scan_timeout`, hiện popup cảnh báo, dừng |
|
||||
| Flash thất bại lần 1-2 | Log cảnh báo, chờ 2s, retry tự động |
|
||||
| Flash thất bại sau 3 lần retry | Log lỗi, đánh dấu ❌, tiếp tục device tiếp theo |
|
||||
| User nhấn DỪNG | Set `_stop_flag`, dừng scan/flash, emit `stopped` |
|
||||
| Chưa chọn firmware | Hiện popup cảnh báo, không cho bắt đầu |
|
||||
| Mạng không hợp lệ | Hiện popup cảnh báo, không cho bắt đầu |
|
||||
|
||||
---
|
||||
|
||||
## 11. Hướng Dẫn Sử Dụng
|
||||
|
||||
### Bước 1: Mở tính năng
|
||||
|
||||
Nhấn nút **"🤖 Tự động hóa nạp FW"** ở cuối cửa sổ chính.
|
||||
|
||||
### Bước 2: Cấu hình
|
||||
|
||||
1. **Chọn firmware** — nhấn 📁 hoặc tự động lấy từ cửa sổ chính
|
||||
2. **Dải mạng** — mặc định lấy từ IP máy host (ví dụ: `192.168.11.0/24`)
|
||||
3. **Số lượng** — số thiết bị cần nạp (1–500)
|
||||
4. **Phương thức** — API (LuCI) hoặc SSH
|
||||
5. **Song song** — số thiết bị nạp cùng lúc (0 = tất cả cùng lúc)
|
||||
|
||||
### Bước 3: Bắt đầu
|
||||
|
||||
Nhấn **"▶ XÁC NHẬN & BẮT ĐẦU"** → xác nhận popup → hệ thống tự động:
|
||||
|
||||
1. Scan mạng liên tục cho đến khi đủ thiết bị (tối đa 15 lần)
|
||||
2. Nạp FW song song cho tất cả thiết bị tìm được
|
||||
3. Tự động retry nếu thiết bị nào bị lỗi (tối đa 3 lần)
|
||||
4. Hiện thông báo tổng hợp khi hoàn thành
|
||||
|
||||
### Bước 4: Theo dõi
|
||||
|
||||
- **Bảng thiết bị** — trạng thái real-time từng thiết bị
|
||||
- **Log** — chi tiết quá trình scan, flash, retry
|
||||
- **Lịch sử nạp** — nhấn 📋 để xem danh sách đã nạp
|
||||
|
||||
### Dừng giữa chừng
|
||||
|
||||
Nhấn **"■ DỪNG"** — worker sẽ dừng an toàn sau khi hoàn thành device đang xử lý.
|
||||
166
docs/load_fw_ssh_docs.md
Normal file
166
docs/load_fw_ssh_docs.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Tài liệu Kỹ thuật: Flash Firmware qua SSH
|
||||
|
||||
Tài liệu này gộp toàn bộ logic SSH cho cả hai quy trình: **Nạp Mới FW** (Factory Reset) và **Update FW** (thiết bị đang chạy). Luồng SSH được tách thành nhiều file với trách nhiệm rõ ràng thay vì gói gọn trong một module duy nhất.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kiến Trúc Tổng Quan — Vai Trò Từng File
|
||||
|
||||
```
|
||||
core/
|
||||
├── ssh_utils.py ← Transport layer dùng chung (không có business logic)
|
||||
├── ssh_new_flash.py ← Luồng SSH Nạp Mới FW (Telnet → SSH → sysupgrade)
|
||||
├── ssh_update_flash.py ← Luồng SSH Update FW (SSH trực tiếp → sysupgrade)
|
||||
├── flash_new_worker.py ← QThread điều phối Nạp Mới (API LuCI hoặc SSH)
|
||||
└── flash_update_worker.py← QThread điều phối Update FW (SSH only)
|
||||
```
|
||||
|
||||
### `core/ssh_utils.py` — Transport Layer Dùng Chung
|
||||
|
||||
Chứa các helper function cấp thấp, **không mang logic nghiệp vụ**, được import bởi cả 2 luồng SSH:
|
||||
|
||||
| Hàm | Mô tả |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `_create_ssh_client(ip, user, password, timeout)` | Tạo SSH client với `AutoAddPolicy`, retry 3 lần. Nếu lỗi `AuthenticationException` thì raise ngay (không retry vô nghĩa). |
|
||||
| `_upload_firmware(client, firmware_path, status_cb)` | Upload file `.bin` qua SCP lên `/tmp/<filename>`. Retry 3 lần, timeout SCP là 350 giây. Trả về `remote_path`. |
|
||||
| `_verify_firmware(client, remote_path, status_cb)` | Chạy `test -f && ls -lh` để xác nhận file tồn tại trên device sau khi upload. |
|
||||
| `_sync_and_sysupgrade(client, remote_path, status_cb)` | Chạy `sync` rồi `sysupgrade -F -v -n`. Đứt kết nối = thành công. Trả về `"DONE"` hoặc `"FAIL: ..."`. |
|
||||
|
||||
### `core/ssh_new_flash.py` — Luồng Nạp Mới FW
|
||||
|
||||
Xử lý thiết bị **vừa Factory Reset** hoặc **chưa có mật khẩu**. Bao gồm:
|
||||
|
||||
- **`_SimpleTelnet`**: Telnet client thủ công bằng raw socket — thay thế `telnetlib` bị xoá khỏi Python 3.13+.
|
||||
- **`set_device_password()`**: Đặt mật khẩu thiết bị, thử Telnet port 23 trước, fallback sang SSH nếu Telnet đóng.
|
||||
- **`flash_device_new_ssh()`**: Hàm flash chính — gọi `set_device_password` (nếu `set_passwd=True`) rồi kết nối SSH và gọi pipeline `_upload → _verify → _sync_and_sysupgrade` từ `ssh_utils`.
|
||||
|
||||
### `core/ssh_update_flash.py` — Luồng Update FW
|
||||
|
||||
Xử lý thiết bị **đang chạy MiraV3**, SSH đã mở sẵn với pass đã biết. Không có Telnet, không có set_passwd:
|
||||
|
||||
- **`flash_device_update_ssh()`**: Kết nối SSH (thử `password`, fallback `backup_password` nếu auth fail), rồi gọi thẳng pipeline `_upload → _verify → _sync_and_sysupgrade` từ `ssh_utils`.
|
||||
|
||||
### `core/flash_new_worker.py` — QThread Nạp Mới
|
||||
|
||||
`NewFlashThread` — điều phối flash song song cho chế độ **Nạp Mới**:
|
||||
|
||||
- Nếu `method="ssh"` → gọi `flash_device_new_ssh()` từ `ssh_new_flash`.
|
||||
- Nếu `method="api"` → gọi `flash_device()` từ `flasher` (LuCI HTTP).
|
||||
- Nhận đủ credentials từ UI: `ssh_user`, `ssh_password`, `ssh_backup_password`, `set_passwd`.
|
||||
|
||||
### `core/flash_update_worker.py` — QThread Update FW
|
||||
|
||||
`UpdateFlashThread` — điều phối flash song song cho chế độ **Update FW**:
|
||||
|
||||
- Luôn dùng SSH, credentials hardcode an toàn trong module (`root` / `admin123a`, backup `admin`).
|
||||
- Không nhận credentials từ UI — tránh nhập sai gây flash nhầm.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sơ Đồ Luồng — Nạp Mới FW (SSH)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[NewFlashThread.run] --> B{method = ssh?}
|
||||
B -- api --> API[flash_device - LuCI HTTP]
|
||||
B -- ssh --> C[flash_device_new_ssh]
|
||||
|
||||
C -->|set_passwd=True| D[set_device_password]
|
||||
C -->|set_passwd=False| Jitter[Jitter 0.1s - 1.5s]
|
||||
|
||||
D --> D1[Thử Telnet port 23]
|
||||
D1 -- Thành công --> D2[passwd > new_password via Telnet]
|
||||
D2 --> D3[Chờ 3s để Dropbear khởi động]
|
||||
D1 -- Lỗi / Timeout --> D4[Fallback SSH với old_password rỗng]
|
||||
D4 -- Auth Fail --> D5[Thử backup_password]
|
||||
D5 -- Auth Fail --> D6[Thử chính new_password - idempotent]
|
||||
D6 -- Fail --> Z[DỪNG - Báo FAIL]
|
||||
D3 --> F
|
||||
D4 -- OK --> F
|
||||
D5 -- OK --> F
|
||||
D6 -- OK --> F
|
||||
|
||||
Jitter --> F[_create_ssh_client - retry 3 lần]
|
||||
F --> G[_upload_firmware via SCP vào /tmp/]
|
||||
G -->|retry 3 lần| H[_verify_firmware - test -f]
|
||||
H --> I[_sync_and_sysupgrade]
|
||||
I --> J[sync rồi sysupgrade -F -v -n]
|
||||
J --> K{Kết nối đứt?}
|
||||
K -- Có --> L[DONE - Device đang Reboot]
|
||||
K -- Không trong 4s --> M[FAIL - Lấy log /tmp/sysup.log]
|
||||
```
|
||||
|
||||
## 3. Sơ Đồ Luồng — Update FW (SSH)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[UpdateFlashThread.run] --> B[flash_device_update_ssh]
|
||||
B --> C[_create_ssh_client với password chính]
|
||||
C -- Auth OK --> F
|
||||
C -- AuthenticationException --> D[Thử backup_password]
|
||||
D -- Auth OK --> F
|
||||
D -- Fail --> Z[DỪNG - Báo FAIL]
|
||||
|
||||
F[_upload_firmware via SCP vào /tmp/] -->|retry 3 lần| G[_verify_firmware - test -f]
|
||||
G --> H[_sync_and_sysupgrade]
|
||||
H --> I[sync rồi sysupgrade -F -v -n]
|
||||
I --> J{Kết nối đứt?}
|
||||
J -- Có --> K[DONE - Device đang Reboot]
|
||||
J -- Không trong 4s --> L[FAIL - Lấy log /tmp/sysup.log]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Phân Tích Logic Cốt Lõi
|
||||
|
||||
### 4.1 Cơ Chế Dự Phòng Mật Khẩu Đa Tầng (Nạp Mới)
|
||||
|
||||
Thuật toán `set_device_password` có 4 lớp dự phòng để chiếm được quyền Root trên thiết bị dù ở trạng thái nào:
|
||||
|
||||
1. **Telnet (Port 23):** OpenWrt ngay sau Factory Reset mở Telnet không cần mật khẩu nhưng chặn SSH. Script luôn thử cổng này đầu tiên. Mọi exception (Refused, Timeout, Broken Pipe) đều kích hoạt fallback.
|
||||
2. **SSH mật khẩu rỗng `""`:** Nếu Telnet đóng, thử SSH với pass rỗng — trường hợp thiết bị đang ở trạng thái semi-configured.
|
||||
3. **SSH Backup Password:** Nếu pass rỗng bị `AuthenticationException`, thiết bị đang kẹt một pass cũ — thử `backup_password`.
|
||||
4. **SSH Target Password (Idempotent):** Thử lại với chính `new_password` để chặn trường hợp device đã đổi pass thành công từ lần flash trước nhưng chưa được flash firmware.
|
||||
|
||||
### 4.2 Cơ Chế Dự Phòng Mật Khẩu (Update FW)
|
||||
|
||||
Đơn giản hơn: thử `password` chính → nếu `AuthenticationException` → thử `backup_password`. Không có Telnet, không có set_passwd. Nếu cả hai đều fail thì trả về `FAIL` ngay.
|
||||
|
||||
### 4.3 Xử Lý Đa Luồng Chống Nghẽn
|
||||
|
||||
`paramiko` dễ báo lỗi `[Errno 64] Host is down` hoặc `[Errno 32] Broken Pipe` khi hàng chục thread cùng hit network stack cùng một lúc. Hệ thống chống nghẽn bằng 2 cơ chế:
|
||||
|
||||
- **Jitter ngẫu nhiên:** `time.sleep(random.uniform(0.1, 1.5))` phân tán chùm request theo thời gian, tránh tự gây DDoS nội bộ.
|
||||
- **Retry Hooks:** `_create_ssh_client` retry 3 lần (nghỉ 1s giữa mỗi lần). `_upload_firmware` retry 3 lần (nghỉ 1.5s). Triệt 99% lỗi TCP transient.
|
||||
|
||||
### 4.4 RAM Disk SCP
|
||||
|
||||
Firmware được upload thẳng vào `/tmp/<tên_file>.bin` — phân vùng RAM ảo của OpenWrt. Lợi ích:
|
||||
|
||||
- Tốc độ ghi nhanh nhất (RAM vs Flash).
|
||||
- Không gây hao mòn FlashNOR của router.
|
||||
- File biến mất sau reboot, không để lại rác.
|
||||
|
||||
### 4.5 Thủ Thuật `sysupgrade -F -v -n`
|
||||
|
||||
| Cờ | Tác dụng |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `-F` (Force) | Bỏ qua kiểm tra Image Metadata — bắt buộc với file build Raw / `tim_uImage` để tránh lỗi _"Image metadata not present"_. |
|
||||
| `-v` (Verbose) | Log chi tiết vào `/tmp/sysup.log` để debug khi cần. |
|
||||
| `-n` (No-keep) | Clean Flash — không giữ cấu hình cũ, tránh xung đột config giữa các phiên bản. |
|
||||
|
||||
**Đứt kết nối = Thành công:** `sysupgrade` phá kernel hiện tại rồi reboot nạp firmware mới → SSH session bị đứt là kết quả tất yếu và mong muốn. Script bẫy exception này và trả về `"DONE"`. Ngược lại, nếu sysupgrade fail sớm (< 4 giây, còn giữ kết nối), script đọc `/tmp/sysup.log` trả về error code đầy đủ.
|
||||
|
||||
---
|
||||
|
||||
## 5. Trình Gỡ Lỗi Nhanh
|
||||
|
||||
Cả hai luồng đều gọi `status_cb(msg)` tại mỗi bước — toàn bộ tiến độ như _"Connecting SSH"_, _"Uploading firmware"_, _"Syncing filesystem"_ hiển thị trực tiếp tại cột **Status** trên giao diện chính.
|
||||
|
||||
Để debug chi tiết hơn, chạy app qua terminal:
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Mọi exception đều bị bắt và trả về chuỗi `"FAIL: <lý do>"` hiển thị lên UI — không có silent failure.
|
||||
101
docs/scanner_docs.md
Normal file
101
docs/scanner_docs.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Tài liệu Kỹ thuật: Cơ chế Quét IP trên Mạng (Network Scanner)
|
||||
|
||||
## 1. Tổng quan
|
||||
|
||||
Thành phần quét IP trong `scanner.py` dò tìm và liệt kê tất cả thiết bị đang hoạt động trên một dải mạng (ví dụ: `192.168.1.0/24`), trả về danh sách chứa **IP** và **MAC Address** của từng thiết bị.
|
||||
|
||||
Để đảm bảo tỷ lệ phát hiện cao trên mọi hệ điều hành (Windows, macOS, Linux), scanner kết hợp 3 giai đoạn:
|
||||
|
||||
1. **Ping Sweep** — đánh thức thiết bị, điền ARP cache
|
||||
2. **Đọc bảng ARP hệ điều hành** (`arp -a`) — không cần quyền Admin
|
||||
3. **Scapy ARP broadcast** — bổ sung thiết bị chặn ICMP
|
||||
|
||||
Bước 2 và 3 chạy **song song** để giảm tổng thời gian scan.
|
||||
|
||||
---
|
||||
|
||||
## 2. Luồng hoạt động chính (Hàm `scan_network`)
|
||||
|
||||
**Bước 1 — Ping Sweep**
|
||||
|
||||
- Gọi `_ping_sweep(network)`: gửi ICMP Echo Request đồng thời tới **toàn bộ host** trong dải mạng.
|
||||
- Mỗi thiết bị phản hồi sẽ khiến hệ điều hành **tự ghi MAC vào ARP Cache**.
|
||||
- Sau khi sweep xong, chờ `0.3s` để OS kịp finalize ARP cache (giảm từ 1s trước đây).
|
||||
|
||||
**Bước 2 + 3 — ARP Table & Scapy (song song)**
|
||||
|
||||
- Hai hàm `_collect_arp()` và `_collect_scapy()` được submit vào `ThreadPoolExecutor(max_workers=2)` và chạy đồng thời:
|
||||
- `_collect_arp()`: đọc `arp -a`, parse regex lấy IP + MAC.
|
||||
- `_collect_scapy()`: gửi ARP broadcast, nhận phản hồi trực tiếp từ thiết bị.
|
||||
- Kết quả merge theo IP: ARP table làm nền, Scapy bổ sung IP còn thiếu.
|
||||
|
||||
**Bước 4 — Trả về kết quả**
|
||||
|
||||
- Danh sách sort tăng dần theo IP:
|
||||
`[{"ip": "192.168.1.2", "mac": "aa:bb:cc:dd:ee:ff"}, ...]`
|
||||
|
||||
---
|
||||
|
||||
## 3. Phân tích chi tiết các hàm
|
||||
|
||||
### `_ping_one(ip, is_win)`
|
||||
|
||||
Ping một IP đơn lẻ với timeout tối ưu theo nền tảng:
|
||||
|
||||
| OS | Lệnh | Timeout wait | Timeout process |
|
||||
| ------- | ------------------ | ----------------- | --------------- |
|
||||
| Windows | `ping -n 1 -w 300` | 300ms | 2s |
|
||||
| macOS | `ping -c 1 -W 500` | 500ms (đơn vị ms) | 2s |
|
||||
| Linux | `ping -c 1 -W 1` | 1s (đơn vị giây) | 2s |
|
||||
|
||||
> macOS và Linux dùng cùng flag `-W` nhưng đơn vị khác nhau — được xử lý tách biệt theo `sys.platform`.
|
||||
|
||||
### `_ping_sweep(network, progress_cb)`
|
||||
|
||||
- Tạo `ThreadPoolExecutor(max_workers=len(hosts))` — **toàn bộ host ping đồng thời**, không batching.
|
||||
- Giới hạn an toàn: chỉ chạy với subnet `/24` trở xuống (`num_addresses <= 256`).
|
||||
- Gọi `progress_cb(done, total)` sau mỗi ping để UI cập nhật thanh tiến độ.
|
||||
|
||||
### `_collect_arp()` (nội bộ trong `scan_network`)
|
||||
|
||||
Đọc và parse output `arp -a`, hỗ trợ đa nền tảng:
|
||||
|
||||
- **Windows:** Regex nhận dạng dạng `cc-2d-21-a5-85-b0 dynamic`, chuẩn hóa MAC sang `cc:2d:21:a5:85:b0`.
|
||||
- **macOS/Linux:** Regex nhận dạng dạng `(192.168.1.1) at aa:bb:cc:dd:ee:ff`, bỏ qua entry `(incomplete)`.
|
||||
|
||||
### `_collect_scapy()` (nội bộ trong `scan_network`)
|
||||
|
||||
- Gửi ARP `Who-has` broadcast (`Ether/ARP` qua `srp`) với **timeout 1s** (giảm từ 2s).
|
||||
- Stderr bị redirect tạm thời khi import scapy để tránh spam log ra console.
|
||||
- Tự động bỏ qua nếu scapy không khả dụng (không có Npcap / không có quyền root).
|
||||
|
||||
---
|
||||
|
||||
## 4. So sánh hiệu năng (trước và sau tối ưu)
|
||||
|
||||
| Thay đổi | Trước | Sau |
|
||||
| --------------------------- | --------------------- | ------------------------------------ |
|
||||
| Ping workers | 100 (batching ~3 đợt) | `len(hosts)` (~254, tất cả cùng lúc) |
|
||||
| Ping timeout — Windows | 600ms | 300ms |
|
||||
| Ping timeout — macOS | 1ms (sai đơn vị) | 500ms |
|
||||
| Sleep sau ping sweep | 1.0s | 0.3s |
|
||||
| ARP + Scapy | Tuần tự | **Song song** |
|
||||
| Scapy timeout | 2s | 1s |
|
||||
| **Tổng thời gian scan /24** | ~5–7s | **~1.5–2s** |
|
||||
|
||||
---
|
||||
|
||||
## 5. Ưu & Nhược điểm
|
||||
|
||||
**Ưu điểm:**
|
||||
|
||||
- Tỷ lệ phát hiện thiết bị cao nhờ kết hợp 3 lớp.
|
||||
- Không cần quyền Admin/Root — ping sweep + ARP table vẫn tìm được ~90% thiết bị.
|
||||
- Tương thích đa nền tảng (Windows/macOS/Linux) qua xử lý riêng từng OS.
|
||||
- ARP table và Scapy chạy song song → không cộng dồn thời gian chờ.
|
||||
|
||||
**Nhược điểm:**
|
||||
|
||||
- Vẫn cần `sleep(0.3s)` để OS kịp ghi ARP cache sau ping sweep.
|
||||
- Thiết bị tắt hoàn toàn cả ICMP lẫn ARP sẽ không bị phát hiện.
|
||||
- Spawn ~254 process `ping` đồng thời trên Windows có overhead cao hơn Unix do Windows tạo process chậm hơn.
|
||||
164
flasher.py
164
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()
|
||||
@@ -1,3 +1,5 @@
|
||||
PyQt6
|
||||
scapy
|
||||
requests
|
||||
paramiko
|
||||
scp
|
||||
|
||||
11
run.bat
11
run.bat
@@ -3,14 +3,15 @@ REM IoT Firmware Loader - Windows launcher
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
if exist "venv" (
|
||||
call venv\Scripts\activate.bat
|
||||
) else (
|
||||
if not exist "venv" (
|
||||
echo Creating virtual environment...
|
||||
python -m venv venv
|
||||
call venv\Scripts\activate.bat
|
||||
pip install PyQt6 scapy requests
|
||||
)
|
||||
|
||||
call venv\Scripts\activate.bat
|
||||
|
||||
echo Checking and installing required packages...
|
||||
pip install -r requirements.txt --quiet
|
||||
|
||||
echo Starting IoT Firmware Loader...
|
||||
python main.py
|
||||
|
||||
224
scanner.py
224
scanner.py
@@ -1,224 +0,0 @@
|
||||
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"]))
|
||||
64
ui/components.py
Normal file
64
ui/components.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from PyQt6.QtWidgets import QGroupBox, QWidget, QVBoxLayout
|
||||
from PyQt6.QtCore import QPropertyAnimation
|
||||
|
||||
class CollapsibleGroupBox(QGroupBox):
|
||||
def __init__(self, title="", parent=None):
|
||||
super().__init__(title, parent)
|
||||
self.setCheckable(True)
|
||||
self.setChecked(True)
|
||||
|
||||
self.animation = QPropertyAnimation(self, b"maximumHeight")
|
||||
self.animation.setDuration(200)
|
||||
|
||||
# Connect the toggled signal to our animation function
|
||||
self.toggled.connect(self._toggle_animation)
|
||||
|
||||
self._full_height = 0
|
||||
|
||||
def set_content_layout(self, layout):
|
||||
# We need a wrapper widget to hold the layout
|
||||
self.content_widget = QWidget()
|
||||
self.content_widget.setStyleSheet("background-color: transparent;")
|
||||
self.content_widget.setLayout(layout)
|
||||
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(self.content_widget)
|
||||
self.setLayout(main_layout)
|
||||
|
||||
def _toggle_animation(self, checked):
|
||||
if not hasattr(self, 'content_widget'):
|
||||
return
|
||||
|
||||
if checked:
|
||||
# Expand: show content first, then animate
|
||||
self.content_widget.setVisible(True)
|
||||
target_height = self.sizeHint().height()
|
||||
self.animation.stop()
|
||||
self.animation.setStartValue(self.height())
|
||||
self.animation.setEndValue(target_height)
|
||||
self.animation.finished.connect(self._on_expand_finished)
|
||||
self.animation.start()
|
||||
else:
|
||||
# Collapse
|
||||
self.animation.stop()
|
||||
self.animation.setStartValue(self.height())
|
||||
self.animation.setEndValue(32)
|
||||
self.animation.finished.connect(self._on_collapse_finished)
|
||||
self.animation.start()
|
||||
|
||||
def _on_expand_finished(self):
|
||||
# Remove height constraint so content can grow dynamically
|
||||
self.setMaximumHeight(16777215)
|
||||
try:
|
||||
self.animation.finished.disconnect(self._on_expand_finished)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
def _on_collapse_finished(self):
|
||||
if not self.isChecked():
|
||||
self.content_widget.setVisible(False)
|
||||
try:
|
||||
self.animation.finished.disconnect(self._on_collapse_finished)
|
||||
except TypeError:
|
||||
pass
|
||||
479
ui/styles.py
Normal file
479
ui/styles.py
Normal file
@@ -0,0 +1,479 @@
|
||||
STYLE = """
|
||||
QWidget {
|
||||
background-color: #1a1b2e;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Segoe UI', 'SF Pro Display', sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QGroupBox {
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 20px 8px 6px 8px;
|
||||
font-weight: bold;
|
||||
color: #7eb8f7;
|
||||
background-color: #1e2035;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
left: 14px;
|
||||
top: 5px;
|
||||
padding: 0px 8px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QGroupBox::indicator {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #3d4a6b;
|
||||
background-color: #13141f;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
QGroupBox::indicator:unchecked {
|
||||
background-color: #13141f;
|
||||
}
|
||||
|
||||
QGroupBox::indicator:checked {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QLabel#title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #7eb8f7;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
QLabel#info {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #2d3352;
|
||||
border: 1px solid #3d4a6b;
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #3d4a6b;
|
||||
border-color: #7eb8f7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #1a2040;
|
||||
}
|
||||
|
||||
QPushButton#scan {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a56db, stop:1 #1e66f5);
|
||||
border-color: #1a56db;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QPushButton#scan:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #2563eb, stop:1 #3b82f6);
|
||||
}
|
||||
|
||||
QPushButton#flash {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #15803d, stop:1 #16a34a);
|
||||
border-color: #15803d;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
QPushButton#flash:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #16a34a, stop:1 #22c55e);
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background-color: #13141f;
|
||||
alternate-background-color: #1a1b2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
gridline-color: #2d3748;
|
||||
selection-background-color: #2d3a5a;
|
||||
selection-color: #e2e8f0;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QTableWidget::item:selected {
|
||||
background-color: #2d3a5a;
|
||||
color: #7eb8f7;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #1e2035;
|
||||
color: #7eb8f7;
|
||||
border: none;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
border-right: 1px solid #2d3748;
|
||||
padding: 4px 6px;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
QHeaderView::section:last {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
QProgressBar {
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
background-color: #13141f;
|
||||
color: #e2e8f0;
|
||||
height: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #3b82f6, stop:1 #7eb8f7);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
QProgressBar#scan_bar {
|
||||
border: 1px solid #374151;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
background-color: #13141f;
|
||||
color: #fbbf24;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QProgressBar#scan_bar::chunk {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #d97706, stop:1 #fbbf24);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QLineEdit {
|
||||
background-color: #13141f;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: #e2e8f0;
|
||||
selection-background-color: #2d3a5a;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #3b82f6;
|
||||
background-color: #161727;
|
||||
}
|
||||
|
||||
QScrollBar:vertical {
|
||||
background: #13141f;
|
||||
width: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background: #3d4a6b;
|
||||
border-radius: 5px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #7eb8f7;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:pressed {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical,
|
||||
QScrollBar::sub-line:vertical {
|
||||
height: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:vertical,
|
||||
QScrollBar::sub-page:vertical {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background: #13141f;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #3d4a6b;
|
||||
border-radius: 5px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background: #7eb8f7;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:pressed {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal,
|
||||
QScrollBar::sub-line:horizontal {
|
||||
width: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:horizontal,
|
||||
QScrollBar::sub-page:horizontal {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QCheckBox {
|
||||
background-color: transparent;
|
||||
spacing: 6px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #3d4a6b;
|
||||
background-color: #13141f;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:hover {
|
||||
border-color: #7eb8f7;
|
||||
}
|
||||
"""
|
||||
|
||||
AUTO_STYLE = """
|
||||
QWidget {
|
||||
background-color: #1a1b2e;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Segoe UI', 'SF Pro Display', sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QGroupBox {
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 20px 8px 6px 8px;
|
||||
font-weight: bold;
|
||||
color: #c4b5fd;
|
||||
background-color: #1e2035;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
left: 14px;
|
||||
top: 5px;
|
||||
padding: 0px 8px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QLabel#title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #c4b5fd;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #2d3352;
|
||||
border: 1px solid #3d4a6b;
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #3d4a6b;
|
||||
border-color: #c4b5fd;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QPushButton#start_btn {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #7c3aed, stop:1 #a78bfa);
|
||||
border-color: #7c3aed;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
QPushButton#start_btn:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #8b5cf6, stop:1 #c4b5fd);
|
||||
}
|
||||
|
||||
QPushButton#start_btn:disabled {
|
||||
background: #3d3d5c;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
QPushButton#stop_btn {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #dc2626, stop:1 #ef4444);
|
||||
border-color: #dc2626;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QPushButton#stop_btn:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #ef4444, stop:1 #f87171);
|
||||
}
|
||||
|
||||
QPushButton#stop_btn:disabled {
|
||||
background: #3d3d5c;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
QLineEdit {
|
||||
background-color: #13141f;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #7c3aed;
|
||||
background-color: #161727;
|
||||
}
|
||||
|
||||
QComboBox {
|
||||
background-color: #1e1e2e;
|
||||
border: 1px solid #3d4a6b;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QSpinBox {
|
||||
background-color: #13141f;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background-color: #13141f;
|
||||
alternate-background-color: #1a1b2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
gridline-color: #2d3748;
|
||||
selection-background-color: #2d3a5a;
|
||||
selection-color: #e2e8f0;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #1e2035;
|
||||
color: #c4b5fd;
|
||||
border: none;
|
||||
border-bottom: 2px solid #7c3aed;
|
||||
border-right: 1px solid #2d3748;
|
||||
padding: 4px 6px;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QHeaderView::section:last {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
QProgressBar {
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
background-color: #13141f;
|
||||
color: #e2e8f0;
|
||||
height: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #7c3aed, stop:1 #a78bfa);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar:vertical {
|
||||
background: #13141f;
|
||||
width: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background: #3d4a6b;
|
||||
border-radius: 5px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #c4b5fd;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical,
|
||||
QScrollBar::sub-line:vertical {
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:vertical,
|
||||
QScrollBar::sub-page:vertical {
|
||||
background: transparent;
|
||||
}
|
||||
"""
|
||||
27
utils/network.py
Normal file
27
utils/network.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import socket
|
||||
|
||||
def _resolve_hostname(ip):
|
||||
"""Reverse DNS lookup for a single IP."""
|
||||
try:
|
||||
return socket.gethostbyaddr(ip)[0]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_local_ip():
|
||||
"""Get the local IP address of this machine."""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "N/A"
|
||||
|
||||
def get_default_network(ip):
|
||||
"""Guess the /24 network from local IP."""
|
||||
try:
|
||||
parts = ip.split(".")
|
||||
return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
|
||||
except Exception:
|
||||
return "192.168.1.0/24"
|
||||
44
utils/system.py
Normal file
44
utils/system.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import socket
|
||||
from utils.network import get_local_ip
|
||||
|
||||
def resource_path(relative_path):
|
||||
""" Get absolute path to resource, works for dev and for PyInstaller """
|
||||
try:
|
||||
base_path = sys._MEIPASS
|
||||
except Exception:
|
||||
base_path = os.path.abspath(".")
|
||||
return os.path.join(base_path, relative_path)
|
||||
|
||||
def get_machine_info():
|
||||
"""Collect machine info."""
|
||||
hostname = socket.gethostname()
|
||||
local_ip = get_local_ip()
|
||||
os_info = f"{platform.system()} {platform.release()}"
|
||||
mac_addr = "N/A"
|
||||
|
||||
try:
|
||||
import uuid
|
||||
mac = uuid.getnode()
|
||||
mac_addr = ":".join(
|
||||
f"{(mac >> (8 * i)) & 0xFF:02X}" for i in reversed(range(6))
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"ip": local_ip,
|
||||
"os": os_info,
|
||||
"mac": mac_addr,
|
||||
}
|
||||
|
||||
def get_version():
|
||||
"""Read version from version.txt"""
|
||||
try:
|
||||
with open(resource_path('version.txt'), 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
1
version.txt
Normal file
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
1.2.1
|
||||
Reference in New Issue
Block a user