From 2e491f8111ea7c429c2d967c788b893ea8be4ebf Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 28 Dec 2025 02:37:39 -0500 Subject: [PATCH] init --- .gitignore | 4 ++ Dockerfile | 28 ++++++++++++ app/c2_main.py | 110 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 52 +++++++++++++++++++++ readme.md | 31 +++++++++++++ requirements.txt | 6 +++ 6 files changed, 231 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/c2_main.py create mode 100644 docker-compose.yml create mode 100644 readme.md create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..525e20b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +*.log +*.db +*.conf \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f93ff39 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Use an official Python runtime as a parent image +FROM python:3.13-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY ./app . + +# Expose FastAPI port +EXPOSE 8000 + +# Run uvicorn +CMD ["uvicorn", "c2_main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/app/c2_main.py b/app/c2_main.py new file mode 100644 index 0000000..5ad96f6 --- /dev/null +++ b/app/c2_main.py @@ -0,0 +1,110 @@ +import json +import os +import asyncio +from fastapi import FastAPI, HTTPException +import paho.mqtt.client as mqtt +from datetime import datetime +from motor.motor_asyncio import AsyncIOMotorClient +from pydantic import BaseModel +from typing import Any, Dict + +app = FastAPI(title="Radio C2 Brain") + +# Configuration +MQTT_BROKER = os.getenv("MQTT_BROKER", "mqtt-broker") +MONGO_URI = os.getenv("MONGO_URI", "mongodb://admin:securepassword@db:27017/radio_c2?authSource=admin") +C2_ID = "central-brain-01" + +# Database Init +mongo_client = AsyncIOMotorClient(MONGO_URI) +db = mongo_client.get_database() +nodes_col = db.get_collection("nodes") + +# Local cache for quick lookups +ACTIVE_NODES_CACHE = {} +MAIN_LOOP = None + +# Pydantic Models +class NodeCommand(BaseModel): + command: str + payload: Dict[str, Any] + +def on_connect(client, userdata, flags, rc): + print(f"Brain connected to MQTT Broker with result code {rc}") + client.subscribe("nodes/+/checkin") + client.subscribe("nodes/+/status") + +def on_message(client, userdata, msg): + if MAIN_LOOP: + asyncio.run_coroutine_threadsafe(handle_message(msg), MAIN_LOOP) + +async def handle_message(msg): + topic_parts = msg.topic.split('/') + if len(topic_parts) < 3: return + + node_id = topic_parts[1] + event_type = topic_parts[2] + + try: + payload = json.loads(msg.payload.decode()) + timestamp = datetime.utcnow() + + if event_type == "checkin": + data = { + "node_id": node_id, + "last_seen": timestamp, + "status": payload.get("status", "online"), + "active_system": payload.get("active_system"), + "available_systems": payload.get("available_systems", []), + "config": payload, + "radio_state": "active" if payload.get("is_listening") else "idle" + } + await nodes_col.update_one({"node_id": node_id}, {"$set": data}, upsert=True) + ACTIVE_NODES_CACHE[node_id] = data + + elif event_type == "status": + status = payload.get("status") + await nodes_col.update_one( + {"node_id": node_id}, + {"$set": {"status": status, "last_seen": timestamp}} + ) + if node_id in ACTIVE_NODES_CACHE: + ACTIVE_NODES_CACHE[node_id]["status"] = status + + except Exception as e: + print(f"Error processing MQTT: {e}") + +# MQTT Setup +mqtt_client = mqtt.Client(client_id=C2_ID) +mqtt_client.on_connect = on_connect +mqtt_client.on_message = on_message + +@app.on_event("startup") +async def startup_event(): + global MAIN_LOOP + MAIN_LOOP = asyncio.get_running_loop() + mqtt_client.connect_async(MQTT_BROKER, 1883, 60) + mqtt_client.loop_start() + +@app.get("/nodes") +async def get_nodes(): + nodes = await nodes_col.find().to_list(length=100) + # Convert ObjectId to string for JSON serialization + for n in nodes: n["_id"] = str(n["_id"]) + return nodes + +@app.post("/nodes/{node_id}/command") +async def send_command_to_node(node_id: str, command: NodeCommand): + if node_id not in ACTIVE_NODES_CACHE: + raise HTTPException(status_code=404, detail="Node not found or is offline") + + topic = f"nodes/{node_id}/commands" + + message_payload = { + "command": command.command, + **command.payload + } + + mqtt_client.publish(topic, json.dumps(message_payload), qos=1) + + return {"status": "command_sent", "node_id": node_id, "command": command.command} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..671bd51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + # The Brain (FastAPI) + c2-brain: + build: . + container_name: radio-c2-brain + restart: always + environment: + - MONGO_URI=mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@db:27017/radio_c2?authSource=admin + - MQTT_BROKER=mqtt-broker + - PORT=8000 + depends_on: + - db + - mqtt-broker + ports: + - "8000:8000" + networks: + - radio-shared-net + + # The Memory (MongoDB) + db: + image: mongo:latest + container_name: radio-db + restart: always + environment: + - MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME} + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD} + volumes: + - mongo_data:/data/db + networks: + - radio-shared-net + + # The Post Office (MQTT Broker) + mqtt-broker: + image: eclipse-mosquitto:latest + container_name: radio-mqtt + restart: always + ports: + - "1883:1883" + - "9001:9001" + volumes: + - ./mosquitto/config/:/mosquitto/config/ + - ./mosquitto/data/:/mosquitto/data/ + - ./mosquitto/log/:/mosquitto/log/ + networks: + - radio-shared-net + +networks: + radio-shared-net: + external: true + +volumes: + mongo_data: \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2b90145 --- /dev/null +++ b/readme.md @@ -0,0 +1,31 @@ +radio-c2-core + +The "Brain" of the project. This central server manages the global state of the radio network, routes commands to nodes, and persists historical data. + +1. Components + +MQTT Broker (Mosquitto): The post office. Handles all traffic between nodes and the C2. + +FastAPI Core: The logic engine. Listens to MQTT events and provides the REST API for the future Web Portal. + +PostgreSQL: The memory. Stores node registry, status history, and metadata logs. + +2. MQTT Topic Structure + +nodes/{node_id}/checkin: Nodes publish their full config here on boot. + +nodes/{node_id}/status: LWT (Last Will) and periodic heartbeat. + +nodes/{node_id}/command: C2 publishes tuning commands here for nodes to execute. + +nodes/{node_id}/metadata: High-resolution talkgroup and signal data. + +3. The Handshake + +Node connects to MQTT. + +Node publishes online to status and full JSON to checkin. + +C2 receives checkin, updates the database, and marks the node as "Manageable". + +If Node vanishes, Broker publishes offline to status via LWT. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f979c15 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +paho-mqtt +motor +pydantic +pydantic-settings \ No newline at end of file