first commit

This commit is contained in:
2025-07-06 21:54:54 -06:00
commit 0fdf023af1
5 changed files with 677 additions and 0 deletions

378
Particle/main.ino Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
flask==2.3.3
gunicorn==21.2.0
Werkzeug==2.3.7

222
Server/webhook_app.py Normal file
View 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)