commit a676136f5d119de1ad42300f3635a6c62905a7ca Author: user Date: Thu Feb 5 20:56:52 2026 +0100 feat: Initial project scaffold Flask API backend for ESP32 sensor fleet: - App factory pattern with blueprints - SQLAlchemy 2.x models (Sensor, Device, Sighting, Alert, Event, Probe) - UDP collector for sensor data streams - REST API endpoints for sensors, devices, alerts, events, probes, stats - pytest setup with fixtures - Containerfile for podman deployment - Makefile for common tasks diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eaec412 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Flask +SECRET_KEY=change-me-in-production +FLASK_ENV=development + +# Database +DATABASE_URL=sqlite:///esp32.db + +# Network +UDP_PORT=5500 +CMD_PORT=5501 +SENSOR_TIMEOUT=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1ba58c --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*.so +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Flask +instance/ +*.db + +# Environment +.env +.env.local + +# Ruff +.ruff_cache/ diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..8c971c4 --- /dev/null +++ b/Containerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +# Copy source +COPY src/ src/ +COPY migrations/ migrations/ + +# Expose ports (TCP for HTTP, UDP for collector) +EXPOSE 5500/tcp +EXPOSE 5500/udp + +# Run with gunicorn +CMD ["gunicorn", "-b", "0.0.0.0:5500", "esp32_web:create_app()"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d0824ac --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +.PHONY: build run dev stop logs test migrate clean install + +APP_NAME := esp32-web +PORT := 5500 + +install: + pip install -e ".[dev]" + +dev: + flask --app src/esp32_web run --port $(PORT) --debug + +test: + pytest -v + +migrate: + flask --app src/esp32_web db upgrade + +migrate-init: + flask --app src/esp32_web db init + +migrate-create: + flask --app src/esp32_web db migrate -m "$(msg)" + +build: + podman build -t $(APP_NAME) . + +run: + podman run -d --name $(APP_NAME) \ + -p $(PORT):$(PORT) \ + -p $(PORT):$(PORT)/udp \ + -v ./instance:/app/instance:Z \ + $(APP_NAME) + +stop: + podman stop $(APP_NAME) && podman rm $(APP_NAME) + +logs: + podman logs -f $(APP_NAME) + +clean: + rm -rf __pycache__ .pytest_cache .ruff_cache + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete diff --git a/README.md b/README.md new file mode 100644 index 0000000..d813a16 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# ESP32-Web + +REST API backend for ESP32 sensor fleet (OPSEC/OSINT/Purple team). + +## Quick Start + +```bash +# Install +pip install -e ".[dev]" + +# Initialize database +flask --app src/esp32_web db init +flask --app src/esp32_web db migrate -m "initial" +flask --app src/esp32_web db upgrade + +# Run development server +make dev +``` + +## Ports + +| Port | Protocol | Description | +|------|----------|-------------| +| 5500 | TCP | HTTP REST API | +| 5500 | UDP | Sensor data collector | +| 5501 | UDP | Sensor commands (outbound) | + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/health` | Health check | +| GET | `/api/v1/sensors` | List sensors | +| GET | `/api/v1/sensors/` | Get sensor | +| POST | `/api/v1/sensors//command` | Send command | +| GET | `/api/v1/devices` | List devices | +| GET | `/api/v1/devices/` | Get device | +| GET | `/api/v1/alerts` | List alerts | +| GET | `/api/v1/events` | List events | +| GET | `/api/v1/probes` | List probe requests | +| GET | `/api/v1/probes/ssids` | List SSIDs | +| GET | `/api/v1/stats` | Statistics | + +## Container + +```bash +make build +make run +make logs +make stop +``` + +## Testing + +```bash +make test +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2b5cbbf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "esp32-web" +version = "0.1.0" +description = "REST API backend for ESP32 sensor fleet" +requires-python = ">=3.11" +dependencies = [ + "flask>=3.0", + "flask-sqlalchemy>=3.1", + "flask-migrate>=4.0", + "flask-cors>=4.0", + "gunicorn>=21.0", + "python-dotenv>=1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=4.0", + "ruff>=0.1", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/esp32_web/__init__.py b/src/esp32_web/__init__.py new file mode 100644 index 0000000..65566da --- /dev/null +++ b/src/esp32_web/__init__.py @@ -0,0 +1,33 @@ +"""ESP32-Web Flask Application.""" +from flask import Flask + +from .config import Config +from .extensions import db, migrate + + +def create_app(config_class=Config): + """Application factory.""" + app = Flask(__name__) + app.config.from_object(config_class) + + # Initialize extensions + db.init_app(app) + migrate.init_app(app, db) + + # Register blueprints + from .api import bp as api_bp + app.register_blueprint(api_bp, url_prefix='/api/v1') + + # Health check + @app.route('/health') + def health(): + return {'status': 'ok'} + + # Start UDP collector in non-testing mode + if not app.config.get('TESTING'): + from .collector import collector + collector.init_app(app) + with app.app_context(): + collector.start() + + return app diff --git a/src/esp32_web/api/__init__.py b/src/esp32_web/api/__init__.py new file mode 100644 index 0000000..c30c059 --- /dev/null +++ b/src/esp32_web/api/__init__.py @@ -0,0 +1,6 @@ +"""API Blueprint.""" +from flask import Blueprint + +bp = Blueprint('api', __name__) + +from . import sensors, devices, alerts, events, probes, stats # noqa: E402, F401 diff --git a/src/esp32_web/api/alerts.py b/src/esp32_web/api/alerts.py new file mode 100644 index 0000000..57e7659 --- /dev/null +++ b/src/esp32_web/api/alerts.py @@ -0,0 +1,29 @@ +"""Alert endpoints.""" +from datetime import datetime, timedelta, UTC +from flask import request +from . import bp +from ..models import Alert +from ..extensions import db + + +@bp.route('/alerts') +def list_alerts(): + """List alerts with filters.""" + alert_type = request.args.get('type') + sensor_id = request.args.get('sensor_id', type=int) + hours = request.args.get('hours', 24, type=int) + limit = min(int(request.args.get('limit', 100)), 1000) + offset = int(request.args.get('offset', 0)) + + since = datetime.now(UTC) - timedelta(hours=hours) + query = db.select(Alert).where(Alert.timestamp >= since).order_by(Alert.timestamp.desc()) + + if alert_type: + query = query.where(Alert.alert_type == alert_type) + if sensor_id: + query = query.where(Alert.sensor_id == sensor_id) + + query = query.limit(limit).offset(offset) + alerts = db.session.scalars(query).all() + + return {'alerts': [a.to_dict() for a in alerts], 'limit': limit, 'offset': offset} diff --git a/src/esp32_web/api/devices.py b/src/esp32_web/api/devices.py new file mode 100644 index 0000000..f85bf72 --- /dev/null +++ b/src/esp32_web/api/devices.py @@ -0,0 +1,42 @@ +"""Device endpoints.""" +from flask import request +from . import bp +from ..models import Device, Sighting +from ..extensions import db + + +@bp.route('/devices') +def list_devices(): + """List all devices.""" + device_type = request.args.get('type') # 'ble' or 'wifi' + limit = min(int(request.args.get('limit', 100)), 1000) + offset = int(request.args.get('offset', 0)) + + query = db.select(Device).order_by(Device.last_seen.desc()) + if device_type: + query = query.where(Device.device_type == device_type) + query = query.limit(limit).offset(offset) + + devices = db.session.scalars(query).all() + return {'devices': [d.to_dict() for d in devices], 'limit': limit, 'offset': offset} + + +@bp.route('/devices/') +def get_device(mac): + """Get device by MAC.""" + mac = mac.lower() + device = db.session.scalar(db.select(Device).where(Device.mac == mac)) + if not device: + return {'error': 'Device not found'}, 404 + + # Include recent sightings + sightings = db.session.scalars( + db.select(Sighting) + .where(Sighting.device_id == device.id) + .order_by(Sighting.timestamp.desc()) + .limit(20) + ).all() + + result = device.to_dict() + result['sightings'] = [s.to_dict() for s in sightings] + return result diff --git a/src/esp32_web/api/events.py b/src/esp32_web/api/events.py new file mode 100644 index 0000000..fe44259 --- /dev/null +++ b/src/esp32_web/api/events.py @@ -0,0 +1,29 @@ +"""Event endpoints.""" +from datetime import datetime, timedelta, UTC +from flask import request +from . import bp +from ..models import Event +from ..extensions import db + + +@bp.route('/events') +def list_events(): + """List sensor events.""" + event_type = request.args.get('type') + sensor_id = request.args.get('sensor_id', type=int) + hours = request.args.get('hours', 24, type=int) + limit = min(int(request.args.get('limit', 100)), 1000) + offset = int(request.args.get('offset', 0)) + + since = datetime.now(UTC) - timedelta(hours=hours) + query = db.select(Event).where(Event.timestamp >= since).order_by(Event.timestamp.desc()) + + if event_type: + query = query.where(Event.event_type == event_type) + if sensor_id: + query = query.where(Event.sensor_id == sensor_id) + + query = query.limit(limit).offset(offset) + events = db.session.scalars(query).all() + + return {'events': [e.to_dict() for e in events], 'limit': limit, 'offset': offset} diff --git a/src/esp32_web/api/probes.py b/src/esp32_web/api/probes.py new file mode 100644 index 0000000..40c7257 --- /dev/null +++ b/src/esp32_web/api/probes.py @@ -0,0 +1,44 @@ +"""Probe request endpoints.""" +from datetime import datetime, timedelta, UTC +from flask import request +from sqlalchemy import func +from . import bp +from ..models import Probe, Device +from ..extensions import db + + +@bp.route('/probes') +def list_probes(): + """List probe requests.""" + ssid = request.args.get('ssid') + hours = request.args.get('hours', 24, type=int) + limit = min(int(request.args.get('limit', 100)), 1000) + offset = int(request.args.get('offset', 0)) + + since = datetime.now(UTC) - timedelta(hours=hours) + query = db.select(Probe).where(Probe.timestamp >= since).order_by(Probe.timestamp.desc()) + + if ssid: + query = query.where(Probe.ssid == ssid) + + query = query.limit(limit).offset(offset) + probes = db.session.scalars(query).all() + + return {'probes': [p.to_dict() for p in probes], 'limit': limit, 'offset': offset} + + +@bp.route('/probes/ssids') +def list_ssids(): + """List SSIDs with counts.""" + hours = request.args.get('hours', 24, type=int) + since = datetime.now(UTC) - timedelta(hours=hours) + + results = db.session.execute( + db.select(Probe.ssid, func.count(Probe.id).label('count')) + .where(Probe.timestamp >= since) + .group_by(Probe.ssid) + .order_by(func.count(Probe.id).desc()) + .limit(100) + ).all() + + return {'ssids': [{'ssid': r.ssid, 'count': r.count} for r in results]} diff --git a/src/esp32_web/api/sensors.py b/src/esp32_web/api/sensors.py new file mode 100644 index 0000000..610815c --- /dev/null +++ b/src/esp32_web/api/sensors.py @@ -0,0 +1,53 @@ +"""Sensor endpoints.""" +import socket +from flask import request, current_app +from . import bp +from ..models import Sensor +from ..extensions import db + + +@bp.route('/sensors') +def list_sensors(): + """List all sensors.""" + sensors = db.session.scalars(db.select(Sensor).order_by(Sensor.hostname)).all() + return {'sensors': [s.to_dict() for s in sensors]} + + +@bp.route('/sensors/') +def get_sensor(hostname): + """Get sensor by hostname.""" + sensor = db.session.scalar(db.select(Sensor).where(Sensor.hostname == hostname)) + if not sensor: + return {'error': 'Sensor not found'}, 404 + return sensor.to_dict() + + +@bp.route('/sensors//command', methods=['POST']) +def send_command(hostname): + """Send UDP command to sensor.""" + sensor = db.session.scalar(db.select(Sensor).where(Sensor.hostname == hostname)) + if not sensor: + return {'error': 'Sensor not found'}, 404 + + data = request.get_json() + if not data or 'command' not in data: + return {'error': 'Missing command'}, 400 + + command = data['command'].strip().upper() + + # Whitelist allowed commands + allowed = ('STATUS', 'REBOOT', 'IDENTIFY', 'BLE', 'ADAPTIVE', 'RATE', 'POWER', + 'CSIMODE', 'PRESENCE', 'CALIBRATE', 'CHANSCAN') + if not any(command.startswith(a) for a in allowed): + return {'error': 'Command not allowed'}, 403 + + # Send UDP command + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(2.0) + sock.sendto(command.encode(), (sensor.ip, current_app.config['SENSOR_CMD_PORT'])) + sock.close() + except socket.error as e: + return {'error': f'Socket error: {e}'}, 500 + + return {'status': 'sent', 'command': command} diff --git a/src/esp32_web/api/stats.py b/src/esp32_web/api/stats.py new file mode 100644 index 0000000..2447f25 --- /dev/null +++ b/src/esp32_web/api/stats.py @@ -0,0 +1,43 @@ +"""Statistics endpoints.""" +from datetime import datetime, timedelta, UTC +from flask import request +from sqlalchemy import func +from . import bp +from ..models import Sensor, Device, Alert, Event, Probe +from ..extensions import db + + +@bp.route('/stats') +def get_stats(): + """Get aggregate statistics.""" + hours = request.args.get('hours', 24, type=int) + since = datetime.now(UTC) - timedelta(hours=hours) + + sensors_total = db.session.scalar(db.select(func.count(Sensor.id))) + sensors_online = db.session.scalar( + db.select(func.count(Sensor.id)).where(Sensor.status == 'online') + ) + devices_total = db.session.scalar(db.select(func.count(Device.id))) + devices_ble = db.session.scalar( + db.select(func.count(Device.id)).where(Device.device_type == 'ble') + ) + devices_wifi = db.session.scalar( + db.select(func.count(Device.id)).where(Device.device_type == 'wifi') + ) + alerts_count = db.session.scalar( + db.select(func.count(Alert.id)).where(Alert.timestamp >= since) + ) + events_count = db.session.scalar( + db.select(func.count(Event.id)).where(Event.timestamp >= since) + ) + probes_count = db.session.scalar( + db.select(func.count(Probe.id)).where(Probe.timestamp >= since) + ) + + return { + 'sensors': {'total': sensors_total, 'online': sensors_online}, + 'devices': {'total': devices_total, 'ble': devices_ble, 'wifi': devices_wifi}, + 'alerts': {'count': alerts_count, 'hours': hours}, + 'events': {'count': events_count, 'hours': hours}, + 'probes': {'count': probes_count, 'hours': hours}, + } diff --git a/src/esp32_web/collector/__init__.py b/src/esp32_web/collector/__init__.py new file mode 100644 index 0000000..2c2e90d --- /dev/null +++ b/src/esp32_web/collector/__init__.py @@ -0,0 +1,6 @@ +"""UDP Collector.""" +from .listener import UDPCollector + +collector = UDPCollector() + +__all__ = ['collector'] diff --git a/src/esp32_web/collector/listener.py b/src/esp32_web/collector/listener.py new file mode 100644 index 0000000..4f57a65 --- /dev/null +++ b/src/esp32_web/collector/listener.py @@ -0,0 +1,67 @@ +"""UDP listener for sensor data.""" +import logging +import socket +import threading + +log = logging.getLogger(__name__) + + +class UDPCollector: + """Threaded UDP collector for sensor data streams.""" + + def __init__(self, app=None): + self.app = app + self._thread = None + self._running = False + self._sock = None + + def init_app(self, app): + """Initialize with Flask app.""" + self.app = app + app.extensions['collector'] = self + + def start(self): + """Start the collector thread.""" + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._listen, daemon=True, name='udp-collector') + self._thread.start() + log.info('UDP collector started on port %d', self.app.config['UDP_LISTEN_PORT']) + + def stop(self): + """Stop the collector thread.""" + self._running = False + if self._sock: + self._sock.close() + + def _listen(self): + """Main listen loop.""" + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind(('0.0.0.0', self.app.config['UDP_LISTEN_PORT'])) + self._sock.settimeout(1.0) + + while self._running: + try: + data, addr = self._sock.recvfrom(2048) + self._handle_packet(data.decode('utf-8', errors='replace'), addr) + except socket.timeout: + continue + except Exception as e: + log.exception('Error receiving packet: %s', e) + + self._sock.close() + + def _handle_packet(self, data: str, addr: tuple): + """Handle incoming packet.""" + data = data.strip() + if not data: + return + + with self.app.app_context(): + from .parsers import parse_packet + try: + parse_packet(data, addr) + except Exception as e: + log.exception('Error parsing packet: %s', e) diff --git a/src/esp32_web/collector/parsers.py b/src/esp32_web/collector/parsers.py new file mode 100644 index 0000000..3c78fb0 --- /dev/null +++ b/src/esp32_web/collector/parsers.py @@ -0,0 +1,180 @@ +"""Packet parsers for sensor data streams.""" +import json +import logging +import re +from datetime import datetime, UTC + +from ..extensions import db +from ..models import Sensor, Device, Sighting, Alert, Event, Probe + +log = logging.getLogger(__name__) + + +def get_or_create_sensor(hostname: str, ip: str) -> Sensor: + """Get or create sensor by hostname.""" + sensor = db.session.scalar(db.select(Sensor).where(Sensor.hostname == hostname)) + if not sensor: + sensor = Sensor(hostname=hostname, ip=ip, status='online') + db.session.add(sensor) + db.session.flush() + else: + sensor.ip = ip + sensor.last_seen = datetime.now(UTC) + sensor.status = 'online' + return sensor + + +def get_or_create_device(mac: str, device_type: str) -> Device: + """Get or create device by MAC.""" + mac = mac.lower() + device = db.session.scalar(db.select(Device).where(Device.mac == mac)) + if not device: + device = Device(mac=mac, device_type=device_type) + db.session.add(device) + db.session.flush() + else: + device.last_seen = datetime.now(UTC) + return device + + +def parse_packet(data: str, addr: tuple): + """Parse and store incoming packet.""" + ip = addr[0] + + if data.startswith('CSI_DATA,'): + parse_csi_data(data, ip) + elif data.startswith('BLE_DATA,'): + parse_ble_data(data, ip) + elif data.startswith('PROBE_DATA,'): + parse_probe_data(data, ip) + elif data.startswith('ALERT_DATA,'): + parse_alert_data(data, ip) + elif data.startswith('EVENT,'): + parse_event(data, ip) + else: + log.debug('Unknown packet type: %s', data[:50]) + + +def parse_csi_data(data: str, ip: str): + """Parse CSI_DATA packet (just update sensor heartbeat).""" + parts = data.split(',') + if len(parts) < 3: + return + hostname = parts[1] + get_or_create_sensor(hostname, ip) + db.session.commit() + + +def parse_ble_data(data: str, ip: str): + """Parse BLE_DATA packet.""" + # BLE_DATA,hostname,mac,rssi,type,name,company_id,tx_power,flags + parts = data.split(',') + if len(parts) < 5: + return + + hostname = parts[1] + mac = parts[2].lower() + rssi = int(parts[3]) + # addr_type = parts[4] # pub/rnd + name = parts[5] if len(parts) > 5 else None + company_id = int(parts[6], 16) if len(parts) > 6 and parts[6].startswith('0x') else None + tx_power = int(parts[7]) if len(parts) > 7 and parts[7] not in ('127', '') else None + + sensor = get_or_create_sensor(hostname, ip) + device = get_or_create_device(mac, 'ble') + + if name: + device.name = name + if company_id: + device.company_id = company_id + if tx_power: + device.tx_power = tx_power + + sighting = Sighting(device_id=device.id, sensor_id=sensor.id, rssi=rssi) + db.session.add(sighting) + db.session.commit() + + +def parse_probe_data(data: str, ip: str): + """Parse PROBE_DATA packet.""" + # PROBE_DATA,hostname,mac,rssi,ssid,channel + parts = data.split(',') + if len(parts) < 6: + return + + hostname = parts[1] + mac = parts[2].lower() + rssi = int(parts[3]) + ssid = parts[4] + channel = int(parts[5]) + + sensor = get_or_create_sensor(hostname, ip) + device = get_or_create_device(mac, 'wifi') + + probe = Probe(device_id=device.id, sensor_id=sensor.id, ssid=ssid, rssi=rssi, channel=channel) + db.session.add(probe) + db.session.commit() + + +def parse_alert_data(data: str, ip: str): + """Parse ALERT_DATA packet.""" + # ALERT_DATA,hostname,type,source,target,rssi OR ALERT_DATA,hostname,deauth_flood,count,window + parts = data.split(',') + if len(parts) < 4: + return + + hostname = parts[1] + alert_type = parts[2] + sensor = get_or_create_sensor(hostname, ip) + + if alert_type == 'deauth_flood': + flood_count = int(parts[3]) + flood_window = int(parts[4]) if len(parts) > 4 else None + alert = Alert( + sensor_id=sensor.id, alert_type=alert_type, + flood_count=flood_count, flood_window=flood_window + ) + else: + source_mac = parts[3].lower() if len(parts) > 3 else None + target_mac = parts[4].lower() if len(parts) > 4 else None + rssi = int(parts[5]) if len(parts) > 5 else None + alert = Alert( + sensor_id=sensor.id, alert_type=alert_type, + source_mac=source_mac, target_mac=target_mac, rssi=rssi + ) + + db.session.add(alert) + db.session.commit() + log.info('Alert: %s from %s', alert_type, hostname) + + +def parse_event(data: str, ip: str): + """Parse EVENT packet.""" + # EVENT,hostname,key=value key=value ... + parts = data.split(',') + if len(parts) < 3: + return + + hostname = parts[1] + payload_str = ','.join(parts[2:]) + + # Parse key=value pairs + payload = {} + for match in re.finditer(r'(\w+)=([^\s,]+)', payload_str): + key, value = match.groups() + # Try to convert to number + try: + payload[key] = int(value) + except ValueError: + try: + payload[key] = float(value) + except ValueError: + payload[key] = value + + # Determine event type from first key + event_type = next(iter(payload.keys()), 'unknown') + + sensor = get_or_create_sensor(hostname, ip) + event = Event(sensor_id=sensor.id, event_type=event_type, payload_json=json.dumps(payload)) + db.session.add(event) + db.session.commit() diff --git a/src/esp32_web/config.py b/src/esp32_web/config.py new file mode 100644 index 0000000..63df0f6 --- /dev/null +++ b/src/esp32_web/config.py @@ -0,0 +1,25 @@ +"""Application configuration.""" +import os + + +class Config: + """Base configuration.""" + SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-change-me') + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///esp32.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # Network + UDP_LISTEN_PORT = int(os.environ.get('UDP_PORT', 5500)) + SENSOR_CMD_PORT = int(os.environ.get('CMD_PORT', 5501)) + SENSOR_TIMEOUT = int(os.environ.get('SENSOR_TIMEOUT', 60)) + + +class TestConfig(Config): + """Testing configuration.""" + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + + +class ProdConfig(Config): + """Production configuration.""" + pass diff --git a/src/esp32_web/extensions.py b/src/esp32_web/extensions.py new file mode 100644 index 0000000..0dc5ec3 --- /dev/null +++ b/src/esp32_web/extensions.py @@ -0,0 +1,6 @@ +"""Flask extensions.""" +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() diff --git a/src/esp32_web/models/__init__.py b/src/esp32_web/models/__init__.py new file mode 100644 index 0000000..db12d52 --- /dev/null +++ b/src/esp32_web/models/__init__.py @@ -0,0 +1,9 @@ +"""Database models.""" +from .sensor import Sensor +from .device import Device +from .sighting import Sighting +from .alert import Alert +from .event import Event +from .probe import Probe + +__all__ = ['Sensor', 'Device', 'Sighting', 'Alert', 'Event', 'Probe'] diff --git a/src/esp32_web/models/alert.py b/src/esp32_web/models/alert.py new file mode 100644 index 0000000..6c4394b --- /dev/null +++ b/src/esp32_web/models/alert.py @@ -0,0 +1,37 @@ +"""Alert model.""" +from datetime import datetime, UTC +from ..extensions import db + + +class Alert(db.Model): + """Security alert (deauth, disassoc, flood).""" + __tablename__ = 'alerts' + + id: db.Mapped[int] = db.mapped_column(primary_key=True) + sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True) + alert_type: db.Mapped[str] = db.mapped_column(db.String(16), index=True) # deauth, disassoc, deauth_flood + source_mac: db.Mapped[str | None] = db.mapped_column(db.String(17), nullable=True) + target_mac: db.Mapped[str | None] = db.mapped_column(db.String(17), nullable=True) + rssi: db.Mapped[int | None] = db.mapped_column(nullable=True) + flood_count: db.Mapped[int | None] = db.mapped_column(nullable=True) + flood_window: db.Mapped[int | None] = db.mapped_column(nullable=True) + timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True) + + # Relationships + sensor = db.relationship('Sensor', back_populates='alerts') + + def to_dict(self): + d = { + 'id': self.id, + 'sensor_id': self.sensor_id, + 'type': self.alert_type, + 'timestamp': self.timestamp.isoformat(), + } + if self.alert_type == 'deauth_flood': + d['flood_count'] = self.flood_count + d['flood_window'] = self.flood_window + else: + d['source_mac'] = self.source_mac + d['target_mac'] = self.target_mac + d['rssi'] = self.rssi + return d diff --git a/src/esp32_web/models/device.py b/src/esp32_web/models/device.py new file mode 100644 index 0000000..3b299c1 --- /dev/null +++ b/src/esp32_web/models/device.py @@ -0,0 +1,37 @@ +"""Device model.""" +from datetime import datetime, UTC +from ..extensions import db + + +class Device(db.Model): + """Discovered BLE/WiFi device.""" + __tablename__ = 'devices' + + id: db.Mapped[int] = db.mapped_column(primary_key=True) + mac: db.Mapped[str] = db.mapped_column(db.String(17), unique=True, index=True) + device_type: db.Mapped[str] = db.mapped_column(db.String(8)) # 'ble' or 'wifi' + vendor: db.Mapped[str | None] = db.mapped_column(db.String(64), nullable=True) + name: db.Mapped[str | None] = db.mapped_column(db.String(64), nullable=True) + first_seen: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC)) + last_seen: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC)) + + # BLE-specific fields + company_id: db.Mapped[int | None] = db.mapped_column(nullable=True) + tx_power: db.Mapped[int | None] = db.mapped_column(nullable=True) + + # Relationships + sightings = db.relationship('Sighting', back_populates='device', lazy='dynamic') + probes = db.relationship('Probe', back_populates='device', lazy='dynamic') + + def to_dict(self): + return { + 'id': self.id, + 'mac': self.mac, + 'type': self.device_type, + 'vendor': self.vendor, + 'name': self.name, + 'first_seen': self.first_seen.isoformat(), + 'last_seen': self.last_seen.isoformat(), + 'company_id': self.company_id, + 'tx_power': self.tx_power, + } diff --git a/src/esp32_web/models/event.py b/src/esp32_web/models/event.py new file mode 100644 index 0000000..1701b39 --- /dev/null +++ b/src/esp32_web/models/event.py @@ -0,0 +1,27 @@ +"""Event model.""" +from datetime import datetime, UTC +from ..extensions import db + + +class Event(db.Model): + """Sensor event (motion, presence, calibration).""" + __tablename__ = 'events' + + id: db.Mapped[int] = db.mapped_column(primary_key=True) + sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True) + event_type: db.Mapped[str] = db.mapped_column(db.String(32), index=True) + payload_json: db.Mapped[str | None] = db.mapped_column(db.Text, nullable=True) + timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True) + + # Relationships + sensor = db.relationship('Sensor', back_populates='events') + + def to_dict(self): + import json + return { + 'id': self.id, + 'sensor_id': self.sensor_id, + 'type': self.event_type, + 'payload': json.loads(self.payload_json) if self.payload_json else {}, + 'timestamp': self.timestamp.isoformat(), + } diff --git a/src/esp32_web/models/probe.py b/src/esp32_web/models/probe.py new file mode 100644 index 0000000..2f08306 --- /dev/null +++ b/src/esp32_web/models/probe.py @@ -0,0 +1,31 @@ +"""Probe request model.""" +from datetime import datetime, UTC +from ..extensions import db + + +class Probe(db.Model): + """WiFi probe request.""" + __tablename__ = 'probes' + + id: db.Mapped[int] = db.mapped_column(primary_key=True) + device_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('devices.id'), index=True) + sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True) + ssid: db.Mapped[str] = db.mapped_column(db.String(32), index=True) + rssi: db.Mapped[int] = db.mapped_column() + channel: db.Mapped[int] = db.mapped_column() + timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True) + + # Relationships + device = db.relationship('Device', back_populates='probes') + sensor = db.relationship('Sensor', back_populates='probes') + + def to_dict(self): + return { + 'id': self.id, + 'device_id': self.device_id, + 'sensor_id': self.sensor_id, + 'ssid': self.ssid, + 'rssi': self.rssi, + 'channel': self.channel, + 'timestamp': self.timestamp.isoformat(), + } diff --git a/src/esp32_web/models/sensor.py b/src/esp32_web/models/sensor.py new file mode 100644 index 0000000..0099383 --- /dev/null +++ b/src/esp32_web/models/sensor.py @@ -0,0 +1,30 @@ +"""Sensor model.""" +from datetime import datetime, UTC +from ..extensions import db + + +class Sensor(db.Model): + """ESP32 sensor node.""" + __tablename__ = 'sensors' + + id: db.Mapped[int] = db.mapped_column(primary_key=True) + hostname: db.Mapped[str] = db.mapped_column(db.String(32), unique=True, index=True) + ip: db.Mapped[str] = db.mapped_column(db.String(15)) + last_seen: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC)) + status: db.Mapped[str] = db.mapped_column(db.String(16), default='unknown') + config_json: db.Mapped[str | None] = db.mapped_column(db.Text, nullable=True) + + # Relationships + sightings = db.relationship('Sighting', back_populates='sensor', lazy='dynamic') + alerts = db.relationship('Alert', back_populates='sensor', lazy='dynamic') + events = db.relationship('Event', back_populates='sensor', lazy='dynamic') + probes = db.relationship('Probe', back_populates='sensor', lazy='dynamic') + + def to_dict(self): + return { + 'id': self.id, + 'hostname': self.hostname, + 'ip': self.ip, + 'last_seen': self.last_seen.isoformat(), + 'status': self.status, + } diff --git a/src/esp32_web/models/sighting.py b/src/esp32_web/models/sighting.py new file mode 100644 index 0000000..c2d9212 --- /dev/null +++ b/src/esp32_web/models/sighting.py @@ -0,0 +1,27 @@ +"""Sighting model.""" +from datetime import datetime, UTC +from ..extensions import db + + +class Sighting(db.Model): + """Device sighting by a sensor.""" + __tablename__ = 'sightings' + + id: db.Mapped[int] = db.mapped_column(primary_key=True) + device_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('devices.id'), index=True) + sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True) + rssi: db.Mapped[int] = db.mapped_column() + timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True) + + # Relationships + device = db.relationship('Device', back_populates='sightings') + sensor = db.relationship('Sensor', back_populates='sightings') + + def to_dict(self): + return { + 'id': self.id, + 'device_id': self.device_id, + 'sensor_id': self.sensor_id, + 'rssi': self.rssi, + 'timestamp': self.timestamp.isoformat(), + } diff --git a/src/esp32_web/services/__init__.py b/src/esp32_web/services/__init__.py new file mode 100644 index 0000000..de2060f --- /dev/null +++ b/src/esp32_web/services/__init__.py @@ -0,0 +1 @@ +"""Business logic services.""" diff --git a/src/esp32_web/utils/__init__.py b/src/esp32_web/utils/__init__.py new file mode 100644 index 0000000..183c974 --- /dev/null +++ b/src/esp32_web/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules.""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..46816dd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dfac5a1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +"""Pytest fixtures.""" +import pytest +from esp32_web import create_app +from esp32_web.extensions import db +from esp32_web.config import TestConfig + + +@pytest.fixture +def app(): + """Create test application.""" + app = create_app(TestConfig) + with app.app_context(): + db.create_all() + yield app + db.drop_all() + + +@pytest.fixture +def client(app): + """Create test client.""" + return app.test_client() + + +@pytest.fixture +def db_session(app): + """Database session for tests.""" + with app.app_context(): + yield db.session diff --git a/tests/test_api/__init__.py b/tests/test_api/__init__.py new file mode 100644 index 0000000..54cd691 --- /dev/null +++ b/tests/test_api/__init__.py @@ -0,0 +1 @@ +"""API tests.""" diff --git a/tests/test_api/test_sensors.py b/tests/test_api/test_sensors.py new file mode 100644 index 0000000..8adb1ab --- /dev/null +++ b/tests/test_api/test_sensors.py @@ -0,0 +1,21 @@ +"""Sensor API tests.""" + + +def test_list_sensors_empty(client): + """Test listing sensors when empty.""" + response = client.get('/api/v1/sensors') + assert response.status_code == 200 + assert response.json == {'sensors': []} + + +def test_get_sensor_not_found(client): + """Test getting non-existent sensor.""" + response = client.get('/api/v1/sensors/nonexistent') + assert response.status_code == 404 + + +def test_health_check(client): + """Test health endpoint.""" + response = client.get('/health') + assert response.status_code == 200 + assert response.json == {'status': 'ok'} diff --git a/tests/test_collector/__init__.py b/tests/test_collector/__init__.py new file mode 100644 index 0000000..d8490ec --- /dev/null +++ b/tests/test_collector/__init__.py @@ -0,0 +1 @@ +"""Collector tests.""" diff --git a/tests/test_collector/test_parsers.py b/tests/test_collector/test_parsers.py new file mode 100644 index 0000000..9462f2d --- /dev/null +++ b/tests/test_collector/test_parsers.py @@ -0,0 +1,35 @@ +"""Parser tests.""" +from esp32_web.collector.parsers import parse_packet +from esp32_web.models import Sensor, Device, Alert + + +def test_parse_ble_data(app): + """Test parsing BLE_DATA packet.""" + with app.app_context(): + from esp32_web.extensions import db + + data = 'BLE_DATA,test-sensor,aa:bb:cc:dd:ee:ff,-50,pub,TestDevice,0x004C,10,0' + parse_packet(data, ('192.168.1.100', 5500)) + + sensor = db.session.scalar(db.select(Sensor).where(Sensor.hostname == 'test-sensor')) + assert sensor is not None + assert sensor.ip == '192.168.1.100' + + device = db.session.scalar(db.select(Device).where(Device.mac == 'aa:bb:cc:dd:ee:ff')) + assert device is not None + assert device.device_type == 'ble' + assert device.name == 'TestDevice' + + +def test_parse_alert_data(app): + """Test parsing ALERT_DATA packet.""" + with app.app_context(): + from esp32_web.extensions import db + + data = 'ALERT_DATA,test-sensor,deauth,aa:bb:cc:dd:ee:ff,11:22:33:44:55:66,-40' + parse_packet(data, ('192.168.1.100', 5500)) + + alert = db.session.scalar(db.select(Alert)) + assert alert is not None + assert alert.alert_type == 'deauth' + assert alert.source_mac == 'aa:bb:cc:dd:ee:ff'