From 0fdf023af1a848d353ecef889d3aecad9dec2df6 Mon Sep 17 00:00:00 2001 From: stephenminakian Date: Sun, 6 Jul 2025 21:54:54 -0600 Subject: [PATCH] first commit --- Particle/main.ino | 378 ++++++++++++++++++++++++++++++++++++++ Server/Dockerfile | 35 ++++ Server/docker-compose.yml | 39 ++++ Server/requirements.txt | 3 + Server/webhook_app.py | 222 ++++++++++++++++++++++ 5 files changed, 677 insertions(+) create mode 100644 Particle/main.ino create mode 100644 Server/Dockerfile create mode 100644 Server/docker-compose.yml create mode 100644 Server/requirements.txt create mode 100644 Server/webhook_app.py diff --git a/Particle/main.ino b/Particle/main.ino new file mode 100644 index 0000000..8b743fc --- /dev/null +++ b/Particle/main.ino @@ -0,0 +1,378 @@ +#include "Particle.h" + +// Ultra low power system settings for Boron LTE +SYSTEM_MODE(MANUAL); // Manual control over cellular connection +SYSTEM_THREAD(ENABLED); + +// Pin definitions for Boron +const int MICROSWITCH_PIN = D2; // Microswitch (NC - normally closed) +const int ALARM_PIN = D3; // Alarm output +const int STATUS_LED = D7; // Built-in LED for status + +// Ultra low power settings for LTE Boron +const unsigned long DAILY_BATTERY_REPORT = 86400; // 24 hours in seconds +const unsigned long ALARM_DURATION = 10000; // 10 seconds alarm duration +const unsigned long CELLULAR_TIMEOUT = 60000; // 60 seconds to connect to cellular +const unsigned long PUBLISH_TIMEOUT = 30000; // 30 seconds to publish + +// Persistent state (survives STOP mode sleep) +retained unsigned long lastBatteryReport = 0; +retained unsigned long bootCount = 0; +retained bool deviceInitialized = false; +retained float lastReportedBattery = 100.0; + +// Wake up reasons for Boron +enum BoronWakeReason { + WAKE_SECURITY_BREACH, // Microswitch triggered + WAKE_DAILY_REPORT, // Daily battery check + WAKE_COLD_START // Power on/reset +}; + +// Function prototypes +BoronWakeReason determineWakeReason(); +void handleSecurityBreach(); +void handleDailyReport(); +void handleColdStart(); +void activateSecurityAlarm(); +void maintainAlarmDuration(); +bool connectToCellular(); +void publishSecurityAlert(String alertData); +String createSecurityAlert(); +String createBatteryReport(float batteryLevel); +String createStartupAlert(); +void flashStatusLED(int count, int duration); +void enterUltraLowPowerSleep(); + +void setup() { + bootCount++; + + // Initialize pins immediately for security + pinMode(MICROSWITCH_PIN, INPUT_PULLUP); // Pullup for NC switch + pinMode(ALARM_PIN, OUTPUT); + pinMode(STATUS_LED, OUTPUT); + + // Ensure alarm starts OFF + digitalWrite(ALARM_PIN, LOW); + digitalWrite(STATUS_LED, LOW); + + // Determine why we woke up + BoronWakeReason wakeReason = determineWakeReason(); + + // Handle based on wake reason + switch (wakeReason) { + case WAKE_SECURITY_BREACH: + handleSecurityBreach(); + break; + case WAKE_DAILY_REPORT: + handleDailyReport(); + break; + case WAKE_COLD_START: + handleColdStart(); + break; + } + + // Always go back to ultra low power sleep + enterUltraLowPowerSleep(); +} + +void loop() { + // Should never reach here in ultra low power mode + // If we do, go to sleep immediately + enterUltraLowPowerSleep(); +} + +BoronWakeReason determineWakeReason() { + // Check if microswitch is open (security breach) + // NC switch: HIGH = closed (normal), LOW = open (breach) + bool switchOpen = (digitalRead(MICROSWITCH_PIN) == LOW); + + if (switchOpen) { + return WAKE_SECURITY_BREACH; + } + + // Check if it's time for daily battery report + unsigned long currentTime = Time.now(); + if (deviceInitialized && (currentTime - lastBatteryReport) >= DAILY_BATTERY_REPORT) { + return WAKE_DAILY_REPORT; + } + + // Must be a cold start (power on, reset, or first boot) + return WAKE_COLD_START; +} + +void handleSecurityBreach() { + // IMMEDIATE ALARM ACTIVATION (before any network activity) + activateSecurityAlarm(); + + // Status indication + flashStatusLED(5, 100); // 5 rapid flashes + + // Now connect and send alert + if (connectToCellular()) { + String alertData = createSecurityAlert(); + publishSecurityAlert(alertData); + + // Brief delay to ensure message is sent + delay(5000); + } + + // Keep alarm on for full duration even if network fails + maintainAlarmDuration(); + + // Turn off alarm + digitalWrite(ALARM_PIN, LOW); +} + +void handleDailyReport() { + // Quick status blink + flashStatusLED(2, 200); + + // Connect and send daily battery report + if (connectToCellular()) { + float currentBattery = System.batteryCharge(); + String reportData = createBatteryReport(currentBattery); + publishSecurityAlert(reportData); + + // Update tracking + lastBatteryReport = Time.now(); + lastReportedBattery = currentBattery; + + delay(3000); // Ensure transmission completes + } +} + +void handleColdStart() { + // Device just powered on or reset + deviceInitialized = false; + + // Startup indication (3 slow blinks) + flashStatusLED(3, 500); + + if (connectToCellular()) { + String startupData = createStartupAlert(); + publishSecurityAlert(startupData); + + // Initialize tracking + deviceInitialized = true; + lastBatteryReport = Time.now(); + lastReportedBattery = System.batteryCharge(); + + delay(3000); + } +} + +void activateSecurityAlarm() { + // IMMEDIATE alarm activation - highest priority + digitalWrite(ALARM_PIN, HIGH); + digitalWrite(STATUS_LED, HIGH); + + // Quick beep pattern to confirm activation + // (Remove if you don't want any delay before network connection) + for (int i = 0; i < 3; i++) { + delay(100); + digitalWrite(STATUS_LED, LOW); + delay(100); + digitalWrite(STATUS_LED, HIGH); + } +} + +void maintainAlarmDuration() { + // Keep alarm on for specified duration + unsigned long alarmStart = millis(); + + while (millis() - alarmStart < ALARM_DURATION) { + // Flash status LED while alarm is active + digitalWrite(STATUS_LED, HIGH); + delay(250); + digitalWrite(STATUS_LED, LOW); + delay(250); + + // Check if switch closed again (breach ended) + if (digitalRead(MICROSWITCH_PIN) == HIGH) { + // Switch closed again - could end alarm early + // Comment out next line if you want fixed duration regardless + // break; + } + } +} + +bool connectToCellular() { + unsigned long startTime = millis(); + + // Status indication - connecting + digitalWrite(STATUS_LED, HIGH); + + // Enable cellular radio + Cellular.on(); + + // Connect to Particle cloud via cellular + Particle.connect(); + + // Wait for connection with timeout + while (!Particle.connected() && (millis() - startTime) < CELLULAR_TIMEOUT) { + Particle.process(); + + // Blink during connection attempt + digitalWrite(STATUS_LED, (millis() / 500) % 2); + delay(100); + } + + bool connected = Particle.connected(); + + // Status indication + if (connected) { + // Success - solid LED for 1 second + digitalWrite(STATUS_LED, HIGH); + delay(1000); + } else { + // Failed - rapid blinks + flashStatusLED(10, 50); + } + + digitalWrite(STATUS_LED, LOW); + return connected; +} + +void publishSecurityAlert(String alertData) { + if (!Particle.connected()) { + return; + } + + // Try to publish with retries + for (int attempts = 0; attempts < 3; attempts++) { + bool success = Particle.publish("Security Alert", alertData, PRIVATE); + + if (success) { + // Success indication + flashStatusLED(2, 100); + break; + } else { + // Retry indication + flashStatusLED(1, 50); + delay(2000); + } + } + + // Allow time for message to be sent + unsigned long publishStart = millis(); + while (millis() - publishStart < PUBLISH_TIMEOUT) { + Particle.process(); + delay(100); + } +} + +String createSecurityAlert() { + float batteryLevel = System.batteryCharge(); + CellularSignal signal = Cellular.RSSI(); + + String alertData = String::format( + "Type:SECURITY_BREACH|Desc:Microswitch opened - INTRUDER DETECTED|Batt:%.1f%%|Signal:%d|Quality:%d|Boot:%lu|Alarm:ON", + batteryLevel, + signal.getStrength(), + signal.getQuality(), + bootCount + ); + + return alertData; +} + +String createBatteryReport(float batteryLevel) { + CellularSignal signal = Cellular.RSSI(); + + String alertType; + String description; + + if (batteryLevel <= 10.0 && batteryLevel > 0) { + alertType = "CRITICAL_BATTERY"; + description = String::format("CRITICAL: Battery at %.1f%% - Device may shutdown soon", batteryLevel); + } else if (batteryLevel <= 20.0) { + alertType = "LOW_BATTERY"; + description = String::format("LOW: Battery at %.1f%% - Consider servicing", batteryLevel); + } else { + alertType = "DAILY_BATTERY_REPORT"; + description = String::format("Daily report: Battery at %.1f%%, operating normally", batteryLevel); + } + + String alertData = String::format( + "Type:%s|Desc:%s|Batt:%.1f%%|Signal:%d|Quality:%d|Boot:%lu|Days:%lu", + alertType.c_str(), + description.c_str(), + batteryLevel, + signal.getStrength(), + signal.getQuality(), + bootCount, + (Time.now() - lastBatteryReport) / 86400 + ); + + return alertData; +} + +String createStartupAlert() { + float batteryLevel = System.batteryCharge(); + CellularSignal signal = Cellular.RSSI(); + + String description = String::format( + "Device startup - Boot #%lu, Battery: %.1f%%, Ready for security monitoring", + bootCount, + batteryLevel + ); + + String alertData = String::format( + "Type:DEVICE_STARTUP|Desc:%s|Batt:%.1f%%|Signal:%d|Quality:%d|Firmware:1.0.0", + description.c_str(), + batteryLevel, + signal.getStrength(), + signal.getQuality() + ); + + return alertData; +} + +void flashStatusLED(int count, int duration) { + for (int i = 0; i < count; i++) { + digitalWrite(STATUS_LED, HIGH); + delay(duration); + digitalWrite(STATUS_LED, LOW); + delay(duration); + } +} + +void enterUltraLowPowerSleep() { + // Disconnect from cellular to save maximum power + if (Particle.connected()) { + Particle.disconnect(); + delay(2000); // Give time to disconnect cleanly + } + + // Turn off cellular radio completely + Cellular.off(); + + // Turn off status LED + digitalWrite(STATUS_LED, LOW); + + // Configure ultra low power sleep for Boron + SystemSleepConfiguration config; + config.mode(SystemSleepMode::STOP) // STOP mode for Boron (supports RTC wake) + .gpio(MICROSWITCH_PIN, FALLING) // Wake on switch opening (NC switch goes LOW) + .duration(24h); // Wake once per day for battery report + + // Enter sleep - device will wake on pin interrupt OR after 24 hours + SystemSleepResult result = System.sleep(config); + + // When we wake up, setup() will run again to handle the wake reason +} + +// Optional: Handle system events for debugging +void onCellularConnect() { + // Cellular connected + flashStatusLED(1, 200); +} + +void onCellularDisconnect() { + // Cellular disconnected + flashStatusLED(2, 100); +} + +// Uncomment for debugging (will increase power consumption) +// STARTUP(cellular.on()); +// SYSTEM(SYSTEM_MODE(SEMI_AUTOMATIC)); \ No newline at end of file diff --git a/Server/Dockerfile b/Server/Dockerfile new file mode 100644 index 0000000..652dc01 --- /dev/null +++ b/Server/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# Install curl for health checks +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Set working directory +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY webhook_app.py . + +# Change ownership to non-root user +RUN chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 5000 + +# Health check with curl (now installed) +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/health || exit 1 + +# Run with gunicorn for production +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "30", "webhook_app:app"] \ No newline at end of file diff --git a/Server/docker-compose.yml b/Server/docker-compose.yml new file mode 100644 index 0000000..1a47fba --- /dev/null +++ b/Server/docker-compose.yml @@ -0,0 +1,39 @@ +services: + webhook-service: + build: . + container_name: webhook-service + restart: unless-stopped + environment: + - FLASK_SECRET_KEY=${FLASK_SECRET_KEY} + - WEBHOOK_SECRET=${WEBHOOK_SECRET} + - PARTICLE_WEBHOOK_SECRET=${PARTICLE_WEBHOOK_SECRET} + - SMTP_EMAIL=${SMTP_EMAIL} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - RECIPIENT_EMAIL=${RECIPIENT_EMAIL} + networks: + - traefik + labels: + - "traefik.enable=true" + - "traefik.http.routers.webhook.rule=Host(`webhook.maverickapplications.com`)" + - "traefik.http.routers.webhook.entrypoints=websecure" + - "traefik.http.routers.webhook.tls.certresolver=letsencrypt" + - "traefik.http.services.webhook.loadbalancer.server.port=5000" + + # Security middleware + - "traefik.http.routers.webhook.middlewares=webhook-headers,webhook-ratelimit" + + # Security headers + - "traefik.http.middlewares.webhook-headers.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.webhook-headers.headers.customresponseheaders.X-Content-Type-Options=nosniff" + - "traefik.http.middlewares.webhook-headers.headers.customresponseheaders.X-Frame-Options=DENY" + - "traefik.http.middlewares.webhook-headers.headers.customresponseheaders.X-XSS-Protection=1; mode=block" + - "traefik.http.middlewares.webhook-headers.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin" + + # Rate limiting + - "traefik.http.middlewares.webhook-ratelimit.ratelimit.average=10" + - "traefik.http.middlewares.webhook-ratelimit.ratelimit.burst=20" + - "traefik.http.middlewares.webhook-ratelimit.ratelimit.period=1m" + +networks: + traefik: + external: true \ No newline at end of file diff --git a/Server/requirements.txt b/Server/requirements.txt new file mode 100644 index 0000000..e0ba633 --- /dev/null +++ b/Server/requirements.txt @@ -0,0 +1,3 @@ +flask==2.3.3 +gunicorn==21.2.0 +Werkzeug==2.3.7 \ No newline at end of file diff --git a/Server/webhook_app.py b/Server/webhook_app.py new file mode 100644 index 0000000..5ebf473 --- /dev/null +++ b/Server/webhook_app.py @@ -0,0 +1,222 @@ +from flask import Flask, request, jsonify +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import os +import hashlib +import hmac +import logging +from datetime import datetime +import json + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Security configurations +app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'your-secret-key-here') +WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', 'your-webhook-secret') +PARTICLE_WEBHOOK_SECRET = os.environ.get('PARTICLE_WEBHOOK_SECRET', 'Not-Loaded') +SMTP_EMAIL = os.environ.get('SMTP_EMAIL', 'your-email@gmail.com') +SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', 'your-app-password') +RECIPIENT_EMAIL = os.environ.get('RECIPIENT_EMAIL', 'your-phone@carrier.com') + +def verify_webhook_signature(payload, signature): + """Verify webhook signature for security""" + if not signature: + return False + + # Create expected signature + expected_signature = hmac.new( + WEBHOOK_SECRET.encode('utf-8'), + payload, + hashlib.sha256 + ).hexdigest() + + # Compare signatures securely + return hmac.compare_digest(f"sha256={expected_signature}", signature) + +def validate_webhook_data(data): + """Validate webhook data structure - handles both Particle and generic formats""" + # Particle.io webhook format + particle_fields = ['event', 'data', 'published_at', 'coreid'] + + # Generic webhook format + generic_fields = ['event', 'data', 'published_at'] + + if not isinstance(data, dict): + return False, "Invalid data format" + + # Check if it's a Particle webhook (has coreid field) + if 'coreid' in data: + required_fields = particle_fields + else: + required_fields = generic_fields + + for field in required_fields: + if field not in data: + return False, f"Missing required field: {field}" + + return True, "Valid" + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint for monitoring""" + return jsonify({"status": "healthy", "timestamp": datetime.utcnow().isoformat()}), 200 + +def verify_particle_auth(auth_header): + """Verify Particle webhook authentication header""" + if not auth_header: + logger.warning("No authorization header provided for Particle webhook") + return False + + if not PARTICLE_WEBHOOK_SECRET: + logger.error("PARTICLE_WEBHOOK_SECRET not configured") + return False + + # Support both "Bearer token" and direct token formats + if auth_header.startswith('Bearer '): + token = auth_header[7:] # Remove "Bearer " prefix + else: + token = auth_header + + # Use secure comparison to prevent timing attacks + is_valid = hmac.compare_digest(token, PARTICLE_WEBHOOK_SECRET) + + if is_valid: + logger.info("Particle webhook authentication successful") + else: + logger.warning("Particle webhook authentication failed - invalid token") + + return is_valid + +@app.route('/webhook', methods=['POST']) +def webhook(): + """Secure webhook endpoint with validation and logging""" + try: + # Get raw payload for signature verification + payload = request.get_data() + signature = request.headers.get('X-Hub-Signature-256') + + # Check if this is a Particle webhook + user_agent = request.headers.get('User-Agent', '') + is_particle_webhook = 'ParticleBot' in user_agent or 'Particle' in user_agent + + if is_particle_webhook: + # Verify Particle webhook authentication + auth_header = request.headers.get('Authorization') or request.headers.get('X-Particle-Webhook-Secret') + logger.info(f"Particle webhook detected. Auth header present: {bool(auth_header)}") + + if not verify_particle_auth(auth_header): + logger.warning(f"Invalid Particle webhook authentication from {request.remote_addr}") + logger.debug(f"Expected secret length: {len(PARTICLE_WEBHOOK_SECRET) if PARTICLE_WEBHOOK_SECRET else 0}") + logger.debug(f"Received auth header: {auth_header[:10] + '...' if auth_header and len(auth_header) > 10 else auth_header}") + return jsonify({"error": "Authentication failed"}), 403 + + logger.info(f"Particle webhook authenticated from {request.remote_addr}") + else: + # Verify webhook signature for non-Particle webhooks + if not verify_webhook_signature(payload, signature): + logger.warning(f"Invalid webhook signature from {request.remote_addr}") + return jsonify({"error": "Invalid signature"}), 403 + + # Parse JSON data + try: + data = request.get_json() + except Exception as e: + logger.error(f"Invalid JSON payload: {e}") + return jsonify({"error": "Invalid JSON"}), 400 + + # Validate data structure + is_valid, message = validate_webhook_data(data) + if not is_valid: + logger.error(f"Invalid webhook data: {message}") + return jsonify({"error": message}), 400 + + # Log the webhook event with device info if available + device_info = f" (Device: {data.get('coreid', 'unknown')})" if 'coreid' in data else "" + logger.info(f"Received webhook: {data['event']}{device_info} from {request.remote_addr}") + + # Send notification with enhanced formatting for Particle devices + if 'coreid' in data: + # Particle.io webhook format + device_name = data.get('device_name', data.get('coreid', 'Unknown Device')) + notification_message = f"🔒 SECURITY ALERT\nDevice: {device_name}\nEvent: {data['event']}\nData: {data['data']}\nTime: {data['published_at']}" + else: + # Generic webhook format + notification_message = f"🔒 SECURITY ALERT\nEvent: {data['event']}\nData: {data['data']}\nTime: {data['published_at']}" + + send_notification(notification_message, data) + + # Return success response + response = { + "status": "success", + "message": "Webhook processed successfully", + "timestamp": datetime.utcnow().isoformat() + } + return jsonify(response), 200 + + except Exception as e: + logger.error(f"Webhook processing error: {e}") + return jsonify({"error": "Internal server error"}), 500 + +def send_notification(message, webhook_data=None): + """Send notification via email/SMS with better error handling and device context""" + try: + # Create message + msg = MIMEMultipart() + msg['From'] = SMTP_EMAIL + msg['To'] = RECIPIENT_EMAIL + + # Enhanced subject line for security alerts + if webhook_data and 'coreid' in webhook_data: + device_name = webhook_data.get('device_name', webhook_data.get('coreid', 'Device')) + msg['Subject'] = f'🔒 Security Alert - {device_name}' + else: + msg['Subject'] = '🔒 Security Alert' + + # Add timestamp and formatting + formatted_message = f"{message}\n\nAlert processed: {datetime.utcnow().isoformat()}" + + # Add device details if available + if webhook_data and 'coreid' in webhook_data: + device_details = f"\n\nDevice Details:\n" + device_details += f"Device ID: {webhook_data.get('coreid', 'N/A')}\n" + device_details += f"Device Name: {webhook_data.get('device_name', 'N/A')}\n" + device_details += f"Firmware Version: {webhook_data.get('fw_version', 'N/A')}\n" + formatted_message += device_details + + msg.attach(MIMEText(formatted_message, 'plain')) + + # Send email + with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server: + server.login(SMTP_EMAIL, SMTP_PASSWORD) + server.send_message(msg) + + logger.info("Security alert notification sent successfully") + + except Exception as e: + logger.error(f"Failed to send notification: {e}") + # Could add fallback notification methods here + +@app.errorhandler(404) +def not_found(error): + """Custom 404 handler""" + return jsonify({"error": "Endpoint not found"}), 404 + +@app.errorhandler(405) +def method_not_allowed(error): + """Custom 405 handler""" + return jsonify({"error": "Method not allowed"}), 405 + +@app.errorhandler(500) +def internal_error(error): + """Custom 500 handler""" + logger.error(f"Internal server error: {error}") + return jsonify({"error": "Internal server error"}), 500 + +if __name__ == '__main__': + # Only for development - production uses WSGI server + app.run(host='0.0.0.0', port=5000, debug=False) \ No newline at end of file