first commit
This commit is contained in:
378
Particle/main.ino
Normal file
378
Particle/main.ino
Normal file
@ -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));
|
||||||
35
Server/Dockerfile
Normal file
35
Server/Dockerfile
Normal file
@ -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"]
|
||||||
39
Server/docker-compose.yml
Normal file
39
Server/docker-compose.yml
Normal file
@ -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
|
||||||
3
Server/requirements.txt
Normal file
3
Server/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask==2.3.3
|
||||||
|
gunicorn==21.2.0
|
||||||
|
Werkzeug==2.3.7
|
||||||
222
Server/webhook_app.py
Normal file
222
Server/webhook_app.py
Normal file
@ -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)
|
||||||
Reference in New Issue
Block a user